前端模塊化成爲了主流的今天,離不開各類打包工具的貢獻。社區裏面對於webpack,rollup以及後起之秀parcel的介紹層出不窮,對於它們各自的使用配置分析也是汗牛充棟。爲了不成爲一位「配置工程師」,咱們須要來了解一下打包工具的運行原理,只有把核心原理搞明白了,在工具的使用上才能更加駕輕就熟。javascript
本文基於parcel核心開發者@ronami的開源項目minipack而來,在其很是詳盡的註釋之上加入更多的理解和說明,方便讀者更好地理解。html
顧名思義,打包工具就是負責把一些分散的小模塊,按照必定的規則整合成一個大模塊的工具。與此同時,打包工具也會處理好模塊之間的依賴關係,最終這個大模塊將能夠被運行在合適的平臺中。前端
打包工具會從一個入口文件開始,分析它裏面的依賴,而且再進一步地分析依賴中的依賴,不斷重複這個過程,直到把這些依賴關係理清挑明爲止。java
從上面的描述能夠看到,打包工具最核心的部分,其實就是處理好模塊之間的依賴關係,而minipack以及本文所要討論的,也是集中在模塊依賴關係的知識點當中。node
爲了簡單起見,minipack項目直接使用ES modules規範,接下來咱們新建三個文件,而且爲它們之間創建依賴:webpack
/* 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
將會成爲打包工具的入口文件。git
可是,這裏面的依賴關係只是咱們人類所理解的,若是要讓機器也可以理解當中的依賴關係,就須要藉助必定的手段了。github
新建一個js文件,命名爲minipack.js
,首先引入必要的工具。web
/* 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
規範構建出最終的代碼。明白了當中每一步的目的,便可以明白一個打包工具的運行原理。