首页
/ webpack 原生 CSS 与 CSS Modules 实战:experiments.css 配置、作用域规则与类型声明自动生成

webpack 原生 CSS 与 CSS Modules 实战:experiments.css 配置、作用域规则与类型声明自动生成

2026-09-07 19:59:46作者:苗圣禹Peter

本篇技术指南基于当前 webpack 仓库中的 examples/css 完整示例展开。该示例演示了如何在不借助任何 CSS loader 的前提下,用 webpack 内置的原生 CSS 能力(experiments.css)处理静态样式、CSS Modules 与按需异步样式,并附带一个利用源码内部数据结构、为 CSS Modules 自动生成 .d.ts 类型声明的自定义插件 CssModuleTypesPlugin。读完后你将掌握:如何配置并运行这一示例、四类 CSS 引入方式的差异与产物形态、webpack 原生 CSS Modules 精确的作用域边界,以及如何在自己的工程里复刻“零 loader + 全类型”的样式方案。

示例全景:一次构建覆盖四类 CSS 用法

example.js 是整套示例的入口,仅 6 行就把现代样式工程最常见的四类用法全部覆盖:

import "./style.css";
import "./style2.css";
import styles from "./style.module.css";
import("./lazy-style.css");

document.getElementsByTagName("main")[0].className = styles.main;
引入方式 目标文件 语义
静态 import style.css 含级联 @import(本地文件 + 远程 Google Fonts)的普通样式
静态 import style2.css style.css 同入口的普通样式,用于演示合并顺序
命名导入 styles from style.module.css CSS Modules 局部作用域样式,按 .module.css 约定被识别
动态 import() lazy-style.css 按需加载的异步 CSS chunk

index.html(见 examples/css/index.html)则给出配对的浏览器侧结构:<head> 中先行引用 dist/output.css(带 data-webpack="app:chunk-0" 标记),<body> 提供 <main> 元素与 <p class="img"> 占位,<script src="dist/output.js"> 负责运行入口逻辑。

值得注意最后一行 JS:styles.main 拿到的并非源文件里的类名,而是构建期生成的局部化名字——这正是 CSS Modules 的核心价值所在,也是后文 CssModuleTypesPlugin 想要“还原出类型”的对象。

启用原生 CSS:experiments.css 与 css/* 模块类型

整套示例的 webpack.config.js 没有任何 CSS loader,核心开关只有两处:

module: {
	rules: [
		{
			test: /\.module\.css$/,
			type: "css/module",
			parser: { namedExports: false }
		}
	]
},
experiments: {
	css: true
},

要点拆解:

  • experiments.css: true:开启 webpack 原生 CSS 处理管线,让 .css 文件成为一等公民模块,走 lib/css/ 目录下的 parser / generator / plugin 实现,而不再依赖 css-loader 把 CSS 转成 JS 字符串。示例配置里只显式声明了 .module.css 的规则,其余普通 .css 交给 webpack 的默认类型处理。
  • type: "css/module":显式把 .module.css 标记为 CSS Modules 模块。该模块类型常量定义在 lib/ModuleTypeConstants.jsCSS_MODULE_TYPE_MODULE = "css/module")。除它之外,原生 CSS 还有 "css""css/global""css/auto" 等类型(同一文件第 156 行的类型定义注释),其中 "css/auto" 会自动把 *.module.css 判定为模块——README 中 “auto-detected *.module.css” 即指此行为。
  • parser: { namedExports: false }:关闭命名导出,使该 CSS 模块的 default export 就是一个“原始类名 → 局部化类名”的映射对象。这与插件生成的 .d.tsexport default styles 的形状严格对齐(也与 css-loader 默认行为一致),为后面的类型方案铺路。

与各类经典 loader 方案的差别在于:整个 CSS 语法分析、模块局部化(localization)与代码生成都在 webpack 进程内完成,产物信息(类名映射、chunk 归属)直接沉淀在模块图中,无需额外的中间加载器。

入口 CSS 的静态合并:内联 @import、远程 @import 与图资源引用

先看 style.css

@import "style-imported.css";
@import "https://fonts.googleapis.com/css?family=Open+Sans";

body {
	background: green;
	font-family: "Open Sans";
}

在打包结果 dist/output.css 中可以看到 webpack 对两类 @import 的差异化处理:

@import url("https://fonts.googleapis.com/css?family=Open+Sans"); /* 远程:原样保留 */
.img { width: 150px; height: 150px; background: url(dist/89a353e9c515885abd8e.png); } /* style-imported.css 内容被内联 */
body { background: green; font-family: "Open Sans"; } /* style.css 本身 */
body { background: red; } /* style2.css */
  • 本地相对 @import "style-imported.css"就地内联:其内引用的 images/file.png 被当作资产拷贝为 dist/89a353e9c515885abd8e.pngurl(...) 自动改写为产物相对路径。该图片在构建信息中被标记为 [immutable] [from: images/file.png],属于 main 入口的辅助资产。
  • 远程 @importhttps://fonts.googleapis.com/...)被原样提升为 @import url(...) 留在文件顶部,让浏览器在运行时自行跨域拉取,而不是把字体 CSS 打进包里。
  • style.cssstyle2.css 都声明 body,按模块顺序先后出现在产物中(先 green 后 red,后者按源码顺序在后,形成覆盖关系),说明多个入口静态样式会按确定性顺序合并进同一份 output.css

