webpack loader 和 plugin 的区别:先分清它们各自插手哪一段流程

“loader 处理文件,plugin 扩展功能” 这句定义大家都会背,但只要追问一步“那它们各自到底插手了 webpack 的哪一段流程”,很多熟练感就会立刻露馅。定义太短,短到不足以指导你判断一个问题该写 loader 还是该写 plugin。

真正把这条边界弄清楚,通常要到你自己动手写一回的时候。我们后来要让帮助中心直接消费 markdown 文档,这逼着我第一次认真写 loader;顺手又把 webpack 生命周期翻了一遍,才终于把 loader、plugin、compiler、compilation 和执行顺序真正对上号。

后面不会停在概念口号上,而是从“让 webpack 看懂 .md 文件”和“在构建流程里额外挂一个能力”这两个具体任务出发,反推两者的职责边界。

先想清楚:webpack 眼里只有 JS

webpack 默认只能理解 JavaScript 和 JSON。其他文件要靠 loader 转换。

例如处理 Vue 文件:

1{
2  test: /\.vue$/,
3  use: 'vue-loader'
4}

处理 CSS:

1{
2  test: /\.css$/,
3  use: ['style-loader', 'css-loader']
4}

实习生问的 css-loaderstyle-loader 的分工,我现在能一句话说清了:css-loader 处理 CSS 里的 @importurl(),把它们当成模块依赖解析掉,输出的是一段 JS 模块;style-loader 再把这段样式通过 <style> 标签塞进 head。这俩职责常被混淆,其实是流水线上的两道工序。开发环境用 style-loader 热更新爽,但生产环境通常换成 MiniCssExtractPlugin.loader,把 CSS 抽成独立文件,避免样式闪一下(FOUC)也能让浏览器并行下载。这就是一个"同一个位置开发用 loader、生产换 plugin 配套 loader"的典型例子。

图片和字体那条链也顺手理了一遍。file-loader 把文件拷到输出目录、返回 URL;url-loader 是它的加强版,小于 limit 的文件直接转成 base64 内联进 JS,省一次 HTTP 请求:

1{
2  test: /\.(png|jpe?g|gif)$/,
3  use: [{
4    loader: 'url-loader',
5    options: {
6      limit: 4096, // 4KB 以下内联,以上走 file-loader
7      name: 'img/[name].[hash:8].[ext]'
8    }
9  }]
10}

这个 limit 是个取舍:内联省请求但撑大 JS 包、还进不了浏览器的图片缓存。之前有人把 limit 调到 100KB,首页 JS 莫名胖了一圈,就是几张 banner 图被 base64 塞进去了。小图标内联,正经图片走文件,4KB~10KB 是常见的线。

我的第一个 loader:三十行看懂本质

回到帮助中心那个需求。loader 本质上是一个函数,接收源码,返回处理后的源码。教科书式的最小例子长这样:

1module.exports = function(source) {
2  return source.replace(/console\.log/g, 'console.warn')
3}

我写的 markdown loader 也没复杂多少:用 marked 把 markdown 转成 HTML 字符串,再包成一个 JS 模块导出:

1var marked = require('marked')
2
3module.exports = function(source) {
4  var html = marked(source)
5  // 输出的必须是合法的 JS 模块代码,这是 loader 的交付契约
6  return 'module.exports = ' + JSON.stringify(html)
7}

配上规则:

1{
2  test: /\.md$/,
3  use: [{ loader: path.resolve(__dirname, 'loaders/markdown-loader.js') }]
4}

业务组件里就能 import helpDoc from '@/docs/refund-guide.md',拿到的直接是 HTML 字符串,v-html 一挂就完事。文档一改,热更新跟着走,产品很满意。

真实 loader 会更复杂,要处理 source map、异步、缓存等,但核心就是转换内容。写的过程中有几个 API 绕不开。this 上挂着 loader context,this.query 拿配置参数(不过更推荐 loader-utilsgetOptions(this)),this.async() 处理异步:

1module.exports = function(source) {
2  const callback = this.async()
3
4  doSomethingAsync(source).then(result => {
5    // 第二个参数是 source map,没有就传 null
6    callback(null, result, null)
7  }).catch(callback)
8
9  // 异步模式下不要 return,靠 callback 交付
10}

要点:异步 loader 必须调 this.async() 拿到 callback,然后通过 callback 交付结果,不能再用 return。我第二版给 loader 加"文档里的图片路径重写"时要走异步,第一次直接 return await,webpack 拿到一个 Promise 当字符串处理,构建产物全是 [object Promise],排查了一个下午。另外 loader 应该是"无副作用、可缓存"的——别在 loader 里去写文件、改全局状态,否则 webpack 的缓存机制会让你的副作用时灵时不灵。

还有两个让开发体验好很多的小配套。一是 schema-utils 校验 options——loader 的参数传错时,与其让它默默产出奇怪结果,不如在入口就报一个人话错误:

