三千字基于 HMR 插件解析 Webpack 源码
本文由团队成员 咕噜 撰写,已授权涂鸦大前端独家使用,包括但不限于编辑、标注原创等权益。
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 实例化:
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 实例化:
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 parameters
而 Webpack 也正是基于这种 强耦合 的方式,下面的 Compiler 和 Compilation 实例在特定时机触发钩子时会附带上足够的上下文信息,使得 Plugin 能够订阅并且基于当前上下文信息和业务逻辑进而产生副作用(修改上下文状态),从而影响后续的编译流程。
// 强耦合的基于 Tapable 类实现 compiler 实例
compiler.hooks.beforeCompile.tapAsync('MyPlugin', (params, callback) => {
params['MyPlugin - data'] = 'important stuff my plugin will use later';
callback();
});
- 拓展了 HookMap 这种集合操作的特性
this.hooks = Object.freeze({
/** @type {HookMap<SyncBailHook<[CallExpressionNode, BasicEvaluatedExpression | undefined], BasicEvaluatedExpression | undefined | null>>} */
evaluateCallExpressionMember: new HookMap(
() => new SyncBailHook(["expression", "param"])
),
...otherHooks,
});
在使用 HookMap 的情况下:
// 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 的情况下:
// 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 里的一些数据。
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 实例的具体结构可参考下图。
常用钩子
compilation 的大部分 hooks 详细描述文档可参考 Webpack 官网提供的 Compilation Hooks
- seal
- finishModules
- record
- optimize
- ...
NormalModuleFactory
NormalModuleFactory 模块被 Compiler 编译用于生成各类模块。从入口点开始,NormalModuleFactory 会分解每个请求,解析文件内容以查找进一步的请求,然后通过分解所有请求以及解析新的文件来爬取全部文件。在最后阶段,每个依赖项都会成为一个模块 (Module) 实例。
NormalModuleFactory 类扩展了 Tapable 并提供了以下的生命周期钩子,NormalModuleFactory 实例可参考下图。
常用钩子
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(/* ... */);
});
});
常用钩子
JavscriptParser hooks 详细描述文档可参考 Webpack 官网提供的 Javascript Hooks
- evaluateTypeof
- evaluate
- evaluateIdentifier
- evaluateDefinedIdentifier
- evaluateCallExpressionMember
- statement
- statementIf
- label
- import
- importSpecifier
- export
- exportImport
- ...
看到上面这些如果对 babel 或者 ast 有过简单的了解是不是有一丝眼熟?
- @babel/types - callExpression
- ESTree Spec - CallExpression @babel/parse 依赖 acorn 而 acorn 则使用的为 ESTree 的规范
关键对象总结
- Tapable: 一个适用于 webpack 插件体系的小型发布订阅库;
- Compiler: 编译管理器,webpack 启动后会创建 compiler 对象,该对象一直存在直到退出。
- Compilation: 单次编辑过程的管理器,运行过程中只有一个 compiler 但每次文件变更触发重新编译时,都会创建一个新的 compilation 对象。
- NormalModuleFactory: 模块生成工厂类,其实例在 compiler 中生成,用于从入口文件开始处理依赖关系并生成模块的关系;
- JavascriptParser: JS 模块解析器,生成的 JavascriptParser 实例在 NormalFactory 中,用于解析 JS 模块。
Webpack 构建流程图
webpack()
实例化 compiler 对象
createCompiler()
创建 compiler 时做了什么
compiler.run() / compiler.watch()
compiler 编译时做了什么
compilation.addEntry()
compilation 在添加入口开始后做了什么
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)
compilation.hooks.record
- 介绍:将在 fullHash 事件触发时获取的一系列数据写入到 records.json 中去。
- 源码 diff:feature/hooks.record - 78d9387f57c4ede4e67f520fffe761515fd0a9fc。
compilation.hooks.fullHash
- 介绍:这里比较坑,webpack 官方文档中并未列出该事件的文档,只能通过 fullHash.call 在源码中,查找其对应触发的位置,实际上的作用有以下几点:
- 遍历 chunks 并获取 hash 写入到 chunkModuleHashes 与上方的 record 事件结合并最终产出到 records.json。
- 记录变动模块信息至 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:
- 介绍:输出 hot-update.js 及 hot-update.json 文件,注意首次编译时不会产出这两个文件的,实际效果详见 feature/hooks.processAssets - f30e9bb46c5969f190ba07ef33e87cd274fe8116(存在 processAssets 事件时) 对比 feature/hooks.processAssets - e5fac945adcd50b587ec2706da228f169961496e(去掉 processAssets 事件时,可以发现去掉 processAssets 事件处理器以后就不会再产出热更新相关的文件资源了。
compilation.hooks.additionalTreeRuntimeRequirements
- 介绍:这里 webpack 官方文档也没有列出该事件的文档,但它实际的作用是最明显的,注入了 HMR 运行时相关代码,实际效果详见源码 diff,可以从 diff 里看到实际产出的 js 资源文件里面 HMR 相关的代码全都没了,所以这里也就是 HMR 插件如何与 dev-server 最核心的实现层。
- 源码 diff:hooks.additionalTreeRuntimeRequirements - c16a0f1df8954cfabd6997db1d1900e85d654771
normalModuleFactory.hooks.parser.for("javascript/auto")
- 介绍:在解析 JS 文件时,如果业务代码里存在 module.hot 或 import.meta.webpackHot API 则会进行 function hooks 事件注册并将代码实际写入到编译后的源码中区。
- 源码 diff:hooks.parser - de6cf202ebcc404f2a6158fc80b7f537894a74d8
Webpack 调试技巧
- npm link 本地调试
- 测试用例结合产出物和源码进行分析
- 使用 ndb 或 vscode debug 断点调试
- 如果对于 webpack 的构建流程实在觉得复杂,可以从需要研究的对象实例上的 hooks 钩子看起,能够大致理解到这个对象的执行流程。
- 如果在官网找不到对应钩子时,可以通过 hooks.[钩子名].call 在原理里进行搜索。
总结
webpack 整体构建流程基于 Tapable 事件流机制实现,非常灵活,但是丧失了可读性,确实阅读起来太...恶心,另外一些方法和模块都有 TS 的类型注释是比较好阅读的,但是注释基本都只存在对参数的描述,对方法的描述几乎没有,这块也很痛苦,不过内置的一些能力基本上全都是基于一些基类去实现的,只要理解了一块其他理解起来都还是相对方便的,如 hooks 全都是基于 Tapable 类,以及一些 Module 类等。
转载自:https://juejin.cn/post/6979058369352957960