写 CSS Modules 的文章,我一直觉得得先从一个真实场景讲起。你可能经历过这种状况:项目规模一上来,styles.css 里躺着上千个类名,谁都不敢删——因为你永远不知道哪个组件还在用这个 class。于是大家开始用 BEM 命名规范,手动加前缀,但规范这种东西,新人一多就崩,总有人写漏一截,样式就互相污染了。我自己最早是在一个多人协作的后台管理系统里真正被这个问题打疼,才下决心把 CSS Modules 引入到项目的每个模块里。
CSS Modules 不是一个框架,也不是什么新语言,它本质上是把“样式文件”当作一个独立的模块来处理。当你 import 一个 .module.css 文件时,构建工具会把里面的每个类名都编译成带有哈希后缀的唯一名称,作用域天然隔离,你在组件 A 里定义的 .title 和组件 B 里的 .title 互不干扰。文章写到这里,我把 CSS Modules 的特性、生态工具和实际工程实践一次性讲透,适合刚接触模块化 CSS 的前端工程师,也适合已经在用但想深挖原理、想优化配置的开发者。
1. 先搞清楚 CSS Modules 到底解决了什么问题
1.1 传统 CSS 全局作用域带来的真实痛点
CSS 这门语言从设计之初就没有“模块”的概念。所有类名、ID、标签选择器默认都挂在全局作用域下,你在页面任何位置写了一个 .card,它就有可能影响整个站点的所有 .card 元素。在小项目里这无所谓,但一旦多人协作、项目迭代超过一年,问题就会集中爆发。
我举一个非常典型的实际案例。之前做一个电商中台,商品列表页和购物车页两个团队分别开发,大家都不约而同地定义了 .count 这个类名。结果上线后,商品列表页的数字角标突然多了一个背景色,排查了一下午才发现是购物车模块的全局样式把 .count 的背景色覆盖了,因为后加载的样式优先级更高。这种问题在传统 CSS 里几乎无解,你能做的只有改类名、加前缀、提高选择器优先级,然后陷入恶性循环。
BEM 规范其实就是为了解决这个问题被提出的,它靠约定来约束类名:.product-list__count、.cart__count。这种方式有效,但它依赖人的执行力。代码评审遗漏一个不规范命名的 PR,隐患就到线上去了。CSS Modules 的思路完全不同,它从机制层面解决问题——编译期直接改写类名,让同名类名变成不同的随机名称,你根本不需要费劲想唯一名称。
1.2 编译期作用域隔离的底层机制
CSS Modules 的核心设计是在构建阶段完成的。以 webpack 生态为例,当你在 JavaScript 中 import styles from './Button.module.css' 时,css-loader 会解析这个 CSS 文件,把里面每个类名当作一个 key,生成一个由原始类名和哈希值组成的新类名,然后导出一个映射对象。
js复制// Button.module.css 源码
.button {
color: red;
}
经过 css-loader 处理之后,导入到 JS 里的 styles 对象大致长这样:
js复制{
button: '_button_abcde_1'
}
页面实际渲染的 class 是 _button_abcde_1,而这个 _button_abcde_1 只存在于组件对应的 DOM 上。另一个组件里同样叫 .button 的类,会被编译成另一个完全不同的哈希名,比如 _button_fghij_2。两个组件各自引用自己模块导出的映射对象,视觉上完全隔离,谁也不会覆盖谁。
这里有一个关键点值得展开:CSS Modules 不是靠“约定”实现隔离,而是靠“改写标识符”实现隔离。这意味着哪怕两个开发者在不同的文件里写了完全相同的类名,编译后也会各自带上独一无二的上下文哈希,完全不会冲突。这比 BEM 更彻底,因为不依赖任何人的自觉性。
从方案选型的角度来看,CSS Modules 在 CSS 领域的定位其实介于“纯约定方案”(BEM)和“运行时方案”(CSS-in-JS)之间。它不要求在 JS 里写样式,保留了 CSS 原生语法、预处理器的能力,同时又在编译期完成作用域隔离。相比 CSS-in-JS,CSS Modules 的性能更好——不需要运行时动态插入样式;相比纯 CSS + BEM,它在大型项目里更省心,不需要人为保证命名唯一性。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. CSS Modules 的核心特性逐项拆解
2.1 局部作用域与 :local/:global 的用法边界
CSS Modules 默认所有类名都是局部的。也就是说,你在 .module.css 文件里写的任何普通类名,都会被编译成带哈希的名称,只对当前模块生效。这就是所谓的 :local 默认行为。
但实际开发中总有需要“逃逸”到全局的场景。比如覆盖第三方组件库的样式、设置 body 背景色、添加根布局容器,这时候就需要显式声明全局作用域。CSS Modules 提供了 :global 关键字:
css复制/* 全局声明,编译后类名保持原样 */
:global(.container) {
width: 100%;
max-width: 1200px;
}
/* 局部类名 */
.header {
padding: 16px;
}
这种写法的编译结果非常直观:被 :global() 包裹的类名原样输出,不会被哈希改写;普通的局部类名则照常生成哈希名称。我在实际项目里的经验是,尽量把全局样式单独放在非 module 的 css 文件里管理,而这些文件不经过 CSS Modules 处理,直接作为全局样式引入。.module.css 文件只放组件自身的局部样式, :global 只用于非常特殊的覆盖场景。
有一个容易踩坑的细节:很多人以为 :global 只能包裹一个选择器,其实它可以包裹一段完整的规则块,也可以用在复合选择器的某个部分:
css复制/* 编译后只把外层包装展开为原样输出 */
:global {
.clearfix::after {
content: '';
display: table;
clear: both;
}
}
/* 混合写法:.wrapper 被哈希,内部的 .legacy 保持原样 */
.wrapper :global(.legacy) {
color: gray;
}
2.2 组合机制 composes:样式复用不再靠复制粘贴
CSS Modules 最容易被低估的特性是 composes。它解决的是类样式之间的复用问题。在没有这个机制之前,CSS 里要做类似“继承”的效果,通常是用预处理器 mixin 或者手动在 HTML 里堆多个类名。前者会重复生成大量样式代码,后者则要求使用方了解组件的内部结构。
composes 的语法很简洁,在当前类中引用其他类,编译时会把被组合类的声明合并到当前类中:
css复制.base {
padding: 12px 20px;
border-radius: 4px;
font-size: 14px;
}
.primary {
composes: base;
background-color: #1890ff;
color: #fff;
}
.danger {
composes: base;
background-color: #ff4d4f;
color: #fff;
}
编译后,.primary 和 .danger 会被转换成同时包含两段类名的选择器。在 DOM 层面,使用 primary 的元素实际会挂上编译后的 .base_hash 和 .primary_hash 两个类,样式也来自两处组合。这种设计的好处是样式代码本身不冗余,复用关系清晰。
composes 还支持跨文件组合,这是它真正有生产力的地方:
css复制/* 在 Button.module.css 中 */
.primary {
composes: base from './button-base.module.css';
background-color: #1890ff;
}
需要注意的是,composes 只能作用于类选择器,不能作用于 ID、标签或属性选择器。另外,组合前不能使用嵌套语法,比如你不能在 .card 嵌套的 .title 里写 composes。这两个限制其实很好理解——composes 的关键是编译期把类型相同、层级平铺的复用关系合并,一旦涉及嵌套和复杂选择器,编译器没法做静态分析。这个限制在实际项目中几乎不影响开发效率,因为需要复用的本来就是那些平铺的基础样式。
2.3 动画与关键帧:CSS Modules 处理 @keyframes 的特殊方式
CSS Modules 对 @keyframes 的处理值得单独讲,因为这里有一个新手很容易踩的坑:动画名称也是全局标识符。如果你在组件样式里定义了一个名为 fade-in 的 keyframes,而另一个组件也定义了同名动画,两者就会冲突。
CSS Modules 的解决方案是把动画名也当作局部标识符处理。你直接这样写:
css复制.fadeIn {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
编译后,不仅 .fadeIn 会被哈希,@keyframes fadeIn 中的动画名也会被改写成 fadeIn_abcde_1 这样的哈希名称,两者在编译后的规则里保持一致。因此不同组件里同名动画完全不会互相干扰。
但有一个坑我必须提醒:如果你用的是内联 style 或在 JS 里动态拼接动画名,那就得小心了。比如你写了一个全局注入的第三方样式,其内部使用了未被作用域处理的动画名,或者你在 JS 里写 animation: 'fadeIn 0.3s',这个字符串不会经过 CSS Modules 编译,自然匹配不到被哈希后的动画名。在组件里动态设置动画时,我的建议是把动画样式写进 module.css,通过类名来控制启动和停止,而不是在 JS 里直接操作 animation 字符串属性。
3. 生态工具链选型与工程集成方案
3.1 webpack 生态:css-loader 是整个链路的基石
CSS Modules 在 webpack 生态里是通过 css-loader 的 modules 选项来开启的,这是最成熟、最主流的使用方式。配置看起来很简单:
js复制// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.module\.css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: {
mode: 'local',
localIdentName: '[path][name]__[local]--[hash:base64:5]',
},
},
},
],
},
],
},
};
这里 mode: 'local' 表示默认所有类名都是局部作用域,这也是我们日常使用的模式。还有两个不常用的模式:mode: 'global' 表示默认所有类名都是全局的,只有显式声明 :local 的才是局部;mode: 'pure' 则严格一些,文件里不允许存在 :global 和 :local 关键字,否则编译报错。
localIdentName 是控制编译后类名格式的关键参数。[path] 是文件路径,[name] 是文件名,[local] 是原始类名,[hash] 是内容哈希。我强烈建议在开发环境把哈希部分调短甚至不加哈希,因为长哈希类名会拖垮调试效率——控制台里全是 _src_components_Button_module_button_a1b2c,看都看不清。我自己常用的配置是开发环境用 [path][name]__[local],生产环境用 [hash:base64:8],既保证开发可读性,又最大化压缩体积。
如果你用的是 rule 方式匹配文件,需要特别注意文件命名。业界惯例是让 CSS Modules 文件以 .module.css 结尾,这样规则可以通过 test 字段精准区分普通 css 和模块化 css。比如:
js复制rules: [
// 普通全局 css
{ test: /\.css$/, exclude: /\.module\.css$/, use: ['style-loader', 'css-loader'] },
// css modules
{ test: /\.module\.css$/, use: ['style-loader', { loader: 'css-loader', options: { modules: true } }] },
]
3.2 Vite 与新一代构建工具:零配置就能用
如果你用的是 Vite,那 CSS Modules 几乎是零成本开启的。Vite 对 .module.css 后缀的文件默认开启 CSS Modules 处理,不需要任何配置。你用 import styles from './Button.module.css' 的方式导入,得到的 styles 对象已经带好了哈希映射。
Vite 里如果想要自定义类名格式,通过 css.modules 配置覆盖:
js复制// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
css: {
modules: {
generateScopedName: '[name]_[local]_[hash:base64:4]',
},
},
});
Vite 底层使用的是 PostCSS modules 相关插件,在 generateScopedName 中你可以实现和 webpack 的 localIdentName 一样的类名格式。另一个 Vite 特有的小细节是,通过 css.modules.scopeBehaviour 可以配置局部或全局模式,但日常使用保持默认 'local' 就够了。
从实际体验来说,Vite 对 CSS Modules 的支持比 webpack 更丝滑,因为 HMR 更新样式时不需要像 webpack 那样频繁触发 JS 模块的重新执行。如果你是新项目,我建议直接上 Vite 这类构建工具,CSS Modules 开箱即用,心智负担比 webpack 小很多。不过,webpack 的生态里 css-loader 依然有它的优势,尤其是老项目迁移场景中,你只需要加一条 rule 就能逐步引入 CSS Modules,不需要整体换构建工具。
3.3 TypeScript 类型支持与编辑器开发体验
CSS Modules 在引入 TypeScript 的项目里有一个比较烦的问题:默认情况下 TS 不认识 .module.css 文件,import styles from './Button.module.css' 会直接报错“找不到模块声明”。解决这个问题需要添加一个全局的类型声明文件:
ts复制// css-modules.d.ts
declare module '*.module.css' {
const classes: { readonly [key: string]: string };
export default classes;
}
declare module '*.module.scss' {
const classes: { readonly [key: string]: string };
export default classes;
}
加了这段声明之后,TS 至少不会报错了,但 styles 对象里的 key 依然是宽泛的 string 类型,你写 styles.buttn 这种拼写错误时不会得到提示。想要精确的类名类型提示,可以用 typed-css-modules 这个工具生成 .d.ts 文件,或者用社区的 css-modules-typescript-loader。它会在你写完 CSS 文件后自动生成对应的类型声明,之后编辑器中写 styles. 就会有具体类名的自动补全。
我的经验是,小型项目用全局声明文件就够了,不值得额外引入代码生成流程。但大型项目、组件库或多人协作场景,精确的类型提示价值非常大,它能在编译期拦截掉很多类名拼写错误,这种错误在纯运行时才能暴露,排查起来很浪费时间。
编辑器端的体验主要靠 PostCSS 语言服务插件解决。VSCode 装了 PostCSS 插件后,对 .module.css 文件的类名补全和样式跳转都能正常工作,而且能正确识别 composes 的跨文件引用。我在项目里还会配一个 stylelint,专门对 module.css 做静态检测,规则里加一条 selector-class-pattern 检查类名命名规范,避免有人写出 _button 这种不合适的类名风格。
3.4 与 Sass/Less 配合的注意事项
CSS Modules 和预处理器不是二选一的关系,它们可以共存,而且很多项目都是这样干的。你可以写 Button.module.scss,先经过 sass-loader 编译成普通 CSS,再进入 css-loader 做模块化处理。整套链路是各司其职:sass-loader 负责变量、mixin、嵌套语法;css-loader 负责类名哈希化和作用域隔离。
不过这里有一个必须注意的坑:composes 必须在最终编译后的 CSS 类上使用,也就是说它不能用在一个被嵌套的类选择器内部。你在 scss 里写嵌套结构时,如果内层类想用 composes,是编译不过的:
scss复制.card {
padding: 10px;
.title {
// 这里写 composes 会报错
composes: heading;
}
}
正确的做法是把需要复用的样式抽出来做成独立的顶层类,然后在外层单独引用,或者在顶层做组合。这是实践中新手最容易卡住的地方。另外,Sass 变量在 CSS Modules 下正常工作,你可以放心用变量管理主题色、间距等设计令牌。但要注意,变量名不会被哈希,如果项目里不同模块定义了同名变量,后加载的会覆盖前面的取值,所以建议变量文件集中管理,全局只保留一份。
4. 实操:从零搭建一个带 CSS Modules 的组件项目
4.1 组件目录设计与命名规范
动手之前先规划目录结构。我基于 Vite + React 来演示,这套结构在 Vue 里同样适用。项目目录大概是这样:
bash复制src/
components/
Button/
Button.tsx
Button.module.css
index.ts
Modal/
Modal.tsx
Modal.module.css
styles/
global.css
variables.css
App.tsx
每个组件一个文件夹,样式文件和组件文件同名,后缀用 .module.css。全局样式的 global.css 和设计变量文件放在独立的 styles 目录,不经过 CSS Modules 处理。这种结构的好处是组件边界清晰,样式跟着组件走,删组件时连样式一起删,不存在删除全局样式后误伤其他页面的风险。
命名规范方面,CSS Modules 的局部类名可以用短横线命名法或者驼峰命名,因为编译后的类名会带哈希,不需要担心全局冲突。但我个人习惯在 JSX 里用驼峰引用,所以建议类名统一用驼峰——buttonText 比 button-text 在 styles.buttonText 中引用起来更顺手,不用写方括号加字符串。
4.2 按钮组件实战:从零写一个可复用的 Button
我从一个实际的按钮组件开始,展示 CSS Modules 在日常组件开发中的完整姿势。先写样式文件:
css复制/* Button.module.css */
.button {
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
padding: 0 16px;
border: 1px solid transparent;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.primary {
composes: button;
background-color: #1677ff;
color: #fff;
}
.danger {
composes: button;
background-color: #ff4d4f;
color: #fff;
}
.large {
composes: button;
height: 44px;
padding: 0 24px;
font-size: 16px;
}
.disabled {
composes: button;
opacity: 0.5;
cursor: not-allowed;
}
这里我把基础样式抽成了 .button,其他变体都通过 composes 复用它。这么做的好处是,按钮的公共基础样式只写一遍,变体之间的差异一眼就能看出来。如果使用传统的类名拼接方案,组件内部会有一长串的 className 判断逻辑,样式模块里也全是重复代码。
组件的 TypeScript 实现:
tsx复制// Button.tsx
import styles from './Button.module.css';
type Variant = 'primary' | 'danger' | 'default';
type Size = 'large' | 'default';
interface ButtonProps {
variant?: Variant;
size?: Size;
disabled?: boolean;
children: React.ReactNode;
}
export function Button({ variant = 'default', size = 'default', disabled, children }: ButtonProps) {
const variantClass = variant === 'default' ? '' : styles[variant];
const sizeClass = size === 'default' ? '' : styles[size];
return (
<button
className={`${styles.button} ${variantClass} ${sizeClass} ${disabled ? styles.disabled : ''}`}
disabled={disabled}
>
{children}
</button>
);
}
如果你看这段代码,会发现它组合类名的逻辑非常直白,没有任何人为维护哈希命名的负担。代码的可读性反而比纯 CSS 项目更高,因为 styles.primary 这个引用就明确告诉读者“这个样式来自 Button 模块”,不会出现全局样式里钻出一个作用不明的类名。
4.3 实际编译产物分析:看看 CSS Modules 生成了什么
写完之后,我们看一眼构建产物的实际输出,这对理解 CSS Modules 的工作原理很有帮助。开发模式下类名格式会保持易读状态,比如我们配置成 [name]_[local],编译后的 CSS 长这样:
css复制/* 编译后 */
.Button_module_button__x7e21 {
display: inline-flex;
align-items: center;
height: 36px;
/* ... */
}
.Button_module_primary__k2m9d {
background-color: #1677ff;
color: #fff;
}
而 JS 部分导出的是一个映射对象:
js复制{
button: 'Button_module_button__x7e21',
primary: 'Button_module_primary__k2m9d'
}
注意这里的细节:原样式里 .primary 通过 composes: button 复用了基础样式,但编译后的 CSS 规则里并没有重复生成 .button 的样式代码到 .primary 里。实际上最终的 DOM 上,primary 按钮会同时挂上 Button_module_button__x7e21 和 Button_module_primary__k2m9d 两个类名,浏览器同时应用两段规则。这就是 composes 和传统资源复用在本质上的区别——它是在“类名组合”层面做文章,而不是复制粘贴样式内容。这个机制保证了最终 CSS 文件体积不会因为大量复用而膨胀。
生产环境的构建优化上,类名会切成短哈希:
css复制._x7e21 {
display: inline-flex;
/* ... */
}
配合 mini-css-extract-plugin 把样式抽取到独立文件、开启 contenthash 文件名,就完成了 CSS Modules 的整套生产配置。不过在实际项目中,类名被压缩成纯哈希后会在一个地方带来不便——调试线上问题时,控制台里全是 _x7e21 这种无意义类名,根本不知道它来自哪个组件。我的解决办法是生产环境用 [local]__[hash:base64:5] 这样的混合格式,兼顾体积和可读性,比如 button__x7e21。这样线上控制台看到类名,至少能第一时间知道是哪个组件。
5. 实战问题实录:那些年我们踩过的 CSS Modules 的坑
5.1 类名对不上的排查思路:哈希生成规则与缓存问题
最常见的线上事故是“样式没生效”。代码逻辑明明写了 styles.primary,DOM 上也看到类名了,但样式规则就是匹配不上。这类问题十有八九出在哈希规则配置不一致。
我遇到过一种情况:开发环境正常,构建部署后样式全部丢失。排查到最后发现,是因为项目的 css-loader 配置在开发和生产用了不同的 localIdentName。开发环境是 [path][name]__[local],生产环境是 [hash:base64:8]。理论上这本身没问题,问题出在项目里有部分 CSS Modules 文件是通过动态 import 异步加载的,而另一部分在服务端渲染阶段被提前拉取,两份哈希规则在浏览器端混杂,导致同名类名指向不同哈希值。解决方案是统一两端的 localIdentName 格式,或者干脆只在 SSR 阶段全部加载模块样式。
另一个容易被忽略的点是浏览器缓存。如果你改了样式内容,但构建产物的文件名 hash 没变(某些框架对 CSS chunk hash 的计算逻辑不一致),用户拉到旧的 CSS 文件,类名哈希自然对不上。排查这类问题时,先在浏览器强制刷新排除缓存,再看构建产物中的类名与运行时类名是否一致,就能快速定位。
5.2 composes 失效:预处理器嵌套和跨文件组合的雷区
composes 失效是另一个高频问题,典型场景有两个。
第一个场景是 scss 中嵌套使用。前面提过,在 Sass 的嵌套块内部写 composes 会直接报错。这个错误信息比较友好,基本一看就懂。但还有一种更隐蔽的失效:你用了 dart-sass 的高级语法 @include mixin 生成了一段带类名的规则,再在另一个类上 composes 这个由 mixin 生成的类。由于 mixin 是在 sass 编译阶段展开的,css-loader 处理 modules 时已经看不到 mixin 源码里的类名,导致 composes 引用的类不存在或者完全没有匹配。
解决方式很简单:不要依赖 mixin 生成可被 composes 的类名。基础样式要么写成实际的类,要么用 CSS 变量做参数化。我项目里的规律是:composes 只用在纯 CSS 类之间,跨技术栈的组合一律不碰。
第二个场景是跨预处理器文件组合。比如 Button.module.scss 里 composes: base from './abstract.module.css',虽然 css-loader 支持跨文件提取,但一旦上下游 loader 配置顺序错误,提取过程就会失败。要保证这条链路稳定,尽量保持“同类型文件组合”,Scss 组件之间互相 composes 最不会出问题。
5.3 动态类名拼错与第三方组件样式覆盖
CSS Modules 下写动态类名,新手特别喜欢踩这个坑。比如组件需要根据 size 枚举切换类名,有人这样写:
tsx复制// 错误写法:这串字符串不会被编译
<button className={styles[`size-${size}`]}>点击</button>
关键在于,styles 对象里的 key 是静态编译后得到的类名映射,也许你写的原始类名是 size-large,但 styles 对象里的 key 是 'size-large' 是没错,但如果你写的是 styles.sizeLarge 而 CSS 里写的是 size-large,就会得到 undefined。最终 DOM 上挂了一个 undefined 类名,样式当然不生效。处理动态类名的正确姿势是预先定义映射对象:
tsx复制const sizeClassMap = {
large: styles.sizeLarge,
small: styles.sizeSmall,
};
const sizeClass = sizeClassMap[size] || styles.sizeDefault;
至于覆盖第三方组件库样式,这是 CSS Modules 项目里绕不开的话题。组件库(比如 antd、element)的样式是全局注入的,在 module.css 里写 .ant-btn 这种类名会被哈希,根本匹配不到。我的做法是给模块根元素添加一个固定类名,然后用 :global 包裹覆盖规则:
css复制.wrapper :global(.ant-btn-primary) {
background-color: var(--brand-color);
}
这里 .wrapper 被哈希,它内部的全局类名 .ant-btn-primary 保持原样,能精准匹配到第三方库的节点,同时又限定了作用范围只在当前组件内部,不会污染全局。这就是 CSS Modules 结合 :global 处理第三方库的通用模式,实用性非常高。
5.4 样式覆盖优先级与 CSS 变量传递问题
CSS Modules 和 CSS 变量(自定义属性)一起用时,有一个隐性问题:CSS 变量本身不参与哈希重写。这意味着你可以在全局定义 --primary-color,在每个组件里引用它。这本身没有问题,甚至推荐。但要注意变量覆盖的优先级:如果你在模块样式里给一个类设置了 --primary-color: red,而子组件同时引用了这个变量,那么变量的取值遵循 CSS 级联规则,会按照 DOM 树继承,而不是按模块边界隔离。跨组件传变量确实方便,但不建议用 CSS 变量做组件私有状态——它是“穿透”模块边界的,和 CSS Modules 的隔离逻辑相反。
在实际主题换肤项目里,我一般把 CSS 变量当作设计令牌层,全部放在全局变量文件里,只有切换主题时才动态修改 :root 上的变量值。组件样式内部只消费变量,不重新定义变量。这样既保持了 CSS Modules 的局部作用域优势,又获得了主题化的灵活性,两全其美。
6. 选型判断:什么情况下该上 CSS Modules
讲了这么多特性和工具,回到最实际的问题:什么样的项目适合用 CSS Modules?基于我自己的项目经验,总结几条选型依据。
如果你的项目是多人协作的中大型前端工程、组件化程度高、迭代频繁,那 CSS Modules 几乎是性价比最高的样式方案。它学习成本低,团队成员只要会 CSS 就能直接上手,不需要学习 CSS-in-JS 的运行时概念,也不需要遵守 BEM 那种严格的命名约定。编译期隔离带来的安心感,是纯 CSS 方案给不了的。
如果你的项目是轻量营销页、纯静态网站,样式量小、生命周期短,那 CSS Modules 反而显得冗余,直接用全局 CSS 就够了,没必要引入构建配置的心智负担。同理,如果你已经在用 Tailwind CSS 这类原子化方案,也不建议强行混入 CSS Modules——两种方案的 class 生成和组合逻辑差异太大,混用会让开发者处于两套心智模型的夹缝中,效率反而下降。
另外要提一下 Vue 单文件组件用户。Vue SFC 的 scoped 样式在功能上和 CSS Modules 有相似之处,都实现了编译期作用域隔离。区别在于 Vue scoped 通过给 DOM 元素添加 data-v-hash 属性选择器来实现隔离,而 CSS Modules 通过类名哈希实现。Vue 3 项目里,scoped 方案已经足够好用,优先用 scoped;但如果在 Vue 项目里有大量样式需要跨组件共享或者更精细的控制需求,CSS Modules 在 vue-loader 中也是开箱支持的,两者可以并存,不冲突。
最后说一个我在团队里推 CSS Modules 时的体会:最大的阻力不在技术,而在习惯。习惯了全局写样式的人,第一时间会觉得“为什么类名不能直接用”,会觉得 toString 之后全是哈希名,调试不方便。但用两周之后,没人愿意回到以前那种“改一个样式提心吊胆”的状态。如果你正面临样式混乱的问题,可以挑一个改造风险小的模块先试跑一个迭代,感受一下编译期作用域隔离带来的安全边际。技术选型没有银弹,但 CSS Modules 在大多数组件化项目中,都是一个值得认真考虑的选项。
