本篇文章是對 Parce
的原始碼解析,程式碼基本架構與執行流程,帶你瞭解打包工具的內部原理,在這之前你如果對 parcel
不熟悉可以先到 Parcel官網 瞭解
介紹
下面是偷懶從官網抄下來的介紹:
極速零配置Web應用打包工具
- 極速打包
Parcel
使用worker
程式去啟用多核編譯。同時有檔案系統快取,即使在重啟構建後也能快速再編譯。 - 將你所有的資源打包
Parcel 具備開箱即用的對
JS
,CSS
,HTML
, 檔案 及更多的支援,而且不需要外掛。 - 自動轉換
如若有需要,
Babel
,PostCSS
, 和PostHTML
甚至node_modules
包會被用於自動轉換程式碼. - 零配置程式碼分拆
使用動態
import()
語法,Parcel
將你的輸出檔案束(bundles
)分拆,因此你只需要在初次載入時載入你所需要的程式碼。 - 熱模組替換
Parcel
無需配置,在開發環境的時候會自動在瀏覽器內隨著你的程式碼更改而去更新模組。 - 友好的錯誤日誌 當遇到錯誤時,Parcel 會輸出 語法高亮的程式碼片段,幫助你定位問題。
打包工具 | 時間 |
---|---|
browserify | 22.98s |
webpack | 20.71s |
parcel | 9.98s |
parcel - with cache | 2.64s |
打包工具
我們常用的打包工具大致功能:
- 模組化(程式碼的拆分, 合併,
Tree-Shaking
等) - 編譯(
es6,7,8 sass typescript
等) - 壓縮 (
js, css, html
包括圖片的壓縮) - HMR (熱替換)
version
parcel-bundler
版本:
"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
cli
"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
bundler.serve
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
方法
bundler.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
看
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()
對資源從入口遞迴開始打包
PromiseQueue
這裡 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
載入資源編譯資源
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
FSCache
read
讀取快取,並判斷最後修改時間和快取的修改時間
write
寫入快取
快取目錄為了加速讀取,避免將所有的快取檔案放在一個資料夾裡,parcel
將 16進位制
兩位數的 256
種可能建立為資料夾,這樣存取快取檔案的時候,將目標檔案路徑 md5
加密轉換為 16進位制
,然後擷取前兩位是目錄,後面幾位是檔名
WorkerFarm
在上面 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
生成程式碼
Asset
下面說幾個實現
HTMLAsset
:
- pretransform 呼叫
posthtml
將html
解析為PostHTMLTree
(如果沒有設定posthtmlrc
之類的不會走) - parse 呼叫
posthtml-parser
將html
解析為PostHTMLTree
- collectDependencies 用
walk
遍歷ast
,找到script, img
的src
,link
的href
等的地址,將其加入到依賴 - transform
htmlnano
壓縮程式碼 - generate 處理內聯的
script
和css
- postProcess
posthtml-render
生成html
程式碼
JSAsset
:
- pretransform 呼叫
@babel/core
將js
解析為AST
,處理process.env
- parse 呼叫
@babel/parser
將js
解析為AST
- collectDependencies 用
babylon-walk
遍歷ast
, 如ImportDeclaration
,import xx from 'xx'
語法,CallExpression
找到require
呼叫,import
被標記為dynamic
動態匯入,將這些模組加入到依賴 - transform 處理
readFileSync
,__dirname, __filename, global
等,如果沒有設定scopeHoist
並存在es6 module
就將程式碼轉換為commonjs
,terser
壓縮程式碼 - generate
@babel/generator
獲取js
與sourceMap
程式碼
VueAsset
:
- parse
@vue/component-compiler-utils
與vue-template-compiler
對.vue
檔案進行解析 - generate 對
html, js, css
處理,就像上面說到會對其分別呼叫processAsset
進行二次解析 - postProcess
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
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
}]
})
複製程式碼
hmr-runtime
上面打包的 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
更加詳細講解與程式碼實現