只用90行代碼實現(xiàn)模塊打包器
大家好,我卡頌。
今天來聊聊如何用90行代碼實現(xiàn)一個現(xiàn)代JS模塊打包器。
我們的打包器雖然迷你,但是實現(xiàn)了webpack的核心功能。
而且,我知道你看到大段代碼頭疼,所以這篇文章都是圖。看完感興趣的話,這里是完整代碼的倉庫地址[1],只有90行代碼哦。
讓我們愉快的開始吧。
生成依賴圖
如果應用是個毛線團的話,那么入口文件就是線頭。打包器要做的第一件事是:
順著線頭開始濾清整條線的走向
假設入口文件是entry.js:
- // entry.js
- import a from './a.js';
- import b from './b.js';
- console.log(a, b);
他依賴了a.js與b.js。

em... 有點太簡陋了,讓我們再擴展下a.js與b.js:
- // a.js
- import c from './c.js';
- // ...
- // b.js
- import d from './d.js';
- import e from './e.js';
- // ...
所以整個依賴關系是這樣:
打包器會從入口文件開始,嘗試建立模塊(即js文件)間的依賴關系,也就是剛才我們講的「順著線頭開始濾清整條線的走向」。
模塊間的依賴關系可以通過分析模塊代碼中的import 聲明語句得知。
為了能分析import 聲明語句,可以使用babel等編譯工具將模塊代碼分解為AST(抽象語法樹)。
遍歷AST,類型為ImportDeclaration的節(jié)點就是import聲明語句。
最后,我們將AST重新轉換為可執(zhí)行的目標代碼,可能還需要根據(jù)代碼要執(zhí)行的宿主環(huán)境(一般為瀏覽器)對代碼做一些轉換。
比如,瀏覽器不支持import './a.js'這樣的ESM語法,那么我們需要將所有ESM語法轉為CJS語法。
- // 源代碼
- import './a.js';
- // 轉換后
- require('./a.js');
所以,對于任一模塊(js文件),會經(jīng)歷:
右邊包含目標代碼和模塊間依賴關系的數(shù)據(jù)結構被稱為asset。
每個asset可以通過模塊間依賴關系找到依賴的模塊,重復這一過程,生成新的asset,最終形成整個應用所有asset間的依賴關系:
應用完整的依賴關系被稱為「依賴圖」(dependency graph)。
打包代碼
接下來,只需要遍歷「依賴圖」,將所有asset的目標代碼打包在一起就行。
所有代碼會被打包在一個「立即執(zhí)行函數(shù)」中:
- (function(modules) {
- // 打包好的代碼
- })(modules)
modules中保存了所有asset及他們之間的依賴關系。
如果你對modules的細節(jié)感興趣,可以去文末倉庫里翻代碼
剛才說過,asset的目標代碼是CJS規(guī)范的,類似:
- // entry.js
- require('./a.js');
- require('./b.js');
這意味著我們需要實現(xiàn):
- require方法(用于引入依賴的其他asset的目標代碼)
- module對象(用于保存當前asset的目標代碼執(zhí)行后導出的數(shù)據(jù))
同時,為了防止不同asset的目標代碼中的變量互相污染,每個目標代碼需要獨立的作用域。
我們將目標代碼包裹在函數(shù)中:
- // 我們操作的是字符串模版
- `function (require, module, exports) {
- ${asset.code}
- }`
所以,最終打包的結果為:
- (function(modules) {
- function require() {// ...}
- require(入口asset的ID)
- })(modules)
這段字符串被包裹在瀏覽器
這段字符串被包裹在瀏覽器<script>標簽內(nèi),會依次執(zhí)行:
- require(入口asset的ID),執(zhí)行入口asset的目標代碼
- 目標代碼內(nèi)部會調(diào)用require執(zhí)行其他asset的目標代碼
- 一步步執(zhí)行下去...
總結
打包器的工作原理分為兩步:
- 從入口文件開始遍歷,生成「依賴圖」
- 根據(jù)依賴圖,將代碼打包進一個「立即執(zhí)行函數(shù)」
這個打包器還很稚嫩,缺失很多必要的功能,比如:
- 解決循環(huán)依賴
- 緩存
但是瑕不掩瑜嘛~
參考資料
[1]完整代碼的倉庫地址: https://github.com/BetaSu/minipack