三千字基於 HMR 插件解析 Webpack 源碼

本文由團隊成員 咕嚕 撰寫,已受權塗鴉大前端獨家使用,包括但不限於編輯、標註原創等權益。javascript

Webpack 關鍵對象簡析

認識 Tapable

Tapable 相似 node 中的重用到的 events 庫,其本質就是一個 發佈訂閱模式 。前端

var EventEmitter = require('events')

var ee = new EventEmitter()
ee.on('message', function (text) {
  console.log(text)
})
ee.emit('message', 'hello world')
複製代碼

與 events 的一些區別

1. 訂閱、發佈的接口名不一樣

基於 tapbale 實例化:java

const { SyncHook } = require("tapable");

// 1. 建立鉤子實例
const sleep = new SyncHook();

// 2. 調用訂閱接口註冊回調
sleep.tap("test", () => {
  console.log("callback A");
});

// 3. 調用發佈接口觸發回調
sleep.call();

// 4. 在 webpack compiler 對象上的訂閱,通常在 webpack 裏都掛載在 hooks 命名空間下
compiler.hooks.someHook.tap('MyPlugin', (params) => {
  /* ... */
});
複製代碼

基於 node EventEmitter 實例化:node

const EventEmitter = require('events');

// 1. 建立鉤子實例
const sleep = new EventEmitter();

// 2. 調用訂閱接口註冊回調
sleep.on('test', () => {
  console.log("callback A");
});

// 3. 調用發佈接口觸發回調
sleep.emit('test');
複製代碼

2. 實例化時傳入的數組其表明的爲參數的語義

// 1. 建立鉤子實例時
class Compiler {
  constructor() {
    this.hooks = {
      compilation: new SyncHook(["compilation", "params"]),
    };
  }
  /* ... */
}

// 2. 調用發佈接口觸發回調時
newCompilation(params) {
  const compilation = this.createCompilation();
  compilation.name = this.name;
  compilation.records = this.records;
  this.hooks.thisCompilation.call(compilation, params);
  this.hooks.compilation.call(compilation, params);
  return compilation;
}
複製代碼

3. 拓展了異步鉤子

// 異步鉤子
compiler.hooks.beforeCompile.tapAsync(
  'MyPlugin',
  (params, callback) => {
    console.log('Asynchronously tapping the run hook.');
    callback();
  }
);

// 異步 promise 鉤子
compiler.hooks.beforeCompile.tapPromise('MyPlugin', (params) => {
  return new Promise((resolve) => setTimeout(resolve, 1000)).then(() => {
    console.log('Asynchronously tapping the run hook with a delay.');
  });
});
複製代碼

另外在 Webpack 中基於 Tapable 實現的類,都是 強耦合 的,以下面 Webpack 官方提供的 demo 所示,在 beforeCompile 階段,其參數是可被修改的,參考官方文檔的描述 This hook can be used to add/modify the compilation parametersreact

而 Webpack 也正是基於這種 強耦合 的方式,下面的 Compiler 和 Compilation 實例在特定時機觸發鉤子時會附帶上足夠的上下文信息,使得 Plugin 可以訂閱而且基於當前上下文信息和業務邏輯進而產生反作用(修改上下文狀態),從而影響後續的編譯流程。webpack

// 強耦合的基於 Tapable 類實現 compiler 實例
compiler.hooks.beforeCompile.tapAsync('MyPlugin', (params, callback) => {
  params['MyPlugin - data'] = 'important stuff my plugin will use later';
  callback();
});
複製代碼
  1. 拓展了 HookMap 這種集合操做的特性

源碼詳見 JavascriptParser - hooksgit

this.hooks = Object.freeze({
  /** @type {HookMap<SyncBailHook<[CallExpressionNode, BasicEvaluatedExpression | undefined], BasicEvaluatedExpression | undefined | null>>} */
  evaluateCallExpressionMember: new HookMap(
    () => new SyncBailHook(["expression", "param"])
  ),
  ...otherHooks,
});
複製代碼

在使用 HookMap 的狀況下:github

// JavascriptParser.js
const property =
    expr.callee.property.type === "Literal"
        ? `${expr.callee.property.value}`
        : expr.callee.property.name;
