手把手系列之—— 自定义 Loader 和 Plugin

8 阅读8分钟

前言:为啥要自己写 Loader 和 Plugin?

用 Webpack 打包时,我们早就习惯了各种现成的 Loader 和 Plugin,比如babel-loader 转 ES6,ts-loader 处理 TypeScript,HtmlWebpackPlugin 生成 HTML……那为啥还要自己写呢?

我们先说 Loader。
Webpack 默认只会把 JS/JSON 当模块处理,其他像 .vue.less.ts、甚至公司内部的自定义格式(比如某种带特殊标记的模板),都要靠 Loader 转成 JS 或 CSS后,打包才能用。现成的 loader 虽然能覆盖大部分通用场景,但总会遇到「没有现成的」或「现成的总差一点」的情况:

比如你们项目用了一套自己的文档/配置格式,想 import config from './xxx.our-format' 直接用;比如要在源码里自动注入构建时间、版本号、环境变量等;比如要对某类文件做一次性的清洗、脱敏或校验。这时候写一个只做「一种文件 => 一段 JS 字符串」的小函数,往往比满世界找轮子或硬塞进别的工具更简单、更可控。
So, 自定义 Loader 就是为了让任意类型的「文件」都能按你的规则变成 Webpack 能理解的模块。

再说 Plugin吧。
Loader 解决的是「单个文件怎么转」的问题;但是构建流程里还有很多「和具体文件无关」的事,比如在打包结束时自动上传产物到 CDN、根据本次构建生成一份资源清单或埋点配置、在输出目录里多塞几个自动生成的文件、或者把构建信息打进某个内部系统等等……

这些都是在「构建的某个时间点」插一脚,而不是对某一种后缀做转换。用现成 Plugin 能搞定一大部分,但公司流程、内部工具、特殊需求多一些,就会遇到「没有现成插件」或「要改源码才能满足」的情况。你有碰到过吗?
自定义 Plugin 就是为了在 Webpack 的编译生命周期里挂上自己的逻辑,想在某一时刻干啥就干啥。

而两者配合,就能在不动 Webpack 源码的前提下,把构建流程扩展成项目真正需要的样子。

下面我将结合官方文档,讲讲怎么写自定义的Loader 和 Plugin,顺便理一理常用钩子和好用的库。

官方文档:写 Loader | 写 Plugin


一、自定义 Loader

Loader 到底是啥?

说白了,Loader 就是一个导出的函数。Webpack 处理文件的时候会调你这个函数,把「文件内容」或者「上一个 loader 吐出来的结果」传给你。你干完活,最后要还给它一段 字符串(或 Buffer),也就是 JS 源码;要是想带 SourceMap,也可以一起还。

总而言之一句话:就是一个文件进来,你把它转成 webpack 能用的 JS 字符串,再送出去。

那么写一个 Loader 要几步呢?

  1. 想清楚要干啥
    比如:只处理某种后缀、做代码检查、或者顺便生成个 source map 之类的。
  2. 建个 .js 文件
    一般会单独建个 loaders 文件夹,专门放自己写的 loader。
  3. 写函数
    函数会收到 (content, map, meta) 三个参数(后两个可以不用),在里面做你的转换逻辑。
  4. 在 webpack 里使用
    webpack.config.jsmodule.rules 里,指定「哪些文件」使用「编写的自定义loader」。

函数长什么样?this 能干啥?

你的 loader 函数大概长这样:

/**
 * content:源文件内容(字符串或 Buffer)
 * map:SourceMap,可选
 * meta:随便传的元数据,可选
 */
function webpackLoader(content, map, meta) {
  // 你的逻辑
}

函数里的 this 是 webpack 给你塞好的,上面有两个特别常用的方法:

  • this.callback(err, content, sourceMap?, meta?)
    当你要「不只返回一串字符串」的时候使用(比如还要带上map、meta)。
    第一个参数是错误(没有就传 null),
    第二个是内容,后面两个可选。
  • this.async()
    你要做异步操作(比如读文件、调接口)时,先调一下 this.async(),它会给你一个 callback。你处理完了再调这个 callback 把结果还回去就行;注意:调完就别再 return 了。

