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-loader 和 style-loader 的分工,我现在能一句话说清了:css-loader 处理 CSS 里的 @import 和 url(),把它们当成模块依赖解析掉,输出的是一段 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-utils 的 getOptions(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 原样搬运静态文件
这些都属于扩展构建流程。注意 MiniCssExtractPlugin 和 BundleAnalyzerPlugin 这俩有意思:它们是 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:compile、compilation、emit、done 等等。运维要的产物清单,我用的是 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-loader 或 babel-loader 自带的 cacheDirectory 缓存编译结果;thread-loader 把耗时 loader 丢到 worker 池里并行。这些都是 loader 层面提速的标配,不过小项目别过度配置,线程池本身也有启动开销,反而更慢。到底哪个 loader 在吃时间,别猜,装个 speed-measure-webpack-plugin 把配置包一层,构建完它会把每个 loader、每个 plugin 的耗时列成清单——我们项目量出来大头是 babel-loader,加 cacheDirectory 之后二次构建时间直接砍半。先测量再优化,构建提速也逃不开这条老规矩。
项目是去年底用 Vue CLI 3 重建的,webpack 配置被包在里面,改配置要走 vue.config.js 的 configureWebpack 或 chainWebpack。这里有个我强烈推荐的调试命令:vue inspect > output.js,它把 CLI 内部合成后的完整 webpack 配置吐出来。我的 markdown loader 一开始不生效,就是靠 inspect 发现 CLI 内置的规则先把 .md 匹配走了,调整了规则顺序才解决。改被封装过的配置,先 inspect 看清现状,再动手。
这道题现在能秒答了
技术问答那天我背的那句"loader 处理文件,plugin 扩展功能",方向没错,但它是别人的结论。写完一个 markdown loader 和一个产物清单 plugin 之后,这句结论才变成我自己的:loader 负责把文件转换成模块,plugin 负责通过 hooks 介入构建流程的各个时机。
项目里真正要做的是:配置少而清楚,知道每个 loader 和 plugin 为什么存在。构建配置最怕能跑但没人敢改——实习生那几个追问,本质上问的就是"你敢不敢改你项目的构建配置"。现在我敢了,下次组内问答,换我出题。