const hook = this.hooks.evaluateCallExpressionMember.get(property);
if (hook !== undefined) {
    return hook.call(expr, param);
}

// index.js
const a = expression.myFunc();

// MyPlugin.js
parser.hooks.evaluateCallExpressionMember
  .for('myFunc')
  .tap('MyPlugin', (expression, param) => {
    /* ... */
    return expressionResult;
  });
複製代碼

在沒有 HookMap 的狀況下:web

// JavascriptParser.js
const property =
    expr.callee.property.type === "Literal"
        ? `${expr.callee.property.value}`
        : expr.callee.property.name;
return this.hooks[`evaluateCallExpressionMember${property.toFirstUpperCase()}`].call();

// index.js
const a = expression.myFunc();

// MyPlugin.js
parser.hooks.evaluateCallExpressionMemberMyFunc
  .tap('MyPlugin', (expression, param) => {
    /* ... */
    return expressionResult;
  });
複製代碼

Compiler

Compiler 即編譯管理器,它記錄了完整的 Webpack 環境及配置信息,負責編譯,在 Webpack 從啓動到結束,compiler 只會生成一次。貫穿了 Webpack 打包的整個生命週期。在 compiler 對象上能夠拿到 當前 Webpack 配置信息,具體能夠查看下面 compiler.options 裏的一些數據。express

image.png

compiler 內部使用了 Tapable 類去實現插件的發佈和訂閱,能夠看下面一個最簡的例子。

const { AsyncSeriesHook, SyncHook } = require('tapable');

// 建立類
class Compiler {
  constructor() {
    this.hooks = {
      run: new AsyncSeriesHook(['compiler']), // 異步鉤子
      compile: new SyncHook(['params']), // 同步鉤子
    };
  }
  run() {
    // 執行異步鉤子
    this.hooks.run.callAsync(this, (err) => {
      this.compile(onCompiled);
    });
  }
  compile() {
    // 執行同步鉤子 並傳參
    this.hooks.compile.call(params);
  }
}

module.exports = Compiler;
複製代碼
const Compiler = require('./Compiler');

class MyPlugin {
  apply(compiler) {
    // 接受 compiler參數
    compiler.hooks.run.tap('MyPlugin', () => console.log('開始編譯...'));
    compiler.hooks.complier.tapAsync('MyPlugin', (name, age) => {
      setTimeout(() => {
        console.log('編譯中...');
      }, 1000);
    });
  }
}

// 這裏相似於 webpack.config.js 的 plugins 配置
// 向 plugins 屬性傳入 new 實例

const myPlugin = new MyPlugin();

const options = {
  plugins: [myPlugin],
};

const compiler = new Compiler(options);

compiler.run();
複製代碼

完整的實現可參考 Webpack v5.38.1 Compiler.js 源碼

經常使用鉤子

compiler 的大部分 hooks 詳細描述文檔可參考 Webpack 官網提供的 Compiler Hooks

  • environment
  • afterEnvironment
  • entryOptions
  • afterPlugins
  • normalModuleFactory
  • compilation
  • make
  • ...

Compilation

Compilation 表明了一次資源版本構建。當運行 webpack 時,每當檢測到一個文件變化,就會建立一個新的 compilation,從而生成一組新的編譯資源。一個 Compilation 對象表現了當前的模塊資源、編譯生成資源、變化的文件、以及被跟蹤依賴的狀態信息,簡單來說就是把本次打包編譯的內容存到內存裏。Compilation 對象也提供了插件須要自定義功能的回調,以供插件作自定義處理時選擇使用拓展。

Compilation 類也繼承自 Tapable 類並提供了一些生命週期鉤子,compilation 實例的具體結構可參考下圖。

image.png

經常使用鉤子

compilation 的大部分 hooks 詳細描述文檔可參考 Webpack 官網提供的 Compilation Hooks

  • seal
  • finishModules
  • record
  • optimize
  • ...

NormalModuleFactory

