想象你是「组件花园」小区的物业经理。小区里每一户都是一位前端组件——按钮家、导航栏家、卡片家。最初物业给大家发了一本《全小区统一装修手册》,也就是全局 CSS:一人刷错墙,整栋楼跟着变,颜色冲突、样式串味成了居民投诉榜第一名。后来市面上出现两套新玩法:一套叫「门牌式装修包」(CSS Modules),交房时每户材料上印好独立门牌号(哈希类名),你家刷蓝绝不影响邻居,而且材料是预制好的,现场零等待;另一套叫「智能装修管家」(CSS-in-JS),管家常驻你家,你喊一声「把客厅刷成珊瑚橙」,他现场调漆、现场施工,要多灵活有多灵活,但每次干活都要花时间,工具箱还占电梯空间(打进 JS 包)。作为物业经理,给整个小区定哪套方案,关系到每一户的体验和物业的维护成本。这篇文章就当你请来的「选型参谋」,把两套方案的原理、产物、开销、生态掰开揉碎讲清楚。

岗位关联

技术选型是前端工作中高杠杆的决策点。读懂 CSS Modules 与 CSS-in-JS 的底层取舍,能让你在架构评审时给出有数据、有依据的建议,也让未来接手项目的同事少踩几个坑。

目录
  1. 可视化演示
  2. 1. 样式方案概览
  3. 2. 作用域隔离
  4. 3. 构建产物对比
  5. 4. 运行时开销
  6. 5. 生态兼容性
  7. 6. 零运行时方案
  8. 7. 原子化CSS搭配
  9. 8. 选型决策指南
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

对比CSS Modules构建时哈希隔离与CSS-in-JS运行时注入的完整流程,展示构建产物、运行时开销和SSR兼容性差异,交互切换不同维度观察技术选型要点。

本节配有交互动效(进入视口后自动加载,约 3KB)。

CSS Modules 构建时隔离 构建时哈希化类名.button_abc123 产出纯CSS零运行时开销 .btn \u2192 .btn_a3f9 \u70b9\u51fb\u5207\u6362\u67e5\u770bCSS-in-JS\u65b9\u6848

1. 样式方案概览

还记得《全小区统一装修手册》的灾难吗?两家人同时把门牌号写成「101」,信件永远送错,你说的是按钮家的蓝色,隔壁卡片家也改了蓝。这个痛点催生了两种自救方案:一种是「门牌式装修包」(CSS Modules,指把 CSS 文件里的类名在构建期局部化的技术),另一种是「智能装修管家」(CSS-in-JS,指用 JavaScript 来书写并动态注入样式的方案统称)。它们都试图回答同一个问题:怎么让每家装修互不干扰,又足够灵活。

CSS Modules 的思路是「提前写好、按户编号」。借助 webpack 这样的构建工具,样式开发者在 .module.css 文件里写的 .btn,在经过 css-loader 的 modules 模式编译后,会自动变成 ._btn_1a2b3 这类带哈希的局部类名。HTML 里的引用也会同步替换,过程全部发生在打包阶段,运行时的浏览器完全无感知。

CSS-in-JS 的思路则相反——「现场生成、用完即走」。以 styled-components 为例,你在组件文件里写 const Button = styled.button 带样式模板,库会在组件首次渲染时把规则编译成一段 <style> 文本并注入页面。两者解决的是同一个问题:全局命名冲突,但一个把功夫花在构建期,一个把功夫花在运行时。

如果把小区比作前端项目,CSS Modules 相当于物业在交房前就把所有门牌印好,业主拎包入住;CSS-in-JS 则像每家配了位高级管家,随叫随到、按需施工。理解了这个起点,后面所有对比都顺理成章。

local_class=hash(file_path+origin_name)\text{local\_class} = \text{hash}(\text{file\_path} + \text{origin\_name})
这个公式说明:CSS Modules 的最终类名,是把文件路径和原始类名拼在一起做哈希得到的。路径保证不同文件同名不撞车,哈希保证类名难以预测、不易冲突。
  1. file\_path(文件路径)样式文件所在的路径,相当于「楼栋号」,确保 button 和 modal 两个文件里都有的 .title 各归各家。
    file_path=’components/Button/Button.module.css’\text{file\_path} = \text{'components/Button/Button.module.css'}
  2. origin\_name(原始类名)开发者书写的原始类名,比如 btn,这是人类可读的语义名,也是冲突的根源。
    origin_name=’btn’\text{origin\_name} = \text{'btn'}
  3. hash()(哈希函数)一种确定性的映射函数,相同的输入永远得到相同的输出,所以同一份源码每次构建出的类名一致,产物可复现。
    hash(’components/Button/Button.module.cssbtn’)→’_btn_1a2b3’\text{hash}(\text{'components/Button/Button.module.cssbtn'}) \rightarrow \text{'\_btn\_1a2b3'}
  4. local\_class(局部类名)哈希后的最终门牌号,源码里的 .btn 一旦完成构建,就再也找不到原始写法,冲突从根上被切断。
    local_class=’_btn_1a2b3’\text{local\_class} = \text{'\_btn\_1a2b3'}

