webpack 原生 CSS 与 CSS Modules 实战:experiments.css 配置、作用域规则与类型声明自动生成
本篇技术指南基于当前 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.js(CSS_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.ts中export 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.png,url(...)自动改写为产物相对路径。该图片在构建信息中被标记为[immutable] [from: images/file.png],属于 main 入口的辅助资产。 - 远程
@import(https://fonts.googleapis.com/...)被原样提升为@import url(...)留在文件顶部,让浏览器在运行时自行跨域拉取,而不是把字体 CSS 打进包里。 style.css与style2.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>(原始名 → 局部化名)。它的数据来源有三重同源关系:
- webpack 用它构造 JS 导出对象——即上面
output.js里{ "large": "--QRIlVD", "main": "zI6JBT" }; - 它是 Lightning CSS
transform()返回的exports——webpack 底层 CSS 变换依赖同一份数据; - 因此生成
.d.ts时不需要任何额外的 CSS 解析步骤。
在源码中,cssData 这一挂载字段实际出现在 lib/css/CssModule.js、lib/css/CssGenerator.js 与 lib/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-modules、css-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 给出了明确的接入流程:
- 运行构建(或
webpack --watch),让.d.ts随 CSS 变更保持同步; - 两种策略任选其一:将生成的
.d.ts提交进版本库,或在.gitignore中追加*.module.css.d.ts; - 无需配置
paths或任何 ambient-module 声明——因为声明文件与源文件同名相邻(style.module.css↔style.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-set与counter()/counters()/target-counter())、view-transition-name/-group/-class及::view-transition-*()伪元素参数等;container:@container名 +container-name;function:@function名及其调用。- 这些 “自定义标识符归属” 的属性清单,实际编码在 lib/css/data.js 中(例如
list-style→customIdents、counter-reset→customIdents、view-transition-name→customIdents等成对声明),由 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-name、position-anchor、anchor()、@position-try、anchor-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-transition 的 types,以及 :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.md 与 examples/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.js、lib/css/CssModule.js、lib/css/CssModulesPlugin.js 以及标识符归属表 lib/css/data.js,并结合 examples/css 中的源码文件逐一对照产物验证理解。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00