Skip to content

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 官方源码仓库等资料整理。


目录 ​

  1. 为什么前端架构师要懂 AST、Loader、Plugin
  2. AST 是什么
  3. Parser、Traverse、Generator 是什么
  4. 常见 AST 节点解释
  5. Babel 插件基础
  6. ESLint 规则和 Babel 插件有什么区别
  7. Webpack Loader 是什么
  8. 自定义 Loader 实战
  9. Webpack Plugin 是什么
  10. 自定义 Plugin 实战
  11. Loader 和 Plugin 的区别
  12. 在 Vue/React/Nuxt/Node 项目中怎么用
  13. Source Map、缓存与性能
  14. 常见问题与反模式
  15. 学习路线与练习
  16. 常用 Loader / Plugin 源码阅读补全
  17. 官方参考资料

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=2

2.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/parser
  • espree
  • typescript-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 code

4. 常见 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() 和 ESLint context.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 7Babel 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.log

6.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 module

7.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 -> a
js
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类型触发时机常见用途
environmentSyncHookcompiler 环境准备阶段,配置里的插件初始化之后读取或修改基础环境,不常用于业务插件。
afterEnvironmentSyncHookenvironment 之后环境准备后的补充初始化。
initializeSyncHookcompiler 对象初始化完成后插件内部状态初始化。
beforeRunAsyncSeriesHook非 watch 构建开始前构建前校验、读取外部配置。
runAsyncSeriesHook非 watch 构建开始时记录构建开始时间、初始化远端任务。
watchRunAsyncSeriesHookwatch 模式每次重新构建前根据变更文件做增量检查。
invalidSyncHookwatch 模式下文件变更导致当前构建失效时清理缓存、提示重新构建。
beforeCompileAsyncSeriesHookcompilation 参数创建之后、正式 compile 之前准备构建参数,较少直接使用。
compileSyncHook新 compilation 创建之前只能拿到 compilation 参数,适合记录“即将开始一次编译”。
thisCompilationSyncHook初始化新的 compilation 时,早于 compilation最常用:注册 compilation.hooks.*,生成/修改 assets。
compilationSyncHookcompilation 创建完成后访问 compilation 和 normalModuleFactory,常用于 parser hooks。
makeAsyncParallelHook从 entry 开始构建模块图时添加入口、创建额外依赖、启动模块构建相关逻辑。
finishMakeAsyncSeriesHookmake 阶段完成后make 后的补充检查。
afterCompileAsyncSeriesHookcompilation 完成后、emit 之前读取构建结果、做发射前检查。
shouldEmitSyncBailHook输出 assets 前,返回 false 可阻止输出bundle size gate、产物合法性检查。
emitAsyncSeriesHookassets 输出到文件系统之前Webpack 4 常见;Webpack 5 修改 assets 更推荐 processAssets。
afterEmitAsyncSeriesHookassets 写入文件系统之后上传 sourcemap、通知部署平台、读取最终文件。
assetEmittedAsyncSeriesHook单个 asset 被写入后记录每个文件的路径、大小、hash。
doneAsyncSeriesHook整次构建完成后输出构建报告、耗时统计、成功通知。
failedSyncHook构建失败时错误上报、失败通知。
watchCloseSyncHookwatch 关闭时清理 watcher 相关资源。
shutdownAsyncSeriesHookcompiler 关闭时关闭连接、释放资源。

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类型触发时机常见用途
buildModuleSyncHook某个模块开始构建前给模块加标记、读取模块信息。
rebuildModuleSyncHookwatch 模式下模块重新构建前观察增量构建。
failedModuleSyncHook模块构建失败时记录失败模块、错误上报。
succeedModuleSyncHook模块构建成功后统计模块数量、分析构建耗时。
finishModulesAsyncSeriesHook所有模块构建完成后分析 module graph,做依赖检查。
sealSyncHookcompilation 开始封存,后续进入优化和产物生成在优化前做最后检查。
optimizeDependenciesSyncBailHook依赖优化阶段分析或调整依赖关系,业务插件少用。
optimizeModulesSyncBailHook模块优化阶段模块级优化,业务插件慎用。
optimizeChunksSyncBailHookchunk 优化阶段chunk 级分析或优化,业务插件慎用。
optimizeTreeAsyncSeriesHookmodule/chunk tree 优化阶段深度构建优化,通常是 Webpack 内部或复杂插件使用。
processAssetsAsyncSeriesHookassets 处理阶段,可按 stage 插入Webpack 5 中生成、修改、压缩、分析 assets 的首选 hook。
afterProcessAssetsSyncHookprocessAssets 全部处理完成后最终读取 assets,不建议再大改。
needAdditionalSealSyncBailHook判断是否需要再次 seal高级插件场景,业务插件少用。
afterSealAsyncSeriesHookseal 完成后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_INLINEinline 相关优化。
PROCESS_ASSETS_STAGE_SUMMARIZE汇总 assets 列表,适合生成清单。
PROCESS_ASSETS_STAGE_OPTIMIZE_HASHasset 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 hookscompiler.hooks.thisCompilation
接入 JS parser,做编译期常量或语法分析compiler.hooks.compilation + normalModuleFactory.hooks.parser
统计模块构建情况compilation.hooks.buildModule / succeedModule / finishModules
生成或修改构建产物compilation.hooks.processAssets
阻止不合法产物输出compiler.hooks.shouldEmit
产物写入磁盘后上传 sourcemapcompiler.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 或内部平台。

