前端推薦!玩轉(zhuǎn)Webpack共需幾步?

導語?|?本文主要介紹webpack的打包流程,及其插件系統(tǒng)Tabable,并手寫了一下簡易打包器。通過這篇文章讀者可以了解webpack的具體實現(xiàn)過程,并且自己也可以理解其打包原理,有利于更好的使用這些工具。
一、開始
Webpack打包原理是從入口文件開始分析AST,遞歸收集依賴,然后生成最終的code。Webpack的插件是貫穿始終的,其插件系統(tǒng)借助了Tapable,Tapable也是Webpack團隊開發(fā)的,其本質(zhì)是一種發(fā)布訂閱模式。
深入理解插件對于深入理解Webpack非常重要。想一下,任何復雜的邏輯都可以抽象成一個插件,在相應的生命周期階段去觸發(fā)即可。
下面先介紹下插件用到的系統(tǒng)Tapable。
二、Tapable
(一)基礎鉤子
Tapable是獨立發(fā)布的,也就是其可以搭配任何庫。本次分析的是V2.2.1版本。
下面是Tapable的一些常見用法:
const { Synchook, AsyncParallelHook } = require('tapable')class Car {constructor() {this.hooks = {// 創(chuàng)建新的鉤子accelerate: new Synchook(['newSpeed']),brake: new SynHook(),calcRoutes: new AsyncParallelHook(['source', 'target', 'routeList'])}}}
上面在Car類定義的時候定義了實例的hooks屬性,它可以包含多個不同的hook。下面為這些hook增加訂閱者。
const car = new Car();// 為brake鉤子增加訂閱者,通常為插件,第一個參數(shù)為插件名稱,第二個參數(shù)為回調(diào)函數(shù)。car.hooks.brake.tap('WarningLampPlugin', () => warningLamp.on())// 為accelerate鉤子增加訂閱者car.hooks.accelerate.tap('LoggerPlugin', (newSpeed) => console.log(`現(xiàn)在車速為:${newSpeed}`))
??
SynHook代表是同步鉤子,其只能用tap增加訂閱者。相應的,還有異步鉤子,支持異步插件。
// 使用tapPromise添加插件car.hooks.calcRoutes.tapPromise('GoogleMapsPlugin', (source, target, routesList) => {// 返回一個Promisereturn google.maps.findRoute(source, target).then(route => {routesList.add(route);});})// 也可通過tapAsync添加插件,與tapPromise的不同之處是回調(diào)放在了最后一個參數(shù)car.hooks.calcRoutes.tapAsync('', (source, target, routesList, callback) => {bing.findRoute(source, target, (err, route) => {if (err) return callback(err)routesList.add(route);// 調(diào)用callbackcallback();})})// 異步鉤子也可以添加同步插件car.hooks.calcRoutes.tap("CachedRoutesPlugin", (source, target, routesList) => {const cachedRoute = cache.get(source, target);if(cachedRoute)routesList.add(cachedRoute);})
通過tap/tapPromise/tapAsync等添加訂閱者后,就可以在相應的時機觸發(fā)它們。
class Car {// ...setSpeed(speed) {// 觸發(fā)同步鉤子this.hooks.accelerate.call(speed)}useNavSystemPromise(source, target) {const routeList = new List();// promise的方式觸發(fā)異步鉤子,對應tapPromisereturn this.hooks.calcRoutes.promise(source, target, routeList).then(res => {return routesList.getRoutes();})}useNavSystemAsync(source, target) {const routesList = new List();// callAsync的方式觸發(fā)異步鉤子,對應tapAsyncthis.hooks.calcRoutes.callAsync(source, target, routesList, err => {if(err) return callback(err);callback(null, routesList.getRoutes());});}}
上面是最簡單的例子,Tapable的鉤子按照回調(diào)執(zhí)行方式可分為以下幾種:
Basic。鉤子名稱中不帶Waterfall、Bail、Loop,其回調(diào)的執(zhí)行方式是按照添加的順序依次執(zhí)行。
Waterfall。也是依次執(zhí)行,不同的是執(zhí)行過程中會把上一個回調(diào)的結果傳給下一個回調(diào)。
Bail。允許提前退出,當某一個回調(diào)返回非空值時,不再繼續(xù)進行。
Loop。插件執(zhí)行中如果有一個不返回空,則又從第一個開始。也就是除非所有回調(diào)都返回空,否則會一直進行。
注意上面所說的返回空,僅指undefined,不包含null、''等。
另外,Tapable的鉤子又可按照同步和異步分為以下類型:
Sync。同步鉤子,只能用hook.tap()注冊回調(diào)。
AsyncSeries。異步鉤子串行執(zhí)行,可以用hook.tap()、hook.tapAsync()、hook.tapPromise()等方法注冊回調(diào)。
AsyncParallel。異步鉤子并行執(zhí)行,注冊回調(diào)的方式同AsyncSeries。
上述兩種分類的組合就是Tapable鉤子真正的類型,體現(xiàn)在其暴露出的鉤子名稱上。比如AsyncSeriesWaterfallHook,就是Waterfall和AsyncSeries的結合,其允許異步回調(diào)并依次執(zhí)行,并且前一個回調(diào)的返回值回傳入下一個回調(diào)的參數(shù)中。
由于AsycnParallel異步并行鉤子不能和WaterFall、Loop結合,因為前者是同時執(zhí)行,后者是順序執(zhí)行,二者矛盾。所以最終結合后的鉤子類型有3*4-2=10種:
SyncHook
SyncBailHook
SyncWaterfallHook
SyncLoopHook
AsyncParallelHook
AsyncParallelBailHook
AsyncSeriesHook
AsyncSeriesBailHook
AsyncSeriesLoopHook
AsyncSeriesWaterfallHook
注意:
AsyncParallelBailHook執(zhí)行過程中注冊的回調(diào)返回非undefined時就會直接執(zhí)行 callAsync或者promise中的函數(shù)(由于并行執(zhí)行的原因,注冊的其他回調(diào)依然會執(zhí)行)。
AsyncSeriesBailHook執(zhí)行過程中注冊的回調(diào)返回(resolve)非undefined時就會直接執(zhí)行callAsync或者promise中的函數(shù),并且注冊的后續(xù)回調(diào)都不會執(zhí)行。
AsyncSeriesWaterfallHook中上一個注冊的異步回調(diào)執(zhí)行之后的返回值會傳遞給下一個注冊的回調(diào)。
(二)攔截器
Tapable中也實現(xiàn)了攔截器功能,其可以在注冊/執(zhí)行回調(diào)等過程中觸發(fā)。
攔截器的類型有:
register:定義tap/tapAsync/tapPromise時觸發(fā)。
call:執(zhí)行call/callAsync/promise時觸發(fā)。
tap:執(zhí)行tap/tapAsync/tapPromise定義的內(nèi)容時觸發(fā)。
loop:loop類型的鉤子執(zhí)行時觸發(fā)。
car.hooks.calcRoutes.intercept({call: (source, target, routesList) => {console.log("Starting to calculate routes");},register: (tapInfo) => {console.log(`${tapInfo.name} is doing its job`);return tapInfo;}})
(三)HookMap/MultiHook
另外Tapable還提供了HookMap和MultiHook等功能。
HookMap是一個Hooks映射的幫助類,實際就是一個hook的key-value數(shù)組。MultiHook就是把其他的hook轉(zhuǎn)化為一個新的hook。
Tapable的核心還是上面,這些是輔助工具。
(四)Tapable實現(xiàn)原理
Tapable實現(xiàn)原理比較簡單,其暴露出來的各種Hook,比如SyncHook、SyncBailHook等,都繼承自Hook類。

tap方法定義在Hook基類上,調(diào)用tap的時候,會執(zhí)行this._insert方法,最終在this.taps[i]處增加一個新的回調(diào)。
call方法也定義在Hook基類上,但是它的調(diào)用鏈比較長,call=> this._createCall=>this.compile,最后這個compile方法是在每個具體的Hook類上重新定義的:
class SyncHookCodeFactory extends HookCodeFactory {// ...}const factory = new SyncHookCodeFactory();const COMPILE = function(options) {factory.setup(this, options);return factory.create(options);}function SyncHook() {// ...hook.compile = COMPILE}
看上面的COMPILE方法,factory.setup方法只是賦值了一些變量,所以call方法核心是通過factory.create方法創(chuàng)建的,其會根據(jù)每個Hook的不同種類生成不同的調(diào)用方法,比如同步/異步執(zhí)行、提前終止、傳入上一個回調(diào)的結果等。
class HookCodeFactory {// ...create(options) {switch (options.type) {case "sync":fn = new Function(this.args(),'"use strict";\n' +this.header() +this.contentWithInterceptors({onError: err => `throw ${err};\n`,onResult: result => `return ${result};\n`,resultReturns: true,onDone: () => "",rethrowIfPossible: true}));break;// ...}}}
由于不是本文的重點,Tapable就不再展開了。
三、Webpack原理
下面僅分析主要流程,對于watch、HMR等功能暫不涉及。本次分析的Webpack版本是V5.62.1。
(一)總覽

Webpack打包流程包含三個階段:
初始化階段:包含了初始化參數(shù),創(chuàng)建Compiler,開始執(zhí)行compiler.run。
構建階段:從entry開始創(chuàng)建Module,調(diào)用loader轉(zhuǎn)為JS,解析JS為AST,收集依賴,并遞歸創(chuàng)建Module。
生成階段:根據(jù)入口和模塊的依賴關系,生成Chunk,輸出到文件。
Webpack打包流程中有很重要的兩個概念:compiler和compilation。
compiler:一次打包流程只會創(chuàng)建一個,貫穿整個編譯過程。
compilation:在watch為true的時候,每次文件變更觸發(fā)更新都會生成新的compilation。

(二)初始化階段
下圖是Webpack初始化階段的流程圖:

我們使用Webpack的方式一般是通過wepback-cli,從webpack-cli的bin文件開始,其調(diào)用鏈大致如下:
class WebpackCLI {async run() {// 加載webpack,可以理解為require('webpack')this.webpack = await this.loadWebpack()const options = [].concat(/* shell配置和配置文件 */)this.runWebpack(options)}async runWebpack(options) {const callback = () => {/* 錯誤處理等回調(diào) */}await this.createCompiler(options, callback);}await createCompiler(options, callback) {this.webpack(options,callback)}}const runCLI = (args) => {const cli = new WebpackCLI()cli.run(args)}runCLI(process.argv);
可以看到最終調(diào)用了Webpack庫的webpack方法。webpack方法定義在lib/webpack.js中:
function webpack = (options, callback) => {const create = () => {const compiler = createCompiler(options)return { compiler }}// ...if (callback) {const { compiler } = create()compiler.run(() => {})}}
webpack首先調(diào)用createCompiler創(chuàng)建了一個compiler,然后調(diào)用了compiler.run方法。
下面先看一下createCompiler方法:
function createCompiler(rawOptions) {const options = getNormalizedWwebpackOptions(rawOptions)const compiler = new Compiler(options.context, options)if (Array.isArray(options.plugins)) {for (const plugin of options.plugins) {if (typeof plugin === "function") {plugin.call(compiler, compiler);} else {plugin.apply(compiler);}}}compiler.hooks.environment.call();compiler.hooks.afterEnvironment.call();new WebpackOptionsApply().process(options, compiler);compiler.hooks.initialize.call();return compiler}
createCompiler先對options進行了一些標準化,然后通過new Compiler創(chuàng)建了一個compiler,然后依次執(zhí)行了optiosn.plugins和compiler.hooks上的幾個鉤子。然后調(diào)用了new WebpackOptionsApply().process()。
下面看一下Compiler:
class Compiler {constructor() {this.hooks = Object.freeze({initialize: new SyncHook([]),shouldEmit: new SyncBailHook(['compilation']),// 其他一系列鉤子的定義})}}
可以看到Compiler的構造函數(shù)中主要是定義了一系列鉤子,這些鉤子在構建的生命周期中會被依次調(diào)用。
然后再看一下WebpackOptionsApply:
class WebpackOptionsApply {process(options, compiler) {// 執(zhí)行plugins// new SomePlugin().apply()new EntryOptionPlugin().apply(compiler);compiler.hooks.entryOption.call(options.context, options.entry);//...}}
在new WebpackOptionsApply().process()方法中,執(zhí)行了很多內(nèi)部plugin,其中比較重要的是EntryOptionPlugin的執(zhí)行。
class EntryOptionPlugin {apply(compiler) {compiler.hooks.entryOption.tap('EntryOptionPlugin', (context, entry) => {EntryOptionPlugin.applyEntryOption(compiler, context, entry);return true;})}static applyEntryOption(compiler, context, entry) {const EntryPlugin = require("./EntryPlugin");for (const name of Object.keys(entry)) {const desc = entry[name];// 整理entry的options,使之返回統(tǒng)一的格式const options = EntryOptionPlugin.entryDescriptionToOptions(compiler,name,desc);for (const entry of desc.import) {new EntryPlugin(context, entry, options).apply(compiler);}}}}
看一下new EntryPlugin().apply()做了什么:
class EntryPlugin {apply(compiler) {compiler.hooks.compilation.tap('EntryPlugin',(compilation, { normalModuleFactory }) => {compilation.dependencyFactories.set(EntryDependency,normalModuleFactory);})const { entry, options, context } = this;const dep = EntryPlugin.createDependency(entry, options);compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => {compilation.addEntry(context, dep, options, err => {callback(err);});});}}
EntryPlugin在apply方法執(zhí)行的時候,會在compiler.hooks.make上注冊一個插件,其回調(diào)為compilation.addEntry,即開始從入口解析、收集依賴等,從這步開始進入了我們的構建階段。
回到webpack方法中,創(chuàng)建compiler后,調(diào)用了compiler.run方法,來看一下run方法:
class Compiler {run (callback) {const onCompiled = (err, compilation) => {this.hooks.shouldEmit.call(compilation)this.hooks.done.callAsync(() => {})this.emitAssets(compilation, () => {this.emitRecords(() => {})})}this.hooks.beforeRun.callAsync(() => {this.hooks.run.callAsync(() => {this.compile(onCompiled)})})}compile(callback) {const params = this.newCompilationParams();this.hooks.beforeCompile.callAsync(params, () => {this.hooks.compile.call(params);const compilation = this.newCompilation(params);this.hooks.make.callAsync(compilation, () => {this.hooks.finishMake.callAsync(compilation, () => {compilation.finish(() => {compilation.seal(() => {this.hooks.afterCompile.callAsync((err) => {if (err) return callback(err)return callback(err, compilation)})})})})})})}emitRecords(callback) {this.outputFileSystem.writeFile();}}
compiler.run方法先觸發(fā)了hooks.beforeRun和hooks.run兩個鉤子,然后執(zhí)行了this.compile方法。
compile方法也是也觸發(fā)了幾個前置回調(diào)hooks.beforeCompile和hooks.Compile,然后創(chuàng)建了compilation對象,之后觸發(fā)了make等回調(diào),make是構建的核心,他注冊的就是上面提到的entryPlugin,此時已經(jīng)進入構建階段。
我們先忽略階段,把compile方法看完。
compile接著調(diào)用了compilation.finish、compilation.seal、hooks.afterCompile,之后調(diào)用了傳入的callback,這里的callback就是run方法中onCompiled。onCompiled就是編譯完成做的事情,也是執(zhí)行了一些回調(diào):shouldEmit、done和this.emitAssets、this.emitRecords。
compilation.seal方法就到了生成階段,我們下面會講到。
小結下初始化階段做的事情:合并options、創(chuàng)建compiler、注冊插件、執(zhí)行compiler.run、創(chuàng)建compilation等。
(三)構建階段
下圖是構建階段的流程圖:

構建階段從make鉤子觸發(fā)的compilation.addEntry開始,我們上面講過構建階段的本質(zhì)是從入口開始分析AST,收集依賴。
Webpack的構建階段調(diào)用鏈比較長:
this.addEntry =>
(https://github.com/webpack/webpack/blob/v5.62.1/lib/Compilation.js#L2100)
this._addEntryItem =>
(https://github.com/webpack/webpack/blob/v5.62.1/lib/Compilation.js#L2135)
this.addModuleTree =>
(https://github.com/webpack/webpack/blob/v5.62.1/lib/Compilation.js#L2049)
this.handleModuleCreation =>
(https://github.com/webpack/webpack/blob/v5.62.1/lib/Compilation.js#L1743)
this.addModule =>
(https://github.com/webpack/webpack/blob/v5.62.1/lib/Compilation.js#L1255)
this._handleModuleBuildAndDependencies =>
(https://github.com/webpack/webpack/blob/v5.62.1/lib/Compilation.js#L1879)
this.buildModule =>
(https://github.com/webpack/webpack/blob/v5.62.1/lib/Compilation.js#L1329)
this._buildModule =>
(https://github.com/webpack/webpack/blob/v5.62.1/lib/Compilation.js#L1340)
module.build ?=>
(https://github.com/webpack/webpack/blob/v5.62.1/lib/NormalModule.js#L929)
module._doBuild =>
(https://github.com/webpack/webpack/blob/v5.62.1/lib/NormalModule.js#L736)
runLoaders =>
(https://github.com/webpack/webpack/blob/v5.62.1/lib/NormalModule.js#L812)
this.parser.parse =>
(https://github.com/webpack/webpack/blob/v5.62.1/lib/javascript/JavascriptParser.js#L3282)
handleParseResult =>
(https://github.com/webpack/webpack/blob/v5.62.1/lib/NormalModule.js#L975)
processModuleDependencies =>
(https://github.com/webpack/webpack/blob/v5.62.1/lib/Compilation.js#L1939)
handleModuleCreation
(https://github.com/webpack/webpack/blob/v5.62.1/lib/Compilation.js#L1743)
看一下上面比較重要的環(huán)節(jié):
handleParseResult作用是處理模塊依賴。
processModuleDependencies是buildModule的回調(diào),其會調(diào)用handleModuleCreation,這樣對于新增的依賴,會創(chuàng)建新的module,回到了第一步,遞歸就是在這里實現(xiàn)的。
注意這里的AST解析是利用acorn實現(xiàn)的:
const { Parser: parser } = require("acorn");class JavascriptParser extends Parse {parse(source) {// ...const ast = parser.parse(source)}}
其他部分大家可點擊上面方法的鏈接網(wǎng)址去查看源代碼,這里就不展開了,下面我們還會手寫一個例子深入理解這部分。
(四)生成階段
生成階段的流程圖如下:

生成階段從上面提到的compilation.seal開始:
class Compilation {seal(callback) {const chunkGraph = new ChunkGraph()this.addChunk();buildChunkGraph();this.hooks.optimizeModules.call()this.hooks.afterOptimizeModules.call()this.hooks.optimizeChunks.call()this.hooks.afterOptimizeChunks.call()this.hooks.optimizeTree.call()// ...this.createModuleAssets();this.createChunkAssets();}createChunkAssets() {this.emitAsset()// ...}createModuleAssets() {this.emitAsset()}}
執(zhí)行完compilation.seal后,會執(zhí)行其回調(diào),也就是上面提到的onCompiled方法,也就是this.outputFileSystem.writeFile(),即把內(nèi)存中的chunk信息存入文件系統(tǒng)。
下面總結下生成階段做的事情:
創(chuàng)建ChunkGraph。
遍歷modules,將module分配給不同的chunk。
調(diào)用createChunkAssets和createModuleAssets分別將chunk和module將assets信息寫入到compilation.assets中。
調(diào)用seal回調(diào),執(zhí)行outputFileSystem.writeFile,寫入文件。
四、手寫打包核心原理
如果看到這里你還是云里霧里,可以手寫一下打包核心原理來加深印象。
Webpack功能復雜、模塊眾多,其核心邏輯被一層層封裝。熟讀其源碼可以理解其架構,但對于核心原理,還是手寫一下印象最深。
下面這個打包例子與Webpack用的庫不一致,但是打包思想是一樣的。
先創(chuàng)建parser.js,導出幾個方法:
getAST:利用@babel/parser生成AST。
getDependencies:利用@babel/traverse遍歷AST,獲取依賴。
transform:將AST轉(zhuǎn)為code,并轉(zhuǎn)化其中的ES6語法。
const fs = require('fs');const parser = require('@babel/parser')const traverse = require('@babel/traverse').defaultconst { transformFromAst } = require('babel-core')module.exports = {getAST: path => {const source = fs.readFileSync(path, 'utf-8')return parser.parse(source, {sourceType: 'module'})},getDependencies: ast => {const depencies = []traverse(ast, {ImportDeclaration: ({node}) => {depencies.push(node.source.value)}})return depencies;},transform: ast => {const { code } = transformFromAst(ast, null, {presets: ['env']})return code;}}
再創(chuàng)建compiler.js:
const path = require('path');const fs = require('fs')const { getAST, getDependencies, transform } = require('./parser')class Compiler {constructor(options) {const { entry, output } = optionsthis.entry = entry;this.output = outputthis.modules = [];}run () {const entryModule = this.buildModule(this.entry, true);this.modules.push(entryModule);this.walk(entryModule);// console.log('modules', this.modules)this.emitFiles();}walk(module) {const moduleNameMap = this.modules.map(item => item.filename)module.dependencies.map(dep => {if (!moduleNameMap.includes(dep)) {const newModule = this.buildModule(dep)this.modules.push(newModule);this.walk(newModule)}})}buildModule(filename, isEntry) {let ast;if (isEntry) {ast = getAST(filename)} else {const absolutePath = path.resolve(process.cwd(), './webpack/demo', filename)ast = getAST(absolutePath)}return {filename,dependencies: getDependencies(ast),transformCode: transform(ast)}}emitFiles() {const outputPath = path.join(this.output.path, this.output.filename)let modules = ''this.modules.map(_module => {modules += `'${_module.filename}': function(require, module, exports) {${_module.transformCode}},`})const bundle = `(function(modules) {function require(filename) {const fn = modules[filename];const module = { exports: {} };fn(require, module, module.exports);return module.exports;}require('${this.entry}')})({${modules}})`fs.writeFileSync(outputPath, bundle, 'utf-8')}}
compiler.js的核心邏輯在run方法中,其從入口開始創(chuàng)建module,然后遞歸的收集依賴,最后調(diào)用emitFiles輸出到文件中。
注意輸出的時候,創(chuàng)建了自執(zhí)行函數(shù),此時的傳入的參數(shù)是一個對象,其key值為模塊地址,value值為模塊內(nèi)容。然后調(diào)用自定義的require函數(shù),傳入第一個module,然后依次執(zhí)行。
一個基本的打包器就是這么簡單。
下面創(chuàng)建幾個文件測試一下:
// a.jsexport const a = 1;// b.jsimport { a } from './a.js'export const b = a + 1;// index.jsimport { b } from './b.js'console.log(b + 1)
然后引入我們的簡易打包器,打包試一下:
// main.jsconst Compiler = require('./compiler')const config = {entry: path.join(__dirname, './index.js'),output: {path: path.join(__dirname, '../dist'),filename: 'bundle.js'}}function main(options) {new Compiler(options).run();}main(config)
執(zhí)行上面的main.js,就可以在dist目錄下看到打包出的bundle.js。
我們可以新建一個index.html引入我們的bundle文件,打開console面板,就能看到打印的內(nèi)容了。

上面例子的地址:
(https://github.com/novlan1/rollup-intro/tree/master/webpack)
五、總結
本文分析了Tapable插件的使用和原理,講解了Webpack主要流程,并手寫了一個簡易打包器。Webpack和Rollup打包原理大同小異,理解其打包原理有利于更好的使用這些工具。
參考資料:
1.Tapable
2.關于tapable你需要知道這些
3.tapable詳解
4.Tapable(一)
5.一文吃透Webpack核心原理
?作者簡介
楊國旺
騰訊前端開發(fā)工程師
騰訊前端開發(fā)工程師,歡迎討論前端問題。
?推薦閱讀
關于Go并發(fā)編程,你不得不知的“左膀右臂”——并發(fā)與通道!


