什麼是bundler
市面上如今有不少bundler,最著名的就是webpack,此外常見的還有 browserify,rollup,parcel等。雖然如今的bundler進化出了各類各樣的功能,但它們都有一個共同的初衷,就是能給前端引入模塊化的開發方式,更好的管理依賴、更好的工程化。前端
Modules(模塊)
目前最多見的模塊系統有兩種:node
ES6 Modules:webpack
// 引入模塊 import _ from 'lodash'; // 導出模塊 export default someObject;
CommonJS Modules:git
// 引入模塊 const _ = require('lodash'); // 導出模塊 module.exports = someObject;
Dependency Graph(依賴關係圖)
通常項目須要一個入口文件(entry point),bundler從該入口文件進入,查找項目依賴的全部模塊,造成一張依賴關係圖,有了依賴關係圖bundler進一步將全部模塊打包成一個文件。github
依賴關係圖:web
Bundler實現思路
要實現一個bundler,有三個主要步驟:npm
- 解析一個文件並提取它的依賴項
- 遞歸地提取依賴並生成依賴關係圖
- 將全部被依賴的模塊打包進一個文件
本文使用一個小例子展現如何實現bundler,以下圖所示,有三個js文件:入口文件 entry.js,entry.js 的依賴文件 greeting.js,greeting.js 的依賴文件 name.js:數組
三個文件內容分別以下:瀏覽器
entry.js:babel
import greeting from './greeting.js'; console.log(greeting);
greeting.js:
import { name } from './name.js'; export default `hello ${name}!`;
name.js:
export const name = 'MudOnTire';
實現bundler
首先咱們新建一個bundler.js
文件,bundler的主要邏輯就寫在裏面。
1. 引入JS Parser
按照咱們的實現思路,首先須要可以解析JS文件的內容並提取其依賴項。咱們能夠把文件內容讀取爲字符串,並用正則去獲取其中的import
, export
語句,可是這種方式顯然不夠優雅高效。更好的方式是使用JS parser(解析器)去解析文件內容。JS parser能解析JS代碼並將其轉化成抽象語法樹(AST)的高階模型,抽象語法樹是把JS代碼拆解成樹形結構,且從中能獲取到更多代碼的執行細節。
在 AST Explorer 這個網站上面能夠查看JS代碼解析成成抽象語法樹以後的結果。好比,greeting.js 的內容用 acron parser 解析後的結果以下:
能夠看到抽象語法樹實際上是一個JSON對象,每一個節點有一個 type
屬性和 import
、export
語句解析後的結果等等。將代碼轉成抽象語法樹以後更方便提取裏面的關鍵信息。
接下來,咱們須要在項目裏面引入一個JS Parser。咱們選擇 babylon(babylon也是babel的內部使用的JS parser,目前以 @babel/parser 的身份存在於babel的主倉庫)。
安裝babylon:
npm install --save-dev @babel/parser
或者yarn:
yarn add @babel/parser --dev
在 bundler.js
中引入babylon:
bundler.js:
const parser = require('@babel/parser');
2. 生成抽象語法樹
有了JS parser以後,生成抽象語法樹就很簡單了,咱們只須要獲取到JS源文件的內容,傳入parser解析就好了。
bundler.js:
const parser = require('@babel/parser'); const fs = require('fs'); /** * 獲取JS源文件的抽象語法樹 * @param {String} filename 文件名稱 */ function getAST(filename) { const content = fs.readFileSync(filename, 'utf-8'); const ast = parser.parse(content, { sourceType: 'module' }); console.log(ast); return ast; } getAST('./example/greeting.js');
執行 node bundler.js
結果以下:
3. 依賴解析
生成抽象語法樹後,即可以去查找代碼中的依賴,咱們能夠本身寫查詢方法遞歸的去查找,也可使用 @babel/traverse 進行查詢,@babel/traverse 模塊維護整個樹的狀態,並負責替換,刪除和添加節點。
安裝 @babel/traverse:
npm install --save-dev @babel/traverse
或者yarn:
yarn add @babel/traverse --dev
使用 @babel/traverse
能夠很方便的獲取 import
節點。
bundler.js:
const traverse = require('@babel/traverse').default; /** * 獲取ImportDeclaration */ function getImports(ast) { traverse(ast, { ImportDeclaration: ({ node }) => { console.log(node); } }); } const ast = getAST('./example/entry.js'); getImports(ast);
執行 node bundler.js
執行結果以下:
由此咱們能夠得到 entry.js
中依賴的模塊和這些模塊的路徑。稍稍修改一下 getImports
方法獲取全部的依賴:
bundler.js:
function getImports(ast) { const imports = []; traverse(ast, { ImportDeclaration: ({ node }) => { imports.push(node.source.value); } }); console.log(imports); return imports; }
執行結果:
最後,咱們將方法封裝一下,爲每一個源文件生成惟一的依賴信息,包含依賴模塊的id、模塊的相對路徑和模塊的依賴項:
let ID = 0; function getAsset(filename) { const ast = getAST(filename); const dependencies = getImports(ast); const id = ID++; return { id, filename, dependencies } } const mainAsset = getAsset('./example/entry.js'); console.log(mainAsset);
執行結果:
4. 生成Dependency Graph
而後,咱們須要寫一個方法生成依賴關係圖,該方法應該接受入口文件路徑做爲參數,並返回一個包含全部依賴關係的數組。生成依賴關係圖能夠經過遞歸的方式,也能夠經過隊列的方式。本文使用隊列,原理是不斷遍歷隊列中的asset對象,若是asset對象的dependencies不爲空,則繼續爲每一個dependency生成asset並加入隊列,併爲每一個asset增長mapping屬性,記錄依賴之間的關係。持續這一過程直到queue中的元素被徹底遍歷。具體實現以下:
bundler.js
/** * 生成依賴關係圖 * @param {String} entry 入口文件路徑 */ function createGraph(entry) { const mainAsset = getAsset(entry); const queue = [mainAsset]; for (const asset of queue) { const dirname = path.dirname(asset.filename); asset.mapping = {}; asset.dependencies.forEach((relPath, index) => { const absPath = path.join(dirname, relPath); const child = getAsset(absPath); asset.mapping[relPath] = child.id; queue.push(child); }); } return queue; }
生成的依賴關係以下:
5. 打包
最後,咱們須要根據依賴關係圖將全部文件打包成一個文件。這一步有幾個關鍵點:
- 打包後的文件須要可以在瀏覽器運行,因此代碼中的ES6語法須要先被babel編譯
- 瀏覽器的運行環境中,編譯後的代碼依然須要實現模塊間的引用
- 合併成一個文件後,不一樣模塊的做用域依然須要保持獨立
(1). 編譯源碼
首先安裝babel並引入:
npm install --save-dev @babel/core
或者yarn:
yarn add @babel/core --dev
bundler.js:
const babel = require('@babel/core');
而後對 getAsset
方法稍做修改,這裏咱們使用 babel.transformFromAstSync()
方法對生成的抽象語法樹進行編譯,編譯成瀏覽器能夠執行的JS:
function getAsset(filename) { const ast = getAST(filename); const dependencies = getImports(ast); const id = ID++; // 編譯 const { code } = babel.transformFromAstSync(ast, null, { presets: ['@babel/env'] }); return { id, filename, dependencies, code } }
源碼編譯後生成的依賴關係圖內容以下:
能夠看到編譯後的代碼中還有 require('./greeting.js')
語法,而瀏覽器中是不支持 require()
方法的。因此咱們還須要實現 require()
方法從而實現模塊間的引用。
(2). 模塊引用
首先打包以後的代碼須要本身獨立的做用域,以避免污染其餘JS文件,在此使用IIFE包裹。咱們能夠先勾勒出打包方法的結構,在bundler.js中新增 bundle()
方法:
bundler.js:
/** * 打包 * @param {Array} graph 依賴關係圖 */ function bundle(graph) { let modules = ''; // 將依賴關係圖中模塊編譯後的代碼、模塊路徑和id的映射關係傳入IIFE graph.forEach(mod => { modules += `${mod.id}:[ function (require, module, exports) { ${mod.code}}, ${JSON.stringify(mod.mapping)} ],` }) // return ` (function(){})({${modules}}) `; }
咱們先看一下執行 bundle()
方法以後的結果(爲方便閱讀使用 js-beautify 和 cli-highlight 進行了美化 ):
如今,咱們須要實現模塊之間的引用,咱們須要實現 require()
方法。實現思路是:當調用 require('./greeting.js')
時,去mapping裏面查找 ./greeting.js
對應的模塊id,經過id找到對應的模塊,調用模塊代碼將 exports
返回,最後打包生成 main.js
文件。bundle()
方法的完整實現以下:
bundler.js:
/** * 打包 * @param {Array} graph 依賴關係圖 */ function bundle(graph) { let modules = ''; // 將依賴關係圖中模塊編譯後的代碼、模塊路徑和id的映射關係傳入IIFE graph.forEach(mod => { modules += `${mod.id}:[ function (require, module, exports) { ${mod.code}}, ${JSON.stringify(mod.mapping)} ],` }) const bundledCode = ` (function (modules) { function require(id) { const [fn, mapping] = modules[id]; function localRequire(relPath) { return require(mapping[relPath]); } const localModule = { exports : {} }; fn(localRequire, localModule, localModule.exports); return localModule.exports; } require(0); })({${modules}}) `; fs.writeFileSync('./main.js', bundledCode); }
最後,咱們在瀏覽器中運行一下 main.js
的內容看一下最後的結果:
一個簡易版本的Webpack大功告成!
本文源碼:https://github.com/MudOnTire/...
本文同步分享在 博客「MudOnTire」(SegmentFault)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。