问题从何而来
在 Webpack 里配置 CSS:
{
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 函数:
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:
// 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;
`;
};
逐段解读
-
remainingRequestpitch 的第一个参数,代表「当前 loader 右侧的所有 loader + 资源」,这里是css-loader!./style.css。 -
'!!' + remainingRequest这是关键。!!前缀会禁用配置里声明的所有 loader,只使用请求中显式写出的 loader。于是require会精确走一遍 css-loader,而不会再次触发my-style-loader,避免无限递归。 -
取到真正的 CSS 文本 css-loader 默认
esModule: true,require得到的是命名空间对象,CSS 在.default上;它还是个数组(exportType: 'array'),其toString()会拼出 CSS。所以:jscontent = content.__esModule ? content.default : content; var css = content.toString(); -
注入 DOM 创建
<style>,写入 CSS,插到head(或按insert选项插到body)。 -
保留导出
module.exports = content;让import styles from './x.css'依然可用(比如 CSS Modules 的 locals)。
配置与验证
{
test: /\.css$/i,
use: [
{
loader: path.resolve(__dirname, 'loaders/my-style-loader.js'),
options: { insert: 'head' },
},
'css-loader',
],
}
构建后在浏览器里打开页面,head 中会出现:
<style data-my-style-loader="true">body { ... }</style>
也可以在 Node 里用假的 document 跑一遍 bundle.js,确认 <style> 被正确注入、CSS 文本完整。
为什么官方也这么做
打开真正的 style-loader,你会发现它的 pitch 里正是用 '!!' + request 去 import CSS 内容:
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 执行顺序的最佳范例。