几种常见写法

情况 1:同步,只返回一段字符串
直接 return 就行。

module.exports = function (content, map, meta) {
  return someSyncOperation(content);
};

情况 2:同步,但要带 map、meta
必须用 this.callback,用完之后记得 return

module.exports = function (content, map, meta) {
  this.callback(null, someSyncOperation(content), map, meta);
  return;
};

情况 3:异步,只返回一段字符串
const callback = this.async(),在异步回调里把结果交给 callback。

module.exports = function (content, map, meta) {
  const callback = this.async();
  someAsyncOperation(content, (err, result) => {
    if (err) return callback(err);
    callback(null, result, map, meta);
  });
};

情况 4:异步,还要带 sourceMap、meta
一样用 this.async(),在回调里把 content、sourceMaps、meta 都传给 callback。

module.exports = function (content, map, meta) {
  const callback = this.async();
  someAsyncOperation(content, (err, result, sourceMaps, meta) => {
    if (err) return callback(err);
    callback(null, result, sourceMaps, meta);
  });
};

下面演示一个简单示例方便大家理解:写一个给文件加注释头的 Loader

下面是一个可直接运行的自定义 Loader:给每个被处理的源文件内容前面加一行注释,写上「文件名」和「处理时间」。注意理解「content 进、字符串出」和 this.resourcePath 的用法。

1. 首先在项目里建文件 loaders/comment-header.loader.js

/**
 * 给源文件内容前面加一行注释头
 * 格式:// [文件名] processed at [时间]
 */
function commentHeaderLoader(content) {
  const filename = this.resourcePath.split(/[/\\]/).pop();
  const header = `// ${filename} processed at ${new Date().toISOString()}\n`;
  return header + content;
}

module.exports = commentHeaderLoader;
  • 入参 content:当前文件的内容(上一个 loader 的产出或原始文件内容)。
  • this.resourcePath:当前资源的绝对路径,webpack 会挂到 loader 的 this 上,这里用来取文件名。
  • 返回值:必须是一段字符串,这里把「注释头 + 原内容」拼好直接 return,是最简单的同步、单结果写法啦

2. 在 webpack 里使用

webpack.config.jsmodule.rules 里加一条,让某种文件(比如 .js)先走这个 loader(注意用 path.resolve 指向本地 的loader 路径,否则 webpack 会去 node_modules 里找):

const path = require('path');

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.(js|mjs|ts)$/,
        use: [
          {
            loader: path.resolve(__dirname, 'loaders/comment-header.loader.js'),
          },
          // 后面可以再接 ts-loader、babel-loader 等
        ],
      },
    ],
  },
};

补充一点,loader的执行顺序大家了解吗?是先右后左,或者先下后上哦(逆序执行)

3. npm run build 后是个啥

打包完成后,打开产物里的 JS,会在文件最上面看到多出来的一行,例如:

