创见博客
Webpack Loader 请求前缀详解:!、-! 与 !!,以及模块何时被构建
七崽爱吃小饼干2026/09/20阅读 0

引子

实现 style-loader 时,pitch 里有一行很关键:

js
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):

前缀内部标记禁用哪些分组保留
!noAutoLoadersnormalpost、inline、pre
-!noPreAutoLoadersnormal + prepost、inline
!!noPrePostAutoLoadersnormal + pre + post仅 inline

一句话记忆:

  • !:只关「配置里的常规 use」;
  • -!:再多关 pre;
  • !!:配置全部关掉,只用请求里内联写明的 loader。

用例子看差异

假设配置:

js
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.csspost → inline(无) → normal(style,css) → pre
!./style.csspost → normal(style,css) → pre(normal 被关)
-!./style.csspost →(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 返回代码里有:

js
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 一次编译大致分三个阶段:

  1. make:从 entry 出发,递归解析依赖,构建模块图。每个模块都要经过「resolve 请求 → 装配 loader → 执行 loader → parse 产物 → 收集依赖」。
  2. seal:在模块图基础上生成 chunk 图,做优化(tree shaking、splitChunks 等)。
  3. 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)时就不会踩到「为什么递归」「为什么重复构建」的坑了。

评论
0/100