webpack 编译原理(How webpack compiles)
作者:大力智能技术团队-前端 chengcyber
大力智能前端基础架构团队在为业务项目优化编译速度的过程中,对基于 webpack 的编译过程进行了研究。本文就以 webpack@5.11.0 版本展开在源码层面讲解 webpack 的编译原理。
理解 Tapable
webpack 的源码是非常抽象的,基本所有的操作都是通过 Tapable 动态注册回调形成插件机制。所以我们先看下 Tapable 是什么。
一句话来说,Tapable 就是支持下列纬度的 EventEmitter,提供了注册事件回调,并能让事件产生方来灵活选择如何执行这些回调。
-
执行方式:同步、异步串行、异步并行
-
过程控制:基本(依次执行)、流式(依次执行但传入上次的结果,类似 reduce)、提前结束(依次执行,但是允许提前结束)、循环(loop,循环执行直到返回的是 undefined)
举个简单的🌰:
class Person {
constructor(name) {
this.name = name;
this.hooks = {
eat: new SyncHook(),
}
}
eat() {
this.hooks.eat.call();
}
}
const zhangsan = new Person('zhangsan');
现在,我们有了一个 Person 类,开放了一个eat 的 hook。接着,我们实例化了一个 zhangsan 作为 Person 实例。然后,我构建了一个减肥插件(FitnessPlugin),目的是在 zhangsan 吃东西的时候就大喊:自觉点!!!
// FitnessPlugin
zhangsan.hooks.eat.tap("FitnessPlugin", () => console.log('自觉点!!!'));
只需要通过 hooks.eat.tap 方法就可以注册一个事件回调
zhangsan.eat(); // 张三吃东西了!
/// 自觉点!!! by FitnessPlugin
之后调用 eat 时,就会执行刚才注册的事件回调,console 输出自觉点。
理解了 Tapable,你已经具备了为 webpack 撰写一个 FitnessPlugin 的能力了 😉
Compiler 编译器实例化
Compiler 是 webpack 根据传入 webpack.config.js 和 cli 参数组合生成的编译器实例,源码传送门:github.com/webpack/web… 。
Compiler 的主要功能
-
根据是否 watch 选择 watchRun 还是 run
-
执行 compile,新建一个 Compilation
-
使用 Compilation 执行编译过程
-
emit 产物
生命周期
在 Compiler 运行的过程中,webpack 定义了许多生命周期。先放一张总览图(排除 watch 和错误处理):
正是由于有如此多的生命周期开放出来,所以 webpack 的插件机制很强大,可以灵活的介入编译的各个环节;同时也对源码阅读带来了难度,因为全是动态注册的。接下来,我们看几个主要的生命周期和其作用帮助了解 Compiler。
compiler.hooks.compilation
Compilation 被创建出来后,同样拥有众多生命周期通过插件介入管控此次的编译过程。例如:
-
JavascriptModulePlugin 会注册 js 文件的 parser 和 generator
-
EntryPlugin 会设置 EntryDependency 的 factory,告诉编译器如何处理 entry 模块
compiler.hooks.make
当 Compilation 被创建完成后(下文讲解 Compilation),就会调用 make 生命周期,webpack 内置了 EntryPlugin 注册了 make 的处理逻辑,用来找到入口模块,即 webpack.config 中的 entry。
// webpack.config.js
module.exports = {
entry: {
main: './index.js',
},
}
处理名为 main 的入口文件 ./index.js 最终会生成 EntryDependency 实例,通过 compilation.addEntry 添加进编译过程。
Compilation
Compiler 是经由配置生成的编译器,而一个 Compilation 实例代表一次完整的编译过程。包括加载入口模块,解析依赖,解析 AST,创建 Chunk,生成产物等一系列工作。
Compilation 主要过程
一个 Module 对应了一份源码文件,或者由源码文件解析过程中产生出的虚拟模块。虚拟模块:能够被编译器认为是一个模块,但不能正常对应到文件系统中的源码文件,一般编译器借此处理某些特殊模块类型。例如 require.context('./a')。
handleModuleCreation
前面说到 EntryPlugin 会把 entry 加入进编译过程。经过这个函数进行处理,最终会通过 NormalModuleFactory 进行实例化,继续获得 dependencies 然后递归这个过程。
buildModule
使用 Parser 对源码进行 parse 这里会遍历 ast 中的所有节点,然后 call 相应的 Tapable 事件,插件通过注册对应的语法回调,干预 parse 的结果。这里有点像是用 Tapable 模拟了 visitor 的逻辑,并且开放语法处理的逻辑给外部。
举一个 🌰:webpack 有一个内置的语法 require.context(webpack.js.org/guides/depe…
const allFiles = require.context('./a', false, /\.js$/);
对 ast 进行遍历,直到遇到 require.context,此时就调用 RequireContextDependencyParserPlugin 注册的回调函数,根据参数创建一个 RequireContextDependency,对当前的 NormalModule 调用 addDependency 添加该依赖。最终在生成代码的时候会被替换成:
const allFiles = __webpack_require__('./a sync \\\\.js$');
而这个 ./ sync \\\\.js$ 是根据 require.context 的参数生产的 ContextModule 的 name,其文件内容如下:
var map = {
"./index.js": "./a/index.js"
};
function webpackContext(req) {
var id = webpackContextResolve(req);
return webpack_require(id);
}
function webpackContextResolve(req) {
if (!webpack_require.o(map, req)) {
var e = new Error("Cannot find module '" + req + "'");
e.code = 'MODULE_NOT_FOUND';
throw e;
}
return map[req];
}
webpackContext.keys = function webpackContextKeys() {
return Object.keys(map);
};
webpackContext.resolve = webpackContextResolve;
module.exports = webpackContext;
webpackContext.id = "./a sync \.js$";
NormalModuleFactory 模块工厂👷♂️
做一个称职的模块工厂,主要含有下列的行为
-
create 创建模块
-
resolve 解析模块依赖
-
parse 将模块代码生成 AST
create 创建模块
执行 resolve,用其结果实例化 NormalModule,记录依赖,文件,源码等信息。还会处理 module.rules 的配置(见下文 RuleSetCompiler)。
resolve 解析模块依赖
通过 getResolver 拿到 normalResolver (所以叫 NormalModule 😁),去解析 ./index.js。获得一系列信息,例如:resource 文件系统的绝对地址,descriptionFilePath 当前项目的 package.json 绝对地址。然后执行 RuleSet 拿到处理规则,再找到对应的 loaders,生成对应的 parser,generator。
parse 将模块代码生成 AST
通过 getParser 根据文件类型拿到对应的 parser,这里的 parser 映射关系是通过插件注册进来(下文讲述)。假设是 js 文件,对应的是 JavascriptParser,会在 NormalModule 的 build 过程中调用 parse 函数,通过 acorn 生成 AST,然后在内部处理 AST(处理过程见下文 Parser)。
NormalResolver
代码中其实是没有 NormalResolver 的,所有的 Resolver 都是通过 ResolverFactory 创建,再通过 type 进行区分。即 NormalResolver === ResolverFactory 一个实例,其 type === 'normal'。
为什么这么设计?
webpack 中的 resolve 都是用的 enhanced-resolve 通过不同的配置来进行。解析不同的目标对象时需要有不同的策略。比如 NormalResolver 的职责是对应源码的模块依赖解析;解析Loader 需要有 LoaderResolver 专门解析类似 ts-loader 的代码位置。在创建不同 Resolver 的时候,可以通过插件注入不同的 resolverOptions,来达到控制 resolver 的目的。
举个🌰:我现在需要解析某个 esm 类型的 NormalModule,对应的是 NormalResolver 也就是ResolverFactory with type 'normal'。在创建时,通过 WebpackOptionsApply 注入参数逻辑。最终得到 enhanced-resolve 对 esm 模块需要的参数。
{
conditionNames: ['import', 'module', 'webpack', 'development', 'browser'],
aliasFields: ['browser'],
mainFields: ['browser', 'module', 'main'],
modules: ['node_modules'],
mainFiles: ['index'],
extensions: ['.ts', '.js', '.json'],
exportsFields: ['exports'],
}
大白话就是依次看 package.json 中的 module 和 main 字段,然后根据 browser 字段来做别名,过程中可以自动尝试添加后缀 .ts,.js,.json。
RuleSetCompiler
RuleSetCompiler 会把用户设置的 module.rules 和 webpack 内置的默认 rules 结合起来,形成一个方法,在后续处理中直接调用来确定模块处理用哪些 loaders 还有 parse 代码时需要的参数。
举一个🌰:
// webpack.config.js
module: {
rules: [
{
test: /\.tsx?$/,
loader: "ts-loader",
options: {
transpileOnly: true
}
}
]
},
经过了 compile 后就变成了:
{
conditions: [
{
property: 'resource',
matchWhenEmpty: false,
fn: (v) => /\.tsx?$/.test(v),
},
],
effects: [
{
loader: 'ts-loader',
options: {
transpileOnly: true,
},
ident: 'ruleSet[1].rules[0]',
},
],
}
下面来看看这个结果是怎么产生的。
CompileRule 过程
首先,BasicMatcherRulePlugin 注入了处理 test 的逻辑,调用 ruleSetCompiler.compileCondition 把这个正则转换成了 (v) => /\.tsx?$/.test 的匹配函数,针对的属性是 resource 属性,即上述结果中的 conditions[0]。接着,UseEffectRulePlugin 注入处理 loader 的逻辑,结果很好理解,就是使用 ts-loader 并且 loaderOptions 是 { transpileOnly: true }。如果对插件注入的位置有兴趣,可以看注入的逻辑地址。
这个结果怎么使用?
compiledRule 里面含有两个 array: conditions 和 effects。当一个 Module resolve 出结果后,会通过所有的 compiledRule 运行一遍,其中对于某条 compiledRule,一旦 conditions 全部符合,就会累积 effects 给后续处理(下文讲解得出的 effects 怎么使用)。
CompiledRule.exec
直接举一个🌰:假如一个 src/index.tsx 文件,经过了处理后得到的结果是:
effects: [
{
type: 'type',
value: 'javascript/auto',
},
{
type: 'use',
value: {
loader: 'ts-loader',
options: {
transpileOnly: true,
},
},
},
]
type: use 会把 ts-loader 作为这个 Module 的 loaders。type: type 会设置这个 Module 的参数,这里就是 type: 'javascript/auto' 指导 parser 如何解析。
Parser
webpack 有一些默认的 Parser
-
JavascriptParser
-
javascript/auto
-
javascript/esm
-
javascript/dynamic
-
JsonParser
-
json
-
WebAssemblyParser
-
webassembly/sync
-
webassembly/async
-
AssetParser
-
asset
-
asset/inline
-
asset/source
-
asset/resource
这里主要讲一下 JavascriptParser
JavascriptParser
通过 JavascriptModulePlugin 插件对 js 类型文件注入 parser 和 generator。
这里的 js 有三种类型:
-
javascript/auto
-
webpack@3 包括之前版本对 js 的默认类型,可以是 CommonJS,AMD,ESM
-
javascript/esm
-
webpack@4 为了 treeshaking 引入的 js 类型,只能处理 ESM
-
有更严格的规则,动态引用必须是 default,不能是 namespace
-
javascript/dynamic
-
只能处理 CommonJS 类型
说点大白话,对 acorn 来说,只有两种 sourceType:module 和 script
javascript/dynamic 就是 sourceType: script
javascript/esm 就是 sourceType: module
javascript/auto 就是先用 module parse 一下,如果失败了就再用 script parse 😉
AssetParser
这里提一下 asset,因为有四种类型,做一下说明:
-
asset
-
webpack 自动判断属于 asset/inline (文件大小 < 8kb),还是 asset/resource
-
asset/inline
-
使用 Data URI 内联在代码中
-
asset/resource
-
产出对应资源的文件,代码中链接形式引用
-
asset/source
-
类似 asset/inline 但是内联的是文件源码,多用于 .txt 类型
更详细的可以查看官方文档的 Asset Modules 章节。
Generator
与 Parser 的概念想对应,自然而然可以想到有
-
JavascriptGenerator
-
JsonGenerator
-
WebAssemblyGenerator
JavascriptGenerator
这里大概讲一下对 js 文件的 generate 行为。
1. 对引用的地方,会进行修改,举个🌰:
require("./index");会替换成require(/*! ./index */"./index.ts");
2. 替换require -> __webpack_require__
require("./index");
__webpack_require__("./index");
3. 绑定的runtime 函数信息
由于 2 中有用到 __webpack_require__,所以需要注入这个 runtime 函数,将 Module 需要 __webpack__require__ 的信息记录下来,在最后 renderManifest 的时候在添加对应的 runtime 代码,源码位置请移步这里。
Tree Shaking
Tree shaking 是在产物中去掉没有用到代码的过程。他的命名和概念来源于另一个 bundle 工具:rollup。
举🌰:
// index.js
import { cube } from './math';
console.log(cube(2)); // 计算 2 的三次方
// math.js
export function square(x) {
return x * x;
}
export function cube(x) {
return x * x * x;
}
通过我们对代码的阅读,很快就能知道 math#square 这个函数没有被使用到,可以在产物中去除来减小代码体积。现在来看一下开发环境中的产物(设置 optimization: { usedExports: true }):
/***/ "./math.js":
/*!*****************!*\
!*** ./math.js ***!
\*****************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ "cube": () => /* binding */ cube
/* harmony export */ });
/* unused harmony export square */
function square(x) {
return x * x;
}
function cube(x) {
return x * x * x;
}
/***/ })
从产物中可以看到 math#square 这个函数还在,没有被 tree shaking 掉,这是由于开发模式是不会触发去除 dead code 这个功能的,需要在生产模式下才能生效。但是可以看到 export 都加上了 harmony 的注释,而且 square 有一行 unused harmony export 这些是怎么来的呢?
首先,需要多个插件配合 HarmonyExportDependencyParserPlugin ,HarmonyImportDependencyParserPlugin,FlagDependencyUsagePlugin
-
解析到 import { cube } from './math' 时,会对当前模块添加 HarmonyImportSpecifierDependency,在这个 dependency 下会记录被使用的 export 变量名,在这里就是记录了 cube 被使用。
-
在 optimize 阶段,由 FlagDependencyUsagePlugin 会从 HarmonyImportSpecifierDependency 中读取被引用的信息,来给所有 export 加上是否被使用的信息
-
在代码生成的时候,获取未使用的 exports 中含有 square,然后添加这行注释 unused harmony export square
上面说了开发环境的处理,那生产环境到底最终是怎么去除的呢?
-
HarmonyExportDependencyPlugin 看到 export 语法的时候会给当前模块添加 HarmonyExportHeaderDependency
-
这个 dependency 的功能就是在生成代码的时候去掉 export(同上面开发环境生成的代码)
-
最终,代码会通过 uglify 的工具去掉 dead code 即 math#square 代码,在当前版本中就是 terser 来处理这块逻辑。
结语
恭喜你,看到了这里👏。对于webpack来说,可以讲解的点实在是太多了。
转载自:https://juejin.cn/post/6969443457450393607