1var validateOptions = require('schema-utils')
2
3var schema = {
4  type: 'object',
5  properties: {
6    breaks: { type: 'boolean' }
7  }
8}
9
10module.exports = function(source) {
11  var options = require('loader-utils').getOptions(this) || {}
12  validateOptions(schema, options, 'markdown-loader')
13  // ...
14}

二是本地 loader 的引用方式。开发阶段不想发 npm 包,除了写绝对路径,还可以配 resolveLoader,让 webpack 去项目自己的目录里找 loader,写起来跟正经包一样:

1resolveLoader: {
2  modules: ['node_modules', path.resolve(__dirname, 'loaders')]
3}

所以 loader 更适合做"某类文件如何变成 webpack 能处理的模块"。

从右到左:写完 loader 才懂的执行顺序

1use: ['style-loader', 'css-loader', 'postcss-loader']

执行顺序是从右到左:

1postcss-loader -> css-loader -> style-loader

这点被实习生问到时,我只答了"就是这么规定的"。现在的理解是:这是一条流水线,前一个 loader 的输出,交给下一个 loader 继续处理。

为什么是从右到左,写 loader 之后我专门较过真。loader 其实有两个执行阶段:pitch(从左到右)和 normal(从右到左)。我们平时说的"从右到左"指的是真正干活的 normal 阶段。所以 SCSS 那条链,文件先被 sass-loader 编译成 CSS,再交给 css-loader 解析依赖,最后 style-loader(或抽离插件)收尾。写顺序时记住一个口诀:离文件最近的处理写在最右边。把 sass-loader 写到 css-loader 右边,否则 css-loader 拿到的还是没编译的 SCSS,直接报语法错误——这个顺序写反的坑,实习生上个月刚踩过,这回给她讲正好有现成案例。

pitch 阶段平时用得少,但它能解释一些"奇怪"的行为:某个 loader 在 pitch 里返回了内容,整条链右边的 loader 就被熔断跳过了。style-loader 内部就用了这招去处理。给实习生补讲到 pitch 这一层的时候,她眼睛亮了——我自己一个月前还完全不知道有这东西。

还有 enforce 可以调整阶段,pre 的 loader 先于普通 loader 执行,eslint-loader 一般就配成 pre,保证它在 babel-loader 编译前先检查源码。

plugin:等我想往构建流程里"塞一脚"时才理解它

markdown loader 上线后,运维提了个附带需求:每次构建完,想要一份"这次构建产出了哪些文件"的清单,方便发布时核对。这个需求 loader 干不了——它跟"某类文件的转换"没关系,是要在构建流程的某个时机做一件事。这就是 plugin 的地盘。

先看熟面孔。常见插件:

1const HtmlWebpackPlugin = require('html-webpack-plugin')
2
3module.exports = {
4  plugins: [
5    new HtmlWebpackPlugin({
6      template: './public/index.html'
7    })
8  ]
9}

它不是处理某一种文件,而是在构建过程中生成 HTML,并把打包后的资源注入进去。

再比如:

  • CleanWebpackPlugin 清理目录
  • DefinePlugin 注入环境变量
  • MiniCssExtractPlugin 抽离 CSS
  • BundleAnalyzerPlugin 分析包体积
  • CopyWebpackPlugin 原样搬运静态文件

这些都属于扩展构建流程。注意 MiniCssExtractPluginBundleAnalyzerPlugin 这俩有意思:它们是 plugin,但配套也提供 loader(前者的 MiniCssExtractPlugin.loader)。这正好说明 loader 和 plugin 不是对立的,复杂能力常常是"loader 管转换、plugin 管输出和注入"一起上。HtmlWebpackPlugin 也是,它本身是插件负责生成 HTML,但要往 HTML 里塞内联脚本、处理模板语法时又会和 loader 协作。CopyWebpackPlugin 则是反例的好教材:它搬运的文件根本不进模块依赖图,不需要"转换",所以它是 plugin 而不是 loader——拿"这个文件要不要进依赖图"来判断,比背名字可靠。

DefinePlugin 值得单独说一句,它注入的是"编译期常量",配 process.env.NODE_ENV 的字符串后,配合压缩工具能把 if (process.env.NODE_ENV !== 'production') 这种判断整段消掉,Vue 自身的开发提示代码就是这么在生产包里被剔除的。我们有个老项目就忘了配,生产包里带着一堆开发警告逻辑,包大了一截还慢,就是 DefinePlugin 没设对。

我的第一个 plugin:产物清单

plugin 是一个带 apply 方法的对象。最小可运行版本长这样:

1class LogPlugin {
2  apply(compiler) {
3    compiler.hooks.done.tap('LogPlugin', stats => {
4      console.log('build done')
5    })
6  }
7}
8
9module.exports = LogPlugin

webpack 会在合适的生命周期调用插件注册的逻辑。

apply 接收的 compiler 是 webpack 整个构建的总控对象,从启动到结束只有一个。它身上挂着一堆 hooks:compilecompilationemitdone 等等。运维要的产物清单,我用的是 emit 这个钩子——它在文件写到磁盘之前触发,能拿到也能改 compilation.assets,想往产物里塞一个文件、或者改改输出内容,就在这里动手:

