我們?yōu)槭裁匆喿xwebpack源碼
相信很多人都有這個疑問,為什么要閱讀源碼,僅僅只是一個打包工具,會用不就行了,一些配置項在官網(wǎng),或者谷歌查一查不就好了嗎,誠然在大部分的時候是這樣的,但這樣在深入時也會遇到以下幾種問題。
webpack 配置繁瑣,具有 100 多個內(nèi)置插件,200 多個鉤子函數(shù),在保持靈活配置的同時,也把問題拋給了開發(fā)者。如不同的配置項會不會對同一個功能產(chǎn)生影響,引用 Plugin 的先后順序會不會影響打包結(jié)果?這些問題,不看源碼是無法真正清晰的。
plugin 也就是插件,是 webpack 的支柱功能。開發(fā)者可以自己使用鉤子函數(shù)寫出插件,來豐富 webpack 的生態(tài),也可以在自己或公司的項目中引用自己開發(fā)的插件,來去解決實際的工程問題,不去探究源碼,無法理解 webpack 插件的運(yùn)行,也無法寫出高質(zhì)量的插件。
從前端整體來看,現(xiàn)代前端的生態(tài)與打包工具高度相關(guān),webpack 作為其中的佼佼者,了解源碼,也就是在了解前端的生態(tài)圈。
Tapable淺析
首先我們要先明白什么是 Tapable,這個小型庫是 webpack 的一個核心工具。在 webpack 的編譯過程中,本質(zhì)上通過 Tapable 實現(xiàn)了在編譯過程中的一種發(fā)布訂閱者模式的插件機(jī)制。它提供了一系列事件的發(fā)布訂閱 API ,通過 Tapable 可以注冊事件,從而在不同時機(jī)去觸發(fā)注冊的事件進(jìn)行執(zhí)行。
下面將會有一個模擬 webpack 注冊插件的例子來嘗試幫助理解。
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ā)布和訂閱這兩個詞,在代碼中呈現(xiàn)的就是 tap 和 call,如果是異步鉤子,使用 tapAsync, tapPromise 注冊(發(fā)布),就要用 callAsync, promise(注意這里的 promise 是 Tapable 鉤子實例方法,不要跟 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)行注冊,在什么時機(jī)注冊的呢,在 Compiler 類的初始化時期,也就是在通過 new 命令生成對象實例的時候,下面的代碼已經(jīng)在 constructor 中被調(diào)用并執(zhí)行了,當(dāng)然這個時候并沒有像函數(shù)一樣被調(diào)用,打印出來姓名和年齡,這時我們只需要先知道,它們已經(jīng)被注冊了。
### 訂閱 ###
?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);
?}
通過 compiler.run() 命令將會執(zhí)行下面兩個函數(shù),使用 call 和 callAsync 訂閱。這個時候就會執(zhí)行 console.log 來打印姓名和年齡了,所以說此時我們就能明白 webpack 中 compiler 和 compilation 中的鉤子函數(shù)是以觸發(fā)的時期進(jìn)行區(qū)分,歸根結(jié)底,是注冊的鉤子在 webpack 不同的編譯時期被觸發(fā)。
注意事項
這里要注意在初始化 Tapable Hook 的同時,要加上參數(shù),傳入?yún)?shù)的數(shù)量需要與實例化時傳遞給鉤子類構(gòu)造函數(shù)的數(shù)組長度保持一致。
? ?this.hooks = {
? ? ?testSyncHook: new SyncHook(['name', 'age']),
? ? ?testAsyncHook: new AsyncParallelHook(['name', 'age'])
? ?}
這里并非要嚴(yán)格的傳入 ['name', 'age'],你也可以取其它的名字,如 ['fff', 'ggg],但是為了語義化,還是要進(jìn)行規(guī)范,如下方代碼,截取自源碼中的 lib/Compiler.js 片段,它們在初始化中也是嚴(yán)格按照了這個規(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)試,筆者這里選擇通過 clone 調(diào)試,運(yùn)行 webpack 也有兩種方式,一是通過 webpack-cli 輸入命令啟動,另外一種如下,引入 webapck,使用 webpack.run() 啟動。
準(zhǔn)備工作
首先可以用 https 從 github 上克隆 webpack 源碼。
? ?git clone https://github.com/webpack/webpack
? ?npm install
之后可以在根目錄創(chuàng)建一個名為 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)建過程中 webpack 會如何處理引入的 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)試, 打開 JavaScript 調(diào)試終端。

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

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

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

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

再往下,
new WebpackOptionsApply().process(options, compiler):注冊了內(nèi)部插件,如 DllPlugin, HotModuleReplacementPlugin 等。
小技巧分享
這里簡單分享了筆者看源碼的步驟,然后還有兩個技巧分享。
一是由于 webpack 運(yùn)用了大量回調(diào)函數(shù),一步步打斷點是很難看的清楚的,可直接在 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é)很是繁多,里面有大量的異常處理,在看的時候要有重點的看,有選擇的看,如果你要看 make 階段所做的事情, 可以重點去看如何生成模塊,模塊分為幾種,如何遞歸處理依賴,如何使用 loader 解析文件等。筆者認(rèn)為看源碼還有一個好處,那就是讓你對這些知名開源庫沒有畏懼心理,它們也是用 js 一行行寫的,里面會有一些代碼片段,可能寫的也沒有那么優(yōu)美,我們在閱讀代碼的同時,說不定也能成為代碼貢獻(xiàn)者,能夠在簡歷上留下濃墨重彩的一筆。
原文地址:我們?yōu)槭裁匆喿xwebpack源碼 - 百寶門的博客 (baibaomen.com)