// index.js processed at 2025-02-26T12:00:00.000Z
(function(modules) { ...

说明你的 loader 已经对「匹配到的文件」做了一次转换,这就是一个完整的自定义 Loader 从写到用的流程啦,是不是还是很简单的!


常用loader有哪些?

类别例子干啥的
文件/依赖val-loader、ref-loader把代码当模块执行、手动画依赖关系
JSONcson-loader读 CSON 并转成 JS 能用的
语法转换babel-loader、ts-loader、esbuild-loader把 ES6+、TS 等转成浏览器能跑的
模板html-loader、pug-loader、markdown-loader、handlebars-loader各种 HTML/模板/Markdown
样式style-loader、css-loader、less-loader、sass-loader、postcss-loaderCSS 和预处理器
框架vue-loader、angular2-template-loaderVue、Angular 单文件组件

二、自定义 Plugin

Plugin 又是啥?

Plugin 就是一个apply 方法的对象(或类)。Webpack 一启动就会调你的 apply(compiler),把 compiler 传给你。有了 compiler,你就能在整个打包过程里插一脚啦。

简单理解就是:Loader 是「一个文件一个文件地转」,Plugin 是「在打包的各个时间点搞事情」。

同样,写一个 Plugin 要几步呢?

  1. 写一个类(或函数),名字随意。
  2. 在这个类上写 apply(compiler),compiler 就是 webpack 传进来的那个。
  3. 在 apply 里「挂钩子」:比如 compiler.hooks.emit.tap(...),意思就是「到 emit 这个时间点,执行我这段代码」。
  4. 在钩子回调里干活:可以看 compilation、改资源、加文件等等。
  5. 如果是异步钩子,干完记得调一下 webpack 给你的那个 callback,不然构建会卡住。

上个简单例子开开胃:构建一开始就打一句 log

const pluginName = 'ConsoleLogOnBuildWebpackPlugin';

class ConsoleLogOnBuildWebpackPlugin {
  apply(compiler) {
    compiler.hooks.run.tap(pluginName, () => {
      console.log('webpack 构建正在启动!');
    });
  }
}

module.exports = ConsoleLogOnBuildWebpackPlugin;

用异步钩子的例子(比如 emit 阶段)

有的钩子会给你一个 callback,你搞完了要调一下,不然 webpack 会一直等。

class MyExampleWebpackPlugin {
  apply(compiler) {
    compiler.hooks.emit.tapAsync(
      'MyExampleWebpackPlugin',
      (compilation, callback) => {
        console.log('这是一个示例插件!');
        console.log('当前这次编译的对象 compilation:', compilation);
        // 这里可以改 compilation.assets、加文件等
        callback(); // 别忘了调,否则构建不结束
      }
    );
  }
}

来个完整的例子:生成资源清单的 Plugin

下面是一个可直接运行的自定义 Plugin:在「快要往磁盘写文件」(emit 阶段)时,遍历本次构建的所有产物,生成一个 asset-list.txt 清单文件(文件名 + 大小),并把它塞进 compilation.assets,这样最后打包结果里就会多出这个文件。
这个例子可以清楚看到「挂钩子 → 拿 compilation → 改 assets → 调 callback」的完整流程。

1. 在项目里建文件 plugins/AssetListWebpackPlugin.js

const pluginName = 'AssetListWebpackPlugin';

class AssetListWebpackPlugin {
  apply(compiler) {
    compiler.hooks.emit.tapAsync(pluginName, (compilation, callback) => {
      const assets = compilation.assets;
      const lines = ['# 本次构建产物清单\n', `生成时间: ${new Date().toISOString()}\n\n`];

      for (const [name, source] of Object.entries(assets)) {
        const size = source.size ? source.size() : (source.source().length || 0);
        lines.push(`${name}  ${size} bytes\n`);
      }

      const content = lines.join('');
      compilation.assets['asset-list.txt'] = {
        source: () => content,
        size: () => Buffer.byteLength(content, 'utf8'),
      };

      callback();
    });
  }
}

module.exports = AssetListWebpackPlugin;
  • apply(compiler):webpack 启动时调用,只有这里能拿到 compiler
  • compiler.hooks.emit.tapAsync:在 emit 阶段挂一个异步钩子,此时 compilation.assets 里已经是即将要输出的所有文件,可以读可以改。
  • compilation.assets:键是文件名,值是一个至少包含 source()size() 的对象。这里我们往里面加了一项 asset-list.txt,所以最终输出目录里会多出这个文件。
  • callback():emit 是异步钩子,处理完必须调一次,否则 webpack 会一直等。

2. 在 webpack 里使用

webpack.config.jsplugins 数组里 new 一下(记得在文件顶部 require):

const path = require('path');
const AssetListWebpackPlugin = require('./plugins/AssetListWebpackPlugin.js');

module.exports = {
  // ...
  plugins: [
    new AssetListWebpackPlugin(),
  ],
};

3. run一次构建

构建完成后,在输出目录(比如 dist/)里会多一个 asset-list.txt,打开大概长这样:

# 本次构建产物清单

生成时间: 2025-02-26T12:00:00.000Z

main.js  12345 bytes
asset-list.txt  256 bytes

说明你的插件在 emit 阶段拿到了所有资源、生成了新内容并写进了本次构建,这就是一个完整的自定义 Plugin 从写到用的流程。


这个是不是有点难度啦?钩子都有啥?该挂哪儿?

Webpack 的钩子很多,不用全记,知道「大概在什么阶段」就行,用的时候查文档或搜源码里的 hooks.xxx.call。(想进一步了解的同学可以看一下tapable)

Compiler 上的钩子(整次构建的生命周期)
从开始到结束,大致顺序是:environmententryOptionruncompilecompilationmakeafterCompileemitafterEmitdone。还有 failedinvalidwatchClose 等。 我们常挂的大概是:run(开始跑)、emit(要往磁盘写文件了)、done(全部搞定)。

Compilation 上的钩子
一次编译里会:加载模块、封存、优化、分 chunk、算 hash、生成资源等等。对应的有 buildModulesucceedModulefinishModulessealoptimizeChunksprocessAssetschunkHash 等。
用法一样:compilation.hooks.xxx.tap(...),有的钩子支持 tapAsync / tapPromise

别的还有:NormalModuleFactory(造模块)、ContextModuleFactory(require.context)、JavascriptParser(解析 JS 的 AST)、Resolver(解析路径)等,都有各自的 hooks。做深入定制时再查就行。

官方和社区有哪些好用的 Plugin?

来源插件名干啥的
官方自带BannerPlugin加注释头
官方自带DefinePlugin编译期常量
官方自带HtmlWebpackPlugin生成 HTML
官方自带MiniCssExtractPlugin抽 CSS
官方自带HotModuleReplacementPlugin热更新
官方自带CopyWebpackPlugin拷贝静态资源
官方自带CompressionWebpackPlugingzip 压缩
官方自带TerserPlugin压缩 JS
官方自带ProgressPlugin打进度
官方自带SplitChunksPlugin代码分割,抽公共 chunk(通过 optimization.splitChunks 配置)
社区Bundle Analyzer看打包体积
社区Fork TS Checker Webpack Plugin单独进程跑 TS 检查
社区Duplicate Package Checker重复依赖提醒
社区Circular Dependency Plugin循环依赖检测
社区Prerender SPA / PWA Manifest / Imagemin 等按需选用

三、Loader 和 Plugin 有啥不一样?咋配合?

简单对比一下:

对比项LoaderPlugin
干啥的针对单个文件做转换(转成 JS/CSS 等)构建的各个阶段做任意操作
输入是啥文件内容 content(加可选的 map、meta)compiler、compilation 等对象
输出是啥返回字符串/Buffer(和可选的 SourceMap)不返回值,靠钩子 + callback 和 webpack 交互
啥时候跑按规则匹配到文件时,在「链」里一个一个跑在 compiler 的各个钩子触发时跑

配合起来用:Loader 负责「这类文件我帮你转成 JS/CSS」;Plugin 负责「打包到一半/要写文件了/打包完了我帮你做点别的事」(比如生成 HTML、压缩、分析、拷贝文件)。一个管「内容怎么变」,一个管「流程里插什么逻辑」。


四、小结

  • 自定义 Loader:导出一个函数,参数是 (content, map, meta),用 returnthis.callback / this.async() 把结果返还给 webpack,再在 module.rules 里配好「谁用这个 loader」。
  • 自定义 Plugin:写一个带 apply(compiler) 的类,在 compiler.hooks.xxx(或 compilation、parser 等)上挂钩子,在回调里更改构建数据,异步钩子记得调用callback。

把这两块搞明白,需要的时候自己写一个 Loader 或 Plugin来扩展 Webpack时就会很顺手啦。