2. 作用域隔离

门牌式装修包之所以不怕串味,是因为它把名字本身改掉了——你家的「101」变成了「101-A-7F」,隔壁再怎么叫「101」也跟你无关。智能管家则是另一种玩法:他给每户门上贴临时贴纸,但这张贴纸只有你在家(组件渲染时)才存在,门一关就消失。两者都实现了「样式只对自家组件生效」,但机制完全不同。

CSS Modules 的作用域隔离靠「选择器重写」。构建工具扫描 CSS 文件,把每个类选择器展开成「哈希后的新选择器」,再同步改写 JSX 或模板里的 className。这是个纯文本级别的变换,不改写 CSS 语言的任何规则,因此浏览器解析产物时没有任何额外负担。

CSS-in-JS 的作用域隔离靠「动态注入」。样式规则不是预先存在于 CSS 文件里的,而是组件渲染时由 JavaScript 函数当场生成,紧接着插入一个专属的 <style> 标签或 hash 属性。规则离组件最近、随组件卸载而移除,天然不会泄漏到别处。代价是每次渲染都要经历「生成规则 → 注入文档」的完整链路。

回头看比喻:门牌号改了,但是门牌号对应的那扇门还是原来的门——CSS Modules 保持了「类名 ↔ 样式」的稳定映射;管家临时贴纸虽然灵活,但每次开门都要重贴一遍。理解了这个差异,你就抓住了两派最本质的分水岭。

