前端知库

webpack 的 loader 和 plugin 有什么区别?怎样手写一个?

loader 是一个函数,接收一个文件的内容,返回转换后的内容,多个 loader 从右往左执行;plugin 是带 apply 方法的对象,通过 compiler 和 compilation 上的钩子介入整个构建,能做生成文件、改产物这类 loader 做不到的事。文中各手写一个,并讲 this.async、getOptions 和 processAssets。

参考答案

简答

比如项目里 import './app.scss',webpack 自己只认识 JS,要靠 sass-loader、css-loader、style-loader 把它一步步转成 JS 模块。loader 就是一个函数,拿到一个文件的源码,返回转换后的结果,一次只管一个文件。plugin 是一个带 apply 方法的对象,webpack 启动时调用它,把 compiler 交给它,它在构建过程的各个钩子上挂回调。

两者分工不同。loader 只看得到单个模块的内容,适合做“把这种文件转成 JS”;plugin 能看到整个构建,生成 HTML、提取 CSS、往产物里加文件、统计体积,这些 loader 做不到的事都由 plugin 完成。

手写 loader,就是导出一个普通函数,用 this.getOptions() 读配置,同步时直接返回字符串,异步时调用 this.async() 拿到回调。手写 plugin,就是写一个类,在 apply(compiler) 里用 compiler.hooks.某个钩子.tap() 注册回调。

要注意两点。多个 loader 是从右往左(数组写成多行时是从下往上)执行的,写反了就会出错。loader 应该保持无状态,不要在模块作用域里存跨文件的数据,因为有缓存时这个 loader 不一定会再执行。

loader 和 plugin 在构建中处在什么位置

webpack 从入口文件开始,遇到一个 import 就去读那个文件。读到文件以后,先按 module.rules 找出匹配的 loader,把内容交给它们转换,转换完的结果必须是 JS,webpack 才能继续分析里面的 import。所以 loader 工作在“单个模块被读进来”这一步。

plugin 不挂在某个文件上。webpack 本身就是用同一套插件系统搭起来的,从开始编译、构建模块、生成 chunk 到写出文件,每个阶段都暴露了钩子。plugin 在这些钩子上注册回调,所以能拿到所有模块、所有产物,做全局的事情。

webpack 文档说 plugin 用来做“loader 做不到的其他任何事”。比如 HtmlWebpackPlugin 要在所有 JS 文件名确定以后生成 HTML,并把带 hash 的文件名写进去,这件事只有在产物阶段才能做。

多个 loader 按什么顺序执行

JavaScript
module.exports = {
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: ['style-loader', 'css-loader', 'sass-loader'],
      },
    ],
  },
};

数组里写在最后的 sass-loader 最先执行,它拿到的是 .scss 原文件;css-loader 拿到 sass-loader 的输出;最前面的 style-loader 最后执行,必须返回 JS。可以用普通函数模拟一下这个顺序:

JavaScript
// 用普通函数模拟 use: ['style-loader', 'css-loader', 'sass-loader'] 的执行顺序
const use = [
  (source) => `style(${source})`,
  (source) => `css(${source})`,
  (source) => `sass(${source})`,
];

const result = use.reduceRight((source, loader) => loader(source), 'app.scss');
console.log(result); // style(css(sass(app.scss)))

结果是一层套一层的函数调用,最里面的 sass 最先执行。为什么设计成从右往左?可以把它看成函数组合 style(css(sass(file))),写法和数学里的复合函数一致。

另外 loader 还可以导出一个 pitch 方法。正式执行之前,webpack 会先从左往右调用每个 loader 的 pitch;如果某个 pitch 返回了结果,后面的 loader 就直接跳过,流程掉头往回走。style-loader 就用到了 pitch。

手写一个 loader

下面写一个给文件加注释头的 loader,前缀从配置里读。

JavaScript
// banner-loader.js
const schema = {
  type: 'object',
  properties: {
    banner: { type: 'string' },
  },
};

module.exports = function bannerLoader(source) {
  // webpack 5 起 getOptions 在 loader 上下文里,可以传 JSON Schema 校验配置
  const { banner = '' } = this.getOptions(schema);
  return `/* ${banner} */\n${source}`;
};

loader 里的 this 是 webpack 提供的 loader 上下文,不能写成箭头函数,否则拿不到 this。在 webpack 5 之前,读配置要用 loader-utils 的 getOptions,现在直接用 this.getOptions。

