GitHub 把 Primer 的样式从 CSS-in-JS 换成 CSS Modules,2024 年 12 月迁完,服务端渲染单页耗时降 55%、组件初始化降 25%。收益不是换个写法换来的:构建期分工、服务端组件边界、设计系统版本同步、灰度口径,四条缺一条就白忙。仅限 Web 端场景。
GitHub 把 Primer 的样式方案从 CSS-in-JS 换成 CSS Modules,2024 年 12 月迁完,服务端渲染单页耗时减少 55%,页面上组件初始化耗时减少 25%。这个结果有前置条件,缺一条就只是把复杂度从一个地方搬到另一个地方。
适用范围:Web 端。本文讨论浏览器里跑的 Next.js 应用(App Router、服务端渲染 + hydration)。不涉及移动端原生、小程序、React Native 的样式方案,也不涉及 Electron / Tauri 这类桌面端壳层的渲染链路——它们的样式注入时机与首屏模型跟 Web 端不是一回事,结论不能平移。
GitHub 官方复盘里,触发迁移的不是审美问题,是三个能算出来的成本:首屏加载因为样式在客户端初始化而变慢;服务端渲染性能因为「样式收集」转移回客户端而下滑;页面上组件一多,样式更新量就失控。后两条说的是同一件事——运行期还在干活。原方案把样式生成放进渲染过程,组件数一涨,这部分开销就按组件数放大。
CSS Modules 走的是另一条路:类名默认局部,样式与组件同目录共置,去掉客户端与服务端的运行期行为,最终滚成样式表随页面 HTML 一起下发(见 GitHub 官方复盘)。换来的是运行期零成本,代价是构建期必须把所有东西算完。
所以第一个前置条件是:样式作用域、主题变量、动态主题切换,都必须在构建期解析完毕。
@primer/primitives/dist/css/functional/themes/light.css),应用启动时导入,组件只引用变量名。变量名在构建期固定,运行期只改值。如果你的主题目前还靠运行期注入——比如在渲染过程中把动态样式塞进 <style>,或者用 style registry 配合 useServerInsertedHTML 收集规则(这是 Next.js CSS-in-JS 指南里的标准三步)——迁到 CSS Modules 之后,这些机制要么删掉,要么换个形态留下。留着不走,迁移只换来「两套系统并存」的构建复杂度,性能收益接近零。
还有个容易漏掉的构建期细节:Next.js 生产构建会自动合并与代码分割 CSS,而最终 CSS 顺序取决于 import 顺序。官方建议把 CSS import 收敛到单一入口,并关掉 ESLint sort-imports 这类会自动重排 import 的规则。全局样式另有一层坑:由于 Next.js 用 React 原生的样式表能力配合 Suspense,路由切换时全局样式表不会被移除,可能产生冲突,官方因此建议全局文件只放真正全局的东西。另外 experimental.cssChunking 可以控制 CSS 分块策略(true / false / strict / graph),但它目前仍是实验特性,官方不建议生产环境启用,规划时把它当兜底手段而不是方案。
App Router 里 layout 和 page 默认是 Server Component,只有需要 state、事件处理、生命周期或浏览器 API 时才加 use client。这个指令划的是一条边界:文件一旦标记为客户端模块,它 import 的一切、以及它直接渲染的组件,都会被拉进客户端 bundle。CSS 的 import 也在其中——样式跟着组件走,所以边界划在哪里,直接决定这份样式是随 HTML 下发,还是进 JS 包在浏览器里执行。
三条规则比记知识点有用:
.module.css 才会以样式表形式随首个 HTML 响应下发。反过来,爬虫不执行 JS,只看首个 HTML 响应,内容能否被索引取决于服务端渲染有没有覆盖到它。对 Web 端来说,这一层连着 SEO,不只是性能问题。迁移顺序由此确定:按组件依赖图倒排,叶子先行。先迁没有下游依赖的基础组件,再迁封装它们的组合组件,最后动页面级组件。理由很实在——每个组件都要能独立验证,叶子组件出问题影响面最小、回滚代价最低。
GitHub 给每个组件定的流程是四步:
值得抄的是第 2 步和第 4 步的组合:回滚点是 flag,不是 revert commit。出问题不用走一次完整的构建与发布,关掉开关即可。批次怎么切?以一个依赖闭环为单位——比如基础表单控件连同它的直接下游算一批,配一个独立 flag。一批的上线、观测、回滚都在同一个开关下完成,不要把两个批次塞进一个 flag。
反面模式:一次性全量替换。典型失败路径是:所有组件同时切到新类名体系,而定层 token 的 CSS 变量还没全部就位,主题切换在替换窗口内失去变量来源,暗色模式下整页错色,最后只能整体回滚、回到改前的构建产物重来——两天的放量窗口白跑。GitHub 之所以坚持「逐个组件 + flag + 视觉回归快照」,本质上就是把这个窗口压缩到单个组件、单次请求的粒度,让任何一次异常都只影响一个开关覆盖的范围。
还有一条顺序上的经验:GitHub 先把 sx prop 的用量压下去,才谈彻底移除 CSS-in-JS。原因是 sx 接受内联对象、运行期解析,正是运行期成本最重的那部分;而它在 GitHub 代码库里有成千上万处,体量比 Primer 自身大得多,只能一点一点收。所以迁到 CSS Modules 的批次里,应该把「把 sx 和内联样式对象改写成静态 class」作为同批工作量一起排,别留到后面当技术债。
GitHub 复盘里有个说法叫「The Duality of Primer」:设计系统本身已经离开 styled-components,但 GitHub 代码库的大部分还没有,两套方案必须并存很长一段时间。这是所有设计系统迁移的常态,也是「组件库更新把下游样式打穿」的根源。控制它的办法是把依赖拆成三层:
@primer/primitives 列为 peer dependency 且要求 9.0.0 以上,就是为了避免 token 与组件版本错位导致样式静默失效。需要说明的是,CSS Modules 的哈希类名让样式冲突概率下降,但它保护的是类名,不保护变量。主题变量是跨组件、跨包的公共接口,这一层的兼容策略要单独设计。
灰度能不能得出结论,取决于口径是否可复算。「感觉快了」不算结论。下面四个指标覆盖这次迁移的真实收益点,建议按 route 分组、取 p75 而不是均值:
| 指标 | 采集位置 | 可复算口径 | 它回答什么 |
|---|---|---|---|
| 服务端渲染耗时 | 服务端 render 计时 | 同一 route、同一数据夹具,取 p50 / p75 对比 | 去掉网络因素后,样式收集开销是否消失 |
| 首字节时间 TTFB | 网关日志或 useReportWebVitals | 按 route 分组的 p75,区分缓存命中与未命中 | 服务端变快是否真正传导到首字节 |
| Hydration 时长 | 客户端 performance mark | 从 HTML 到达至 hydration 结束,同样取 p75 | 客户端样式初始化是否被移除 |
| 首屏样式闪烁次数 | 样式表注入计数 + 截图 diff | 同一 URL 下样式标签注入次数、视觉回归快照的差异像素比 | 是否存在首屏闪白 / 主题切换时闪一下 |
Next.js 提供的 useReportWebVitals 可以直接上报 TTFB、FCP、LCP、FID、CLS、INP,并且给出 good / needs-improvement / poor 的评级,便于直接建看板。注意两点工程细节:这个 hook 需要 'use client',官方建议单独抽一个组件挂在根布局上,把客户端边界收窄到这一个组件;上报用 navigator.sendBeacon,没有则退回带 keepalive 的 fetch。另外,TTFB 里包含网络时间,要和服务端 render 计时分开看,否则网络抖动会盖掉真实的样式收益。
第四个指标容易被跳过,但它恰恰是这次迁移最该盯的:迁移到 CSS Modules 之后样式不再由 JS 在运行期注入,理论上首屏不应该出现「先无样式再上色」的过程。如果灰度期间注入次数没降反升,通常说明仍有客户端组件在运行期处理样式,回到第一、二节重新划边界。
不一定。GitHub 那 55% 的前提是原先的方案把样式生成放在运行期,且页面上组件数量很大。如果项目本来就只有几十个组件、主题也已经是构建期变量,迁移的收益会明显小于这个数字,此时要评估的是长期维护成本,而不是首屏指标。
能。JavaScript 负责在根节点上切换 class 或 data 属性,颜色取值交给 CSS 变量完成。要避免的是「用 JS 重新生成整套样式规则」,那会把运行期成本重新引回来。
能,而且必须共存。GitHub 的做法是逐组件加 feature flag,并让底层 CSS-in-JS 继续为尚未迁移的组件服务。关键是共存期要有明确的退出条件——每个 flag 对应一个移除计划,否则两套方案会长期并存。
不能直接套。移动端原生、小程序、React Native 没有「服务端渲染 + hydration」这条链路,桌面端壳层(Electron / Tauri)的首屏模型也不一样。可复用的是「构建期解析 vs 运行期注入」这条判断原则,具体的指标口径要按各端重新定义。
把上面四条前置条件当成立项检查表来用,基本能过滤掉绝大多数「迁完发现没变快」的情况。蓝曜炬辉(www.lanyaoai.com)在 Web 端 Next.js 定制开发项目里,也是按「先划构建期边界、再定组件迁移批次、最后用统一口径验收」这个顺序推进的。