本篇文章是對 Parce
的源碼解析,代碼基本架構與執行流程,帶你瞭解打包工具的內部原理,在這以前你若是對 parcel
不熟悉能夠先到 Parcel官網 瞭解javascript
下面是偷懶從官網抄下來的介紹:css
極速零配置Web應用打包工具
Parcel
使用 worker
進程去啓用多核編譯。同時有文件系統緩存,即便在重啓構建後也能快速再編譯。html
Parcel 具有開箱即用的對 JS
, CSS
, HTML
, 文件 及更多的支持,並且不須要插件。vue
如如有須要,Babel
, PostCSS
, 和 PostHTML
甚至 node_modules
包會被用於自動轉換代碼.java
使用動態 import()
語法, Parcel
將你的輸出文件束(bundles
)分拆,所以你只須要在初次加載時加載你所須要的代碼。node
Parcel
無需配置,在開發環境的時候會自動在瀏覽器內隨着你的代碼更改而去更新模塊。webpack
當遇到錯誤時,Parcel 會輸出 語法高亮的代碼片斷,幫助你定位問題。es6
打包工具 | 時間 |
---|---|
browserify | 22.98s |
webpack | 20.71s |
parcel | 9.98s |
parcel - with cache | 2.64s |
咱們經常使用的打包工具大體功能:web
Tree-Shaking
等)es6,7,8 sass typescript
等)js, css, html
包括圖片的壓縮)parcel-bundler
版本:typescript
"version": "1.11.0"
|-- assets 資源目錄 繼承自 Asset.js |-- builtins 用於最終構建 |-- packagers 打包 |-- scope-hoisting 做用域提高 Tree-Shake |-- transforms 轉換代碼爲 AST |-- utils 工具 |-- visitors 遍歷 js AST樹 收集依賴等 |-- Asset.js 資源 |-- Bundle.js 用於構建 bundle 樹 |-- Bundler.js 主目錄 |-- FSCache.js 緩存 |-- HMRServer.js HMR服務器提供 WebSocket |-- Parser.js 根據文件擴展名獲取對應 Asset |-- Pipeline.js 多線程執行方法 |-- Resolver.js 解析模塊路徑 |-- Server.js 靜態資源服務器 |-- SourceMap.js SourceMap |-- cli.js cli入口 解析命令行參數 |-- worker.js 多線程入口
Parcel
是面向資源的,JavaScript,CSS,HTML
這些都是資源,並非 webpack
中 js
是一等公民,Parcel
會自動的從入口文件開始分析這些文件 和 模塊中的依賴,而後構建一個 bundle
樹,並對其進行打包輸出到指定目錄
咱們從一個簡單的例子開始瞭解 parcel
內部源碼與流程
index.html |-- index.js |-- module1.js |-- module2.js
上面是咱們例子的結構,入口爲 index.html
, 在 index.html
中咱們用 script
標籤引用了 src/index.js
,在 index.js
中咱們引入了2個子模塊
npx parcel index.html
或者 ./node_modules/.bin/parcel index.html
,或者使用 npm script
"bin": { "parcel": "bin/cli.js" }
查看 parcel-bundler
的 package.json
找到 bin/cli.js
,在cli.js
裏又指向 ../src/cli
const program = require('commander'); program .command('serve [input...]') // watch build ... .action(bundle); program.parse(process.argv); async function bundle(main, command) { const Bundler = require('./Bundler'); const bundler = new Bundler(main, command); if (command.name() === 'serve' && command.target === 'browser') { const server = await bundler.serve(); if (server && command.open) {...啓動自動打開瀏覽器} } else { bundler.bundle(); } }
在 cli.js
中利用 commander
解析命令行並調用 bundle
方法
有 serve, watch, build
3個命令來調用 bundle
函數,執行 pracel index.html
默認爲 serve
,因此調用的是 bundler.serve
方法
進入 Bundler.js
async serve(port = 1234, https = false, host) { this.server = await Server.serve(this, port, host, https); try { await this.bundle(); } catch (e) {} return this.server; }
bundler.serve
方法 調用 serveStatic
起了一個靜態服務指向 最終打包的文件夾
下面就是重要的 bundle
方法
async bundle() { // 加載插件 設置env 啓動多線程 watcher hmr await this.start(); if (isInitialBundle) { // 建立 輸出目錄 await fs.mkdirp(this.options.outDir); this.entryAssets = new Set(); for (let entry of this.entryFiles) { let asset = await this.resolveAsset(entry); this.buildQueue.add(asset); this.entryAssets.add(asset); } } // 打包隊列中的資源 let loadedAssets = await this.buildQueue.run(); // findOrphanAssets 獲取全部資源中獨立的沒有父Bundle的資源 let changedAssets = [...this.findOrphanAssets(), ...loadedAssets]; // 由於接下來要構建 Bundle 樹,先對上一次的 Bundle樹 進行 clear 操做 for (let asset of this.loadedAssets.values()) { asset.invalidateBundle(); } // 構建 Bundle 樹 this.mainBundle = new Bundle(); for (let asset of this.entryAssets) { this.createBundleTree(asset, this.mainBundle); } // 獲取新的最終打包文件的url this.bundleNameMap = this.mainBundle.getBundleNameMap( this.options.contentHash ); // 將代碼中的舊文件url替換爲新的 for (let asset of changedAssets) { asset.replaceBundleNames(this.bundleNameMap); } // 將改變的資源經過websocket發送到瀏覽器 if (this.hmr && !isInitialBundle) { this.hmr.emitUpdate(changedAssets); } // 對資源打包 this.bundleHashes = await this.mainBundle.package( this, this.bundleHashes ); // 將獨立的資源刪除 this.unloadOrphanedAssets(); return this.mainBundle; }
咱們一步步先從 this.start
看
if (this.farm) { return; } await this.loadPlugins(); if (!this.options.env) { await loadEnv(Path.join(this.options.rootDir, 'index')); this.options.env = process.env; } if (this.options.watch) { this.watcher = new Watcher(); this.watcher.on('change', this.onChange.bind(this)); } if (this.options.hmr) { this.hmr = new HMRServer(); this.options.hmrPort = await this.hmr.start(this.options); } this.farm = await WorkerFarm.getShared(this.options, { workerPath: require.resolve('./worker.js') });
start
:
開頭的判斷
防止屢次執行,也就是說 this.start
只會執行一次loadPlugins
加載插件,找到 package.json
文件 dependencies, devDependencies
中 parcel-plugin-
開頭的插件進行調用loadEnv
加載環境變量,利用 dotenv, dotenv-expand
包將 env.development.local, .env.development, .env.local, .env
擴展至 process.env
watch
初始化監聽文件並綁定 change
回調函數,內部 child_process.fork
起一個子進程,使用 chokidar
包來監聽文件改變hmr
起一個服務,WebSocket
向瀏覽器發送更改的資源farm
初始化多進程並指定 werker
工做文件,開啓多個 child_process
去解析編譯資源接下來回到 bundle
,isInitialBundle
是一個判斷是不是第一次構建fs.mkdirp
建立輸出文件夾
遍歷入口文件,經過 resolveAsset
,內部調用 resolver
解析路徑,並 getAsset
獲取到對應的 asset
(這裏咱們入口是 index.html
,根據擴展名獲取到的是 HTMLAsset
)
將 asset
添加進隊列
而後啓動 this.buildQueue.run()
對資源從入口遞歸開始打包
這裏 buildQueue
是一個 PromiseQueue
異步隊列PromiseQueue
在初始化的時候傳入一個回調函數 callback
,內部維護一個參數隊列 queue
,add
往隊列裏 push
一個參數,run
的時候while
遍歷隊列 callback(...queue.shift())
,隊列所有執行完畢 Promise
置爲完成(resolved
)(能夠將其理解爲 Promise.all
)
這裏定義的回調函數是 processAsset
,參數就是入口文件 index.html
的 HTMLAsset
async processAsset(asset, isRebuild) { if (isRebuild) { asset.invalidate(); if (this.cache) { this.cache.invalidate(asset.name); } } await this.loadAsset(asset); }
processAsset
函數內先判斷是不是 Rebuild
,是第一次構建,仍是 watch
監聽文件改變進行的重建,若是是重建則對資源的屬性重置
,並使其緩存失效
以後調用 loadAsset
加載資源編譯資源
async loadAsset(asset) { if (asset.processed) { return; } // Mark the asset processed so we don't load it twice asset.processed = true; // 先嚐試讀緩存,緩存沒有在後臺加載和編譯 asset.startTime = Date.now(); let processed = this.cache && (await this.cache.read(asset.name)); let cacheMiss = false; if (!processed || asset.shouldInvalidate(processed.cacheData)) { processed = await this.farm.run(asset.name); cacheMiss = true; } asset.endTime = Date.now(); asset.buildTime = asset.endTime - asset.startTime; asset.id = processed.id; asset.generated = processed.generated; asset.hash = processed.hash; asset.cacheData = processed.cacheData; // 解析和加載當前資源的依賴項 let assetDeps = await Promise.all( dependencies.map(async dep => { dep.parent = asset.name; let assetDep = await this.resolveDep(asset, dep); if (assetDep) { await this.loadAsset(assetDep); } return assetDep; }) ); if (this.cache && cacheMiss) { this.cache.write(asset.name, processed); } }
loadAsset
在開始有個判斷防止重複編譯
以後去讀緩存,讀取失敗就調用 this.farm.run
在多進程裏編譯資源
編譯完就去加載並編譯依賴的文件
最後若是是新的資源沒有用到緩存,就從新設置一下緩存
下面說一下這裏嗎涉及的兩個東西:緩存 FSCache
和 多進程 WorkerFarm
read
讀取緩存,並判斷最後修改時間和緩存的修改時間write
寫入緩存
緩存目錄爲了加速讀取,避免將全部的緩存文件放在一個文件夾裏,parcel
將 16進制
兩位數的 256
種可能建立爲文件夾,這樣存取緩存文件的時候,將目標文件路徑 md5
加密轉換爲 16進制
,而後截取前兩位是目錄,後面幾位是文件名
在上面 start
裏初始化 farm
的時候,workerPath
指向了 worker.js
文件,worker.js
裏有兩個函數,init
和 run
WorkerFarm.getShared
初始化的時候會建立一個 new WorkerFarm
,調用 worker.js
的 init
方法,根據 cpu
獲取最大的 Worker
數,並啓動一半的子進程farm.run
會通知子進程執行 worker.js
的 run
方法,若是進程數沒有達到最大會再次開啓一個新的子進程,子進程執行完畢後將 Promise
狀態更改成完成worker.run -> pipeline.process -> pipeline.processAsset -> asset.process
Asset.process
處理資源:
async process() { if (!this.generated) { await this.loadIfNeeded(); await this.pretransform(); await this.getDependencies(); await this.transform(); this.generated = await this.generate(); } return this.generated; }
將上面的代碼內部擴展一下:
async process() { // 已經有就不須要編譯 if (!this.generated) { // 加載代碼 if (this.contents == null) { this.contents = await this.load(); } // 可選。在收集依賴以前轉換。 await this.pretransform(); // 將代碼解析爲 AST 樹 if (!this.ast) { this.ast = await this.parse(this.contents); } // 收集依賴 await this.collectDependencies(); // 可選。在收集依賴以後轉換。 await this.transform(); // 生成代碼 this.generated = await this.generate(); } return this.generated; } // 最後處理代碼 async postProcess(generated) { return generated }
processAsset
中調用 asset.process
生成 generated
這個generated
不必定是最終代碼 ,像 html
裏內聯的 script
,vue
的 html, js, css
,都會進行二次或屢次遞歸處理,最終調用 asset.postProcess
生成代碼
下面說幾個實現HTMLAsset
:
posthtml
將 html
解析爲 PostHTMLTree
(若是沒有設置posthtmlrc
之類的不會走)posthtml-parser
將 html
解析爲 PostHTMLTree
walk
遍歷 ast
,找到 script, img
的 src
,link
的 href
等的地址,將其加入到依賴htmlnano
壓縮代碼script
和 css
posthtml-render
生成 html
代碼JSAsset
:
@babel/core
將 js
解析爲 AST
,處理 process.env
@babel/parser
將 js
解析爲 AST
babylon-walk
遍歷 ast
, 如 ImportDeclaration
,import xx from 'xx'
語法,CallExpression
找到 require
調用,import
被標記爲 dynamic
動態導入,將這些模塊加入到依賴readFileSync
,__dirname, __filename, global
等,若是沒有設置scopeHoist
並存在 es6 module
就將代碼轉換爲 commonjs
,terser
壓縮代碼@babel/generator
獲取 js
與 sourceMap
代碼VueAsset
:
@vue/component-compiler-utils
與 vue-template-compiler
對 .vue
文件進行解析html, js, css
處理,就像上面說到會對其分別調用 processAsset
進行二次解析component-compiler-utils
的 compileTemplate, compileStyle
處理 html,css
,vue-hot-reload-api
HMR處理,壓縮代碼回到 bundle
方法:
let loadedAssets = await this.buildQueue.run()
就是上面說到的PromiseQueue
和 WorkerFarm
結合起來:buildQueue.run —> processAsset -> loadAsset -> farm.run -> worker.run -> pipeline.process -> pipeline.processAsset -> asset.process
,執行以後全部資源編譯完畢,並返回入口資源loadedAssets
就是 index.html
對應的 HTMLAsset
資源
以後是 let changedAssets = [...this.findOrphanAssets(), ...loadedAssets]
獲取到改變的資源
findOrphanAssets
是從全部資源中查找沒有 parentBundle
的資源,也就是獨立的資源,這個 parentBundle
會在等會的構建 Bundle
樹中被賦值,第一次構建都沒有 parentBundle
,因此這裏會重複入口文件,這裏的 findOrphanAssets
的做用是在第一次構建以後,文件change
的時候,在這個文件 import
了新的一個文件,由於新文件沒有被構建過 Bundle
樹,因此沒有 parentBundle
,這個新文件也被標記物 change
invalidateBundle
由於接下來要構建新的樹因此調用重置全部資源上一次樹的屬性
createBundleTree
構建 Bundle
樹:
首先一個入口資源會被建立成一個 bundle,而後動態的 import() 會被建立成子 bundle ,這引起了代碼的拆分。
當不一樣類型的文件資源被引入,兄弟 bundle 就會被建立。例如你在 JavaScript 中引入了 CSS 文件,那它會被放置在一個與 JavaScript 文件對應的兄弟 bundle 中。
若是資源被多於一個 bundle 引用,它會被提高到 bundle 樹中最近的公共祖先中,這樣該資源就不會被屢次打包。
Bundle
:
type
:它包含的資源類型 (例如:js, css, map, ...)name
:bundle 的名稱 (使用 entryAsset 的 Asset.generateBundleName() 生成)parentBundle
:父 bundle ,入口 bundle 的父 bundle 是 nullentryAsset
:bundle 的入口,用於生成名稱(name)和聚攏資源(assets)assets
:bundle 中全部資源的集合(Set)childBundles
:全部子 bundle 的集合(Set)siblingBundles
:全部兄弟 bundle 的集合(Set)siblingBundlesMap
:全部兄弟 bundle 的映射 Map<String(Type: js, css, map, ...), Bundle>offsets
:全部 bundle 中資源位置的映射 Map<Asset, number(line number inside the bundle)> ,用於生成準確的 sourcemap 。咱們的例子會被構建成:
html ( index.html ) |-- js ( index.js, module1.js, module2.js ) |-- map ( index.js, module1.js, module2.js )
module1.js
和 module2.js
被提到了與 index.js
同級,map
由於類型不一樣被放到了 子bundle
一個複雜點的樹:
// 資源樹 index.html |-- index.css |-- bg.png |-- index.js |-- module.js
// mainBundle html ( index.html ) |-- js ( index.js, module.js ) |-- map ( index.map, module.map ) |-- css ( index.css ) |-- js ( index.css, css-loader.js bundle-url.js ) |-- map ( css-loader.js, bundle-url.js ) |-- png ( bg.png )
由於要對 css 熱更新,因此新增了 css-loader.js, bundle-url.js
兩個 js
replaceBundleNames
替換引用:生成樹以後將代碼中的文件引用替換爲最終打包的文件名,若是是生產環境會替換爲 contentHash
根據內容生成 hash
hmr
更新: 判斷啓用 hmr
而且不是第一次構建的狀況,調用 hmr.emitUpdate
將改變的資源發送給瀏覽器
Bundle.package
打包
unloadOrphanedAssets
將獨立的資源刪除
package
將generated
寫入到文件
有6種打包:CSSPackager
,HTMLPackager
,SourceMapPackager
,JSPackager
,JSConcatPackager
,RawPackager
當開啓 scopeHoist
時用 JSConcatPackager
不然 JSPackager
圖片等資源用 RawPackager
最終咱們的例子被打包成 index.html, src.[hash].js, src.[hash].map
3個文件
index.html
裏的 js
路徑被替換成立最終打包的地址
咱們看一下打包的 js:
parcelRequire = (function (modules, cache, entry, globalName) { // Save the require from previous bundle to this closure if any var previousRequire = typeof parcelRequire === 'function' && parcelRequire; var nodeRequire = typeof require === 'function' && require; function newRequire(name, jumped) { if (!cache[name]) { localRequire.resolve = resolve; localRequire.cache = {}; var module = cache[name] = new newRequire.Module(name); modules[name][0].call(module.exports, localRequire, module, module.exports, this); } return cache[name].exports; function localRequire(x){ return newRequire(localRequire.resolve(x)); } function resolve(x){ return modules[name][4][x] || x; } } for (var i = 0; i < entry.length; i++) { newRequire(entry[i]); } // Override the current require with this new one return newRequire; })({"src/module1.js":[function(require,module,exports) { "use strict"; },{}],"src/module2.js":[function(require,module,exports) { "use strict"; },{}],"src/index.js":[function(require,module,exports) { "use strict"; var _module = require("./module"); var _module2 = require("./module1"); var _module3 = require("./module2"); console.log(_module.m); },{"./module":"src/module.js","./module1":"src/module1.js","./module2":"src/module2.js","fs":"node_modules/parcel-bundler/src/builtins/_empty.js"}] ,{}]},{},["node_modules/parcel-bundler/src/builtins/hmr-runtime.js","src/index.js"], null) //# sourceMappingURL=/src.a2b27638.map
能夠看到代碼被拼接成了對象的形式,接收參數 module, require
用來模塊導入導出,實現了 commonjs
的模塊加載機制,一個更加簡化版:
parcelRequire = (function (modules, cache, entry, globalName) { function newRequire(id){ if(!cache[id]){ let module = cache[id] = { exports: {} } modules[id][0].call(module.exports, newRequire, module, module.exports, this); } return cache[id] } for (var i = 0; i < entry.length; i++) { newRequire(entry[i]); } return newRequire; })()
代碼被拼接起來:
`(function(modules){ //...newRequire })({` + asset.id + ':[function(require,module,exports) {\n' + asset.generated.js + '\n},' + '})'
(function(modules){ //...newRequire })({ "src/index.js":[function(require,module,exports){ // code }] })
上面打包的 js
中還有個 hmr-runtime.js
太長被我省略了hmr-runtime.js
建立一個 WebSocket
監聽服務端消息
修改文件觸發 onChange
方法,onChange
將改變的資源 buildQueue.add
加入構建隊列,從新調用 bundle
方法,打包資源,並調用 emitUpdate
通知瀏覽器更新
當瀏覽器接收到服務端有新資源更新消息時
新的資源就會設置或覆蓋以前的模塊modules[asset.id] = new Function('require', 'module', 'exports', asset.generated.js)
對模塊進行更新:
function hmrAccept(id){ // dispose 回調 cached.hot._disposeCallbacks.forEach(function (cb) { cb(bundle.hotData); }); delete bundle.cache[id]; // 刪除以前緩存 newRequire(id); // 從新此加載 // accept 回調 cached.hot._acceptCallbacks.forEach(function (cb) { cb(); }); // 遞歸父模塊 進行更新 getParents(global.parcelRequire, id).some(function (id) { return hmrAccept(global.parcelRequire, id); }); }
至此整個打包流程結束
parcle index.html
進入 cli
,啓動Server
調用 bundle
,初始化配置(Plugins
, env
, HMRServer, Watcher, WorkerFarm
),從入口資源開始,遞歸編譯(babel, posthtml, postcss, vue-template-compiler
等),編譯完設置緩存,構建 Bundle
樹,進行打包
若是沒有 watch
監聽,結束關閉 Watcher, Worker, HMR
有 watch
監聽:
文件修改,觸發 onChange
,將修改的資源加入構建隊列,遞歸編譯,查找緩存(這一步緩存的做用就提醒出來了),編譯完設置新緩存,構建 Bundle
樹,進行打包,將 change
的資源發送給瀏覽器,瀏覽器接收 hmr
更新資源
經過此文章但願你對 parcel
的大體流程,打包工具原理有更深的瞭解
瞭解更多請關注專欄,後續 深刻Parcel 同系列文章,對 Asset
,Packager
,Worker
,HMR
,scopeHoist
,FSCache
,SourceMap
,import
更加 詳細講解與代碼實現