生产模式下这段 CSS 被压缩为一行(见 README 的 ## production 片段),同时把 darkblue 收敛为 #00008b、把 (min-width: 1024px) 改写为更短的 (width>=1024px) 媒体查询语法——这些压缩改写来自 webpack 原生的 CSS 代码生成与压缩阶段。

JS 眼中的 CSS 模块:导出映射与运行时应用

style.module.css 作为 css/module,在 output.js 中是一个独立的 JS 模块。产物中用注释标明了它导出的名字:

/*! css ./style.module.css ***!/
/*! default exports */
/*! export default [not provided] ... */
/*! export large [provided] ... */
/*! export main [provided] ... */
module.exports = { "large": "--QRIlVD", "main": "zI6JBT" };

这就是默认导出(对象形式)的真实形态:key 是源码里的类名/标识符名,value 是构建期生成的短哈希作用域名。入口模块随后这样消费它:

document.getElementsByTagName("main")[0].className =
	_style_module_css__WEBPACK_IMPORTED_MODULE_0__.main; // => "zI6JBT"

对照最终的 output.css,就能看到作用域改写后的规则本体:

:root { --QRIlVD: 72px; }      /* 自定义属性 --large 被局部化改名 */
.zI6JBT { font-size: var(--QRIlVD); color: darkblue; }
@media (min-width: 1024px) { .zI6JBT { color: green; } }
@supports (display: grid) { .zI6JBT { display: grid } }

这里有一个关键细节:写在 :root 里的自定义属性 --large 也被局部化为 --QRIlVD,而 .main 规则体内的 var(--QRIlVD) 同步改写成新名字。这正是 README “What native CSS scopes” 一节所说 dashed ident(--foo)默认也会被局部化 的直观证据——改的是声明与引用两处,因此跨规则引用不会断裂。

按需 CSS chunk:import() 驱动的懒加载样式

import("./lazy-style.css") 不走静态合并,而是生成独立的异步 chunk:1.output.js + 1.output.css(生产模式为 822.output.*)。1.output.css 内容即原样样式:

body { color: blue; }

output.js 的运行时中可以看到支撑它的基础设施:

  • __webpack_require__.k(“get css chunk filename”)把 chunk id 映射为 chunkId + ".output.css"
  • __webpack_require__.f.css 负责真正的 CSS chunk 加载:为未加载的 chunk 建立 [resolve, reject] 记录、拼出 publicPath + 文件名 并调用 loadStylesheet
  • loadStylesheet<link rel="stylesheet"> 注入样式,通过 data-webpack="<uniqueName>:chunk-<id>" 属性去重(避免重复插入),并维护 data-webpack-loading 标记、120 秒超时与 ChunkLoadError 错误上报。

运行时里的 uniqueName: "app" 正对应配置中 output.uniqueName: "app"(见 webpack.config.js),浏览器端的 index.html 也用手工写死同一 data-webpack="app:chunk-0" 值,保证运行时能识别页面中已有的样式标签。

为 CSS Modules 生成 TypeScript 类型:CssModuleTypesPlugin 拆解

普通 CSS 不需要类型,但 CSS Modules 的 styles.main 若在 TypeScript 工程里使用,需要告诉编译器“该对象有哪些属性、值都是字符串”。README 明确指出:目前没有打包器原生内置该能力,而 webpack 其实已经在内部算出了这张映射表,因此只需要寥寥数行插件即可补齐类型。webpack.config.js 中的 CssModuleTypesPlugin 就是完整实现。

数据同源:读取 module.buildInfo.cssData.exports

插件没有重新解析 CSS,而是直接读取编译期就已算好的映射:

const cssData = module.buildInfo && module.buildInfo.cssData;
if (!cssData || !cssData.exports || cssData.exports.size === 0) continue;
fs.writeFileSync(`${resource}.d.ts`, toDts(cssData.exports));

module.buildInfo.cssData.exports 是一个 Map<string, string>(原始名 → 局部化名)。它的数据来源有三重同源关系:

  1. webpack 用它构造 JS 导出对象——即上面 output.js{ "large": "--QRIlVD", "main": "zI6JBT" }
  2. 它是 Lightning CSS transform() 返回的 exports——webpack 底层 CSS 变换依赖同一份数据;
  3. 因此生成 .d.ts不需要任何额外的 CSS 解析步骤

在源码中,cssData 这一挂载字段实际出现在 lib/css/CssModule.jslib/css/CssGenerator.jslib/css/CssModulesPlugin.js 等文件中,负责在模块解析、代码生成与打包阶段之间传递类名映射信息。

挂载时机:processAssets 附加阶段

插件的 apply(compiler) 通过 compiler.webpack.Compilation 拿到编译对象,按标准钩子链注册:

compiler.hooks.thisCompilation.tap("CssModuleTypesPlugin", (compilation) => {
	compilation.hooks.processAssets.tap(
		{
			name: "CssModuleTypesPlugin",
			stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL
		},
		() => { /* 遍历 compilation.modules,写 .d.ts */ }
	);
});

选择 PROCESS_ASSETS_STAGE_ADDITIONAL 阶段(在常规资产处理完成后追加额外资产),此时 compilation.modules 中所有 CSS 模块的 buildInfo.cssData 都已填充完毕。插件对每个模块取出 resource(源文件绝对路径),用 fs.writeFileSync 把声明文件写到 源文件路径 + ".d.ts",即紧挨 CSS 源文件落盘,而不是输出到 dist——这正是编辑器和 tsc 能无需任何配置自动发现它的原因。

toDts:引号化 key 的类型安全写法

const toDts = (exports) => {
	const lines = [
		"// Generated by CssModuleTypesPlugin. Do not edit.",
		"declare const styles: {"
	];
	for (const name of [...exports.keys()].sort()) {
		lines.push(`\treadonly ${JSON.stringify(name)}: string;`);
	}
	lines.push("};", "export default styles;", "");
	return lines.join("\n");
};

两个设计点值得注意:

  • JSON.stringify(name) 给每个 key 加引号,使 kebab-case 类名(如 my-class)与保留字(如 default)都能直接作为对象键,无需任何特殊转义分支——与 typed-css-modulescss-modules-typescript-loader 等既有工具输出的对象形状保持一致。
  • 声明为 readonly,防止运行期被误改;并按 key 排序保证输出确定性、便于 diff。

产物形态与编辑器体验

生成的 style.module.css.d.ts 内容如下:

// Generated by CssModuleTypesPlugin. Do not edit.
declare const styles: {
	readonly "large": string;
	readonly "main": string;
};
export default styles;

配合 parser: { namedExports: false }(default export 即该对象),example.js 里的导入便获得完整类型:

import styles from "./style.module.css"; // styles.main: string

由此得到三条可验证的体验收益:styles.main 被推断为 string;访问不存在的 styles.nope 会产生编译错误;编辑器能对类名做自动补全。

接入真实工程的三步

README 给出了明确的接入流程:

  1. 运行构建(或 webpack --watch),让 .d.ts 随 CSS 变更保持同步;
  2. 两种策略任选其一:将生成的 .d.ts 提交进版本库,或在 .gitignore 中追加 *.module.css.d.ts
  3. 无需配置 paths 或任何 ambient-module 声明——因为声明文件与源文件同名相邻(style.module.cssstyle.module.css.d.ts),TypeScript 的默认解析规则会自动命中。

webpack 原生 CSS Modules 的作用域边界

README 用专门一节完整列出“native CSS scopes”的规则。与经典 loader 相比,webpack 原生实现对 CSS 标识符的局部化覆盖面更大,可归纳为四档:

1. 始终局部化

  • 类选择器 .foo 与 id 选择器 #foo

2. 按 parser 选项显式开关(默认全部为 true

  • animation@keyframes 名 + animation-name
  • grid:grid line / area 名;
  • customIdents@counter-style + list-style、counter 系列(counter-reset / counter-increment / counter-setcounter() / counters() / target-counter())、view-transition-name / -group / -class::view-transition-*() 伪元素参数等;
  • container@container 名 + container-name
  • function@function 名及其调用。
  • 这些 “自定义标识符归属” 的属性清单,实际编码在 lib/css/data.js 中(例如 list-stylecustomIdentscounter-resetcustomIdentsview-transition-namecustomIdents 等成对声明),由 CSS 解析器(lib/css/CssParser.js)在分析样式表时据此决定哪些名字要被改写。

3. dashed ident 自动处理(dashedIdents,默认 true 任何 --foo 形式的标识都会被局部化,覆盖范围包括:自定义属性与 var(--foo)(含跨文件 var(--foo from "./x.css")from global)、@property / @font-palette-values / @color-profile 的名称、anchor positioning(anchor-nameposition-anchoranchor()@position-tryanchor-scope)、scroll-driven animation 名称、@container style(--foo) 查询等。README 特别强调:新增的 dashed-ident CSS 特性会被自动覆盖,无需为每种特性写专门代码——这正是前文 --large → --QRIlVD 改写能够发生的原因。

4. 组合与取值语法 composes(同文件、from "./x.css"from global)、@value(含跨文件)、ICSS 的 :import / :export 均被支持,用于在模块间复用样式或暴露取值。

刻意保持全局(不做局部化)的清单同样重要——这些名字跨越文档或整个应用协同,加作用域反而会破坏语义:@layer@page 名称、@font-feature-values 的 family 名、@view-transitiontypes,以及 :global(...) 选择器内部。

理解这组边界,能帮你预判“某段 CSS 编译后类名会不会变”,从而准确写出与 CSS Modules 协作的 JS/TS 代码。

运行方式与构建信息解读

在仓库根目录下进入示例目录后执行 webpack 即可复现 README 展示的全部产物:

cd examples/css
npx webpack            # 开发模式构建
npx webpack --watch    # 让 .d.ts 随编辑自动再生成
npx webpack --mode production

注意 README 构建输出中的版本行写作 webpack X.X.X compiled successfully——X.X.X 是模板占位符(见 examples/css/template.mdexamples/buildAll.js 的生成机制),真实构建时会打印当前 webpack 版本。

两份构建统计(README 末尾 “Info” 节)还可以读出三条有工程价值的信息:

  • chunk 划分稳定:开发模式 main chunk 产出 output.js/output.css,懒加载样式独立为 1.output.*;生产模式懒加载 chunk 更名 822.output.*,且 JS 从 14.8 KiB 压到 3.12 KiB、CSS 从 1.04 KiB 压到 360 bytes。
  • CSS 模块的 tree-shaking 可见:生产模式统计显示 css ./style.module.css [exports: large, main] [only some exports used: main]——只有被 styles.main 用到的 main 被保留,未使用的 large 类名映射及对应规则会在压缩产物中被剔除,这与 JS 侧“导出未使用即删除”是同一套分析逻辑。
  • 资产类型维度清晰:单条 chunk 会分别列出 (javascript)(css)(asset)(asset-url)(css-import)(runtime) 等字节构成,便于定位体积来源。

小结

examples/css 是一个“少配置、多功能”的示范:experiments.css: true + type: "css/module" 即可获得合并、按需加载、局部化、压缩在内的完整原生 CSS 能力;而 CssModuleTypesPlugin 则示范了如何利用 webpack 已计算出的 module.buildInfo.cssData.exports 映射,以极低成本为 CSS Modules 补齐 TypeScript 类型。若要深入其内部实现,可继续阅读 lib/css/CssParser.jslib/css/CssModule.jslib/css/CssModulesPlugin.js 以及标识符归属表 lib/css/data.js,并结合 examples/css 中的源码文件逐一对照产物验证理解。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388