引子
实现 style-loader 时,pitch 里有一行很关键:
const request = JSON.stringify('!!' + remainingRequest);
为什么这里必须加 !!?要回答它,得先搞清 Loader 请求前缀的规则,顺带理解 Webpack 到底在什么时候去构建一个模块。
Loader 的四个分组
Webpack 里的 loader 按来源和 enforce 分成四组:
| 分组 | 来源 | 说明 |
|---|---|---|
postLoaders | 配置 enforce: 'post' | 最后执行 |
| inline loaders | 写在 import/require 的请求里 | 内联指定 |
| normal loaders | 配置 module.rules 的 use | 常规配置 |
preLoaders | 配置 enforce: 'pre' | 最先执行 |
Webpack 在 NormalModuleFactory 里把它们的顺序拼成(NormalModuleFactory.js:1042):
allLoaders = [ ...postLoaders, ...inlineLoaders, ...normalLoaders, ...preLoaders ]
因为 normal 阶段从右往左执行,所以真实执行顺序是:
pre → normal → inline → post
而 pitch 阶段从左往右,顺序正好反过来:
post → inline → normal → pre
三种前缀
前缀写在请求的最前面,用来关掉某些分组(NormalModuleFactory.js:860):
| 前缀 | 内部标记 | 禁用哪些分组 | 保留 |
|---|---|---|---|
! | noAutoLoaders | normal | post、inline、pre |
-! | noPreAutoLoaders | normal + pre | post、inline |
!! | noPrePostAutoLoaders | normal + pre + post | 仅 inline |
一句话记忆:
!:只关「配置里的常规 use」;-!:再多关 pre;!!:配置全部关掉,只用请求里内联写明的 loader。
用例子看差异
假设配置:
module.exports = {
module: {
rules: [
{ test: /\.css$/, use: ['style-loader', 'css-loader'] },
{ enforce: 'pre', test: /\.css$/, use: ['pre-loader'] },
{ enforce: 'post', test: /\.css$/, use: ['post-loader'] },
],
},
};
对 ./style.css 应用不同前缀:
| 请求 | 实际参与的 loader |
|---|---|
./style.css | post → inline(无) → normal(style,css) → pre |
!./style.css | post → normal(style,css) → pre(normal 被关) |
-!./style.css | post →(pre、normal 都被关) |
!!./style.css | 只有 inline(若 inline 为空则无 loader) |
!!css-loader!./style.css | 只有 inline 的 css-loader |
注意:
app请求里已经内联的 loader 属于 inline 组,!!不会把它们去掉,去掉的只是配置里的那几组。
回到 !! 为什么能避免递归
remainingRequest 是「当前 loader 右侧的 loader + 资源」,不含当前 loader,也不含配置信息。它长这样:
/abs/node_modules/css-loader/dist/cjs.js??ruleSet...!/abs/src/style.css
如果直接 require(remainingRequest),这个 .css 请求会再次匹配 module.rules,把配置里的 style-loader/my-style-loader 附加回来:
style-loader → css-loader → (inline) css-loader → style.css
↑ 又回来 → pitch 再次执行 → 无限递归
加上 !! 后,配置里的 loader 被关闭,只剩请求里内联的 css-loader:
!!css-loader!style.css → 只跑 css-loader
所以 !! 的作用是「这次请求不要应用配置中的 loader」,避免递归只是它的效果。
什么时候会触发模块构建
理解了请求,再看「谁会让 Webpack 去构建一个模块」。触发点主要有:
1. 入口
entry 指定的文件是第一批模块。
2. 源码里的静态依赖 Webpack 解析一个模块后,从 AST 里收集依赖:
import x from './a'export { y } from './b'require('./c')
每一个都是新的模块请求,去走一遍「解析 → loader → 编译」。
3. 动态依赖
import('./page')require.context('./dir', false, /\.js$/)require.ensure(...)new Worker(new URL('./worker.js', import.meta.url))
它们会生成额外的模块(常被拆成独立 chunk)。
4. Loader 产物里的依赖(本篇重点)
Loader 返回的 JS 代码同样会被 Webpack 解析。style-loader 的 pitch 返回代码里有:
require('!!css-loader!./style.css')
Webpack 解析到它,就按 !! 规则构建出「css-loader 编译后的 CSS 模块」。这就是 loader 能间接触发新模块构建的原因。
5. 插件与子编译
插件可以主动添加模块、发起 child compiler,例如 mini-css-extract-plugin、HtmlWebpackPlugin。
模块的身份与缓存
一个模块由它的请求标识符决定(包含所有 loader 路径、query、资源路径):
request = stringifyLoadersAndResource(allLoaders, resource)
因此:
- 相同请求 → 只构建一次,后续复用;
- 不同前缀 / 不同 loader query → 不同的模块。
这正是为什么 style.css 会出现两个模块:配置链那个(被 pitch 短路、没有实际 normal 产物),和内联 !!css-loader!style.css 那个(css-loader 真正编译出的)。构建日志里能看到:
./node_modules/css-loader/dist/cjs.js!./src/style.css 618 bytes [built]
整个构建流程速览
Webpack 一次编译大致分三个阶段:
- make:从 entry 出发,递归解析依赖,构建模块图。每个模块都要经过「resolve 请求 → 装配 loader → 执行 loader → parse 产物 → 收集依赖」。
- seal:在模块图基础上生成 chunk 图,做优化(tree shaking、splitChunks 等)。
- emit:把每个模块的代码拼接、生成最终 bundle,写入
output。
Loader 只在 make 阶段执行;它产出的代码会在同一阶段被解析,从而可能触发下一轮模块构建。
小结
- Loader 分
post/ inline / normal /pre四组,装配顺序[post, inline, normal, pre],执行时 normal 右到左、pitch 左到右。 - 前缀:
!关 normal,-!关 normal + pre,!!关掉全部配置 loader,只留内联。 !!避免递归的本质是「别让配置把当前 loader 再加回来」。- 模块构建由入口、静态/动态依赖,以及 loader 产物中的依赖触发;请求不同就是不同模块。
搞清楚这两件事,写 loader(尤其是 pitch 型 loader)时就不会踩到「为什么递归」「为什么重复构建」的坑了。