NormalModuleFactory 模塊被 Compiler 編譯用於生成各種模塊。從入口點開始,NormalModuleFactory 會分解每一個請求,解析文件內容以查找進一步的請求,而後經過分解全部請求以及解析新的文件來爬取所有文件。在最後階段,每一個依賴項都會成爲一個模塊 (Module) 實例。

NormalModuleFactory 類擴展了 Tapable 並提供瞭如下的生命週期鉤子,NormalModuleFactory 實例可參考下圖。

image.png

經常使用鉤子

normalModuleFactory hooks 詳細描述文檔可參考 Webpack 官網提供的 NormalModuleFactory Hooks

  • NormalModuleFactory Hooks
  • factorize
  • resolve
  • resolveForScheme
  • afterResolve
  • createModule
  • module
  • createParser
  • parser
  • createGenerator
  • generator

JavascriptParser

parser 實例在 webpack 中被用於解析各種模塊,parser 是另外一個 webpack 中的繼承自 Tapable 的類,並基於此提供了一系列的鉤子函數方便插件開發者在解析模塊的過程當中進行一些自定義的操做,而 JavascriptParser 則是在 webpack 中用到相對來講最多的一個解析器,具體使用方式及其實例結構可參考下方;

compiler.hooks.normalModuleFactory.tap('MyPlugin', (factory) => {
  factory.hooks.parser
    .for('javascript/auto')
    .tap('MyPlugin', (parser, options) => {
      parser.hooks.someHook.tap(/* ... */);
    });
});
複製代碼

image.png

經常使用鉤子

JavscriptParser hooks 詳細描述文檔可參考 Webpack 官網提供的 Javascript Hooks

  • evaluateTypeof
  • evaluate
  • evaluateIdentifier
  • evaluateDefinedIdentifier
  • evaluateCallExpressionMember
  • statement
  • statementIf
  • label
  • import
  • importSpecifier
  • export
  • exportImport
  • ...

看到上面這些若是對 babel 或者 ast 有過簡單的瞭解是否是有一絲眼熟?

關鍵對象總結

  • Tapable: 一個適用於 webpack 插件體系的小型發佈訂閱庫;
  • Compiler: 編譯管理器,webpack 啓動後會建立 compiler 對象,該對象一直存在直到退出。
  • Compilation: 單次編輯過程的管理器,運行過程當中只有一個 compiler 但每次文件變動觸發從新編譯時,都會建立一個新的 compilation 對象。
  • NormalModuleFactory: 模塊生成工廠類,其實例在 compiler 中生成,用於從入口文件開始處理依賴關係並生成模塊的關係;
  • JavascriptParser: JS 模塊解析器,生成的 JavascriptParser 實例在 NormalFactory 中,用於解析 JS 模塊。

Webpack 構建流程圖

webpack()

實例化 compiler 對象

image.png

graph TD
    O1[node api]-->A
    O2[webpack-cli]-->A
    O3[webpack-dev-server]-->A
    A["webpack(options, callback)"] -->B{"Array.isArray(options)"}
    B -->|是|D["createMultiCompiler(options)"]
    B -->|否|E["createCompiler(options)"]
    D -->F{callback?}
    E -->F
    F -->|是|F1{options.watch?}
    F1 -->|是|W["compiler.watch()"] --> Return
    F1 -->|否|Run["compiler.run()"]
    F -->|否|Run
    Run-->Return["return compiler"]

createCompiler()

建立 compiler 時作了什麼

graph TD
    A["createCompiler(rawOptions)"]-->B["getNormalizedWebpackOptions(rawOptions)"]
    B-->|歸一化 options|C["applyWebpackOptionsBaseDefaults(options)"]
    C-->|給 options 寫入 context 默認值|D["new Compiler(options.context)"]
    D-->|實例化 compiler 對象|E["new NodeEnvironmentPlugin().apply(compiler)"]
    E-->F{"Array.isArray(options.plugin)"}
    F-->|是|F1["遍歷 plugin.apply(compiler)"] -->|應用插件|G
    F-->|否|G
    G["applyWebpackOptionsDefaults(options)"]-->|給 options 寫入默認值|Evt1
    subgraph compiler.hooks
    Evt1["compiler.hooks.environment.call()"]-->Evt2
    Evt2["compiler.hooks.afterEnvironment.call()"]
    I["compiler.hooks.initialize.call()"]
    end
    Evt2-->H["new WebpackOptionsApply().process(options, compiler)"]-->|基於 options 配置應用內置插件|I
    I-->R[return compiler]