配置里用 loader 加 options 引用它,本地文件可以直接写路径:

JavaScript
{
  test: /\.js$/,
  use: [
    {
      loader: path.resolve(__dirname, 'banner-loader.js'),
      options: { banner: 'built by webpack' },
    },
  ],
}

如果转换要读文件或请求网络,就用异步写法。this.async() 告诉 webpack 这个 loader 会稍后回调,它返回的就是 this.callback,参数依次是错误、内容、可选的 Source Map 和元数据:

JavaScript
module.exports = function asyncLoader(source, map, meta) {
  const callback = this.async();
  someAsyncTransform(source)
    .then((result) => callback(null, result, map, meta))
    .catch((error) => callback(error));
};

默认情况下 loader 拿到的是 UTF-8 字符串。处理图片、字体这类二进制文件时,导出 module.exports.raw = true,loader 就会收到 Buffer。

手写 loader 要守的规矩

webpack 文档给 loader 定了几条准则:每个 loader 只做一件事,靠串联组合完成复杂处理;输出要是正常的模块;不要保存状态。

为什么不能保存状态?因为 webpack 会缓存模块的构建结果,loader 默认就被标记为可缓存。如果你在模块作用域里用一个变量收集所有文件的信息,有缓存的那次构建里,没改动的文件根本不会再经过 loader,变量里的数据就不全了。确实要给 plugin 传数据,文档建议写到模块的 buildInfo 上,它会和模块一起被序列化进缓存。

手写一个 plugin

下面的 plugin 在产物里额外生成一个 assets.md,列出这次构建输出的所有文件。写法照 webpack 文档里的 FileListPlugin。

JavaScript
// file-list-plugin.js
class FileListPlugin {
  constructor(options = {}) {
    this.outputFile = options.outputFile ?? 'assets.md';
  }

  apply(compiler) {
    const pluginName = FileListPlugin.name;
    // 从 compiler 上取 webpack,保证和当前构建用的是同一个版本
    const { Compilation, sources } = compiler.webpack;

    compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
      compilation.hooks.processAssets.tap(
        {
          name: pluginName,
          // 选靠后的阶段,确保其他插件已经把产物加进来了
          stage: Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE,
        },
        (assets) => {
          const list = Object.keys(assets).map((name) => `- ${name}`).join('\n');
          compilation.emitAsset(this.outputFile, new sources.RawSource(`# 构建产物\n\n${list}`));
        },
      );
    });
  }
}

module.exports = FileListPlugin;

使用时在 plugins 里放一个实例:plugins: [new FileListPlugin()]。plugin 可以接收参数,所以配置里要用 new。

这里出现了两个对象。compiler 代表整个 webpack 实例,从启动到结束只有一个,run、emit、done 这些钩子是它的。compilation 代表一次构建,watch 模式下每次重新构建都会创建一个新的 compilation,模块、chunk、产物相关的钩子在它上面。所以跨多次构建要保留的数据放在 compiler 上,只和这一次构建有关的放在 compilation 上。

tap 是同步注册。钩子本身支持异步时,可以用 tapAsync,回调最后一个参数是 callback,做完要调用它;或者用 tapPromise,返回一个 Promise。processAssets 就是异步钩子,上面因为没有异步操作才用了 tap。

常见追问

loader 和 plugin 能互相替代吗?

大多不能。loader 只能拿到单个文件的内容,不知道别的模块和最终产物,所以生成 HTML、合并统计这类事做不了。plugin 虽然也能在钩子里改模块源码,但“按文件类型转换”这件事交给 loader 更简单,也能和别的 loader 串起来复用。有些工具会同时提供两者,比如 mini-css-extract-plugin 既有 loader 也有 plugin,loader 负责收集 CSS,plugin 负责把它写成单独的文件。

怎样调试自己写的 loader 和 plugin?

loader 可以在函数里打印 this.resourcePath,看它处理了哪些文件;也可以用 loader-runner 在不启动 webpack 的情况下单独运行 loader 做测试。plugin 可以先在 done 钩子里打印 stats,确认钩子触发了,再逐步换到更具体的钩子上。

Vite 插件和 webpack 插件有什么不同?

Vite 没有单独的 loader 概念,转换文件也通过插件的 transform 钩子完成,Vite 8 的插件接口在 Rolldown 插件接口上扩展而来,旧版本基于 Rollup。具体见《Vite 插件的 resolveId、load 和 transform 分别做什么?》。