前端模組化成為了主流的今天,離不開各種打包工具的貢獻。社群裡面對於webpack,rollup以及後起之秀parcel的介紹層出不窮,對於它們各自的使用配置分析也是汗牛充棟。為了避免成為一位“配置工程師”,我們需要來了解一下打包工具的執行原理,只有把核心原理搞明白了,在工具的使用上才能更加得心應手。
本文基於parcel核心開發者@ronami的開源專案minipack而來,在其非常詳盡的註釋之上加入更多的理解和說明,方便讀者更好地理解。
1、打包工具核心原理
顧名思義,打包工具就是負責把一些分散的小模組,按照一定的規則整合成一個大模組的工具。與此同時,打包工具也會處理好模組之間的依賴關係,最終這個大模組將可以被執行在合適的平臺中。
打包工具會從一個入口檔案開始,分析它裡面的依賴,並且再進一步地分析依賴中的依賴,不斷重複這個過程,直到把這些依賴關係理清挑明為止。
從上面的描述可以看到,打包工具最核心的部分,其實就是處理好模組之間的依賴關係,而minipack以及本文所要討論的,也是集中在模組依賴關係的知識點當中。
為了簡單起見,minipack專案直接使用ES modules規範,接下來我們新建三個檔案,並且為它們之間建立依賴:
1 2 3 |
/* name.js */ export const name = 'World' |
1 2 3 4 5 |
/* message.js */ import { name } from './name.js' export default `Hello ${name}!` |
1 2 3 4 5 |
/* entry.js */ import message from './message.js' console.log(message) |
它們的依賴關係非常簡單:entry.js
→ message.js
→ name.js
,其中entry.js
將會成為打包工具的入口檔案。
但是,這裡面的依賴關係只是我們人類所理解的,如果要讓機器也能夠理解當中的依賴關係,就需要藉助一定的手段了。
2、依賴關係解析
新建一個js檔案,命名為minipack.js
,首先引入必要的工具。
1 2 3 4 5 6 7 |
/* minipack.js */ const fs = require('fs') const path = require('path') const babylon = require('babylon') const traverse = require('babel-traverse').default const { transformFromAst } = require('babel-core') |
接下來,我們會撰寫一個函式,這個函式接收一個檔案作為模組,然後讀取它裡面的內容,分析出其所有的依賴項。當然,我們可以通過正則匹配模組檔案裡面的import
關鍵字,但這樣做非常不優雅,所以我們可以使用babylon
這個js解析器把檔案內容轉化成抽象語法樹(AST),直接從AST裡面獲取我們需要的資訊。
得到了AST之後,就可以使用babel-traverse
去遍歷這棵AST,獲取當中關鍵的“依賴宣告”,然後把這些依賴都儲存在一個陣列當中。
最後使用babel-core
的transformFromAst
方法搭配babel-preset-env
外掛,把ES6語法轉化成瀏覽器可以識別的ES5語法,並且為該js模組分配一個ID。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
let ID = 0 function createAsset (filename) { // 讀取檔案內容 const content = fs.readFileSync(filename, 'utf-8') // 轉化成AST const ast = babylon.parse(content, { sourceType: 'module', }); // 該檔案的所有依賴 const dependencies = [] // 獲取依賴宣告 traverse(ast, { ImportDeclaration: ({ node }) => { dependencies.push(node.source.value); } }) // 轉化ES6語法到ES5 const {code} = transformFromAst(ast, null, { presets: ['env'], }) // 分配ID const id = ID++ // 返回這個模組 return { id, filename, dependencies, code, } } |
執行createAsset('./example/entry.js')
,輸出如下:
1 2 3 4 |
{ id: 0, filename: './example/entry.js', dependencies: [ './message.js' ], code: '"use strict";nnvar _message = require("./message.js");nnvar _message2 = _interopRequireDefault(_message);nnfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }nnconsole.log(_message2.default);' } |
可見entry.js
檔案已經變成了一個典型的模組,且依賴已經被分析出來了。接下來我們就要遞迴這個過程,把“依賴中的依賴”也都分析出來,也就是下一節要討論的建立依賴關係圖集。
3、建立依賴關係圖集
新建一個名為createGragh()
的函式,傳入一個入口檔案的路徑作為引數,然後通過createAsset()
解析這個檔案使之定義成一個模組。
接下來,為了能夠挨個挨個地對模組進行依賴分析,所以我們維護一個陣列,首先把第一個模組傳進去並進行分析。當這個模組被分析出還有其他依賴模組的時候,就把這些依賴模組也放進陣列中,然後繼續分析這些新加進去的模組,直到把所有的依賴以及“依賴中的依賴”都完全分析出來。
與此同時,我們有必要為模組新建一個mapping
屬性,用來儲存模組、依賴、依賴ID之間的依賴關係,例如“ID為0的A模組依賴於ID為2的B模組和ID為3的C模組”就可以表示成下面這個樣子:
1 2 3 |
{ 0: [function A () {}, { 'B.js': 2, 'C.js': 3 }] } |
搞清楚了箇中道理,就可以開始編寫函式了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
function createGragh (entry) { // 解析傳入的檔案為模組 const mainAsset = createAsset(entry) // 維護一個陣列,傳入第一個模組 const queue = [mainAsset] // 遍歷陣列,分析每一個模組是否還有其它依賴,若有則把依賴模組推進陣列 for (const asset of queue) { asset.mapping = {} // 由於依賴的路徑是相對於當前模組,所以要把相對路徑都處理為絕對路徑 const dirname = path.dirname(asset.filename) // 遍歷當前模組的依賴項並繼續分析 asset.dependencies.forEach(relativePath => { // 構造絕對路徑 const absolutePath = path.join(dirname, relativePath) // 生成依賴模組 const child = createAsset(absolutePath) // 把依賴關係寫入模組的mapping當中 asset.mapping[relativePath] = child.id // 把這個依賴模組也推入到queue陣列中,以便繼續對其進行以來分析 queue.push(child) }) } // 最後返回這個queue,也就是依賴關係圖集 return queue } |
可能有讀者對其中的for...of ...
迴圈當中的queue.push
有點迷,但是隻要嘗試過下面這段程式碼就能搞明白了:
1 2 3 4 5 6 7 8 |
var numArr = ['1', '2', '3'] for (num of numArr) { console.log(num) if (num === '3') { arr.push('Done!') } } |
嘗試執行一下createGraph('./example/entry.js')
,就能夠看到如下的輸出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
[ { id: 0, filename: './example/entry.js', dependencies: [ './message.js' ], code: '"use strict";nnvar _message = require("./message.js");nnvar _message2 = _interopRequireDefault(_message);nnfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }nnconsole.log(_message2.default);', mapping: { './message.js': 1 } }, { id: 1, filename: 'example/message.js', dependencies: [ './name.js' ], code: '"use strict";nnObject.defineProperty(exports, "__esModule", {n value: truen});nnvar _name = require("./name.js");nnexports.default = "Hello " + _name.name + "!";', mapping: { './name.js': 2 } }, { id: 2, filename: 'example/name.js', dependencies: [], code: '"use strict";nnObject.defineProperty(exports, "__esModule", {n value: truen});nvar name = exports.name = 'world';', mapping: {} } ] |
現在依賴關係圖集已經構建完成了,接下來就是把它們打包成一個單獨的,可直接執行的檔案啦!
4、進行打包
上一步生成的依賴關係圖集,接下來將通過CommomJS
規範來實現載入。由於篇幅關係,本文不對CommomJS
規範進行擴充套件,有興趣的讀者可以參考@阮一峰 老師的一篇文章《瀏覽器載入 CommonJS 模組的原理與實現》,說得非常清晰。簡單來說,就是通過構造一個立即執行函式(function () {})()
,手動定義module
,exports
和require
變數,最後實現程式碼在瀏覽器執行的目的。
接下來就是依據這個規範,通過字串拼接去構建程式碼塊。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
function bundle (graph) { let modules = '' graph.forEach(mod => { modules += `${mod.id}: [ function (require, module, exports) { ${mod.code} }, ${JSON.stringify(mod.mapping)}, ],` }) const result = ` (function(modules) { function require(id) { const [fn, mapping] = modules[id]; function localRequire(name) { return require(mapping[name]); } const module = { exports : {} }; fn(localRequire, module, module.exports); return module.exports; } require(0); })({${modules}}) ` return result } |
最後執行bundle(createGraph('./example/entry.js'))
,輸出如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
(function (modules) { function require(id) { const [fn, mapping] = modules[id]; function localRequire(name) { return require(mapping[name]); } const module = { exports: {} }; fn(localRequire, module, module.exports); return module.exports; } require(0); })({ 0: [ function (require, module, exports) { "use strict"; var _message = require("./message.js"); var _message2 = _interopRequireDefault(_message); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } console.log(_message2.default); }, { "./message.js": 1 }, ], 1: [ function (require, module, exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var _name = require("./name.js"); exports.default = "Hello " + _name.name + "!"; }, { "./name.js": 2 }, ], 2: [ function (require, module, exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var name = exports.name = 'world'; }, {}, ], }) |
這段程式碼將能夠直接在瀏覽器執行,輸出“Hello world!”。
至此,整一個打包工具已經完成。
5、歸納總結
經過上面幾個步驟,我們可以知道一個模組打包工具,第一步會從入口檔案開始,對其進行依賴分析,第二步對其所有依賴再次遞迴進行依賴分析,第三步構建出模組的依賴圖集,最後一步根據依賴圖集使用CommonJS
規範構建出最終的程式碼。明白了當中每一步的目的,便能夠明白一個打包工具的執行原理。