流程:

  1. 构建生成 sourcemap。
  2. plugin 在 assets 阶段找到 .map 文件。
  3. 调用平台 API 上传。
  4. 上传成功后可删除公开产物中的 .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 的区别 ​

维度LoaderPlugin
核心职责转换文件内容介入构建生命周期
输入单个模块源码compiler / compilation
输出转换后的模块代码修改构建过程或产物
执行位置module.rulesplugins
典型例子babel-loader、css-loader、vue-loaderHtmlWebpackPlugin、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 基础阶段 ​

  1. 使用 AST Explorer 观察 const a = 1 的 AST。
  2. 找出 Identifier、CallExpression、ImportDeclaration。
  3. 写一个 Babel 插件删除 console.log。

15.2 进阶阶段 ​

  1. 写一个 loader 给 JS 文件加 banner。
  2. 写一个 loader 把 Markdown 转成 JS 字符串。
  3. 写一个 plugin 生成 build-info.json。
  4. 给 loader/plugin 加测试。

15.3 高级阶段 ​

  1. 写一个 AST loader 删除生产环境 debugger。
  2. 写一个 plugin 上传 sourcemap。
  3. 写一个 codemod 把旧 API 替换为新 API。
  4. 写一个 ESLint 规则限制跨模块 import。

15.4 架构师阶段 ​

  1. 为团队封装内部 webpack plugin。
  2. 为组件库构建生成 manifest。
  3. 为监控系统自动注入 release id。
  4. 为旧项目升级写 codemod。
  5. 设计 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 执行链和 pitchWebpack系统学习文档.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 源码阅读路线 ​

类型项目源码入口重点看什么
Loaderbabel-loaderhttps://github.com/babel/babel-loader/blob/main/src/index.jsthis.getOptions()、异步转换、Babel config、缓存、source map、错误包装。
Loadercss-loaderhttps://github.com/webpack-contrib/css-loader/blob/master/src/index.js@import、url()、CSS Modules、PostCSS 插件链、把 CSS 转成 JS 模块。
Loaderstyle-loaderhttps://github.com/webpack-contrib/style-loader/blob/master/src/index.jspitch loader、拼接 remaining request、运行时把 CSS 注入 DOM。
Loader + Pluginmini-css-extract-pluginhttps://github.com/webpack-contrib/mini-css-extract-plugin/blob/master/src/loader.js 和 https://github.com/webpack-contrib/mini-css-extract-plugin/blob/master/src/index.jsloader 和 plugin 协作、CSS 依赖收集、chunk 级 CSS 输出、顺序冲突处理。
Pluginhtml-webpack-pluginhttps://github.com/jantimon/html-webpack-plugin/blob/main/index.js读取 compilation assets、模板处理、注入 script/link、对外暴露 hooks。
PluginDefinePluginhttps://github.com/webpack/webpack/blob/main/lib/DefinePlugin.jsparser hooks、常量替换、编译期宏、process.env.NODE_ENV 为什么不是运行时读取。
Plugincopy-webpack-pluginhttps://github.com/webpack-contrib/copy-webpack-plugin/blob/master/src/index.jsglob 文件匹配、异步 IO、transform、缓存、compilation.emitAsset()。

阅读顺序建议:

  1. 先读 babel-loader:它最接近普通自定义 loader。
  2. 再读 style-loader:理解 pitch loader 为什么存在。
  3. 再读 css-loader:理解一个 loader 如何把非 JS 资源变成 JS 模块。
  4. 再读 mini-css-extract-plugin:理解为什么复杂能力经常需要 loader + plugin 协作。
  5. 最后读 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 插件链。

学习版只演示两个核心动作:

  1. 把 @import "./base.css" 转成 JS import。
  2. 默认导出 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 源码时不要从第一行硬啃,按下面顺序拆:

  1. 找入口:loader 看 module.exports = function loader 或 export default function;plugin 看 class XxxPlugin 的 apply(compiler)。
  2. 找 Webpack 接口:loader 重点看 this.getOptions、this.async、this.callback、this.emitFile、this.addDependency;plugin 重点看 compiler.hooks.*、compilation.hooks.*。
  3. 找输入输出:loader 问“输入什么资源,输出什么 JS 模块”;plugin 问“在哪个生命周期读写了哪些 assets”。
  4. 找依赖声明:外部文件、目录、配置文件是否正确加入 watch/cache 依赖。
  5. 找 source map:源码被转换后是否继续传递 map。
  6. 找性能点:同步 IO、重复计算、大文件处理、缓存失效条件。
  7. 找边界: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. 官方参考资料 ​

基于 VitePress 构建的个人学习文档知识库