2025年10月10日6 分钟

如何保证 Webpack Loader 按照预想方式工作

深入理解 Webpack Loader 的执行顺序、分类、Pitch 机制和调试技巧,确保 Loader 链按预期工作

Loader 的基本执行顺序

Webpack Loader 的执行顺序是从右到左、从下到上

module: {
  rules: [
    {
      test: /\.css$/,
      use: ['style-loader', 'css-loader', 'postcss-loader'],
      //       ③ ←──────── ② ←──────── ①
      //    注入 DOM     解析 CSS     PostCSS 处理
    },
  ],
}
源文件 .css
    │
    ▼  ① postcss-loader(处理 Tailwind、autoprefixer)
  PostCSS 处理后的 CSS
    │
    ▼  ② css-loader(解析 @import、url(),转为 JS 模块)
  CSS 的 JS 模块表示
    │
    ▼  ③ style-loader(将 CSS 注入 DOM 的 <style> 标签)
  最终 JS 代码(包含注入逻辑)

为什么是从右到左?

这是函数组合(compose) 的概念:

// Loader 链本质上等价于函数组合
const result = styleLoader(cssLoader(postcssLoader(source)))
//                ③           ②          ①

// 就像 Unix 管道
// cat file.css | postcss | css-loader | style-loader
//     源文件       ①          ②            ③

Loader 的四种分类

Webpack 的 rule.enforce 属性可以控制 Loader 的执行阶段:

module: {
  rules: [
    // pre loader:最先执行
    {
      test: /\.js$/,
      enforce: 'pre',
      use: 'eslint-loader',  // 代码检查在最前面
    },

    // normal loader:默认,按配置顺序执行
    {
      test: /\.js$/,
      use: 'babel-loader',
    },

    // post loader:最后执行
    {
      test: /\.js$/,
      enforce: 'post',
      use: 'coverage-loader',  // 代码覆盖率在最后
    },
  ],
}
完整执行顺序:

  源文件
    │
    ▼  Pre Loaders(enforce: 'pre')
  eslint-loader
    │
    ▼  Normal Loaders(默认)
  babel-loader
    │
    ▼  Post Loaders(enforce: 'post')
  coverage-loader
    │
    ▼
  最终结果

实际上还有一种 inline loader(行内 loader):
  import 'style-loader!css-loader!./style.css'
  // 不推荐使用,但了解其存在

完整顺序: pre → normal → inline → post

Pitch 机制

每个 Loader 除了正常的处理函数,还可以有一个 pitch 方法。Pitch 的执行顺序和正常处理相反

                 Pitch 阶段(从左到右)
  ─────────────────────────────────────────────▶

  style-loader    css-loader    postcss-loader
  pitch() ──────▶ pitch() ────▶ pitch()
                                    │
                                    ▼ 正常阶段(从右到左)
  ◀─────────────────────────────────────────────

  style-loader    css-loader    postcss-loader
  ◀── normal() ◀── normal() ◀── normal()

Pitch 的"熔断"机制

如果 pitch 方法返回了值,就会跳过后续 Loader,直接进入前面 Loader 的 normal 阶段:

// style-loader 的 pitch 实现(简化)
module.exports.pitch = function (remainingRequest) {
  // remainingRequest = 'css-loader!postcss-loader!./style.css'

  // 直接返回一段 JS 代码(不再执行 css-loader 和 postcss-loader 的 normal)
  return `
    var content = require(${JSON.stringify('!!' + remainingRequest)})
    var style = document.createElement('style')
    style.textContent = content
    document.head.appendChild(style)
  `
}
如果 style-loader 的 pitch 返回了值:

  style-loader    css-loader    postcss-loader
  pitch() ───── 返回值!
       │
       ▼  跳过后续 pitch 和 normal
  直接得到结果

  但 require() 会再次触发 css-loader → postcss-loader 的处理

如何保证 Loader 按预期工作

1. 正确的顺序配置

