创见博客
用 pitch 实现一个 style-loader:解决 css-loader 返回 JS 的问题
七崽爱吃小饼干2026/09/20阅读 0专栏 webpack原理

问题从何而来

在 Webpack 里配置 CSS:

js
{
  test: /\.css$/i,
  use: ['style-loader', 'css-loader'],
}

use 数组是从右往左执行的:css-loader 先把 CSS 编译成一段 JS 模块(内部是一个带 toString 的数组),然后 style-loader 才拿到这段 JS。

但 style-loader 要做的是把 CSS 塞进 <style> 标签,它需要的是 CSS 文本,不是 JS。正常阶段拿到的却是 JS,怎么办?

答案就是 pitch。

pitch 是什么

每个 loader 都可以挂一个 pitch 函数:

js
module.exports = function normal() {};
module.exports.pitch = function pitch(remainingRequest, precedingRequest, data) {};

Loader 链有两个阶段:

        pitch 阶段  ──────────────────────────────▶  (从左到右)
use: [ A, B, C ]
        ◀──────────────────────────────  normal 阶段  (从右到左)
  • pitch 从左往右执行;
  • normal 从右往左执行;
  • 如果某个 loader 的 pitch 有返回值,Webpack 会 跳过它右侧所有 loader 的 normal 阶段,把这个返回值直接当成模块结果。

这正是突破口:让 style-loader 在最左侧的 pitch 阶段就接管一切。

实现

新建 loaders/my-style-loader.js:

js
// options 的校验规则:insert 只允许 head / body
const schema = {
  type: 'object',
  properties: {
    insert: { enum: ['head', 'body'] },
  },
  additionalProperties: false,
};

// normal 阶段用不到:pitch 已返回值,本 loader 的 normal 不会执行
module.exports = function myStyleLoader() {};

// pitch 从左到右先执行,参数 remainingRequest 是「右侧所有 loader + 资源」
module.exports.pitch = function pitch(remainingRequest) {
  // 读取 options,默认插入 head
  const { insert = 'head' } = this.getOptions(schema) || {};

  // remainingRequest 只含「右侧 loader + 资源」,即 css-loader!./style.css,不含本 loader
  // '!!' 禁用 module.rules 里配置的 loader,防止再次把 my-style-loader 加进来造成递归
  // JSON.stringify 把请求串转义成合法的 JS 字符串字面量
  const request = JSON.stringify('!!' + remainingRequest);

  // 返回的字符串就是最终模块代码,会被打包进 bundle 在浏览器执行
  return `
// 运行时 require:取回 css-loader 编译出的 CSS 模块(构建时建立依赖,运行时才求值)
var content = require(${request});
// css-loader 默认 esModule,CSS 内容在 default 上
content = content && content.__esModule ? content.default : content;
// 该模块是带 toString 的数组,调用后得到 CSS 文本
var css = content.toString();

// 创建 <style> 标签
var style = document.createElement('style');
style.setAttribute('data-my-style-loader', 'true');

// 写入 CSS 文本
if (typeof css === 'string') {
  style.appendChild(document.createTextNode(css));
} else {
  style.innerHTML = css;
}

// 按 options 决定插入 head 还是 body
var target = ${JSON.stringify(insert)} === 'body' ? document.body : document.head;
target.appendChild(style);

// 导出原模块,保留 CSS Modules 的 locals 等使用方式
module.exports = content;
`;
};

逐段解读

  1. remainingRequest pitch 的第一个参数,代表「当前 loader 右侧的所有 loader + 资源」,这里是 css-loader!./style.css。

  2. '!!' + remainingRequest 这是关键。!! 前缀会禁用配置里声明的所有 loader,只使用请求中显式写出的 loader。于是 require 会精确走一遍 css-loader,而不会再次触发 my-style-loader,避免无限递归。

  3. 取到真正的 CSS 文本 css-loader 默认 esModule: true,require 得到的是命名空间对象,CSS 在 .default 上;它还是个数组(exportType: 'array'),其 toString() 会拼出 CSS。所以:

    js
    content = content.__esModule ? content.default : content;
    var css = content.toString();
    
  4. 注入 DOM 创建 <style>,写入 CSS,插到 head(或按 insert 选项插到 body)。

  5. 保留导出 module.exports = content; 让 import styles from './x.css' 依然可用(比如 CSS Modules 的 locals)。

配置与验证

js
{
  test: /\.css$/i,
  use: [
    {
      loader: path.resolve(__dirname, 'loaders/my-style-loader.js'),
      options: { insert: 'head' },
    },
    'css-loader',
  ],
}

构建后在浏览器里打开页面,head 中会出现:

html
<style data-my-style-loader="true">body { ... }</style>

也可以在 Node 里用假的 document 跑一遍 bundle.js,确认 <style> 被正确注入、CSS 文本完整。

为什么官方也这么做

打开真正的 style-loader,你会发现它的 pitch 里正是用 '!!' + request 去 import CSS 内容:

js
function getImportStyleContentCode(esModule, loaderContext, request) {
  const modulePath = stringifyRequest(loaderContext, `!!${request}`);
  return esModule
    ? `import content, * as namedExport from ${modulePath};`
    : `var content = require(${modulePath});`;
}

官方额外处理了 HMR、injectType(styleTag / lazyStyleTag / linkTag)、insert 选择器等,但核心思路与这个 demo 完全一致:用 pitch 抢在 css-loader 之前生成模块,绕开「右边返回 JS」的尴尬。

注意事项

  • !! 不是可选:少了它,配置中的 loader 会被再次应用,导致递归。
  • 路径转义:示例用 JSON.stringify 生成请求字符串;官方用 loader-utils 的 stringifyRequest 处理相对路径,更稳妥。
  • HMR:demo 未处理热更新,改 CSS 不会自动替换 <style>。生产插件需要 module.hot 逻辑。
  • 与 experiments.css 冲突:开启原生 CSS 支持后不应再叠加 JS 版 style-loader。

小结

pitch 提供了一次「提前介入」的机会,让左侧 loader 能在右侧 loader 执行前决定整个模块长什么样。style-loader 正是借此把 css-loader 的 JS 输出在运行时 toString() 成 CSS 并注入页面——这也是理解 Webpack loader 执行顺序的最佳范例。

评论
0/100