1. 一场“去CSS文件化”的实验为什么我会认真考虑这件事去年年中我接手了一个已经迭代了两年的中后台项目。代码量不算夸张四个前端维护总计 380 多个组件但让我头疼的是样式文件——每个页面入口的 CSS 文件动辄上千行类名靠 BEM 硬撑但依然频繁出现“改一个按钮颜色三个页面跟着变”的灵异事件。全局搜索某条样式规则能搜出七八处覆写谁也不敢删只能继续往上叠。当时团队里有人提议要不换成 CSS-in-JS彻底告别 CSS 文件我承认听到这个提议的第一反应是抵触。毕竟从入行起HTML、CSS、JavaScript 三者分离就是前端的基本功CSS 文件天经地义。但那个月我刚好被样式污染问题折磨得够呛于是没有直接拒绝而是带着“试试看”的心态把项目里的一个核心业务模块用 styled-components 重写了一遍作为对照实验。结果很有意思模块的体积没有明显变化但开发体验确实有了质的提升——不用担心样式冲突改样式的时候不用全局搜索组件删掉的时候样式也跟着消失。不过真到场景切换、主题定制、SSR 渲染这些硬需求出现时新的问题又冒了出来。这篇文章我就把这个实验的完整过程、踩过的坑、以及最后的选型结论整理出来。不站队只讲实践。如果你也正在纠结“项目要不要上 CSS-in-JS”这篇文章应该能帮你省下至少两周的试错时间。先说结论CSS-in-JS 不是“CSS 文件的替代品”而是一种“样式封装模型的切换”。它解决的并不是 CSS 好不好用的问题而是在大型前端项目中样式如何跟组件生命周期绑定的问题。理解了这一层你就不会再问“要不要告别 CSS 文件”而是会问“我的项目到底需要哪种样式组织方式”。2. 原生 CSS 的真正痛点不是语法是作用域和生命周期在聊 CSS-in-JS 的价值之前我们得先把原生 CSS 的问题说透。很多人以为大家抛弃 CSS 文件是因为 CSS 语法不够强大其实不是。CSS 本身的语言能力完全够用真正让大型项目崩溃的是另外两件事全局作用域和样式生命周期。2.1 全局作用域与层叠带来的连锁污染CSS 的层叠机制是它最强大的特性但同时也是最危险的特性。只要两条规则的选择器优先级相同且匹配同一个元素后加载的那条就会覆盖前者。问题是在一个大型应用里“后加载”往往取决于你的模块加载顺序而这个顺序随着代码拆分会产生不可预知的变化。我举一个实际例子。项目里有两个业务组件用户列表和订单列表。用户列表的样式文件里有一条规则.list .item { padding: 16px; border-bottom: 1px solid #eee; }订单列表的样式文件里也有一条规则.detail .item { padding: 12px; border-bottom: 1px solid #ddd; }单独看都没有问题。但如果用户列表组件的 DOM 结构里出现了.detail这个类名比如某个状态类而且订单列表的 CSS 恰好在用户列表之后加载订单列表的.item样式就会覆盖用户列表的样式padding 从 16px 变成 12px。这种问题很难复现因为它依赖的是模块加载顺序、组件嵌套结构、类名重用等多个条件同时触发。通常大家用 BEM 规范来解决这类问题也就是通过严格的命名约定来降低类名冲突概率。但 BEM 靠的是人的纪律性人总有失误的时候。而且 BEM 类名特别长写起来本身就是一种负担。2.2 样式生命周期与组件生命周期的脱节第二个痛点更隐蔽在传统 CSS 文件模式下样式是静态存在的但组件是动态创建和销毁的。假设你的应用里有一个“弹窗组件”它渲染的时候需要一段样式关闭之后这段样式就再也用不到了。但在传统写法中这段样式会一直存在于打包产物里不管用户是否打开了弹窗。组件越多无效样式越多包体积就这么一点点膨胀起来。还有一个常见场景你删掉了一个业务组件但它的样式文件如果没有同步删除这段 CSS 就变成了僵尸代码。如果项目足够大、人员流动足够频繁攒上一年样式文件里可能有三分之一以上的规则是找不到对应 DOM 的。我用 PurgeCSS 跑过一次旧项目未使用规则占比 28%。这些死代码不仅浪费带宽还会干扰后续开发时的排查。CSS-in-JS 解决这两个问题的思路很直接把样式的生命周期绑定到组件的生命周期上。组件挂载时生成样式并插入组件卸载时移除样式组件不存在则样式根本不会出现在产物里。它从制度上消灭了“样式污染”和“僵尸样式”这两个顽疾。3. 从“类名地狱”到“组件即样式”CSS-in-JS 到底改变了什么实话实说CSS-in-JS 这个概念本身涵盖了一大批实现方式完全不同的库——从运行时插入样式的 styled-components、Emotion到构建时提取静态 CSS 的 Linaria、vanilla-extract再到介于两者之间的 CSS Modules。它们共享同一个核心思想样式不再以独立的 CSS 文件存在而是以 JavaScript 对象或模板字符串的形式跟组件写在同一个文件里。3.1 从“命名”到“不用命名”作用域问题的终极解法在传统 CSS 里写任何样式之前都要先想类名怎么起。这个类名要在整个项目范围内唯一还要能表达出“这个元素在组件中扮演什么角色”。这是个看似简单但实际上非常消耗脑力的工作。用了 styled-components 之后这个问题直接消失了。你不需要再给元素起一个全局唯一的类名你只需要写一段带样式的组件import styled from styled-components; const Button styled.button padding: 10px 16px; font-size: 14px; color: #fff; background-color: #1890ff; border: none; border-radius: 4px; cursor: pointer; :hover { background-color: #40a9ff; } ;由于 styled-components 在运行时生成了哈希类名每个样式规则都只作用于当前组件实例天然不存在冲突问题。而且样式和组件在同一个文件里改动一个组件的时候样式就在手边不需要来回切换文件。不需要命名就没有命名冲突没有命名冲突BEM 命名规范也就不再需要了。这套逻辑是自洽的。3.2 样式与逻辑的同构动态样式不再是拼接地狱原生 CSS 里做动态样式无非两种方式一是切换类名二是用 CSS 变量三是内联 style。前两种在复杂场景下都会捉襟见肘。比如一个进度条组件要根据传入的百分比动态改变宽度和颜色。如果用类名切换你得预先定义好所有可能的类名如果用 CSS 变量你得在 JavaScript 里算好每个颜色值如果用内联 stylehover 伪类状态就没办法处理了。CSS-in-JS 对动态样式是一种降维打击。因为样式本身是 JavaScript 表达式任何 JS 能力都可以直接用于样式计算const ProgressBar styled.div width: ${props props.percent}%; background-color: ${props props.percent 80 ? #f5222d : #1890ff}; transition: width 0.3s ease; ;这种方式写起来非常直觉化——想要什么效果直接在模板字符串里写逻辑就行。不需要约定 CSS 变量名不需要在逻辑文件里同步维护一份变量映射表。这里的核心价值是“消除了两层之间的转译成本”。3.3 主题切换能力的重新定义有没有问过自己为什么传统 CSS 的主题切换这么费劲因为变量作用域和嵌套层级不匹配。你在:root里定义了一堆 CSS 变量切换主题的时候直接替换变量的值。但遇到场景需要局部覆盖或者组件内部有自己定义的颜色常量时用 CSS 变量就会很别扭。CSS-in-JS 处理主题的方式本质上是用 React Context 把主题对象注入到每个 styled component 里样式函数可以直接读取const Card styled.div border: 1px solid ${props props.theme.borderColor}; background-color: ${props props.theme.cardBg}; ;在顶层通过 ThemeProvider 传入不同的主题对象所有子组件都自动响应变化。主题的粒度、嵌套、覆盖都由 JavaScript 的对象模型来管理比 CSS 变量的字符串拼接直观太多了。4. 主流方案的取舍地图别被“CSS-in-JS”四个字一锅炖CSS-in-JS 是一个大类的统称下面每个库的取舍差异大得惊人。网上很多争论其实是在拿 A 库的缺点对比 B 库的优点根本不在一个维度上对话。我先梳理一份主流方案图谱方便你对号入座。方案运行时代表库核心特点适合场景运行时 CSS-in-JS是styled-components, Emotion样式在运行时解析、注入动态能力最强组件库、高度主题化应用、小型团队构建时提取否Linaria, vanilla-extract构建阶段把样式抽成 CSS 文件运行时零开销对性能敏感、需要兼容非 JS 环境的场景CSS Modules否编译期处理css-loader 内置局部作用域类名但仍是独立 CSS 文件渐进改造存量项目原子化 CSS否Tailwind CSS, UnoCSS预置工具类HTML 里直接写类名快速开发、设计约束严格的团队4.1 运行时方案的隐藏成本为什么大厂不一定选它styled-components 这类运行时方案体验是最好的但成本也最直观——它让你的 JavaScript 包体积变大而且首屏渲染时会有额外的样式计算。我实测过一个 30 个组件的模块引入 styled-components 之后gzip 后的包体积增加了约 42KB。单看数值不大但放在一个 100 个模块的大型应用里这个增长会被放大到不可忽视的程度。更关键的是首屏性能浏览器需要先下载、解析、执行 JavaScript然后才能生成样式并插入到页面里。如果你的应用需要做 SSR服务端渲染还得额外配置 styled-components 的样式收集逻辑否则首屏会出现 FOUC无样式内容闪烁用户会先看到没有样式的裸 HTML然后样式才被注入体验很差。Emotion 提供了emotion/server来把样式在服务端收集并内联到 HTML 中。但这一步配置多了不少而且如果你用的是 Next.js 这类有自己渲染流程的框架还要针对框架的文档单独配置。能配通但确实要花时间。4.2 构建时方案的思路把 CSS-in-JS 的体验和 CSS 文件的性能合并Linaria 和 vanilla-extract 是近两年比较受关注的方案。它们保留了“在 JS 文件里写样式”的开发体验但在构建阶段会把样式静态分析出来生成真正的 CSS 文件。vanilla-extract 的写法类似 CSS Modules但是用 TypeScript 对象来定义样式// button.css.ts import { style } from vanilla-extract/css; export const button style({ padding: 10px 16px, backgroundColor: #1890ff, :hover: { backgroundColor: #40a9ff, }, });然后在组件里直接引入这个对象import { button } from ./Button.css; function Button() { return button className{button}Click me/button; }编译之后样式被抽成了独立的 CSS 文件运行时没有额外开销。动态样式则通过 CSS 变量来桥接——TS 里计算出变量的值通过 style 属性传给元素。这个方案比较适合对性能敏感的中大型应用也是我个人更偏好的方向。它保留了“样式跟组件走”的心智模型又不牺牲运行时性能。4.3 原子化 CSS 的搅局Tailwind 其实也是一种“类 CSS-in-JS”很多人忽略了一个事实Tailwind CSS 这类原子化方案本质上解决的也是 CSS 文件的问题只不过走的是另一个极端——不用写自定义样式直接用预置的工具类拼装。button classpx-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 Click me /button它的优势是类名即样式不用维护 CSS 文件跟组件的耦合度也很高。但它把“样式逻辑”转移到了 HTML 类名上本质上你需要学习一套新的规则语法。而且遇到比较复杂的状态样式比如基于 props 的组合判断Tailwind 的写法会变得很啰嗦需要把类的条件拼接逻辑抽到 JS 里// 这种写法写多了真的很累 const buttonClass clsx( px-4 py-2 rounded, variant primary ? bg-blue-500 text-white : bg-gray-200 text-gray-700, disabled opacity-50 cursor-not-allowed );不能说它不好但它的心智模型跟 CSS-in-JS 是完全不同的。如果你习惯了“样式是 JS 的延续”Tailwind 会显得很别扭如果你喜欢“约定优于配置”Tailwind 又很顺手。二者没有优劣纯看团队口味。5. 当我决定回归 CSS 文件真实项目的代价分析前面讲了不少 CSS-in-JS 的好处但这篇文章不是无脑吹。我在实验之后最终把大部分模块又迁移回了 CSS Modules Sass 的组合。不是因为 CSS-in-JS 不好而是这个项目的实际情况决定了回归是更优解。我把决策过程里的关键考量列出来你可以对照自己的项目来判断。5.1 首屏性能预算数据不会说谎我们的项目对首屏性能有硬性指标LCP 必须控制在 2.5 秒以内。实验阶段我把核心路由改造为 styled-components 后跑了一次 Lighthouse 对比指标原生 CSSstyled-components首屏 JS 体积148KB196KBLCP2.1s2.4sTBT180ms260ms样式加载方式CSS 文件并行加载JS 执行后注入数值的增长还不算致命但趋势是明确的所有性能指标都在变差而且方向跟项目的核心 KPI 冲突。作为技术负责人我不能为了开发体验而牺牲用户可感知的性能指标。这里要补充一点styled-components 的性能问题不是绝对的它跟组件树深度和样式数量强相关。如果你的应用本身是重交互、样式数量少的类型比如可视化大屏运行时方案的开销可以忽略不计。但如果你的应用是重内容、结构化页面多的业务系统样式往往是页面骨架开销就会非常明显。5.2 调试体验的倒退浏览器 DevTools 不认识你的组件本地开发的时候styled-components 会生成一个带组件名的类名比如.sc-bdnxMx很难一眼看出对应哪个组件。虽然它支持自定义displayName配置但配置之后会增加打包体积很多人不会开。Chrome DevTools 的 Styles 面板里样式来源显示的是一个style标签内的哈希字符串而不是你熟悉的.scss文件路径。遇到样式问题不能再“右键检查 → 看 Styles 来源 → 点开定位到代码”一条龙了得先在 styled-components 生成的哈希类名和组件文件之间建立映射关系。可能有人觉得这不是大问题但真实开发中样式排查占用的时间比例相当高。我统计过我们团队日常开发的时间分配样式相关问题的定位耗时平均占比约 18%。用样式耦合组件的方式这部分时间会进一步上升。而且对于新加入团队的成员理解“这个 div 的样式到底来自哪个 styled-component”需要额外的上下文学习成本不低。5.3 被锁死在运行时库的生态依赖里选了 styled-components意味着你的项目的渲染阶段就依赖这个库。将来想迁移到别的方案比如 vanilla-extract 或者 Tailwind那几乎等于把所有组件的样式重写一遍。这是巨大的沉没成本。我见过一个项目用了三年的 styled-components后来升级 React 18 的时候发现 styled-components 的 SSR 兼容性问题迟迟没修复导致整个团队卡在旧版本上不敢动。这种“被库绑架”的感觉非常糟糕。所以如果你在做技术选型一定要问一个问题这个方案如果未来不再维护我迁移的代价是多少运行时 CSS-in-JS 的迁移成本是最高的几乎等于重写所有组件。相比之下CSS Modules 的迁移成本低很多因为样式逻辑还是在 CSS 语法体系内只是类名加了哈希后缀。6. 给还在摇摆的人一个务实的选型清单这些年的经验让我明白一件事技术选型没有绝对的正确只有适用的边界。同一个方案在这个项目是蜜糖在另一个项目就是砒霜。所以我最后一次总结这个实验的完整结论给你一套可以跟着走的判断标准。6.1 什么情况下值得用 CSS-in-JS如果你遇到以下情况中的大部分CSS-in-JS 是值得认真考虑的选项你的项目是组件库或者 UI 框架样式需要被外部使用者覆盖CSS-in-JS 的局部作用域能避免你的样式污染使用者的页面项目主题定制需求强烈需要支持多品牌、多主题动态切换CSS-in-JS 的主题模型最直接你的团队规模小成员经验比较统一不需要依靠命名规范约束行为项目首屏性能要求不高或者应用本身就是后台管理系统对加载速度不敏感6.2 什么情况下建议保持 CSS 文件反过来下面的情况可能更适合继续用传统的 CSS 文件方案项目 To C 且对首屏性能有硬性指标LCP、TBT 都有明确的预算项目需要长期维护且未来可能存在大版本框架升级的情况你的团队成员流动性大需要让新成员能够快速上手并排查样式问题项目已经有完善的 BEM 或者类似命名规范样式污染的概率已经被纪律压到很低6.3 折中方案渐进增强的思路如果你觉得两边都有道理不妨走一条折中路线核心业务组件继续用 CSS Modules Sass 保持性能在特定场景比如主题化需求明确的业务模块局部引入 CSS-in-JS。这样既控制了性能和调试成本又能享受动态样式的便利。我自己的技术栈倾向是核心基础组件库用 vanilla-extract构建时提取没有运行时开销业务组件里的高频小部件用 CSS Modules遇到非常复杂的联动样式比如拖拽组件的状态样式再局部使用 styled-components。这种搭配在性能和开发体验之间找到了一个比较舒服的平衡点。最后分享一个比较个人的体会样式的管理本质上跟状态管理很像——没有银弹只有合适与不合适。与其在网上争论“CSS 文件到底要不要被淘汰”不如打开自己的项目统计一下样式文件里有多少死代码测一下首屏里样式加载耗时占比多少再算一笔改造的账。数据不会骗人它会告诉你答案。