// CSS 处理链(必须严格按顺序)
{
  test: /\.scss$/,
  use: [
    'style-loader',      // 4. 注入 DOM(开发模式)
    // MiniCssExtractPlugin.loader,  // 4. 提取为文件(生产模式)
    {
      loader: 'css-loader',  // 3. 解析 CSS 依赖
      options: {
        modules: true,         // CSS Modules
        importLoaders: 2,      // 确保 @import 的文件也经过后面 2 个 loader
      },
    },
    'postcss-loader',    // 2. PostCSS 处理(autoprefixer)
    'sass-loader',       // 1. SCSS → CSS
  ],
}

importLoaders 很关键

/* main.scss */
@import './variables.scss';  /* 这个文件也需要经过 sass-loader 和 postcss-loader */

如果不设置 importLoaders: 2@import 的文件只会经过 css-loader,不会经过 postcss-loadersass-loader

2. 使用 oneOf 避免重复匹配

module: {
  rules: [
    {
      oneOf: [
        // CSS Modules(.module.css)
        {
          test: /\.module\.css$/,
          use: [
            'style-loader',
            { loader: 'css-loader', options: { modules: true } },
            'postcss-loader',
          ],
        },
        // 普通 CSS
        {
          test: /\.css$/,
          use: ['style-loader', 'css-loader', 'postcss-loader'],
        },
        // 匹配到第一个就停止,避免同一文件被多个 rule 处理
      ],
    },
  ],
}

3. 条件匹配精确控制

{
  test: /\.(ts|tsx)$/,
  include: [
    path.resolve(__dirname, 'src'),
    path.resolve(__dirname, 'shared'),  // 也需要编译的目录
  ],
  exclude: [
    /node_modules/,
    /\.test\.(ts|tsx)$/,  // 排除测试文件
  ],
  use: 'swc-loader',
},

4. 内联 Loader 控制

// 在 import 语句中可以覆盖 Loader 行为

// 禁用所有 pre 和 normal loader
import file from '-!raw-loader!./file.txt'

// 禁用所有 loader
import file from '!!raw-loader!./file.txt'

// ! 前缀:禁用 normal loader
import file from '!raw-loader!./file.txt'

5. 编写自定义 Loader 的规范

// my-custom-loader.js
module.exports = function (source) {
  // this.cacheable()     // 标记可缓存(默认 true)
  // this.async()         // 如果是异步处理
  // this.getOptions()    // 获取 loader options
  // this.emitFile()      // 输出额外文件

  // source 是上一个 Loader 传过来的结果
  const result = transform(source)

  // 返回处理后的结果
  return result

  // 也可以返回多个值(source, sourceMap, meta)
  // this.callback(null, result, sourceMap, meta)
}

// Loader 规则:
// 1. 单一职责:每个 Loader 只做一件事
// 2. 链式调用:输出给下一个 Loader 的输入
// 3. 无状态:不在 Loader 中保存状态
// 4. 使用 loader-utils 解析选项

6. 调试 Loader 执行

// 方法1:添加调试 Loader
{
  test: /\.tsx$/,
  use: [
    'swc-loader',
    {
      loader: path.resolve(__dirname, 'debug-loader.js'),
      // 插入在两个 Loader 之间,打印中间结果
    },
    'some-other-loader',
  ],
}

// debug-loader.js
module.exports = function (source) {
  console.log('=== Debug Loader ===')
  console.log('Resource:', this.resourcePath)
  console.log('Source length:', source.length)
  console.log('First 200 chars:', source.slice(0, 200))
  return source  // 透传,不修改
}

总结

要点说明
执行顺序从右到左、从下到上(函数组合)
分类pre → normal → inline → post
Pitch从左到右,可以熔断跳过后续
importLoaders确保 @import 文件也走完整 Loader 链
oneOf避免同一文件匹配多个 rule
include/exclude精确控制处理范围
单一职责每个 Loader 只做一件事