相信很多人都有這個疑問,為什麼要閱讀原始碼,僅僅只是一個打包工具,會用不就行了,一些設定項在官網,或者谷歌查一查不就好了嗎,誠然在大部分的時候是這樣的,但這樣在深入時也會遇到以下幾種問題。
webpack 設定繁瑣,具有 100 多個內建外掛,200 多個勾點函數,在保持靈活設定的同時,也把問題拋給了開發者。如不同的設定項會不會對同一個功能產生影響,參照 Plugin 的先後順序會不會影響打包結果?這些問題,不看原始碼是無法真正清晰的。
plugin 也就是外掛,是 webpack 的支柱功能。開發者可以自己使用勾點函數寫出外掛,來豐富 webpack 的生態,也可以在自己或公司的專案中參照自己開發的外掛,來去解決實際的工程問題,不去探究原始碼,無法理解 webpack 外掛的執行,也無法寫出高質量的外掛。
從前端整體來看,現代前端的生態與打包工具高度相關,webpack 作為其中的佼佼者,瞭解原始碼,也就是在瞭解前端的生態圈。
首先我們要先明白什麼是 Tapable,這個小型庫是 webpack 的一個核心工具。在 webpack 的編譯過程中,本質上通過 Tapable 實現了在編譯過程中的一種釋出訂閱者模式的外掛機制。它提供了一系列事件的釋出訂閱 API ,通過 Tapable 可以註冊事件,從而在不同時機去觸發註冊的事件進行執行。
下面將會有一個模擬 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;
我相信有些小夥伴看到上述程式碼,就已經明白了大概的邏輯,我們只需要抓住釋出和訂閱這兩個詞,在程式碼中呈現的就是 tap 和 call,如果是非同步勾點,使用 tapAsync, tapPromise 註冊(釋出),就要用 callAsync, promise(注意這裡的 promise 是 Tapable 勾點實體方法,不要跟 Promise API 搞混) 觸發(訂閱)。
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 進行註冊,在什麼時機註冊的呢,在 Compiler 類的初始化時期,也就是在通過 new 命令生成物件範例的時候,下面的程式碼已經在 constructor 中被呼叫並執行了,當然這個時候並沒有像函數一樣被呼叫,列印出來姓名和年齡,這時我們只需要先知道,它們已經被註冊了。
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() 命令將會執行下面兩個函數,使用 call 和 callAsync 訂閱。這個時候就會執行 console.log 來列印姓名和年齡了,所以說此時我們就能明白 webpack 中 compiler 和 compilation 中的勾點函數是以觸發的時期進行區分,歸根結底,是註冊的勾點在 webpack 不同的編譯時期被觸發。
這裡要注意在初始化 Tapable Hook 的同時,要加上引數,傳入引數的數量需要與範例化時傳遞給勾點類建構函式的陣列長度保持一致。
this.hooks = {
testSyncHook: new SyncHook(['name', 'age']),
testAsyncHook: new AsyncParallelHook(['name', 'age'])
}
這裡並非要嚴格的傳入 ['name', 'age'],你也可以取其它的名字,如 ['fff', 'ggg],但是為了語意化,還是要進行規範,如下方程式碼,擷取自原始碼中的 lib/Compiler.js 片段,它們在初始化中也是嚴格按照了這個規範。
/** @type {AsyncSeriesHook<[Compiler]>} */
beforeRun: new AsyncSeriesHook(["compiler"]),
/** @type {AsyncSeriesHook<[Compiler]>} */
run: new AsyncSeriesHook(["compiler"]),
/** @type {AsyncSeriesHook<[Compilation]>} */
emit: new AsyncSeriesHook(["compilation"]),
更具體的可以檢視這篇文章 走進 Tapable - 掘金 (juejin.cn)
想偵錯 webpack 原始碼,一般有兩種方式,一種是 clone 偵錯,一種是 npm 包偵錯,筆者這裡選擇通過 clone 偵錯,執行 webpack 也有兩種方式,一是通過 webpack-cli 輸入命令啟動,另外一種如下,引入 webapck,使用 webpack.run() 啟動。
首先可以用 https 從 github 上克隆 webpack 原始碼。
git clone https://github.com/webpack/webpack
npm install
之後可以在根目錄建立一個名為 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 主要是想更清晰看到在構建過程中 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');
}
檔案建立好了,這裡使用 Vscode 進行偵錯, 開啟 JavaScript 偵錯終端。
按照下面命令,啟動 webpack
cd source
node index.js
這裡為了更加清晰, 可以打上一個斷點。如在 lib/webpack.js 中,將斷點打在 158 行,檢視是如何生成的 compiler 範例。
這裡需要點選單步偵錯,這樣才能進入 create 函數中,一步步偵錯可以看到,首先會對傳入的 options 進行校驗, 如果不符合規範,將會丟擲錯誤,由於這裡的 options 是一個物件,將會進入到 createCompiler 函數內。
筆者將會一步步的講解這個函數都做了什麼事,如
applyWebpackOptionsBaseDefaults:給沒設定的基本設定加上預設值。
new Compiler:生成 compiler 範例,初始化一些勾點和引數。
NodeEnvironmentPlugin:主要是對檔案模組進行了封裝和優化,感興趣的讀者可以打斷點,詳細去檢視。
接下來要做的事情就是註冊勾點,如上文中引入了 html-webpack-plugin, 這裡將會呼叫 HtmlWebpackplugin 範例的 apply 函數,這樣就能明白為什麼以 class 類的方式,寫外掛,為什麼裡面一定要加上 apply。緊接著建立完 compiler 範例後,正如官網上描述的,關於 compiler.hooks.environment 的訂閱時期,在編譯器準備環境時呼叫,時機就在組態檔中初始化外掛之後。我們就能知其然,也能知所以然了。
再往下,
new WebpackOptionsApply().process(options, compiler):註冊了內部外掛,如 DllPlugin, HotModuleReplacementPlugin 等。
這裡簡單分享了筆者看原始碼的步驟,然後還有兩個技巧分享。
一是由於 webpack 運用了大量回撥函數,一步步打斷點是很難看的清楚的,可直接在 Vscode 中全域性搜尋 compiler.hooks.xxx 和 compilation.hooks.xxx, 去看 tap 中回撥函數的執行。
二是可在 Vscode 偵錯中的 watch 模組,新增上 compiler 和 compilation,這樣也是更方便觀察回撥函數的執行。如
webpack 中的細節很是繁多,裡面有大量的例外處理,在看的時候要有重點的看,有選擇的看,如果你要看 make 階段所做的事情, 可以重點去看如何生成模組,模組分為幾種,如何遞迴處理依賴,如何使用 loader 解析檔案等。筆者認為看原始碼還有一個好處,那就是讓你對這些知名開源庫沒有畏懼心理,它們也是用 js 一行行寫的,裡面會有一些程式碼片段,可能寫的也沒有那麼優美,我們在閱讀程式碼的同時,說不定也能成為程式碼貢獻者,能夠在簡歷上留下濃墨重彩的一筆。
作者:百寶門-前端組-閆磊剛
原文地址:https://blog.baibaomen.com/我們為什麼要閱讀webpack原始碼/