1class FileListPlugin {
2  apply(compiler) {
3    compiler.hooks.emit.tapAsync('FileListPlugin', (compilation, callback) => {
4      let list = '本次构建产物:\n'
5      for (const name in compilation.assets) {
6        list += `- ${name}\n`
7      }
8      compilation.assets['filelist.txt'] = {
9        source: () => list,
10        size: () => list.length,
11      }
12      callback()
13    })
14  }
15}

三十来行,构建完 dist 里就多一个 filelist.txt,运维直接拿它核对发布包。

写这个插件让我把两个层次的对象彻底分清了:compiler 是全局唯一的、贯穿整个生命周期;compilation 是每次构建(比如 watch 模式下每次文件变动重新编译)都会新建一个,代表"这一次编译的资源和模块"。问 compiler 和 compilation 的区别,答案就是这个——一个是"整台机器",一个是"一次生产批次"。实习生要是再问我,我能给她画图。

钩子还分同步和异步:tap 注册同步钩子,tapAsync(带 callback)和 tapPromise(返回 Promise)用于异步钩子。异步钩子忘了调 callback,构建会卡在那一步不动——我第一版就忘了,npm run build 停在 92% 一动不动,等了五分钟才反应过来是自己插件的锅。

不是人人都要写完整插件,但要知道 plugin 通过 hooks 介入构建,并能说清 compiler / compilation 的区别——这是能不能读懂社区插件源码的分水岭。

分界线:一道我现在能秒答的判断题

写完一个 loader 和一个 plugin,那道问答题在我这儿终于有了不靠背的答案。

如果问题是"某类文件怎么转换",用 loader。

例如:

  • .vue 转成 JS 模块
  • .scss 转成 CSS
  • 图片转成 URL
  • Markdown 转成 HTML(就是我那个)

如果问题是"构建流程里要做一件事",用 plugin。

例如:

  • 生成 HTML
  • 清理输出目录
  • 抽离 CSS
  • 定义环境变量
  • 输出构建报告(就是我那个)

这个判断比背定义更有用。我甚至觉得可以再压缩一层:loader 面向"输入",plugin 面向"过程和输出"

顺手理了理项目里的 loader 配置

借着这次改造,我把项目的构建配置从头到尾读了一遍。复杂项目里 loader 很容易堆很多:

1{
2  test: /\.scss$/,
3  use: [
4    'style-loader',
5    'css-loader',
6    'postcss-loader',
7    'sass-loader'
8  ]
9}

要清楚每一层做什么。遇到样式没生效、兼容前缀没加、变量不识别,就按 loader 顺序排查。我现在排查样式问题的顺序基本固定:变量/嵌套不识别,是不是 sass-loader 没接上或者顺序错了;前缀没加,是不是 postcss-loader 漏了或者 autoprefixer 没配 browserslist;样式压根没出来,先看是 style-loader 还是抽离插件那条路出了问题。按链路一层层看,比瞎改快得多。

性能上还有几个常用手段顺手记一下:babel-loader 一定要配 exclude: /node_modules/,否则把第三方库也编译一遍,构建慢到怀疑人生;大项目可以上 cache-loaderbabel-loader 自带的 cacheDirectory 缓存编译结果;thread-loader 把耗时 loader 丢到 worker 池里并行。这些都是 loader 层面提速的标配,不过小项目别过度配置,线程池本身也有启动开销,反而更慢。到底哪个 loader 在吃时间,别猜,装个 speed-measure-webpack-plugin 把配置包一层,构建完它会把每个 loader、每个 plugin 的耗时列成清单——我们项目量出来大头是 babel-loader,加 cacheDirectory 之后二次构建时间直接砍半。先测量再优化,构建提速也逃不开这条老规矩。

项目是去年底用 Vue CLI 3 重建的,webpack 配置被包在里面,改配置要走 vue.config.jsconfigureWebpackchainWebpack。这里有个我强烈推荐的调试命令:vue inspect > output.js,它把 CLI 内部合成后的完整 webpack 配置吐出来。我的 markdown loader 一开始不生效,就是靠 inspect 发现 CLI 内置的规则先把 .md 匹配走了,调整了规则顺序才解决。改被封装过的配置,先 inspect 看清现状,再动手

这道题现在能秒答了

技术问答那天我背的那句"loader 处理文件,plugin 扩展功能",方向没错,但它是别人的结论。写完一个 markdown loader 和一个产物清单 plugin 之后,这句结论才变成我自己的:loader 负责把文件转换成模块,plugin 负责通过 hooks 介入构建流程的各个时机。

项目里真正要做的是:配置少而清楚,知道每个 loader 和 plugin 为什么存在。构建配置最怕能跑但没人敢改——实习生那几个追问,本质上问的就是"你敢不敢改你项目的构建配置"。现在我敢了,下次组内问答,换我出题。