compiler.run() / compiler.watch()

compiler 編譯時作了什麼

graph TD
    A["compiler.run(callback)"]-->B["getNormalizedWebpackOptions(rawOptions)"]-->Evt1
    subgraph compiler.hooks
    Evt1["compiler.hooks.beforeRun.callAsync(compiler)"]
    Evt2["compiler.hooks.run.callAsync(compiler)"]
    Evt3["compiler.hooks.beforeCompile.callAsync(params)"]
    Evt4["compiler.hooks.compile.call(compiler)"]
    Evt5["compiler.hooks.make.callAsync(compilation)"]
    Evt6["compiler.hooks.finishMake.callAsync(compilation)"]
    Evt7["compiler.hooks.afterCompile.callAsync(compilation)"]
    Evt8["compiler.hooks.done.callAsync(stats)"]
    EvtNMF["compiler.hooks.normalModuleFactory.call(normalModuleFactory)"]
    EvtCMF["compiler.hooks.contextModuleFactory.call(contextModuleFactory)"]
    end
    Evt1-->Evt2
    Evt2-->E["compiler.readRecords()"]
    E-->|參考 options.recordsInputPath,這一步不重要|F
    F["compiler.compile(onCompiled)"]-->H["compile.newCompilationParams()"]
    H.->H1["params.normalModuleFactory = new NormalModuleFactory()"]-->EvtNMF
    H1.->H2["params.contextModuleFactory = new ContextModuleFactory()"]-->EvtCMF-->|params|Evt3
    Evt3-->Evt4
    Evt4-->I["compiler.newCompilation(params)"]
    I-->J["new Compilation(compiler)"]-->EvtC1
    subgraph compilation.hooks
    EvtC1["compilation.hooks.thisCompilation.call(compilation, params)"]
    EvtC1-->EvtC2["compilation.hooks.compilation.call(compilation, params)"]
    end
    EvtC2-->EntryEvt1
    EvtC2-->Evt5
    Evt5-->EntryEvt2-->EntryCD
    EntryCD["EntryPlugin.createDependency(entry, options)"]-->|dep|addEntry
    addEntry["compilation.addEntry(context, dep, options)"]-->Evt6
    Evt6-->L["compilation.finish()"]
    L-->M["compilation.seal()"]
    M-->Evt7
    Evt7-->N["compiler.emitAssets()"]
    N-->O["compiler.emitRecords()"]
    O-->|參考options.recordsOutputPath,這一步不重要|P["new Stats(compilation)"]-->|stats|Evt8

    subgraph EntryPlugin
    EntryEvt1["compiler.hooks.compilation.tap"]
    EntryEvt2["compiler.hooks.make.tapAsync"]
    end

compilation.addEntry()

compilation 在添加入口開始後作了什麼

graph TD


addEntry["compilation.addEntry"]
addEntryItem["compilation._addEntryItem"]
addModuleTree["compilation.addModuleTree"]
handleModuleCreation["compilation.handleModuleCreation"]
factorizeModule["compilation.factorizeModule"]
factorizeQueue["compilation.factorizeQueue.add"]
ensureProcessing["compilation.factorizeQueue._ensureProcessing"]
startProcessing["compilation.factorizeQueue._startProcessing"]
processor["compilation.factorizeQueue._processor"]
_factorizeModule["compilation._factorizeModule"]
factoryCreate["NormalModuleFactory.create"]

subgraph NormalModuleFactory
beforeResolve["normalModuleFactory.hooks.beforeResolve.callAsync(resolveData)"]
factorize["normalModuleFactory.hooks.factorize.callAsync(resolveData)"]
resolve["normalModuleFactory.hooks.resolve.callAsync(resolveData)"]
afterResolve["normalModuleFactory.hooks.afterResolve.callAsync(resolveData)"]
createModule["normalModuleFactory.hooks.createModule.callAsync(createData, resolveData)"]
end

