寫於 2018.06.14javascript
前端模塊化成爲了主流的今天,離不開各類打包工具的貢獻。社區裏面對於webpack,rollup以及後起之秀parcel的介紹層出不窮,對於它們各自的使用配置分析也是汗牛充棟。爲了不成爲一位「配置工程師」,咱們須要來了解一下打包工具的運行原理,只有把核心原理搞明白了,在工具的使用上才能更加駕輕就熟。html
本文基於parcel核心開發者@ronami的開源項目minipack而來,在其很是詳盡的註釋之上加入更多的理解和說明,方便讀者更好地理解。前端
顧名思義,打包工具就是負責把一些分散的小模塊,按照必定的規則整合成一個大模塊的工具。與此同時,打包工具也會處理好模塊之間的依賴關係,最終這個大模塊將能夠被運行在合適的平臺中。java
打包工具會從一個入口文件開始,分析它裏面的依賴,而且再進一步地分析依賴中的依賴,不斷重複這個過程,直到把這些依賴關係理清挑明爲止。node
從上面的描述能夠看到,打包工具最核心的部分,其實就是處理好模塊之間的依賴關係,而minipack以及本文所要討論的,也是集中在模塊依賴關係的知識點當中。webpack
爲了簡單起見,minipack項目直接使用ES modules規範,接下來咱們新建三個文件,而且爲它們之間創建依賴:git
/* name.js */
export const name = 'World'
複製代碼
/* message.js */
import { name } from './name.js'
export default `Hello ${name}!`
複製代碼
/* entry.js */
import message from './message.js'
console.log(message)
複製代碼
它們的依賴關係很是簡單:entry.js
→ message.js
→ name.js
,其中entry.js
將會成爲打包工具的入口文件。github
可是,這裏面的依賴關係只是咱們人類所理解的,若是要讓機器也可以理解當中的依賴關係,就須要藉助必定的手段了。web
新建一個js文件,命名爲minipack.js
,首先引入必要的工具。數組
/* 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。
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')
,輸出以下:
{ id: 0,
filename: './example/entry.js',
dependencies: [ './message.js' ],
code: '"use strict";\n\nvar _message = require("./message.js");\n\nvar _message2 = _interopRequireDefault(_message);\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nconsole.log(_message2.default);' }
複製代碼
可見entry.js
文件已經變成了一個典型的模塊,且依賴已經被分析出來了。接下來咱們就要遞歸這個過程,把「依賴中的依賴」也都分析出來,也就是下一節要討論的創建依賴關係圖集。
新建一個名爲createGragh()
的函數,傳入一個入口文件的路徑做爲參數,而後經過createAsset()
解析這個文件使之定義成一個模塊。
接下來,爲了可以挨個挨個地對模塊進行依賴分析,因此咱們維護一個數組,首先把第一個模塊傳進去並進行分析。當這個模塊被分析出還有其餘依賴模塊的時候,就把這些依賴模塊也放進數組中,而後繼續分析這些新加進去的模塊,直到把全部的依賴以及「依賴中的依賴」都徹底分析出來。
與此同時,咱們有必要爲模塊新建一個mapping
屬性,用來儲存模塊、依賴、依賴ID之間的依賴關係,例如「ID爲0的A模塊依賴於ID爲2的B模塊和ID爲3的C模塊」就能夠表示成下面這個樣子:
{
0: [function A () {}, { 'B.js': 2, 'C.js': 3 }]
}
複製代碼
搞清楚了箇中道理,就能夠開始編寫函數了。
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
有點迷,可是隻要嘗試過下面這段代碼就能搞明白了:
var numArr = ['1', '2', '3']
for (num of numArr) {
console.log(num)
if (num === '3') {
arr.push('Done!')
}
}
複製代碼
嘗試運行一下createGraph('./example/entry.js')
,就可以看到以下的輸出:
[ { id: 0,
filename: './example/entry.js',
dependencies: [ './message.js' ],
code: '"use strict";\n\nvar _message = require("./message.js");\n\nvar _message2 = _interopRequireDefault(_message);\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nconsole.log(_message2.default);',
mapping: { './message.js': 1 } },
{ id: 1,
filename: 'example/message.js',
dependencies: [ './name.js' ],
code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\n\nvar _name = require("./name.js");\n\nexports.default = "Hello " + _name.name + "!";',
mapping: { './name.js': 2 } },
{ id: 2,
filename: 'example/name.js',
dependencies: [],
code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\nvar name = exports.name = \'world\';',
mapping: {} } ]
複製代碼
如今依賴關係圖集已經構建完成了,接下來就是把它們打包成一個單獨的,可直接運行的文件啦!
上一步生成的依賴關係圖集,接下來將經過CommomJS
規範來實現加載。因爲篇幅關係,本文不對CommomJS
規範進行擴展,有興趣的讀者能夠參考@阮一峯 老師的一篇文章《瀏覽器加載 CommonJS 模塊的原理與實現》,說得很是清晰。簡單來講,就是經過構造一個當即執行函數(function () {})()
,手動定義module
,exports
和require
變量,最後實現代碼在瀏覽器運行的目的。
接下來就是依據這個規範,經過字符串拼接去構建代碼塊。
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'))
,輸出以下:
(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!」。
至此,整一個打包工具已經完成。
通過上面幾個步驟,咱們能夠知道一個模塊打包工具,第一步會從入口文件開始,對其進行依賴分析,第二步對其全部依賴再次遞歸進行依賴分析,第三步構建出模塊的依賴圖集,最後一步根據依賴圖集使用CommonJS
規範構建出最終的代碼。明白了當中每一步的目的,便可以明白一個打包工具的運行原理。