scope={rewrite(selector)CSS Modulesinject(rule)CSS-in-JS\text{scope} = \begin{cases} \text{rewrite}(\text{selector}) & \text{CSS Modules} \\ \text{inject}(\text{rule}) & \text{CSS-in-JS} \end{cases}
这组公式用两个分支概括了作用域隔离的两种实现路径:左侧是构建期的选择器改写,右侧是运行时的规则注入。殊途同归,但时机和代价截然不同。
  1. rewrite(selector)(改写选择器)CSS Modules 在构建期把 .btn 改写成唯一哈希类名,源码里再也找不到原始选择器,从命名空间上杜绝冲突。
    rewrite(’.btn’)=’._btn_1a2b3’\text{rewrite}(\text{'.btn'}) = \text{'.\_btn\_1a2b3'}
  2. inject(rule)(注入样式规则)CSS-in-JS 在运行时把 color: blue 这类规则打包进动态 <style> 标签,规则的存在周期与组件生命周期绑定。
    inject(’color: blue’)→<style> 节点\text{inject}(\text{'color: blue'}) \rightarrow \text{<style> 节点}
  3. cases(条件分支)两条分支说明没有唯一正确答案,只是把「隔离」这个目标拆成了「改名字」和「现做现卖」两种策略。

3. 构建产物对比

门牌式装修包交到你手上的,是一份完整的《预制建材清单》——所有材料在工厂里就打包好,快递送到现场直接施工。这份清单就是独立 .css 文件。智能管家的工具箱则不同:他把所有可能用到的工具都塞进电梯(JS bundle),你每上下楼一次,都得为这份重量买单。

CSS Modules 的构建产物就是普通的静态 CSS 文件。它可以在浏览器里被当作文本快速解析,可以交给 CDN 缓存,发布后只要类名不变就几乎不会变动。更重要的是,CSS 文件与 JavaScript 文件在浏览器中是两条独立的加载通道,CSS 不会阻塞 JS 执行,还可以利用 <link rel="stylesheet"> 的并发加载能力。

CSS-in-JS 的产物则全部收编进 JavaScript。样式模板以字符串形式存在于组件源码中,打包后成为 bundle 的一部分。浏览器需要先下载 JS、再解析执行、最后才能把样式注入页面。这带来两个连锁反应:一是首屏 CSS 的到达时间被 JS 的下载执行时间拖累;二是代码分割时,样式必须跟着组件 JS 一起加载,很难像独立 CSS 那样精细控制缓存粒度。

所以当你看到「CSS-in-JS 让首屏变慢」的说法,本质不是在黑某一种工具,而是在陈述一个体积模型上的事实:样式走 JS 通道,就必然被 JS 的加载和执行节奏拖住。如果你的项目对首屏和缓存极度敏感,这个对比就是关键决策依据。

Sizein-JS=Bbase+∑i=1nsiSizeModules=Cbase+∑i=1nci\text{Size}_{\text{in-JS}} = B_{\text{base}} + \sum_{i=1}^{n} s_i \qquad \text{Size}_{\text{Modules}} = C_{\text{base}} + \sum_{i=1}^{n} c_i
左边是 CSS-in-JS 的包体模型:运行时框架基础体积 + 每个组件的样式字符串;右边是 CSS Modules 的产物模型:基础样式 + 每个组件对应的 CSS 规则。同样随组件数增长,但增长载体不同。
  1. B\_base(基础框架体积)CSS-in-JS 运行时框架本身的大小,以 styled-components 为例约 12KB gzip,它是一切注入能力的地基。
    Bbase≈12KB (gzip)B_{\text{base}} \approx \text{12KB (gzip)}
  2. Σ s\_i(样式字符串之和)每个组件的样式模板字符串都会被打进 JS bundle,组件越多,bundle 膨胀越明显。
    ∑i=1nsi=s1+s2+⋯+sn\sum_{i=1}^{n} s_i = s_1 + s_2 + \cdots + s_n
  3. C\_base(基础样式文件)CSS Modules 也有一份公共基础样式,但它独立成文件,可以被浏览器单独缓存。
    Cbase=styles.base.cssC_{\text{base}} = \text{styles.base.css}
  4. Σ c\_i(CSS 规则之和)每个组件对应的哈希类名 CSS 规则,因为有很多共享声明,实际增量远小于 JS 中的模板字符串累计量。
    ∑i=1nci=c1+c2+⋯+cn\sum_{i=1}^{n} c_i = c_1 + c_2 + \cdots + c_n

4. 运行时开销

管家接到「刷成珊瑚橙」的指令后,要经历「调漆—试色—上墙」三步。调漆是计算样式值,试色是解析 props,上墙是更新 <style> 标签。每一户装修时都要来一遍,户数越多,物业的整体响应越慢。而门牌式装修包的材料是预拌好的一桶漆,工人拎起来就刷,没有任何等待时间。

CSS-in-JS 的运行时可拆成三笔账。第一笔是函数执行:组件渲染时,样式函数跑一遍,处理条件 props、计算动态值;第二笔是序列化:把样式对象转换成 CSS 文本时需要进行字符串拼接和哈希生成;第三笔是 DOM 操作:样式类名或 <style> 节点发生变化时,浏览器要重新计算样式。这三笔在组件挂载、更新、卸载的每个生命周期里反复发生。

CSS Modules 的运行时开销则约等于零。组件拿到的是一串已经编译好的哈希类名,render 时把它拼进 className 即可。浏览器只需要在样式表里做一次类名匹配,没有任何动态计算。组件再多,也只是多一次查找,而不是多一次生成。

比作小区物业,就是「每位管家每户都调一次漆」和「中央厨房统一配好漆、施工队直接刷」的区别。CSS-in-JS 并非不能用,而是要把这笔账算进性能预算里,尤其当你有复杂的动态主题或成百上千个组件时。

Textra=k⋅nmountedT_{\text{extra}} = k \cdot n_{\text{mounted}}
公式表达的是:额外的渲染耗时约等于单个组件生成样式的平均耗时乘以已挂载的组件数量。它是个线性模型,用来告诉你组件规模变大时,CSS-in-JS 的开销是如何被放大的。
  1. k(单组件样式生成耗时)包含插值计算、对象序列化、节点更新三步累积的单位耗时,量级通常在毫秒或亚毫秒。
    k=tinterp+tserialize+tdomk = t_{\text{interp}} + t_{\text{serialize}} + t_{\text{dom}}
  2. n\_mounted(已挂载组件数)页面中实际渲染出来的动态样式组件数量,100 个组件就是 100 次完整流程。
    nmounted=100 (常见中后台页面)n_{\text{mounted}} = \text{100 (常见中后台页面)}
  3. 乘积关系总开销与组件数成正比,这也是为什么组件一多,交互卡顿的感受会指数级变明显。
    Textra=k⋅nmountedT_{\text{extra}} = k \cdot n_{\text{mounted}}

5. 生态兼容性

小区要接入城市管网:门牌式装修包用的是标准建材,任何建筑队都能施工;智能管家则需要物业预先装好「对接盒」——在 React 项目里是 Provider 上下文,在 SSR 里则是样式抽取插件。这个比喻贯穿生态兼容性的全部讨论。

CSS Modules 是「框架无关」的。它本质上是 CSS 语言与构建工具的一次约定,React、Vue、Svelte、原生 JS 都能用。SSR 场景最简单:服务端渲染出 HTML,同时通过 <link> 把构建好的 CSS 文件带上,浏览器拿到的首屏就是「有样式的完整页面」,不需要额外处理。Vue 的 SFC 里 <style scoped> 还内置了类似的思路。

CSS-in-JS 则往往深度绑定框架。styled-components 依赖 React 的 Context 机制做主题传递,Emotion 同样围绕 React 生态设计。如果项目是 Vue,你得选 @vueuse/head 或 vue-styled-components 这类移植方案,体验总有落差。SSR 更是重灾区:样式在运行时才产生,服务端必须遍历组件树收集所有样式规则,内联进 HTML,否则浏览器先渲染无样式内容、JS 加载后才补上样式,造成 FOUC(无样式闪烁)。

生态兼容性说白了是「这套方案在你们小区外的世界走得通吗」。如果团队的技术栈很纯、基本锁死 React,CSS-in-JS 的绑定不是问题;如果项目未来可能换框架、做多端、重度依赖 SSR,那零依赖的 CSS Modules 会给你更多退路。

SSRhtml=render(App)+collectStyles(App)\text{SSR}_{\text{html}} = \text{render}(\text{App}) + \text{collectStyles}(\text{App})
这个公式是说:要让 CSS-in-JS 在服务端渲染里不闪一下,最终的 HTML 必须是「组件渲染结果」加上「收集到的样式标签」两块拼接而成。这是纯静态 CSS 不需要的额外步骤。
  1. render(App)(渲染组件)服务端把 React 组件树渲染成静态 HTML 字符串,完成首屏内容输出。
    render(App)→<div class="app">...HTML...</div>\text{render}(\text{App}) \rightarrow \text{<div class="app">...HTML...</div>}
  2. collectStyles(App)(收集样式)服务端在渲染同时遍历组件树,把每个动态生成的样式规则收集起来,相当于把管家的调漆配方装进一个筐。
    collectStyles(App)→[样式规则 1, 规则 2, ...]\text{collectStyles}(\text{App}) \rightarrow \text{[样式规则 1, 规则 2, ...]}
  3. 拼接输出把内容筐和样式筐一起塞进响应体,浏览器解析时才能「内容与样式同帧出现」,避免白屏闪烁。
    SSRhtml=HTML 骨架+<style> 标签块</style>\text{SSR}_{\text{html}} = \text{HTML 骨架} + \text{<style> 标签块}</style>

6. 零运行时方案

能不能让管家把常点的色号提前做成预制料包,用户下单时拆包即用,把「调漆」的功夫全省了?这就是零运行时方案(如 Linaria、vanilla-extract)想做的事:保留「用 JS 写样式」的开发体验,却在构建期就把样式提取成静态 CSS,运行时不再有任何 JS 参与。

Linaria 的思路是「模板字符串静态分析」。你在组件里写 ` const title = csscolor: blue; ,Babel 插件在编译时解析这段模板,只要没有动态插值,就直接把 color: blue 提取进 CSS 文件,组件代码里只留一个哈希类名。vanilla-extract 更进一步,用 TypeScript 对象语法定义样式,编译时对整份样式表做类型检查,构建产物是标准的 .css.ts` 文件。

这类方案完美命中「既要又要」:开发体验接近 CSS-in-JS——可以写条件样式、做主题函数、获得类型安全;运行时性能接近 CSS Modules——产物是纯 CSS,零 JS 开销。代价是动态能力的边界被压缩:凡是「运行时才知道的值」,必须显式地通过 CSS 变量或 data 属性在运行时传入,不能再指望 JS 在渲染时现场计算。

如果把选型比作装修,零运行时方案就是「管家在工厂把料包做好,现场只负责拆包」——你要的颜色依然是管家配的,但配色的时间不再占用你家施工的时间。对于组件库作者和追求极致性能的团队,这个中间地带常常是更优解。

styles.css=compile(styled_templates)\text{styles.css} = \text{compile}(\text{styled\_templates})
公式用一句话概括零运行时方案:一份用 JS/TS 语法书写的样式模板,经过编译期求值,得到一份静态 CSS 文件。运行时只剩浏览器与 CSS 的直接对话。
  1. styled\_templates(样式模板)开发者用 JS/TS 语法写的样式定义,例如 color: ${brandColor},这里的插值只允许编译期可知的常量。
    \text{styled\_templates} = \text{css\0}
  2. compile()(编译提取)Babel 或 Vite 插件在构建期执行模板求值,把所有静态规则抽出来,相当于管家在工厂里预制料包。
    \text{compile}(\text{css\0}) \rightarrow \text{'.\_title\_x9k2 { background: \#6366F1; }'}
  3. styles.css(静态产物)编译输出与手写 CSS 文件无异,可以被缓存、被并发加载、被任何框架使用。
    styles.css=contenthash 命名的静态文件\text{styles.css} = \text{contenthash 命名的静态文件}

7. 原子化CSS搭配

物业在小区门口建了「公共建材市场」:标准色卡、标准门框、标准间距,随取随用。这就是原子化 CSS 的定位——以 Tailwind 为代表,提供 flex、text-sm、bg-brand 这类单一职责的低层级类,让开发者直接在 HTML 里组合出布局和视觉。很多人以为它和 CSS Modules 是敌人,其实它们是不同楼层的建材。

实践中两者可以清晰分层。Tailwind 负责「跨组件共用」的部分:间距、排版、通用布局、常用配色,这些往往占一个页面 70% 的视觉规则。CSS Modules 负责「组件专属」的部分:组件自身的结构布局、状态样式、不可复用的特殊视觉。最上层再用 CSS 变量统管主题与品牌色,实现暗黑模式或换肤。

分工边界一旦清楚,团队的协作规范也随之明确:所有人在 Tailwind 里优先选现成令牌,不造新词;组件内部结构一律写进局部 CSS;主题色必须走 CSS 变量,禁止在原子类里写死色值。这样既避免了「类名爆炸」,也防止了「全局样式失控」,把两种方案的优点都吃到。

把小区建得漂亮,从来不是「只用一种建材」。公共建材市场解决通用需求,门牌式装修包满足定制要求,主题变量则是连接两者的「色卡中枢」。三者各司其职,就是当下大型项目最稳的样式架构。

Total=Atomic0.7+Local0.25+Var0.05\text{Total} = \text{Atomic}_{0.7} + \text{Local}_{0.25} + \text{Var}_{0.05}
这是一个经验比例,不是硬性规定:约七成通用样式交给原子类,约两成半组件结构交给局部类,剩下少量主题变量用 CSS 变量承载。比例可以调,分层思维是关键。
  1. Atomic(原子类占比)Tailwind 类承接通用布局与配色,类名可复用、体积固定,不随组件增长而膨胀。
    Atomic=’flex items-center text-sm bg-brand’\text{Atomic} = \text{'flex items-center text-sm bg-brand'}
  2. Local(局部类占比)CSS Modules 类承接组件专属结构,带有哈希作用域,避免组件间的意外串扰。
    Local=styles.card_header__title\text{Local} = \text{styles.card\_header\_\_title}
  3. Var(主题变量占比)CSS 变量统一收口品牌色、圆角、阴影等主题令牌,运行时切换类名或 data-theme 即可换肤。
    Var=var(—color-brand-primary)\text{Var} = \text{var(---color-brand-primary)}

8. 选型决策指南

选装修方案要看楼型:出租公寓(内部管理系统)求稳、样板间(营销活动页)求炫、高端住宅(开源组件库)求极致。没有哪套方案通吃,关键是把项目的三个参数代入决策函数:规模、主题需求、团队背景。

如果你的项目是重 SSR 的大型中后台,追求首屏速度和缓存友好,CSS Modules 或零运行时方案是稳健起点。如果项目是强运营的营销页,主题和个性化是命根子,CSS-in-JS 的动态能力让设计师天马行空的想法能快速落地,但要搭配性能预算和 memo 优化。如果是发布给全世界的组件库,请毫不犹豫选择零运行时——你的用户不该为你的便利支付运行时成本。

最后看团队:全员 React 深度使用者,styled-components 的生态熟稔,CSS-in-JS 的学习曲线不是问题;团队更偏传统 CSS 背景,CSS Modules + Tailwind 的门槛更低。技术选型没有银弹,只有「基于现状做加权评分」的工程决策。

回到「组件花园」小区:没人能替你决定要不要请管家,但当你把楼型、住户诉求、物业团队的能力都列清楚,答案会自然浮现。记住两条底线——别让样式架构拖垮首屏,也别忘了换肤和个性化是运营的命脉。

D=g(size,theme,team)D = g(\text{size}, \text{theme}, \text{team})
公式的含义是:最终决策 D 是项目规模、主题需求、团队背景三个变量的函数。它没有标准答案,但每个变量都值得你在评审会上明确打分。
  1. size(规模变量)组件总量、迭代频率、参与人数。规模越大,越需要构建期确定性和可缓存性。
    size∈{小(<50 组件),中(50-200),大(>200)}\text{size} \in \{\text{小(<50 组件)}, \text{中(50-200)}, \text{大(>200)}\}
  2. theme(主题需求)动态换肤、品牌个性化、运行时配置样式的强度。越强,越需要 CSS-in-JS 的灵活性或 CSS 变量的桥接。
    theme=运行时动态 vs 编译期静态\text{theme} = \text{运行时动态}\ \text{vs}\ \text{编译期静态}
  3. team(团队背景)团队对 React 生态的深度、对构建链路的掌控力,决定了新方案的学习与维护成本。
    team=框架熟悉度+工程化经验\text{team} = \text{框架熟悉度} + \text{工程化经验}
  4. g()(决策函数)把三个变量摆上桌面,给每个维度分配权重,算出最适合你项目的组合。
    g(⋅)=w1⋅size+w2⋅theme+w3⋅teamg(\cdot) = w_1 \cdot \text{size} + w_2 \cdot \text{theme} + w_3 \cdot \text{team}

AI 应用场景

场景一:中后台管理系统技术选型

阶段:前端工程化方案设计

解决的问题:传统全局 CSS 在 8 人并行开发的团队里极易发生样式冲突——你改的 .table 影响了我做的页面。CSS Modules 把每个组件的类名局部化后,开发者可以放心命名,不必担心「撞车」;配合 CSS 变量实现暗黑模式切换,避免运行时开销。就像给每户门牌加了哈希前缀,再在楼顶装一套统一色卡系统。

具体案例:某 SaaS 公司重构 200+ 组件的管理后台,选定「CSS Modules + CSS 变量」组合。改造后样式冲突工单从每周 15 条降到 0 条,夜间模式上线只需在根节点切换 data-theme 属性,无需重新构建皮肤包。

场景二:强运营的营销活动页

阶段:前端渲染性能优化

解决的问题:营销页最大的痛点是「每个活动一套视觉」,且运营在后台配置品牌色后要立即生效。CSS-in-JS 能直接把配置作为 props 传入样式组件,按需生成色板;搭配组件级 memo 后,运行时开销可以控制在可接受范围。就像样板间的智能管家,为每场活动现场调出专属配色。

具体案例:某电商平台大促期间上线 50 个活动页,每个页面由运营配置主色和辅助色。团队用 styled-components 实现 ThemeProvider 动态注入色板,页面渲染速度满足 2 秒标准,视觉个性化需求零改版上线。

场景三:开源组件库发布

阶段:构建产物交付优化

解决的问题:第三方组件库如果用 CSS-in-JS,会把整个运行时框架强制塞进用户的项目里,用户为自己的「方便」埋单,体验很差。零运行时方案(vanilla-extract)在编译期产出纯 CSS,用户拿到的是干净静态文件,既保留类型安全又节省体积。就像物业把预制料包交给住户,住户自己拆包就行,不用连管家一起聘用。

具体案例:某团队用 vanilla-extract 重写组件库,发布后在 unpkg 上的包体积从 45KB 降至 8KB,接入方反馈「不再需要在 Next.js 里配置 styled-components 的 SSR 插件」。

Python 演示

python39 行
"""对比 CSS Modules 与 CSS-in-JS 两种样式方案的开销模型(模拟数据)。""" BASE_CSS_KB = 8       # CSS Modules 基础样式体积BASE_JS_KB = 120      # CSS-in-JS 运行时框架体积CSS_PER_COMP = 0.3    # 每组件新增 CSS 量(KB)JS_PER_COMP = 1.2     # 每组件打入 JS 的样式代码量(KB)RUNTIME_PER_COMP = 0.08  # 每组件运行时计算耗时(ms)  def estimate_modules(components):    return {        "css_kb": round(BASE_CSS_KB + components * CSS_PER_COMP, 1),        "js_kb": 0,        "runtime_ms": 0.0,    }  def estimate_css_in_js(components):    return {        "css_kb": 0,        "js_kb": round(BASE_JS_KB + components * JS_PER_COMP, 1),        "runtime_ms": round(components * RUNTIME_PER_COMP, 2),    }  def main():    print("组件数  |  CSS Modules          |  CSS-in-JS")    print("--------+-----------------------+---------------------")    for n in (10, 50, 100, 500):        m = estimate_modules(n)        j = estimate_css_in_js(n)        print(            f"{n:6d}  |  css {m['css_kb']:6.1f}KB / 0ms  |  "            f"js {j['js_kb']:6.1f}KB / {j['runtime_ms']:5.2f}ms"        )  if __name__ == "__main__":    main()
目的:用一组可调的模拟参数,估算两种样式方案在不同组件规模下的产物体积与运行时耗时,为技术选型提供直观的量化对比。
  1. BASE_JS_KB = 120定义 CSS-in-JS 运行时框架的固定负担,这是它起跑就落后的部分。
  2. CSS_PER_COMP = 0.3每个组件带来的 CSS 增量很小,因为多个组件的共享声明会被合并去重。
  3. def estimate_modules(components):封装 CSS Modules 的估算逻辑,它只产出 CSS 体积,没有 JS 体量和运行时耗时。
  4. "runtime_ms": 0.0这行直接写死为 0,强调 CSS Modules 最硬的优点:运行时不参与任何计算。
  5. round(components * RUNTIME_PER_COMP, 2)CSS-in-JS 的运行时开销与组件数成正比,模型里用线性关系表达。
  6. __name__ == "__main__"程序入口,保证脚本可以直接运行,输出可对比的表格。

常见误区

误区不就是把 CSS 写进 JS 里了吗?多打包几十 KB 而已,渲染时浏览器该咋画还咋画,性能应该差不多吧。

正解差别在「样式是什么时候、由谁生成的」。CSS Modules 的类名在构建期就定死了,运行时只剩字符串查找;CSS-in-JS 每次组件渲染都要执行样式函数、序列化样式对象、更新 <style> 节点。当页面有几百个动态样式组件时,这不再是「几十 KB」的问题,而是每帧都多出来的计算量。就像一个管家每顿饭都现场调酱汁,另一个直接用中央厨房的预制酱包,后厨的忙闲程度完全不同。

为何常见:小项目里组件少,性能差异在 DevTools 里几乎测不出来,容易让人以为两种方案开销相同。

误区类名都被哈希了,写死在 CSS 文件里,用户点一下切换暗黑模式,这怎么可能实现?

正解CSS Modules 只管「类名作用域」,不管「样式值」。具体颜色完全可以交给 CSS 变量:.button { color: var(--text-primary); } 只需要切换根节点的 data-theme 属性,--text-primary 的值就会全局更新。门牌号不变,刷漆用公共色卡换色即可。

为何常见:把「类名隔离」误当成「样式值冻结」,忽略了 CSS 变量这个天然的运行时桥梁。

误区一个页面里既有 `flex justify-between` 又有 `styles.header__title`,类名混在一起不乱吗?肯定只能选一种。

正解两者解决的问题层级不同。Tailwind 管跨组件的通用布局和配色,CSS Modules 管组件专属结构,CSS 变量管主题。实践中「七成原子类 + 三成局部类」的分层非常常见,只要团队约定好边界,就不会乱。就像小区既有公共建材市场,也有每户的自选装修包,二者服务不同的装修需求。

为何常见:框架之争让很多人默认「选 A 就必须扔掉 B」,忽视了工程方案本来就是可以组合的。

误区服务端渲染返回的 HTML 里应该已经带上了样式吧?毕竟服务器也执行了一遍组件代码。

正解CSS-in-JS 默认只在浏览器端注入样式。SSR 时必须在服务端遍历组件树、收集样式规则、内联到 HTML,否则浏览器先渲染无样式的首屏、JS 加载后才补上样式,造成 FOUC(无样式闪烁)。这需要 babel-plugin 或样式抽取 API 的额外配置。

为何常见:本地开发几乎都是纯客户端渲染,很少模拟 SSR 验证,很多团队直到上线压测才发现首屏样式闪一下的问题。

面试题

Q1. CSS Modules 的局部类名通过什么机制隔离冲突?

单选题 · easy

  • A.浏览器 Shadow DOM 自动隔离
  • B.构建期哈希改写原类名← 正确答案
  • C.运行时注入唯一 style 标签
  • D.CSS 变量实现命名空间

答案:B。CSS Modules 的核心是构建工具(如 css-loader)将 .btn 改写为带哈希的局部类名,从命名上杜绝冲突。Shadow DOM 是浏览器原生隔离方案,与 CSS Modules 无关;运行时注入 style 标签是 CSS-in-JS 的做法;CSS 变量只管值,不管命名空间。

Q2. CSS Modules 构建后的产物形态是什么?

单选题 · easy

  • A.独立静态 CSS 文件← 正确答案
  • B.嵌入组件 JS 的字符串
  • C.运行时生成的 style 标签
  • D.浏览器扩展注入的样式

答案:A。CSS Modules 在构建期把哈希类名和样式规则输出为独立 .css 文件,浏览器可直接加载并缓存。B、C 描述的是 CSS-in-JS 的产物与运行方式,D 与主题无关。

Q3. CSS-in-JS 样式生效的时机是何时?

单选题 · easy

  • A.构建时编译为 CSS 文件
  • B.浏览器运行时注入节点← 正确答案
  • C.服务端渲染时静态输出
  • D.开发者保存代码时生成

答案:B。CSS-in-JS 的样式规则在组件渲染时由 JS 计算生成,并注入 <style> 标签,因此生效时机是浏览器运行时。构建期和服务端渲染都是 CSS Modules 或零运行时方案的特征。

Q4. 零运行时方案(如 Linaria)的核心思路是什么?

单选题 · mid

  • A.运行时用 Worker 压缩样式字符串
  • B.构建期把样式提取成静态 CSS 文件← 正确答案
  • C.每次渲染都动态计算样式对象
  • D.用 CSS 变量在运行时解析全部

答案:B。Linaria 通过在构建期对样式模板做静态分析,把可确定的部分提取为纯 CSS 文件,JS 运行时不再参与样式生成,从而兼具开发体验与性能。A、C 仍是运行时开销,D 是把负担转嫁给 CSS 变量解析。

Q5. SSR 场景下 CSS-in-JS 额外要做哪件事?

单选题 · mid

  • A.在服务端抽取样式并内联← 正确答案
  • B.让浏览器禁用全部 JS 水合
  • C.将样式统一转成 CSS 变量
  • D.把样式表托管到 CDN 加速

答案:A。CSS-in-JS 的样式在客户端运行时才产生,SSR 时必须用插件在服务端收集所有样式规则,内联进返回的 HTML,否则首屏会出现无样式闪烁(FOUC)。禁用水合会毁掉交互,转 CSS 变量和 CDN 都不是问题的关键。

Q6. CSS Modules 相对 CSS-in-JS 的优势有哪些?

多选题 · mid

  • A.运行时开销几乎为零← 正确答案
  • B.构建产物可独立缓存← 正确答案
  • C.天然支持样式代码分割← 正确答案
  • D.开箱实现动态主题换肤

答案:ABC。CSS Modules 的类名构建期确定,产物是独立 CSS 文件,浏览器可单独缓存,也天然跟着组件做代码分割。但动态换肤并不是它的开箱能力,需要配合 CSS 变量才能实现,所以 D 不正确。

Q7. 需要动态换肤时,CSS Modules 的最佳搭档是?

单选题 · mid

  • A.配置 CSS 变量统一主题← 正确答案
  • B.为每套皮肤生成独立 CSS
  • C.用更高优先级全局样式覆盖
  • D.在运行时向头部追加 style 标签

答案:A。CSS 变量(custom properties)可以在根节点切换 data-theme 时整体更新品牌色,类名无需变化,是 CSS Modules 实现换肤的最自然桥梁。为每套皮肤生成独立 CSS 会导致构建产物爆炸,全局覆盖和运行时追加标签都会绕回样式失控的老路。

Q8. 以下哪些属于 CSS-in-JS 的运行时开销?

多选题 · hard

  • A.样式对象的序列化计算← 正确答案
  • B.组件渲染时函数插值计算← 正确答案
  • C.动态 style 标签的节点更新← 正确答案
  • D.CSS 文件请求的阻塞时间

答案:ABC。CSS-in-JS 在运行时需要把样式对象序列化为 CSS 文本、执行带插值的样式函数、并更新 <style> 节点,这三项都是它的固有开销。CSS 文件请求阻塞是「样式走独立 CSS 文件」才会有的问题,属于 CSS Modules 的讨论范畴。

Q9. vanilla-extract 属于哪一类样式方案?

单选题 · hard

  • A.构建期类型安全的样式方案← 正确答案
  • B.纯运行时注入的样式框架
  • C.基于 Shadow DOM 的样式方案
  • D.面向 Tailwind 的原子类框架

答案:A。vanilla-extract 用 TS 对象语法定义样式,在构建期编译为静态 CSS,同时提供完整的类型推导,是零运行时方案的代表。B 是 styled-components 的路线,C 是 Web Components 的机制,D 是另一个维度的工具。

Q10. 大型项目混用 Tailwind 与 CSS Modules,合理的分层是?

多选题 · extreme

  • A.Tailwind 负责通用布局与常用配色← 正确答案
  • B.CSS Modules 负责组件专属的结构样式← 正确答案
  • C.CSS 变量统一管理主题与品牌色← 正确答案
  • D.所有样式统一进入全局 CSS 文件

答案:ABC。成熟的混用策略是分层治理:Tailwind 原子类承接约七成通用规则,CSS Modules 局部类承接组件特有结构,CSS 变量在最上层统管主题令牌。把一切写进全局 CSS 会回到样式冲突的原点,也是最早被否定的方案。