我們?yōu)槭裁匆喿xwebpack源碼
作者:百寶門(mén)-前端組-閆磊剛
相信很多人都有這個(gè)疑問(wèn),為什么要閱讀源碼,僅僅只是一個(gè)打包工具,會(huì)用不就行了,一些配置項(xiàng)在官網(wǎng),或者谷歌查一查不就好了嗎,誠(chéng)然在大部分的時(shí)候是這樣的,但這樣在深入時(shí)也會(huì)遇到以下幾種問(wèn)題。
webpack 配置繁瑣,具有 100 多個(gè)內(nèi)置插件,200 多個(gè)鉤子函數(shù),在保持靈活配置的同時(shí),也把問(wèn)題拋給了開(kāi)發(fā)者。如不同的配置項(xiàng)會(huì)不會(huì)對(duì)同一個(gè)功能產(chǎn)生影響,引用 Plugin 的先后順序會(huì)不會(huì)影響打包結(jié)果?這些問(wèn)題,不看源碼是無(wú)法真正清晰的。
plugin 也就是插件,是 webpack 的支柱功能。開(kāi)發(fā)者可以自己使用鉤子函數(shù)寫(xiě)出插件,來(lái)豐富 webpack 的生態(tài),也可以在自己或公司的項(xiàng)目中引用自己開(kāi)發(fā)的插件,來(lái)去解決實(shí)際的工程問(wèn)題,不去探究源碼,無(wú)法理解 webpack 插件的運(yùn)行,也無(wú)法寫(xiě)出高質(zhì)量的插件。
從前端整體來(lái)看,現(xiàn)代前端的生態(tài)與打包工具高度相關(guān),webpack 作為其中的佼佼者,了解源碼,也就是在了解前端的生態(tài)圈。
Tapable淺析
首先我們要先明白什么是 Tapable,這個(gè)小型庫(kù)是 webpack 的一個(gè)核心工具。在 webpack 的編譯過(guò)程中,本質(zhì)上通過(guò) Tapable 實(shí)現(xiàn)了在編譯過(guò)程中的一種發(fā)布訂閱者模式的插件機(jī)制。它提供了一系列事件的發(fā)布訂閱 API ,通過(guò) Tapable 可以注冊(cè)事件,從而在不同時(shí)機(jī)去觸發(fā)注冊(cè)的事件進(jìn)行執(zhí)行。
下面將會(huì)有一個(gè)模擬 webpack 注冊(cè)插件的例子來(lái)嘗試幫助理解。
compiler.js
const { SyncHook, AsyncParallelHook } ?= require('tapable');class Compiler { ?constructor(options) { ? ?this.hooks = { ? ? ?testSyncHook: new SyncHook(['name', 'age']), ? ? ?testAsyncHook: new AsyncParallelHook(['name', 'age'])
? ?} ? ?let plugins = options.plugins;
? ?plugins.forEach(plugin => {
? ? ?plugin.apply(this);
? ?});
?} ?run() { ? ?this.testSyncHook('ggg', 25); ? ?this.testAsyncHook('hhh', 24);
?} ?testSyncHook(name, age) { ? ?this.hooks.testSyncHook.call(name, age);
?} ?testAsyncHook(name, age) { ? ?this.hooks.testAsyncHook.callAsync(name, age);
?}
}module.exports = Compiler;
index.js
const Compiler = require('./complier');const MockWebpackPlugin = require('./mock-webpack-plugin');const complier = new Compiler({ ?plugins: [ ? ?new MockWebpackPlugin(),
?]
});
complier.run();
mock-webpack-plugin.js
class MockWebpackPlugin { ?apply(compiler) {
? ?compiler.hooks.testSyncHook.tap('MockWebpackPlugin', (name, age) => { ? ? ?console.log('同步事件', name, age);
? ?})
? ?compiler.hooks.testAsyncHook.tapAsync('MockWebpackPlugin', (name, age) => { ? ? ?setTimeout(() => { ? ? ? ?console.log('異步事件', name, age)
? ? ?}, 3000)
? ?})
?}
}module.exports = MockWebpackPlugin;
我相信有些小伙伴看到上述代碼,就已經(jīng)明白了大概的邏輯,我們只需要抓住發(fā)布和訂閱這兩個(gè)詞,在代碼中呈現(xiàn)的就是 tap 和 call,如果是異步鉤子,使用 tapAsync, tapPromise 注冊(cè)(發(fā)布),就要用 callAsync, promise(注意這里的 promise 是 Tapable 鉤子實(shí)例方法,不要跟 Promise API 搞混) 觸發(fā)(訂閱)。
發(fā)布
? ?compiler.hooks.testSyncHook.tap('MockWebpackPlugin', (name, age) => { ? ? ?console.log('同步事件', name, age);
? ?})
? ?compiler.hooks.testAsyncHook.tapAsync('MockWebpackPlugin', (name, age) => { ? ? ?setTimeout(() => { ? ? ? ?console.log('異步事件', name, age)
? ? ?}, 3000)
? ?})
這里可以看到使用 tab 和 tabAsync 進(jìn)行注冊(cè),在什么時(shí)機(jī)注冊(cè)的呢,在 Compiler 類的初始化時(shí)期,也就是在通過(guò) new 命令生成對(duì)象實(shí)例的時(shí)候,下面的代碼已經(jīng)在 constructor 中被調(diào)用并執(zhí)行了,當(dāng)然這個(gè)時(shí)候并沒(méi)有像函數(shù)一樣被調(diào)用,打印出來(lái)姓名和年齡,這時(shí)我們只需要先知道,它們已經(jīng)被注冊(cè)了。
訂閱
?run() { ? ?this.testSyncHook('ggg', 25); ? ?this.testAsyncHook('hhh', 24);
?} ?testSyncHook(name, age) { ? ?this.hooks.testSyncHook.call(name, age);
?} ?testAsyncHook(name, age) { ? ?this.hooks.testAsyncHook.callAsync(name, age);
?}
通過(guò) compiler.run() 命令將會(huì)執(zhí)行下面兩個(gè)函數(shù),使用 call 和 callAsync 訂閱。這個(gè)時(shí)候就會(huì)執(zhí)行 console.log 來(lái)打印姓名和年齡了,所以說(shuō)此時(shí)我們就能明白 webpack 中 compiler 和 compilation 中的鉤子函數(shù)是以觸發(fā)的時(shí)期進(jìn)行區(qū)分,歸根結(jié)底,是注冊(cè)的鉤子在 webpack 不同的編譯時(shí)期被觸發(fā)。
注意事項(xiàng)
這里要注意在初始化 Tapable Hook 的同時(shí),要加上參數(shù),傳入?yún)?shù)的數(shù)量需要與實(shí)例化時(shí)傳遞給鉤子類構(gòu)造函數(shù)的數(shù)組長(zhǎng)度保持一致。
? ?this.hooks = {
? ? ?testSyncHook: new SyncHook(['name', 'age']),
? ? ?testAsyncHook: new AsyncParallelHook(['name', 'age'])
? ?}
這里并非要嚴(yán)格的傳入 ['name', 'age'],你也可以取其它的名字,如 ['fff', 'ggg],但是為了語(yǔ)義化,還是要進(jìn)行規(guī)范,如下方代碼,截取自源碼中的 lib/Compiler.js 片段,它們?cè)诔跏蓟幸彩菄?yán)格按照了這個(gè)規(guī)范。
? ?/** @type {AsyncSeriesHook<[Compiler]>} */
? ?beforeRun: new AsyncSeriesHook(["compiler"]),
? ?/** @type {AsyncSeriesHook<[Compiler]>} */
? ?run: new AsyncSeriesHook(["compiler"]),
? ?/** @type {AsyncSeriesHook<[Compilation]>} */
? ?emit: new AsyncSeriesHook(["compilation"]),
更具體的可以查看這篇文章?走進(jìn) Tapable - 掘金 (juejin.cn)
如何調(diào)試
想調(diào)試 webpack 源碼,一般有兩種方式,一種是 clone 調(diào)試,一種是 npm 包調(diào)試,筆者這里選擇通過(guò) clone 調(diào)試,運(yùn)行 webpack 也有兩種方式,一是通過(guò) webpack-cli 輸入命令啟動(dòng),另外一種如下,引入 webapck,使用 webpack.run() 啟動(dòng)。
準(zhǔn)備工作
首先可以用 https 從 github 上克隆 webpack 源碼。
? ?git clone https://github.com/webpack/webpack
? ?npm install
之后可以在根目錄創(chuàng)建一個(gè)名為 source 的文件夾,source 文件夾目錄如下
-- webpack
? ?-- source
? ? ? ?-- src
? ? ? ? ? ? -- foo.js
? ? ? ? ? ? -- main.js
? ? ? ?-- index.html
? ? ? ?-- index.js
? ? ? ?-- webpack.config.js
index.js
const webpack = require('../lib/index.js');const config = require('./webpack.config.js');const complier = webpack(config);
complier.run((err, stats) => { ?if (err) { ? ?console.error(err);
?} else { ? ?console.log(stats);
?}
})
webpack.config.js
const path = require('path');const HtmlWebpackPlugin = require('html-webpack-plugin');module.exports = { ?mode: 'development', ?devtool: 'source-map', ?entry: './src/main.js', ?output: { ? ? ?path: path.join(__dirname, './dist'),
?}, ?module: { ? ?rules: [
? ? ?{ ? ? ? ?test: /\.js$/, ? ? ? ?use: ['babel-loader'], ? ? ? ?exclude: /node_modules/,
? ? ?}
? ?]
?}, ?plugins: [ ? ?new HtmlWebpackPlugin({ ? ? ?title: 'Test Webpack', ? ? ?template: './index.html', ? ? ?filename: 'template.html'
? ?})
?]
}
引用 html-webpack-plugin 和 babel-loader 主要是想更清晰看到在構(gòu)建過(guò)程中 webpack 會(huì)如何處理引入的 plugin 和 loader。
main.js
import foo from './foo.js';import { isEmpty } from 'lodash';foo();const obj = {};console.log(isEmpty(obj));console.log('main.js');
foo.js
export default function foo() { ?console.log('foo');
}
文件創(chuàng)建好了,這里使用 Vscode 進(jìn)行調(diào)試, 打開(kāi) JavaScript 調(diào)試終端。

源碼閱讀
按照下面命令,啟動(dòng) webpack
? ?cd source
? ?node index.js
這里為了更加清晰, 可以打上一個(gè)斷點(diǎn)。如在 lib/webpack.js 中,將斷點(diǎn)打在 158 行,查看是如何生成的 compiler 實(shí)例。

這里需要點(diǎn)擊單步調(diào)試,這樣才能進(jìn)入 create 函數(shù)中,一步步調(diào)試可以看到,首先會(huì)對(duì)傳入的 options 進(jìn)行校驗(yàn), 如果不符合規(guī)范,將會(huì)拋出錯(cuò)誤,由于這里的 options 是一個(gè)對(duì)象,將會(huì)進(jìn)入到 createCompiler 函數(shù)內(nèi)。

在這個(gè)函數(shù)內(nèi)將會(huì)創(chuàng)造 Compiler 實(shí)例,以及注冊(cè)引入的插件和內(nèi)置插件。

筆者將會(huì)一步步的講解這個(gè)函數(shù)都做了什么事,如
applyWebpackOptionsBaseDefaults:給沒(méi)設(shè)置的基本配置加上默認(rèn)值。
new Compiler:生成 compiler 實(shí)例,初始化一些鉤子和參數(shù)。
NodeEnvironmentPlugin:主要是對(duì)文件模塊進(jìn)行了封裝和優(yōu)化,感興趣的讀者可以打斷點(diǎn),詳細(xì)去查看。
接下來(lái)要做的事情就是注冊(cè)鉤子,如上文中引入了 html-webpack-plugin, 這里將會(huì)調(diào)用 HtmlWebpackplugin 實(shí)例的 apply 函數(shù),這樣就能明白為什么以 class 類的方式,寫(xiě)插件,為什么里面一定要加上 apply。緊接著創(chuàng)建完 compiler 實(shí)例后,正如官網(wǎng)上描述的,關(guān)于 compiler.hooks.environment 的訂閱時(shí)期,在編譯器準(zhǔn)備環(huán)境時(shí)調(diào)用,時(shí)機(jī)就在配置文件中初始化插件之后。我們就能知其然,也能知所以然了。

再往下,
new WebpackOptionsApply().process(options, compiler):注冊(cè)了內(nèi)部插件,如 DllPlugin, HotModuleReplacementPlugin 等。
小技巧分享
這里簡(jiǎn)單分享了筆者看源碼的步驟,然后還有兩個(gè)技巧分享。
一是由于 webpack 運(yùn)用了大量回調(diào)函數(shù),一步步打斷點(diǎn)是很難看的清楚的,可直接在 Vscode 中全局搜索 compiler.hooks.xxx 和 compilation.hooks.xxx, 去看 tap 中回調(diào)函數(shù)的執(zhí)行。
二是可在 Vscode 調(diào)試中的 watch 模塊,添加上 compiler 和 compilation,這樣也是更方便觀察回調(diào)函數(shù)的執(zhí)行。如

總結(jié)
webpack 中的細(xì)節(jié)很是繁多,里面有大量的異常處理,在看的時(shí)候要有重點(diǎn)的看,有選擇的看,如果你要看 make 階段所做的事情, 可以重點(diǎn)去看如何生成模塊,模塊分為幾種,如何遞歸處理依賴,如何使用 loader 解析文件等。筆者認(rèn)為看源碼還有一個(gè)好處,那就是讓你對(duì)這些知名開(kāi)源庫(kù)沒(méi)有畏懼心理,它們也是用 js 一行行寫(xiě)的,里面會(huì)有一些代碼片段,可能寫(xiě)的也沒(méi)有那么優(yōu)美,我們?cè)陂喿x代碼的同時(shí),說(shuō)不定也能成為代碼貢獻(xiàn)者,能夠在簡(jiǎn)歷上留下濃墨重彩的一筆。