Ant Design v6:zeroRuntime 使用与踩坑总结
React 19 与 Ant Design v6 升级完成后,我明显感觉到页面 CLS(累积布局偏移)性能变差。首屏渲染时样式出现闪烁,组件尺寸直至 JS 执行完毕才最终稳定。这种情况在 Docusaurus 等静态站点中尤为严重。
一开始我尝试通过 CSS 强制锁定组件宽高(min-width / min-height)缓解偏移,但这牺牲了代码的可维护性。为此,我开始尝试 Ant Design 官方提供的 zeroRuntime 方案。

下面是这一路踩下来的坑。半年后我又回头把当初那个「只能单一主题」的结论推翻了,明暗两套主题现在同样是静态的,代价 brotli 2.1 KB,这段过程也一并写在后面。
问题根因:静态 CSS 里的变量命不中
antd/dist/antd.css 里 var(--ant-*) 出现了七千多次,只看引用会以为变量压根没定义。定义其实就在同一个文件里,一千两百多个,--ant-color-primary:#1677ff 这些一个不少。问题出在它们挂的作用域上:
.css-var-_R_0_{--ant-color-primary:#1677ff;…}_R_0_ 是 React 的 useId,antd 打包那一次渲染留下来的。组件在 React 树里的位置变了,useId 就换一个值:最简单的一棵树是 _R_0_,前面先挂一个同样用 useId 的组件就变成 _R_2_,跟文件里那份对不上,变量自然取不到值。
对 Docusaurus 这类预渲染的静态站点,后果是首屏只有骨架,颜色、字号、圆角都得等 JS 跑完才补上,CLS 和闪烁跑不掉。想靠引一份 antd/dist/antd.css 了事,命中与否全看运气。
zeroRuntime 的设计原理
zeroRuntime 自己不生成任何 CSS,它只关掉运行时那条生成路径。官方在 ConfigProvider 的类型定义里写得很直白:
开启零运行时模式,不会在运行时产生样式,需要手动引入 CSS 文件。
CSS 从哪来是调用方的事。官方示例直接引 antd/dist/antd.css 就能跑,因为变量块照旧由运行时补上(后面会看到 zeroRuntime 并不拦这一步)。但静态站点首屏还没执行 JS,这份补丁来不及,所以得自己在构建期用 extractStyle 提一份,并且给它一个固定的 cssVar.key。
构建阶段
- 通过
genAntdCss.mjs(或类似脚本)调extractStyle - 基于指定的
theme、token 配置 - 生成一份完整、静态 CSS
运行阶段
- 组件不再生成或注入样式规则
- 默认假设所有样式已经在页面里
所以使用 zeroRuntime 后,所有样式相关配置必须在构建期确定。
还有一个容易忽略的入口。ConfigProvider 里那行判断是 zeroRuntime: !!layer || mergedTheme?.zeroRuntime,只要开了 CSS layer,zeroRuntime 就跟着一起打开了,你可能没显式配过它却已经在这个模式里。
zeroRuntime 的能力边界
可以做的
- 在生成脚本中配置全局
theme - 构建阶段生成一套或多套固定主题的 CSS
- 通过
ConfigProvider在这些已提取的主题之间切换 - 使用
ConfigProvider设置locale(不影响样式) - 用
theme.useToken()读 token 值
不能做的
- 运行时算出一个构建期不存在的主题色
- 运行时改
componentSize等会生成新样式的 token
theme.useToken() 常被当成禁区,其实它照常返回 token,zeroRuntime 开与关读到的色值、字号、圆角完全一样。分界线是「这套样式构建时提取过没有」,不是「运行时能不能改配置」。切到一套已经躺在 CSS 里的主题,JS 只是给组件换个 class;要一套没提取过的,JS 就得现场生成样式,那才是 zeroRuntime 关掉的能力。
cssVar.key 必须完全一致
启用 zeroRuntime 时,最容易忽略又最要命的是这个配置:
cssVar: {
key: "aishort",
}这个 key 就是 antd CSS 变量的命名空间,配了它,上一节那个随 useId 漂移的作用域才会变成一个固定的类名。生成 CSS 的脚本和应用主题的配置必须写同一个值,差一个字母,构建出来的变量就命不中组件,zeroRuntime 等于没开,刷新时字号先大后小、组件样式先错后对。
判断方法很粗暴:刷新页面还闪,就是没生效。
多主题:我一开始走错了路
我最早的做法是生成浅色和深色两份完整 CSS,运行时切换。很快就撞墙:两份同时存在,后加载的会覆盖前者;就算切了主题,生效的还是权重更高的那套变量。为了解决覆盖,得人为拆分、加权、隔离,可维护性直线下降。当时我的结论是不值得投入,站点就只留了深色。
半年后回头查体积,才发现这条路从第一步就错了。
关键在 cssVar。它打开之后,antd 的组件规则里只剩 var(--ant-*) 引用,具体色值全部落在变量定义块里。我把明暗两套算法各提取一次做逐字比对,组件规则两边都是 1588 条,拼起来的字符串完全相等。也就是说两份 CSS 有 93% 是重复的,真正随主题变的只有那些变量块。
那就不该生成两份,只要在暗色那份后面追加一段浅色变量块。下面的 extract 是自己包的一层,里面就是 renderToString 渲一遍组件再交给 extractStyle,两次调用只差 cssVar.key(aishort 与 aishort-light)和算法:
const darkCss = extract("dark", theme.darkAlgorithm);
const lightCss = extract("light", theme.defaultAlgorithm);
// 只取 scope 到 light key 的变量块,组件规则复用暗色那份
const lightVarBlocks = (
lightCss.match(/[^{}]+\{[^{}]*--ant-[^{}]*\}/g) || []
).filter((b) => b.slice(0, b.indexOf("{")).includes("aishort-light"));
fs.writeFileSync(OUT, darkCss + lightVarBlocks.join(""));两套变量各挂在 .aishort 和 .aishort-light 上,靠 scope class 隔离,不靠权重竞争。所以「后加载覆盖前者」根本不会发生。cssVar.key 本来就是命名空间机制,我当初把它只当成一个必须对齐的配置项,没看出它就是多主题的解法。
实测代价:
| raw | gzip | brotli | |
|---|---|---|---|
| 单主题 | 372,294 | 42,740 | 30,859 |
| 双主题 | 400,681 | 48,388 | 32,964 |
| 增量 | +28,387 | +5,648 | +2,105 |
brotli 多 2.1 KB。运行时那边,我给 ConfigProvider 按 data-theme 切 cssVar.key,页面上的 antd 元素全部跟着换了 scope class,没有注入任何新样式。
这里还有一个坑,我是查完体积才反应过来的。scope class 是构建期写死在 HTML 里的,我这份产物首页 151 处全是暗色的 .aishort,浅色的一处没有。静态站点没法知道来访者用哪套主题,而 Docusaurus 会在 <head> 里用预绘制脚本读 localStorage 立刻设 data-theme。两件事凑一起,浅色用户刷新时首帧是这样的:页面底色已经是浅的,antd 组件还在用暗色 token,得等 React hydrate 完换了 class 才纠正。移动端实测这个窗口一两秒,肉眼很明显。
它不产生布局位移,所以 CLS 查不出来(我跑了六轮,全是 0);Lighthouse 每次都是全新 profile,恒为默认主题,也照不到。
解法不用加体积,给浅色变量块多挂一条属性作用域的选择器就行,块体不复制:
.aishort-light.ant-btn,
html[data-theme="light"] .aishort.ant-btn { … }特异度是刻意安排的。新那条 (0,3,1) 压过暗色的 .aishort.ant-btn (0,2,0),所以 hydration 之前浅色就赢了;hydration 之后元素只剩 .aishort-light,属性选择器不再匹配,交回原选择器接管。两条挂的是同一份取值,交接时不会有第二次跳色。我实测过首帧和 hydration 后都是 #f8f9f3。整份 CSS 因此多了 1,232 字节,brotli 后 191 字节。
zeroRuntime 压的是组件样式,不是全部
我一直以为开了 zeroRuntime,页面上就一个 antd 注入的 <style> 都不该有。生产构建上数了一下,首页有 12 个,暗色 19,862 字节,浅色 19,505。
翻开看内容,12 个全是这种:
.aishort.ant-btn{--ant-button-blue-shadow-color:0 2px 0 rgba(0,16,53,0.41);…}一条组件样式规则都没有,全是 cssVar 的 token 块。静态文件里那 6585 条 .ant- 规则,运行时一条都没有重新生成。
源码里这个分工写得很清楚。@ant-design/cssinjs-utils 的 lib/util/genStyleUtils.js 里,genComponentStyleHook 在调 useStyleRegister 之前就早退了:
if (memoizedZeroRuntime) {
return hashId;
}同一个文件里负责变量的 genCSSVarRegister 没有这道判断,照样往下调 useCSSVarRegister,所以 token 注册全程照常走。
麻烦的是这些 token 块我的静态 CSS 里已经有了。我把注入的 .aishort 块和静态文件里的同名块逐条比对,两边都是 381 个属性,取值只差在压缩器把 0.08 写成 .08 这类归一化上。更直接的验法是把这 12 个 <style> 全部 disabled = true,再量按钮、卡片、Tag、Input 的计算样式,一个字节都没变。也就是说这 19 KB 纯粹是重复,不影响渲染,也不会带来闪烁,但它就在那儿。
提取时记得收窄组件清单
extractStyle 的 includes 不填就是把 antd 全量组件都渲一遍,我这站实际只 import 了 29 个组件,扫源码收窄之后 raw 从 100 万字节降到 40 万。要注意扫描漏网的是命令式 API,App.useApp() 拿到的 message 全项目没有一处 import { message } from "antd",得手工补进清单。
总结
zeroRuntime 解决的是 CLS、首屏闪烁,以及运行时注入样式带来的那种「刷新一次一个样」的不确定性。代价是所有样式都得在构建期定死,运行时算出来的主题色用不了。静态站点、设计系统长期稳定,那这个取舍是划算的。
但「构建期定死」不等于「只能一套」。我把这两件事混为一谈了半年,白白少了一个浅色主题。真正的边界是那套样式提取过没有,提取过的主题之间切换只是换 class,JS 一行样式都不用生成。多主题的成本不在复杂度,在体积,而 cssVar 把这个体积压到了只剩变量块那一点。
留几个可以直接拿去用的数字,都是 AiShort 这个 Docusaurus 站的实测:静态 antd CSS 按实际用到的 29 个组件收窄,单主题 raw 372 KB、brotli 30 KB;加第二套主题 brotli 多 2.1 KB;zeroRuntime 开着,antd 仍会注入 12 个 cssVar token 块约 19 KB,跟静态文件完全重复,这部分目前没得躲。
真要运行时算主题色,那还是老老实实用 CSS-in-JS。除此之外的场景,zeroRuntime 都能兜住,比我去年以为的宽得多。