addModule["compilation.addModule"]
buildModule["compilation.buildModule"]
processModuleDependencies["compilation.processModuleDependencies"]

addEntry-->addEntryItem-->addModuleTree
-->handleModuleCreation-->factorizeModule
-->factorizeQueue-->ensureProcessing-->
startProcessing-->processor-->
_factorizeModule-->factoryCreate-->
beforeResolve-->factorize-->resolve-->afterResolve-->createModule-->
addModule-->buildModule-->processModuleDependencies

Webpack HMR 插件分析

運行 demo

# clone webpack 倉庫
$ git clone https://github.com/ShinyLeee/webpack.git

# 將 webpack link 到全局
$ cd webpack && npm link

# clone webpack-dev-server 倉庫
$ git clone https://github.com/ShinyLeee/webpack-dev-server.git

# 安裝依賴
$ cd webpack-dev-server & npm i

# 將 webpack-dev-server link 到全局
$ npm link

# 將 node_modules 裏的 webpack 和 webpack-dev-server 連接到剛剛 link 到全局本地倉庫
$ npm link webpack && npm link webpack-dev-server

# 安裝 ndb 方便調試
$ npm install -g ndb

# 運行 react-hmr demo
$ cd examples/cli/hmr && ndb webpack-dev-server
複製代碼

HMR 源碼分析

compiler.hooks.compilation

compilation.dependencyFactories、compilation.dependencyTemplates 中注入 HMR API(accept、decline)

image.png

compilation.hooks.record

compilation.hooks.fullHash

  • 介紹:這裏比較坑,webpack 官方文檔中並未列出該事件的文檔,只能經過 fullHash.call 在源碼中,查找其對應觸發的位置,實際上的做用有如下幾點:
    1. 遍歷 chunks 並獲取 hash 寫入到 chunkModuleHashes 與上方的 record 事件結合並最終產出到 records.json。
    2. 記錄變更模塊信息至 updatedModules,與下面 processAssets 事件結合,最終經過 compilation.emitAsset 方法並配合 outputs.hotUpdateMainFilename('[runtime].[fullhash].hot-update.json') 和 outputs.hotUpdateChunkFilename('[runtime].[fullhash].hot-update.json') 產出熱更新 js、json 文件。
  • 源碼 diff:feature/hooks.fullHash - 244ff381577e67231299cd3ae81d43a1d62a837c

compilation.hooks.processAssets:

compilation.hooks.additionalTreeRuntimeRequirements

  • 介紹:這裏 webpack 官方文檔也沒有列出該事件的文檔,但它實際的做用是最明顯的,注入了 HMR 運行時相關代碼,實際效果詳見源碼 diff,能夠從 diff 裏看到實際產出的 js 資源文件裏面 HMR 相關的代碼全都沒了,因此這裏也就是 HMR 插件如何與 dev-server 最核心的實現層。 image.png
  • 源碼 diff:hooks.additionalTreeRuntimeRequirements - c16a0f1df8954cfabd6997db1d1900e85d654771

normalModuleFactory.hooks.parser.for("javascript/auto")

Webpack 調試技巧

  • npm link 本地調試
  • 測試用例結合產出物和源碼進行分析
  • 使用 ndb 或 vscode debug 斷點調試
  • 若是對於 webpack 的構建流程實在以爲複雜,能夠從須要研究的對象實例上的 hooks 鉤子看起,可以大體理解到這個對象的執行流程。
  • 若是在官網找不到對應鉤子時,能夠經過 hooks.[鉤子名].call 在原理裏進行搜索。

總結

webpack 總體構建流程基於 Tapable 事件流機制實現,很是靈活,可是喪失了可讀性,確實閱讀起來太...噁心,另一些方法和模塊都有 TS 的類型註釋是比較好閱讀的,可是註釋基本都只存在對參數的描述,對方法的描述幾乎沒有,這塊也很痛苦,不過內置的一些能力基本上全都是基於一些基類去實現的,只要理解了一塊其餘理解起來都仍是相對方便的,如 hooks 全都是基於 Tapable 類,以及一些 Module 類等。

相關文章
相關標籤/搜索