外观
AST 与 Webpack 自定义 Loader / Plugin 系统学习文档
适合人群:希望理解 Babel、ESLint、代码转换、工程化插件、Webpack Loader、Webpack Plugin 底层原理,并能写出真实可用自定义 loader/plugin 的前端工程化学习者。
学习目标:从 AST 基础开始,理解 parser、traverse、generator、visitor、node/path、source map,再掌握 webpack loader 与 plugin 的职责边界、执行顺序、开发方式和项目落地场景。
核对时间:2026-07-04。本文参考 Babel Handbook、Babel parser/traverse/types/generator、ESTree、Webpack Loader API、Webpack Plugin API、Tapable、常用 loader/plugin 官方源码仓库等资料整理。
目录
- 为什么前端架构师要懂 AST、Loader、Plugin
- AST 是什么
- Parser、Traverse、Generator 是什么
- 常见 AST 节点解释
- Babel 插件基础
- ESLint 规则和 Babel 插件有什么区别
- Webpack Loader 是什么
- 自定义 Loader 实战
- Webpack Plugin 是什么
- 自定义 Plugin 实战
- Loader 和 Plugin 的区别
- 在 Vue/React/Nuxt/Node 项目中怎么用
- Source Map、缓存与性能
- 常见问题与反模式
- 学习路线与练习
- 常用 Loader / Plugin 源码阅读补全
- 官方参考资料
1. 为什么前端架构师要懂 AST、Loader、Plugin
很多工程化能力都建立在“读取代码、分析代码、转换代码、参与构建生命周期”之上。
例如:
- Babel 把新语法转换成目标环境支持的代码。
- ESLint 通过 AST 检查代码问题。
- TypeScript 编译器分析类型和语法树。
- Vue SFC 编译器把
.vue文件拆成 template/script/style。 - Webpack loader 把 TypeScript、CSS、图片、Markdown 转成模块。
- Webpack plugin 在构建生命周期里生成 HTML、压缩资源、上传 sourcemap、生成统计报告。
如果你只会配置工具,就很难解决深层工程问题。
如果你懂 AST、loader、plugin,就能:
- 写代码迁移脚本。
- 写自定义 ESLint 规则。
- 写 Babel 插件注入埋点。
- 写 loader 处理特殊文件。
- 写 plugin 生成构建报告。
- 理解 Vite/Webpack/Babel 的职责边界。
2. AST 是什么
AST:Abstract Syntax Tree,抽象语法树。
它把代码变成结构化对象。
代码:
js
const count = 1 + 2;可以被表示为:
text
Program
VariableDeclaration kind="const"
VariableDeclarator
Identifier name="count"
BinaryExpression operator="+"
NumericLiteral value=1
NumericLiteral value=22.1 为什么不用字符串处理代码
字符串替换很脆弱。
例如你想把变量 foo 改成 bar:
js
const foo = 1;
console.log(foo);
const text = "foo";
obj.foo = 2;简单字符串替换会错误地修改字符串和属性名。
AST 可以区分:
- 变量声明。
- 变量引用。
- 字符串字面量。
- 对象属性。
- import 名称。
- 作用域。
2.2 AST 用在哪里
常见应用:
- Babel 插件。
- ESLint 规则。
- Prettier 格式化。
- Codemod 代码迁移。
- Tree shaking。
- 代码压缩。
- 依赖分析。
- 文档生成。
3. Parser、Traverse、Generator 是什么
3.1 Parser
Parser 把源码转成 AST。
Babel 示例:
ts
import { parse } from "@babel/parser";
const ast = parse("const count = 1 + 2;", {
sourceType: "module",
});常见 parser:
@babel/parserespreetypescript-estree- TypeScript compiler parser
- SWC parser
3.2 Traverse
Traverse 遍历 AST。
ts
import traverse from "@babel/traverse";
traverse(ast, {
Identifier(path) {
console.log(path.node.name);
},
});Identifier 是节点类型。
path 是节点路径对象,它不只是 node,还包含父节点、作用域、替换、删除等操作能力。
3.3 Generator
Generator 把 AST 重新生成代码。
ts
import generate from "@babel/generator";
const output = generate(ast, {
sourceMaps: true,
});
console.log(output.code);3.4 完整流程
text
source code -> parser -> AST -> traverse/transform -> new AST -> generator -> output code4. 常见 AST 节点解释
4.1 Program
整个文件的根节点。
js
const a = 1;对应:
text
Program
body: [VariableDeclaration]4.2 Identifier
标识符,例如变量名、函数名。
js
const count = 1;count 是 Identifier。
4.3 Literal
字面量。
ESTree 中常叫 Literal,Babel 中更细分:
- StringLiteral。
- NumericLiteral。
- BooleanLiteral。
- NullLiteral。
4.4 VariableDeclaration
变量声明。
js
const name = "Ada";结构:
text
VariableDeclaration kind="const"
declarations: [VariableDeclarator]4.5 CallExpression
函数调用。
js
console.log("hello");结构:
text
CallExpression
callee: MemberExpression
arguments: [StringLiteral]4.6 MemberExpression
成员访问。
js
console.log
user.name
obj["key"]4.7 ImportDeclaration
ESM import。
js
import { ref } from "vue";可用于分析依赖。
4.8 JSXElement
JSX 元素。
jsx
<Button type="primary">Save</Button>React 相关 Babel 插件会处理 JSXElement。
4.9 作用域 Scope
作用域用于判断变量绑定关系。
js
const foo = 1;
function test() {
const foo = 2;
console.log(foo);
}两个 foo 不是同一个绑定。
写 AST 转换时必须理解作用域,否则会误改变量。
4.10 ESTree 与 Babel AST 的差异
不同工具输出的 AST 并不完全一样。ESTree 是社区规范(estree 仓库),@babel/parser 默认输出 Babel AST(ESTree 的超集加扩展),开启 estree 插件后输出更接近 ESTree 的节点:
| 维度 | ESTree(espree、Acorn 默认) | Babel AST(@babel/parser 默认) |
|---|---|---|
| 字面量 | 统一 Literal,带 value/raw | 细分 StringLiteral、NumericLiteral、BooleanLiteral、NullLiteral、BigIntLiteral、RegExpLiteral |
| 程序体 | Program 的 body | 相同 |
| 位置信息 | 可选(开 locations 才有 loc) | 默认带 start、end、loc |
| 额外信息 | 无 | leadingComments、trailingComments、innerComments 等 |
| 类型声明 | @types/estree | @babel/types |
这直接影响“规则写在哪边”:
- ESLint 规则面对的是 ESTree 风格 AST(
espree或@typescript-eslint输出),所以用Literal。 - Babel 插件面对 Babel AST,用
StringLiteral等细分类型。 - 同一段“禁止某种字面量”的逻辑,在两边匹配的节点类型不同,迁移时要改判断条件。
4.11 位置信息:loc、range、start/end
AST 节点都带位置信息,这是报错定位、sourcemap、代码修改不越界的基础:
js
// const count = 1 + 2;
// BinaryExpression 节点大致是:
{
type: "BinaryExpression",
operator: "+",
start: 14, // 源码字符偏移(0 起)
end: 19,
loc: {
start: { line: 1, column: 14 },
end: { line: 1, column: 19 },
},
}用途:
- 报错定位:
path.buildCodeFrameError()和 ESLintcontext.report()的红线位置都来自这里。 - 精确替换:拿到
start/end后可以只在源码对应区间做字符串替换(codemod 里保持其余代码不变的常用技巧)。 - sourcemap:generator 靠节点的
loc生成映射。
💡 手写 AST 修改后如果直接
generate,输出格式可能和原代码不一致(空格、括号、引号风格)。想要“只改必要部分、其余逐字保留”,更稳妥的做法是基于range对原始源码做区间替换,或用 Prettier/babel 的格式化选项统一风格。
5. Babel 插件基础
5.1 最小插件
js
export default function renamePlugin() {
return {
name: "rename-foo-to-bar",
visitor: {
Identifier(path) {
if (path.node.name === "foo") {
path.node.name = "bar";
}
},
},
};
}输入:
js
const foo = 1;
console.log(foo);输出:
js
const bar = 1;
console.log(bar);5.2 这个插件有什么问题
它会误改:
js
obj.foo;也可能误改:
js
const foo = "foo";真实插件要判断:
- 当前 Identifier 是否是变量绑定。
- 是否是对象属性。
- 是否在当前作用域。
- 是否来自某个 import。
5.3 判断 console.log
js
export default function removeConsoleLogPlugin() {
return {
visitor: {
CallExpression(path) {
const callee = path.get("callee");
if (
callee.isMemberExpression() &&
callee.get("object").isIdentifier({ name: "console" }) &&
callee.get("property").isIdentifier({ name: "log" })
) {
path.remove();
}
},
},
};
}输入:
js
console.log("debug");
doSomething();输出:
js
doSomething();5.4 自动注入埋点示例
目标:
tsx
<button data-track="save">保存</button>转换为:
tsx
<button data-track="save" onClick={trackClick}>保存</button>真实项目要注意:
- 是否已有
onClick。 - 不能覆盖业务事件。
- 组件和原生元素处理不同。
- 需要 import
trackClick。 - sourcemap 要正确。
5.5 Babel 插件适合做什么
适合:
- 语法转换。
- 代码注入。
- import 改写。
- 自动埋点。
- 删除调试代码。
- 框架 DSL 编译。
- codemod 迁移。
不适合:
- 纯代码质量检查,优先 ESLint。
- 需要类型信息的复杂判断,可能更适合 TypeScript compiler API。
- 运行时才能确定的逻辑。
5.6 Babel 8 对 AST 和插件写法的改变
本文前面的示例以 Babel 7 风格为主(存量项目居多)。如果你在 Babel 8 上写插件或工具,官方 API 迁移指南里有几处必须知道的变化:
| 变化 | Babel 7 | Babel 8 |
|---|---|---|
动态 import() 的节点 | CallExpression,callee 是特殊的 Import 节点 | 独立的 ImportExpression 节点,带 source 和 options |
BigIntLiteral.value | 字符串 | 真正的 bigint(注意 JSON.stringify 不能序列化 bigint) |
| TS 相关节点 | 与 @typescript-eslint 差异较大 | 主动对齐 @typescript-eslint 的形状(如 TSTypeParameter.name 变成 Identifier) |
| 插件写法 | 允许 export const visitor = {} 这种具名导出 | 必须 default export 插件对象或工厂函数 |
pass.getModuleName() | 可用 | 移除,改用 pass.file.getModuleName() |
动态 import 的 AST 对比:
js
// 输入
import("./foo");
// Babel 7 AST
// CallExpression
// callee: { type: "Import" }
// arguments: [StringLiteral("./foo")]
// Babel 8 AST
// ImportExpression
// source: StringLiteral("./foo")⚠️ 分析依赖、统计动态 import 的插件,升级 Babel 8 后必须把
CallExpression + callee.type === "Import"的判断改成匹配ImportExpression。可以先在 Babel 7.23+ 里开parserOpts: { createImportExpressions: true }提前验证。
工具脚本的写法本身没有变化:@babel/parser + @babel/traverse + @babel/generator 仍是标准三件套。
6. ESLint 规则和 Babel 插件有什么区别
6.1 ESLint 规则
目标:检查代码。
输出:
- error。
- warning。
- fix。
- suggestion。
例子:
text
禁止直接导入废弃模块
禁止跨模块引用内部文件
禁止在组件中使用 console.log6.2 Babel 插件
目标:转换代码。
输出:
- 新代码。
- sourcemap。
例子:
text
把 JSX 转换成函数调用
自动注入运行时代码
删除调试语句
按需引入组件6.3 怎么选择
如果目标是“发现问题”,用 ESLint。
如果目标是“改变构建产物”,用 Babel 插件。
例子:
- 禁止
legacyRequest:ESLint 规则。 - 把
import { Button } from "ui"改成按需路径:Babel 插件或构建插件。 - 自动埋点:Babel 插件。
- 禁止模块跨层 import:ESLint 规则或 Nx 边界。
7. Webpack Loader 是什么
7.1 Loader 的职责
Webpack 默认只理解 JavaScript 和 JSON。loader 让 webpack 能处理其他类型文件。
例如:
babel-loader:把新 JS/TS/JSX 转成 JS。css-loader:把 CSS 转成 JS 模块。style-loader:把 CSS 注入页面。vue-loader:处理.vue单文件组件。file-loader/ asset modules:处理图片字体等资源。
一句话:
Loader 是“文件内容转换函数”。
7.2 Loader 输入输出
loader 接收源文件内容,返回转换后的内容。
text
source file -> loader1 -> loader2 -> final JS module7.3 执行顺序
配置:
js
use: ["style-loader", "css-loader", "sass-loader"]执行顺序是从右到左:
text
sass-loader -> css-loader -> style-loader原因:
sass-loader把 Sass 转 CSS。css-loader把 CSS 转 JS 模块。style-loader把样式注入页面。
7.4 pitch 阶段:两阶段执行
loader 其实有两个执行阶段,很多人只知道 normal 阶段:
text
配置:use: ["a-loader", "b-loader", "c-loader"]
pitch 阶段(从左到右):a.pitch -> b.pitch -> c.pitch
normal 阶段(从右到左):c -> b -> ajs
module.exports = function normalLoader(source) {
// 正常转换,从右到左执行
return source;
};
module.exports.pitch = function pitchLoader(remainingRequest, precedingRequest, data) {
// remainingRequest:当前 loader 之后还要执行的请求串
// precedingRequest:之前已经执行过的请求串
// data:可以传值给同 loader 的 normal 阶段
};关键规则:如果某个 pitch 返回了结果,剩余的 pitch 和所有 normal 阶段都会被跳过,返回值直接作为模块源码进入后续流程。
这就是 style-loader 的工作原理:
js
module.exports.pitch = function (remainingRequest) {
// 不再处理 CSS 文本,直接返回一段“import 剩余链路并注入样式”的 JS
return `
import css from ${JSON.stringify("!!" + remainingRequest)};
injectStyle(css);
`;
};- pitch 里拼出的请求字符串会重新进入 loader 流水线。
- 前缀
!!表示跳过所有其他匹配的 loader,只执行显式写出的链路,避免递归。 - 典型用途:短路后续处理、生成运行时代码、在 loader 之间传元数据(通过
data)。
💡 写自定义 loader 时不一定需要 pitch,但读
style-loader、vue-loader源码时绕不开它:它们都靠 pitch 阶段“劫持”请求链,把多文件编译编排成一个模块。
7.5 loader 上下文(this)速查
loader 函数里的 this 是 Webpack 提供的 loader context,常用成员:
| 成员 | 作用 |
|---|---|
this.resourcePath | 当前正在处理的文件绝对路径 |
this.getOptions(schema) | 读取 options(Webpack 5+,支持 schema 校验) |
this.async() / this.callback(err, code, map, meta) | 异步返回结果 |
this.cacheable(true) | 声明结果可缓存(默认就是可缓存,读取外部文件时要配合依赖声明) |
this.addDependency(file) / this.addContextDependency(dir) | 声明额外文件/目录依赖,让 watch 和持久缓存正确失效 |
this.emitFile(name, content) | 直接向构建产物输出文件(不经过模块图) |
this.sourceMap | 当前是否需要生成 source map |
this.mode / this.target | 构建模式(development/production)和目标(web/node) |
this.utils.contextify() | 把绝对路径转成相对请求路径(拼请求串时用) |
8. 自定义 Loader 实战
8.1 最小 loader
loaders/banner-loader.js:
js
module.exports = function bannerLoader(source) {
return `/* Generated by banner-loader */\n${source}`;
};Webpack 配置:
js
export default {
module: {
rules: [
{
test: /\.js$/,
use: [
{
loader: path.resolve("loaders/banner-loader.js"),
},
],
},
],
},
};8.2 使用 loader options
loaders/banner-loader.js:
js
const { getOptions } = require("loader-utils");
module.exports = function bannerLoader(source) {
const options = getOptions(this) || {};
const text = options.text || "Generated";
return `/* ${text} */\n${source}`;
};配置:
js
{
loader: path.resolve("loaders/banner-loader.js"),
options: {
text: "Company Internal Build"
}
}注意:新 webpack 版本可直接使用 this.getOptions()。
js
module.exports = function bannerLoader(source) {
const options = this.getOptions();
return `/* ${options.text} */\n${source}`;
};8.3 异步 loader
js
module.exports = function asyncLoader(source) {
const callback = this.async();
setTimeout(() => {
callback(null, source.replace("__BUILD_TIME__", Date.now().toString()));
}, 10);
};8.4 返回 source map
js
module.exports = function loader(source, inputSourceMap) {
const callback = this.async();
const result = transform(source, inputSourceMap);
callback(null, result.code, result.map);
};source map 用于把构建后的代码映射回源码,方便调试。
8.5 用 AST 写 loader
目标:删除代码中的 debugger。
js
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const generate = require("@babel/generator").default;
module.exports = function removeDebuggerLoader(source) {
const ast = parser.parse(source, {
sourceType: "module",
plugins: ["jsx", "typescript"],
});
traverse(ast, {
DebuggerStatement(path) {
path.remove();
},
});
const output = generate(ast, {
sourceMaps: true,
sourceFileName: this.resourcePath,
});
this.callback(null, output.code, output.map);
};8.6 Loader 适合场景
适合:
- 转换某类文件内容。
- Markdown 转组件。
- YAML 转 JS 对象。
- 给源码注入 banner。
- 处理自定义 DSL。
- 对单个模块做编译转换。
不适合:
- 生成全局构建报告。
- 上传 sourcemap。
- 修改构建输出资源列表。
- 参与构建生命周期。
这些更适合 plugin。
9. Webpack Plugin 是什么
9.1 Plugin 的职责
Plugin 可以介入 webpack 构建生命周期。
它能做:
- 生成 HTML。
- 清理 dist。
- 复制静态资源。
- 压缩资源。
- 生成 manifest。
- 上传 sourcemap。
- 输出 bundle 分析报告。
- 修改 assets。
一句话:
Plugin 是“构建生命周期扩展”。
9.2 Compiler
Compiler 代表整个 webpack 编译器实例。
它包含:
- 配置。
- hooks。
- 文件系统。
- resolver。
- 构建入口。
Plugin 的入口是:
js
class MyPlugin {
apply(compiler) {
compiler.hooks.done.tap("MyPlugin", (stats) => {
console.log("Build done");
});
}
}9.3 Compilation
Compilation 代表一次具体构建。
它包含:
- modules。
- chunks。
- assets。
- dependencies。
- errors。
- warnings。
watch 模式下,一个 Compiler 会产生多次 Compilation。
9.4 Tapable
Webpack hooks 基于 Tapable。
常见 hook 类型:
- 同步 hook。
- 异步 callback hook。
- 异步 promise hook。
- waterfall hook。
- bail hook。
使用:
js
compiler.hooks.done.tap("PluginName", (stats) => {});
compiler.hooks.emit.tapAsync("PluginName", (compilation, callback) => {});
compiler.hooks.thisCompilation.tap("PluginName", (compilation) => {});9.4.1 Hook 类型与订阅方式
Tapable 提供多种 hook 类,理解类型才能知道“我的回调什么时候被调用、返回值有没有用”:
| Hook 类 | 执行方式 | 返回值语义 |
|---|---|---|
SyncHook | 同步,逐个调用 | 返回值被忽略 |
SyncBailHook | 同步,某个监听返回非 undefined 就停止(bail) | 第一个非 undefined 返回值生效,如 shouldEmit 返回 false 阻止输出 |
SyncWaterfallHook | 同步,上一个监听的返回值传给下一个 | 最终返回值是流过所有监听后的结果 |
AsyncSeriesHook | 异步,按顺序执行完所有监听 | 返回值被忽略,如 done、emit |
AsyncParallelHook | 异步,所有监听并行启动 | 全部完成后才继续,如 make |
订阅方式有三种,必须和 hook 类型匹配:
js
// 1) tap:同步订阅,可用于所有 hook
compiler.hooks.done.tap("MyPlugin", (stats) => {});
// 2) tapAsync:回调风格,可用于异步 hook,必须调用 callback
compiler.hooks.emit.tapAsync("MyPlugin", (compilation, callback) => {
setTimeout(() => callback(), 10); // 忘记调用 callback 会卡死构建
});
// 3) tapPromise:Promise 风格,可用于异步 hook
compiler.hooks.afterEmit.tapPromise("MyPlugin", async (compilation) => {
await upload(compilation);
});常见错误:
- 对
SyncHook用tapAsync/tapPromise会直接报错。 tapAsync里异步操作出错时,要把错误传给callback(err),否则构建会挂起或吞掉错误。- 订阅时第一个参数是插件名(用于调试和报错定位),应该始终写自己的插件名而不是随意复制。
9.5 Compiler Hooks 与 Compilation Hooks 使用时机
先把几个容易混淆的词分清:
Compiler:整个 Webpack 编译器实例,生命周期覆盖一次命令或一次 watch 会话。compiler.hooks.compile:Compiler 上的一个 hook,表示“即将创建新的 compilation”。compiler.hooks.compilation:Compiler 上的一个 hook,表示“新的 compilation 已创建,可以拿到 compilation 对象”。Compilation:一次具体构建。watch 模式下,一个Compiler会不断创建新的Compilation。compilation.hooks.*:一次构建内部的模块构建、优化、生成 asset 等生命周期 hook。
简化生命周期:
text
初始化 compiler
-> environment / afterEnvironment
-> beforeRun / run 或 watchRun
-> beforeCompile
-> compile
-> thisCompilation
-> compilation
-> make
-> 模块构建:buildModule / succeedModule / finishModules
-> seal
-> 优化:optimizeModules / optimizeChunks / optimizeTree
-> 资源处理:processAssets
-> afterCompile
-> shouldEmit
-> emit
-> afterEmit / assetEmitted
-> done记忆方式:
compiler.hooks.*负责“整次构建流程在哪里”,compilation.hooks.*负责“一次构建内部的模块、chunk、asset 如何处理”。
9.5.1 常用 Compiler Hooks
| Hook | 类型 | 触发时机 | 常见用途 |
|---|---|---|---|
environment | SyncHook | compiler 环境准备阶段,配置里的插件初始化之后 | 读取或修改基础环境,不常用于业务插件。 |
afterEnvironment | SyncHook | environment 之后 | 环境准备后的补充初始化。 |
initialize | SyncHook | compiler 对象初始化完成后 | 插件内部状态初始化。 |
beforeRun | AsyncSeriesHook | 非 watch 构建开始前 | 构建前校验、读取外部配置。 |
run | AsyncSeriesHook | 非 watch 构建开始时 | 记录构建开始时间、初始化远端任务。 |
watchRun | AsyncSeriesHook | watch 模式每次重新构建前 | 根据变更文件做增量检查。 |
invalid | SyncHook | watch 模式下文件变更导致当前构建失效时 | 清理缓存、提示重新构建。 |
beforeCompile | AsyncSeriesHook | compilation 参数创建之后、正式 compile 之前 | 准备构建参数,较少直接使用。 |
compile | SyncHook | 新 compilation 创建之前 | 只能拿到 compilation 参数,适合记录“即将开始一次编译”。 |
thisCompilation | SyncHook | 初始化新的 compilation 时,早于 compilation | 最常用:注册 compilation.hooks.*,生成/修改 assets。 |
compilation | SyncHook | compilation 创建完成后 | 访问 compilation 和 normalModuleFactory,常用于 parser hooks。 |
make | AsyncParallelHook | 从 entry 开始构建模块图时 | 添加入口、创建额外依赖、启动模块构建相关逻辑。 |
finishMake | AsyncSeriesHook | make 阶段完成后 | make 后的补充检查。 |
afterCompile | AsyncSeriesHook | compilation 完成后、emit 之前 | 读取构建结果、做发射前检查。 |
shouldEmit | SyncBailHook | 输出 assets 前,返回 false 可阻止输出 | bundle size gate、产物合法性检查。 |
emit | AsyncSeriesHook | assets 输出到文件系统之前 | Webpack 4 常见;Webpack 5 修改 assets 更推荐 processAssets。 |
afterEmit | AsyncSeriesHook | assets 写入文件系统之后 | 上传 sourcemap、通知部署平台、读取最终文件。 |
assetEmitted | AsyncSeriesHook | 单个 asset 被写入后 | 记录每个文件的路径、大小、hash。 |
done | AsyncSeriesHook | 整次构建完成后 | 输出构建报告、耗时统计、成功通知。 |
failed | SyncHook | 构建失败时 | 错误上报、失败通知。 |
watchClose | SyncHook | watch 关闭时 | 清理 watcher 相关资源。 |
shutdown | AsyncSeriesHook | compiler 关闭时 | 关闭连接、释放资源。 |
compile 示例:只能感知“新的 compilation 即将创建”,此时还不能操作 assets。
js
class CompileLogPlugin {
apply(compiler) {
compiler.hooks.compile.tap("CompileLogPlugin", (params) => {
console.log("A new compilation is about to be created");
console.log(Boolean(params.normalModuleFactory));
});
}
}thisCompilation 示例:进入某一次构建,并注册 compilation hooks。
js
const { Compilation, sources } = require("webpack");
class BuildMetaPlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap("BuildMetaPlugin", (compilation) => {
compilation.hooks.processAssets.tap(
{
name: "BuildMetaPlugin",
stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
},
() => {
compilation.emitAsset(
"build-meta.json",
new sources.RawSource(
JSON.stringify({ hash: compilation.hash }, null, 2)
)
);
}
);
});
}
}compilation 示例:常用于接入 normalModuleFactory 或 parser hooks。
js
class ParserDebugPlugin {
apply(compiler) {
compiler.hooks.compilation.tap(
"ParserDebugPlugin",
(compilation, { normalModuleFactory }) => {
normalModuleFactory.hooks.parser
.for("javascript/auto")
.tap("ParserDebugPlugin", (parser) => {
parser.hooks.program.tap("ParserDebugPlugin", () => {
console.log("A JavaScript module is being parsed");
});
});
}
);
}
}9.5.2 常用 Compilation Hooks
| Hook | 类型 | 触发时机 | 常见用途 |
|---|---|---|---|
buildModule | SyncHook | 某个模块开始构建前 | 给模块加标记、读取模块信息。 |
rebuildModule | SyncHook | watch 模式下模块重新构建前 | 观察增量构建。 |
failedModule | SyncHook | 模块构建失败时 | 记录失败模块、错误上报。 |
succeedModule | SyncHook | 模块构建成功后 | 统计模块数量、分析构建耗时。 |
finishModules | AsyncSeriesHook | 所有模块构建完成后 | 分析 module graph,做依赖检查。 |
seal | SyncHook | compilation 开始封存,后续进入优化和产物生成 | 在优化前做最后检查。 |
optimizeDependencies | SyncBailHook | 依赖优化阶段 | 分析或调整依赖关系,业务插件少用。 |
optimizeModules | SyncBailHook | 模块优化阶段 | 模块级优化,业务插件慎用。 |
optimizeChunks | SyncBailHook | chunk 优化阶段 | chunk 级分析或优化,业务插件慎用。 |
optimizeTree | AsyncSeriesHook | module/chunk tree 优化阶段 | 深度构建优化,通常是 Webpack 内部或复杂插件使用。 |
processAssets | AsyncSeriesHook | assets 处理阶段,可按 stage 插入 | Webpack 5 中生成、修改、压缩、分析 assets 的首选 hook。 |
afterProcessAssets | SyncHook | processAssets 全部处理完成后 | 最终读取 assets,不建议再大改。 |
needAdditionalSeal | SyncBailHook | 判断是否需要再次 seal | 高级插件场景,业务插件少用。 |
afterSeal | AsyncSeriesHook | seal 完成后 | seal 后异步收尾。 |
模块构建统计示例:
js
class ModuleCountPlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap("ModuleCountPlugin", (compilation) => {
let count = 0;
compilation.hooks.succeedModule.tap("ModuleCountPlugin", () => {
count += 1;
});
compilation.hooks.finishModules.tapPromise(
"ModuleCountPlugin",
async () => {
console.log(`Built modules: ${count}`);
}
);
});
}
}9.5.3 processAssets 阶段怎么选
Webpack 5 推荐用 compilation.hooks.processAssets 处理 assets。选择 stage 时按“越早越偏生成,越晚越偏分析/报告”的思路。
| Stage | 使用时机 |
|---|---|
PROCESS_ASSETS_STAGE_ADDITIONAL | 添加额外 asset,例如生成 manifest.json、build-info.json。 |
PROCESS_ASSETS_STAGE_PRE_PROCESS | 对已有 assets 做基础预处理。 |
PROCESS_ASSETS_STAGE_DERIVED | 从已有 asset 派生新 asset。 |
PROCESS_ASSETS_STAGE_ADDITIONS | 给已有 asset 添加内容,例如 banner、license。 |
PROCESS_ASSETS_STAGE_OPTIMIZE | 通用优化阶段。 |
PROCESS_ASSETS_STAGE_OPTIMIZE_COUNT | 优化 asset 数量,例如合并。 |
PROCESS_ASSETS_STAGE_OPTIMIZE_COMPATIBILITY | 兼容性优化。 |
PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE | 体积优化,例如压缩。 |
PROCESS_ASSETS_STAGE_DEV_TOOLING | 添加开发工具相关内容,例如 source map 支持。 |
PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE | inline 相关优化。 |
PROCESS_ASSETS_STAGE_SUMMARIZE | 汇总 assets 列表,适合生成清单。 |
PROCESS_ASSETS_STAGE_OPTIMIZE_HASH | asset hash 相关优化。 |
PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER | 传输优化,例如 gzip/brotli 预生成。 |
PROCESS_ASSETS_STAGE_ANALYSE | 分析 assets。 |
PROCESS_ASSETS_STAGE_REPORT | 生成报告类 asset,例如 bundle 报告。 |
常见选择:
- 生成新文件:
ADDITIONAL或ADDITIONS。 - 修改 JS/CSS 内容:
ADDITIONS或OPTIMIZE。 - 压缩文件:
OPTIMIZE_SIZE。 - 生成 manifest:
SUMMARIZE。 - 生成分析报告:
REPORT。
9.5.4 选 Hook 的经验规则
| 需求 | 推荐 hook |
|---|---|
| 构建开始前读取配置 | compiler.hooks.beforeRun / compiler.hooks.run |
| watch 模式文件变更后重新准备 | compiler.hooks.watchRun / compiler.hooks.invalid |
| 注册本次构建的 compilation hooks | compiler.hooks.thisCompilation |
| 接入 JS parser,做编译期常量或语法分析 | compiler.hooks.compilation + normalModuleFactory.hooks.parser |
| 统计模块构建情况 | compilation.hooks.buildModule / succeedModule / finishModules |
| 生成或修改构建产物 | compilation.hooks.processAssets |
| 阻止不合法产物输出 | compiler.hooks.shouldEmit |
| 产物写入磁盘后上传 sourcemap | compiler.hooks.afterEmit |
| 构建结束后输出报告 | compiler.hooks.done |
| 构建失败后通知平台 | compiler.hooks.failed |
注意:
- Webpack 5 中,修改 assets 不建议优先用
compiler.hooks.emit,更推荐compilation.hooks.processAssets。 compile阶段还没有具体 assets,不适合生成文件。afterEmit已经写入文件系统,适合上传或通知,不适合再改构建产物。- watch 模式每次变更都会有新的
Compilation,插件不要把本次构建状态错误地存到全局变量里。
10. 自定义 Plugin 实战
10.1 构建完成提示 Plugin
js
class BuildDonePlugin {
apply(compiler) {
compiler.hooks.done.tap("BuildDonePlugin", (stats) => {
console.log(`Build finished in ${stats.endTime - stats.startTime}ms`);
});
}
}
module.exports = BuildDonePlugin;配置:
js
plugins: [new BuildDonePlugin()]10.2 生成构建信息文件
Webpack 5 推荐使用 processAssets。
js
const { sources, Compilation } = require("webpack");
class BuildInfoPlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap("BuildInfoPlugin", (compilation) => {
compilation.hooks.processAssets.tap(
{
name: "BuildInfoPlugin",
stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
},
() => {
const info = {
buildTime: new Date().toISOString(),
hash: compilation.hash,
assets: Object.keys(compilation.assets),
};
compilation.emitAsset(
"build-info.json",
new sources.RawSource(JSON.stringify(info, null, 2))
);
}
);
});
}
}
module.exports = BuildInfoPlugin;输出:
json
{
"buildTime": "2026-06-30T02:00:00.000Z",
"hash": "abc123",
"assets": ["main.js", "style.css"]
}10.3 上传 sourcemap Plugin 思路
生产中经常需要构建后上传 sourcemap 到 Sentry 或内部平台。
流程:
- 构建生成 sourcemap。
- plugin 在 assets 阶段找到
.map文件。 - 调用平台 API 上传。
- 上传成功后可删除公开产物中的
.map。
伪代码:
js
class UploadSourceMapPlugin {
apply(compiler) {
compiler.hooks.afterEmit.tapPromise("UploadSourceMapPlugin", async (compilation) => {
const sourceMaps = Object.keys(compilation.assets).filter((name) =>
name.endsWith(".map")
);
for (const file of sourceMaps) {
await uploadToMonitorPlatform(file);
}
});
}
}注意:
- 失败是否阻塞构建要明确。
- token 不要写死在代码里。
- release id 要和前端运行时代码一致。
- 上传后不要公开暴露 sourcemap。
10.4 Plugin 适合场景
适合:
- 构建报告。
- 资源清单。
- HTML 注入。
- sourcemap 上传。
- CDN 上传。
- 构建产物检查。
- bundle size gate。
不适合:
- 转换单个源文件内容。
- 处理某种文件格式。
这些更适合 loader。
11. Loader 和 Plugin 的区别
| 维度 | Loader | Plugin |
|---|---|---|
| 核心职责 | 转换文件内容 | 介入构建生命周期 |
| 输入 | 单个模块源码 | compiler / compilation |
| 输出 | 转换后的模块代码 | 修改构建过程或产物 |
| 执行位置 | module.rules | plugins |
| 典型例子 | babel-loader、css-loader、vue-loader | HtmlWebpackPlugin、DefinePlugin |
| 适合场景 | 文件转换 | 资源生成、构建统计、上传、优化 |
一句话:
Loader 处理“某个文件怎么变成模块”,Plugin 处理“整个构建过程要额外做什么”。
12. 在 Vue/React/Nuxt/Node 项目中怎么用
12.1 Vue2 / Vue CLI
Vue2 项目通常基于 Webpack。
vue.config.js:
js
const BuildInfoPlugin = require("./plugins/BuildInfoPlugin");
module.exports = {
configureWebpack: {
plugins: [new BuildInfoPlugin()],
},
chainWebpack(config) {
config.module
.rule("md")
.test(/\.md$/)
.use("raw-loader")
.loader("raw-loader");
},
};适合:
- 老项目补构建信息。
- 上传 sourcemap。
- 自定义 Markdown 处理。
- 构建产物检查。
12.2 Vue3 / Vite
Vite 不使用 webpack loader/plugin 体系。
Vite 有自己的 plugin 机制,基于 Rollup plugin 思想。
如果是 Vite 项目:
- 不写 webpack loader。
- 使用 Vite plugin。
- AST 转换可在 Vite plugin 的
transform钩子中做。
示例:
ts
export function removeDebuggerPlugin() {
return {
name: "remove-debugger",
transform(code, id) {
if (!id.endsWith(".ts")) return null;
return {
code: code.replace(/\bdebugger;?/g, ""),
map: null,
};
},
};
}真实项目建议使用 AST,而不是正则。
12.3 React / Webpack
如果是 CRA 老项目或自定义 Webpack:
js
module.exports = {
module: {
rules: [
{
test: /\.[jt]sx?$/,
use: [
{
loader: path.resolve("loaders/remove-debugger-loader.js"),
},
"babel-loader",
],
},
],
},
plugins: [new BuildInfoPlugin()],
};注意 loader 顺序:从右到左执行。
12.4 Next / Nuxt
Next 和 Nuxt 都有框架封装。
Nuxt 现代版本更多是 Vite/Nitro/Rollup 生态,不建议直接套 webpack loader 思维。
如果确实需要底层构建扩展,应优先查看框架官方扩展方式。
12.5 Node 工具项目
AST 不只用于前端构建,也适合 Node CLI:
- 批量迁移 Vue2 API。
- 扫描项目依赖。
- 生成路由文档。
- 替换废弃 API。
- 统计组件使用次数。
示例用途:
text
扫描所有 import "@/features/*/*" 的跨边界引用
输出违反架构规则的文件列表如果只是检查,优先写 ESLint 规则。
如果要批量修改,写 codemod。
13. Source Map、缓存与性能
13.1 Source Map
Source Map 把构建后的代码映射回源码。
loader 和 plugin 如果修改代码,应该尽量传递或生成 sourcemap。
否则生产报错会定位到构建后代码,难以排查。
13.2 Loader 缓存
Webpack 5 有持久化缓存。
loader 应避免:
- 每次读取大量无关文件。
- 使用不可追踪的随机值。
- 忽略依赖文件。
如果 loader 依赖额外文件,要声明:
js
this.addDependency(options.configFile);13.3 Plugin 性能
Plugin 不要在频繁 hook 中做重活。
注意:
- 避免同步 IO。
- 大文件上传用异步。
- watch 模式减少重复计算。
- 明确哪些错误阻塞构建。
13.4 Source Map 上传
生产建议:
- 构建生成 sourcemap。
- 上传到监控平台。
- 不把
.map暴露到 CDN。 - release id 保持一致。
14. 常见问题与反模式
14.1 用正则改代码
简单场景可以,但复杂代码转换不可靠。
例如:
js
code.replace("foo", "bar")会误伤字符串、注释、属性名。
14.2 Loader 做全局事情
Loader 应处理单个模块。
如果你想生成构建报告,用 Plugin。
14.3 Plugin 改源码内容
如果只是转换某类文件,用 Loader 或 Babel/Vite plugin 更自然。
14.4 忽视 sourcemap
构建能跑不代表能上线。
没有 sourcemap,生产问题定位成本会明显增加。
14.5 插件里写死环境变量和 token
不要把上传平台 token 写在 plugin 源码里。
使用:
- 环境变量。
- CI secret。
- 配置文件。
14.6 没有测试
自定义 loader/plugin 是工程基础设施,必须有测试。
测试要覆盖:
- 正常转换。
- sourcemap。
- 错误输入。
- 配置项。
- watch 模式。
15. 学习路线与练习
15.1 基础阶段
- 使用 AST Explorer 观察
const a = 1的 AST。 - 找出 Identifier、CallExpression、ImportDeclaration。
- 写一个 Babel 插件删除
console.log。
15.2 进阶阶段
- 写一个 loader 给 JS 文件加 banner。
- 写一个 loader 把 Markdown 转成 JS 字符串。
- 写一个 plugin 生成
build-info.json。 - 给 loader/plugin 加测试。
15.3 高级阶段
- 写一个 AST loader 删除生产环境
debugger。 - 写一个 plugin 上传 sourcemap。
- 写一个 codemod 把旧 API 替换为新 API。
- 写一个 ESLint 规则限制跨模块 import。
15.4 架构师阶段
- 为团队封装内部 webpack plugin。
- 为组件库构建生成 manifest。
- 为监控系统自动注入 release id。
- 为旧项目升级写 codemod。
- 设计 loader/plugin 的版本和测试策略。
16. 常用 Loader / Plugin 源码阅读补全
本章补的是“能写自定义 loader/plugin”之后的下一步:看懂常用 loader/plugin 为什么这么设计。已有文档已经覆盖了自定义 loader/plugin 的基础写法、Compiler/Compilation/Tapable、source map、缓存和性能,但缺少把这些知识映射到真实源码的学习路径。
说明:第三方项目源码以官方仓库为准。这里给出源码入口、阅读重点和“学习版源码”,不整段复制第三方库源码。学习版代码刻意保留核心机制,省略了真实项目里的边界兼容、缓存细节、错误提示和历史包袱,适合用来理解原理。
16.1 当前文档覆盖结论
| 问题 | 当前覆盖位置 | 结论 |
|---|---|---|
| 自定义 loader 怎么写 | 本文第 8 章 | 已覆盖最小 loader、options、异步 loader、source map、AST loader。 |
| 自定义 plugin 怎么写 | 本文第 10 章 | 已覆盖 done、thisCompilation、processAssets、emitAsset 等常用写法。 |
| Webpack 构建流程 | Webpack系统学习文档.md 第 21.1 节 | 已覆盖从配置、compiler、compilation、module graph、chunk graph 到 assets 的主流程。 |
| Loader 执行链和 pitch | Webpack系统学习文档.md 第 21.3、21.4 节 | 已覆盖从右到左的 normal loader 和从左到右的 pitch 阶段。 |
| 性能优化 | Webpack系统学习文档.md 第 12、13、21.11、21.12 节 | 已覆盖 production mode、hash、splitChunks、tree shaking、source map、filesystem cache、loader include 等。 |
| 常用 loader/plugin 源码阅读 | 原文档缺少系统章节 | 本章补全。 |
16.2 源码阅读路线
阅读顺序建议:
- 先读
babel-loader:它最接近普通自定义 loader。 - 再读
style-loader:理解 pitch loader 为什么存在。 - 再读
css-loader:理解一个 loader 如何把非 JS 资源变成 JS 模块。 - 再读
mini-css-extract-plugin:理解为什么复杂能力经常需要 loader + plugin 协作。 - 最后读
HtmlWebpackPlugin、DefinePlugin、CopyWebpackPlugin:进入 compilation assets、parser hooks 和构建产物处理。
16.3 Loader 的通用源码骨架
真实 loader 大多围绕这些点展开:
- 读取 options:
this.getOptions(schema)。 - 缓存声明:
this.cacheable()。 - 异步返回:
const callback = this.async()。 - 声明额外依赖:
this.addDependency(file)、this.addContextDependency(dir)。 - 传递 source map:
callback(null, code, map, meta)。 - 生成 JS 模块代码:loader 的最终输出通常仍要被 Webpack 当作模块继续解析。
最小骨架:
js
module.exports = function exampleLoader(source, inputSourceMap, meta) {
this.cacheable && this.cacheable();
const options = this.getOptions();
const callback = this.async();
transform(source, options)
.then((result) => {
callback(null, result.code, result.map || inputSourceMap, meta);
})
.catch((error) => {
callback(error);
});
};16.4 babel-loader 学习版源码
babel-loader 的本质:把 Webpack 传入的模块源码交给 Babel 转换,再把 Babel 输出的 code、map、metadata 交还给 Webpack。
学习版:
js
// loaders/learning-babel-loader.js
const babel = require("@babel/core");
const schema = {
type: "object",
properties: {
presets: { type: "array" },
plugins: { type: "array" },
sourceMaps: { type: ["boolean", "string"] },
},
additionalProperties: true,
};
module.exports = function learningBabelLoader(source, inputSourceMap) {
this.cacheable && this.cacheable();
const callback = this.async();
const options = this.getOptions(schema);
babel
.transformAsync(source, {
...options,
filename: this.resourcePath,
inputSourceMap,
sourceMaps: true,
caller: {
name: "learning-babel-loader",
supportsStaticESM: true,
},
})
.then((result) => {
if (!result) {
callback(null, source, inputSourceMap);
return;
}
callback(null, result.code, result.map, result.metadata);
})
.catch((error) => {
error.message = `[learning-babel-loader] ${error.message}`;
callback(error);
});
};配置:
js
{
test: /\.[jt]sx?$/,
include: path.resolve(__dirname, "src"),
use: {
loader: path.resolve("loaders/learning-babel-loader.js"),
options: {
presets: ["@babel/preset-env", "@babel/preset-react"]
}
}
}和真实 babel-loader 对照看:
- 真实源码会处理 Babel 配置合并、缓存标识、外部 config 依赖、caller 信息和错误美化。
- 学习版只保留“Webpack loader 调 Babel transform”的主干。
- 如果 loader 读取了额外配置文件,要调用
this.addDependency(configFile),否则 watch/cache 可能不知道何时失效。
16.5 css-loader 学习版源码
css-loader 的本质:把 CSS 变成 JS 模块。真实 css-loader 还会处理 @import、url()、CSS Modules、ICSS、source map 和 PostCSS 插件链。
学习版只演示两个核心动作:
- 把
@import "./base.css"转成 JSimport。 - 默认导出 CSS 字符串,让后续 loader 或运行时代码继续处理。
js
// loaders/learning-css-loader.js
module.exports = function learningCssLoader(source) {
this.cacheable && this.cacheable();
const imports = [];
const css = source.replace(/@import\s+["']([^"']+)["'];?/g, (_, request) => {
const index = imports.length;
imports.push(request);
return `/* replaced @import ${index} */`;
});
const importCode = imports
.map((request, index) => {
return `import importedCss${index} from ${JSON.stringify(request)};`;
})
.join("\n");
const importedList = imports.map((_, index) => `importedCss${index}`).join(", ");
return `
${importCode}
const localCss = ${JSON.stringify(css)};
const importedCss = [${importedList}].filter(Boolean).join("\\n");
export default importedCss
? importedCss + "\\n" + localCss
: localCss;
`;
};配置:
js
{
test: /\.css$/,
use: [
path.resolve("loaders/learning-style-loader.js"),
path.resolve("loaders/learning-css-loader.js")
]
}和真实 css-loader 对照看:
- 真实源码会使用 PostCSS AST,而不是只用正则。
url()里的资源会变成模块依赖,交给 Webpack asset modules 继续处理。- CSS Modules 会生成局部类名映射,并导出给 JS 使用。
16.6 style-loader 学习版源码
style-loader 的核心是 pitch loader。它不直接处理 CSS 文本,而是在 pitch 阶段“接管”后续请求,生成一段运行时代码:先 import 后续 loader 处理结果,再插入 <style>。
js
// loaders/learning-style-loader.js
module.exports = function learningStyleLoader() {};
module.exports.pitch = function pitch(remainingRequest) {
const request = this.utils.contextify(
this.context,
"!!" + remainingRequest
);
return `
import css from ${JSON.stringify(request)};
const style = document.createElement("style");
style.textContent = Array.isArray(css) ? css.join("\\n") : css;
document.head.appendChild(style);
export default css;
`;
};为什么这里要加 "!!":
remainingRequest表示当前 loader 后面的 loader 链和资源路径。"!!"表示禁用其他配置里的 pre/normal/post loader,只执行当前显式拼出来的链路。- 这样可以避免当前 loader 再次被匹配,造成递归或重复处理。
和真实 style-loader 对照看:
- 真实源码支持多种注入方式,例如
styleTag、singletonStyleTag、linkTag。 - 真实源码会把 DOM 注入逻辑拆到 runtime 模块中,方便复用和处理 HMR。
- pitch loader 是读懂
style-loader的关键。
16.7 mini-css-extract-plugin 学习版源码
mini-css-extract-plugin 的真实实现比 style-loader 复杂很多。它不是把 CSS 注入 DOM,而是把 CSS 从 JS 依赖图里提取成独立 CSS asset。真实源码需要处理 chunk、顺序、runtime、HMR、source map 等问题。
学习版 loader:把每个 CSS 文件直接输出为一个独立 CSS 文件,并把文件名导出给 JS。
js
// loaders/learning-css-extract-loader.js
const path = require("path");
module.exports = function learningCssExtractLoader(source) {
this.cacheable && this.cacheable();
const options = this.getOptions({
type: "object",
properties: {
filename: { type: "string" },
},
additionalProperties: false,
});
const baseName = path.basename(this.resourcePath).replace(/\.[^.]+$/, ".css");
const filename = options.filename || `css/${baseName}`;
this.emitFile(filename, source);
return `export default ${JSON.stringify(filename)};`;
};配套 plugin:扫描最终 assets,生成 CSS 清单,帮助你观察“哪些 CSS 被提取出来”。
js
// plugins/LearningCssManifestPlugin.js
const { Compilation, sources } = require("webpack");
class LearningCssManifestPlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap("LearningCssManifestPlugin", (compilation) => {
compilation.hooks.processAssets.tap(
{
name: "LearningCssManifestPlugin",
stage: Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE,
},
() => {
const cssAssets = compilation
.getAssets()
.map((asset) => asset.name)
.filter((name) => name.endsWith(".css"));
compilation.emitAsset(
"css-manifest.json",
new sources.RawSource(JSON.stringify(cssAssets, null, 2))
);
}
);
});
}
}
module.exports = LearningCssManifestPlugin;和真实 mini-css-extract-plugin 对照看:
- 真实源码不是简单
emitFile,而是把 CSS 作为依赖参与 chunk 生成。 - 真实 plugin 会按 chunk 输出 CSS,并处理 CSS 顺序冲突。
- 这类能力说明:当需求超出“转换单个文件内容”时,通常要 loader + plugin 协作。
16.8 html-webpack-plugin 学习版源码
html-webpack-plugin 的本质:读取 compilation 里的 JS/CSS assets,生成 HTML,并把资源标签注入进去。
js
// plugins/LearningHtmlPlugin.js
const { Compilation, sources } = require("webpack");
class LearningHtmlPlugin {
constructor(options = {}) {
this.options = {
title: "Webpack App",
filename: "index.html",
...options,
};
}
apply(compiler) {
compiler.hooks.thisCompilation.tap("LearningHtmlPlugin", (compilation) => {
compilation.hooks.processAssets.tap(
{
name: "LearningHtmlPlugin",
stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
},
() => {
const assets = compilation.getAssets().map((asset) => asset.name);
const cssTags = assets
.filter((name) => name.endsWith(".css"))
.map((name) => `<link rel="stylesheet" href="${name}">`)
.join("\n ");
const jsTags = assets
.filter((name) => name.endsWith(".js"))
.map((name) => `<script defer src="${name}"></script>`)
.join("\n ");
const html = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${this.options.title}</title>
${cssTags}
</head>
<body>
<div id="app"></div>
${jsTags}
</body>
</html>`;
compilation.emitAsset(
this.options.filename,
new sources.RawSource(html)
);
}
);
});
}
}
module.exports = LearningHtmlPlugin;真实 html-webpack-plugin 还会处理模板引擎、chunk 排序、publicPath、minify、favicon、script loading、插件 hooks 等。
16.9 DefinePlugin 学习版源码
DefinePlugin 不是在运行时创建变量,而是在编译期把表达式替换成常量。真实实现依赖 Webpack JavaScript parser hooks,属于更底层的 plugin。
学习版只支持替换简单表达式,例如 process.env.NODE_ENV。
js
// plugins/DefinePluginLite.js
class DefinePluginLite {
constructor(definitions) {
this.definitions = definitions;
}
apply(compiler) {
const pluginName = "DefinePluginLite";
const { ConstDependency } = compiler.webpack.dependencies;
compiler.hooks.compilation.tap(pluginName, (compilation, { normalModuleFactory }) => {
compilation.dependencyTemplates.set(
ConstDependency,
new ConstDependency.Template()
);
const tapParser = (parser) => {
for (const [key, value] of Object.entries(this.definitions)) {
parser.hooks.expression.for(key).tap(pluginName, (expression) => {
const code = JSON.stringify(value);
const dependency = new ConstDependency(code, expression.range);
dependency.loc = expression.loc;
parser.state.module.addPresentationalDependency(dependency);
return true;
});
}
};
normalModuleFactory.hooks.parser
.for("javascript/auto")
.tap(pluginName, tapParser);
normalModuleFactory.hooks.parser
.for("javascript/esm")
.tap(pluginName, tapParser);
normalModuleFactory.hooks.parser
.for("javascript/dynamic")
.tap(pluginName, tapParser);
});
}
}
module.exports = DefinePluginLite;配置:
js
plugins: [
new DefinePluginLite({
"process.env.NODE_ENV": "production",
"__APP_VERSION__": "1.0.0"
})
]注意:
- 这类代码使用 Webpack 内部依赖对象,适合源码学习,不建议团队业务插件优先使用。
- 业务项目直接使用官方
webpack.DefinePlugin。 DefinePlugin的值通常要显式JSON.stringify(),否则字符串可能被当作变量名。
16.10 copy-webpack-plugin 学习版源码
copy-webpack-plugin 的本质:在 assets 阶段把外部文件读入 compilation,并作为构建产物输出。
js
// plugins/CopyPluginLite.js
const path = require("path");
const fs = require("fs/promises");
const fg = require("fast-glob");
const { Compilation, sources } = require("webpack");
class CopyPluginLite {
constructor(options) {
this.patterns = options.patterns || [];
}
apply(compiler) {
compiler.hooks.thisCompilation.tap("CopyPluginLite", (compilation) => {
compilation.hooks.processAssets.tapPromise(
{
name: "CopyPluginLite",
stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
},
async () => {
for (const pattern of this.patterns) {
const from = pattern.from;
const to = pattern.to || "";
const files = await fg(from, {
cwd: compiler.context,
dot: true,
onlyFiles: true,
});
for (const file of files) {
const absolutePath = path.resolve(compiler.context, file);
const content = await fs.readFile(absolutePath);
const target = path.posix.join(to, path.basename(file));
compilation.fileDependencies.add(absolutePath);
compilation.emitAsset(target, new sources.RawSource(content));
}
}
}
);
});
}
}
module.exports = CopyPluginLite;配置:
js
plugins: [
new CopyPluginLite({
patterns: [
{ from: "public/**/*", to: "static" }
]
})
]真实 copy-webpack-plugin 还会处理并发、glob parent、transform、缓存、去重、路径模板和错误聚合。
16.11 源码阅读方法
读 loader/plugin 源码时不要从第一行硬啃,按下面顺序拆:
- 找入口:loader 看
module.exports = function loader或export default function;plugin 看class XxxPlugin的apply(compiler)。 - 找 Webpack 接口:loader 重点看
this.getOptions、this.async、this.callback、this.emitFile、this.addDependency;plugin 重点看compiler.hooks.*、compilation.hooks.*。 - 找输入输出:loader 问“输入什么资源,输出什么 JS 模块”;plugin 问“在哪个生命周期读写了哪些 assets”。
- 找依赖声明:外部文件、目录、配置文件是否正确加入 watch/cache 依赖。
- 找 source map:源码被转换后是否继续传递 map。
- 找性能点:同步 IO、重复计算、大文件处理、缓存失效条件。
- 找边界:HMR、watch、Windows 路径、publicPath、ESM/CJS、错误提示。
16.12 最小测试方式
loader/plugin 不建议只靠手动跑项目验证。可以用 Webpack Node API 做黑盒测试:输入一个临时 fixture,断言输出 assets。
js
const path = require("path");
const webpack = require("webpack");
function compile(config) {
return new Promise((resolve, reject) => {
webpack(config, (error, stats) => {
if (error) {
reject(error);
return;
}
const info = stats.toJson({ all: false, errors: true, warnings: true, assets: true });
if (stats.hasErrors()) {
reject(new Error(info.errors.map((item) => item.message).join("\n")));
return;
}
resolve({ stats, info });
});
});
}
test("LearningHtmlPlugin emits index.html", async () => {
const { info } = await compile({
mode: "production",
context: path.resolve(__dirname, "fixtures/basic"),
entry: "./src/index.js",
output: {
path: path.resolve(__dirname, "dist/basic"),
filename: "main.js",
},
plugins: [new LearningHtmlPlugin({ title: "Test" })],
});
expect(info.assets.some((asset) => asset.name === "index.html")).toBe(true);
});测试建议覆盖:
- 正常输入。
- 错误输入。
- options 校验。
- source map。
- watch/cache 依赖。
- Windows 和 POSIX 路径。
- production/development 两种模式。
16.13 生产化检查清单
写团队内部 loader/plugin 时至少检查:
- options 是否有 schema 校验。
- 是否支持异步并正确返回错误。
- 是否声明了额外文件依赖。
- 是否正确处理 source map。
- 是否避免不必要的同步 IO。
- 是否避免在 loader 中做全局构建决策。
- 是否避免在 plugin 的高频 hook 里做重活。
- 是否兼容 watch 模式和 filesystem cache。
- 是否有最小 fixture 测试。
- 是否在 README 里写清楚 Webpack 版本范围。
17. 官方参考资料
- Babel Handbook:https://github.com/jamiebuilds/babel-handbook
- Babel Parser:https://babeljs.io/docs/babel-parser
- Babel Traverse:https://babeljs.io/docs/babel-traverse
- Babel Generator:https://babeljs.io/docs/babel-generator
- Babel Types:https://babeljs.io/docs/babel-types
- ESTree:https://github.com/estree/estree
- ESLint Custom Rules:https://eslint.org/docs/latest/extend/custom-rules
- Webpack Loaders:https://webpack.js.org/concepts/loaders/
- Webpack Loader API:https://webpack.js.org/api/loaders/
- Webpack Plugins:https://webpack.js.org/concepts/plugins/
- Webpack Parser API:https://webpack.js.org/api/parser/
- Webpack Compiler Hooks:https://webpack.js.org/api/compiler-hooks/
- Webpack Compilation Hooks:https://webpack.js.org/api/compilation-hooks/
- Webpack Writing a Plugin:https://webpack.js.org/contribute/writing-a-plugin/
- Webpack Build Performance:https://webpack.js.org/guides/build-performance/
- Webpack Caching:https://webpack.js.org/guides/caching/
- Tapable:https://github.com/webpack/tapable
- Source Map Revision 3:https://sourcemaps.info/spec.html
- babel-loader 源码:https://github.com/babel/babel-loader/blob/main/src/index.js
- css-loader 源码:https://github.com/webpack-contrib/css-loader/blob/master/src/index.js
- style-loader 源码:https://github.com/webpack-contrib/style-loader/blob/master/src/index.js
- mini-css-extract-plugin loader 源码:https://github.com/webpack-contrib/mini-css-extract-plugin/blob/master/src/loader.js
- mini-css-extract-plugin plugin 源码:https://github.com/webpack-contrib/mini-css-extract-plugin/blob/master/src/index.js
- html-webpack-plugin 源码:https://github.com/jantimon/html-webpack-plugin/blob/main/index.js
- Webpack DefinePlugin 源码:https://github.com/webpack/webpack/blob/main/lib/DefinePlugin.js
- copy-webpack-plugin 源码:https://github.com/webpack-contrib/copy-webpack-plugin/blob/master/src/index.js