KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
07 · 交付面:体积、首屏与 SSR — keel 龙骨
这一章回答:这套组合装到线上之后,用户到底下载了什么、第一眼看到的是什么——以及为什么"首屏先闪一下"这件事,只在服务端渲染时才会变成一个必须自己动手解决的问题。
这一章回答:这套组合装到线上之后,用户到底下载了什么、第一眼看到的是什么——以及为什么"首屏先闪一下"这件事,只在服务端渲染时才会变成一个必须自己动手解决的问题。
现场:首屏闪了一下
一个用了 antd + Tailwind 的页面,本地开发时一切正常。上线之后有人反馈:打开页面会先看到一屏没样式的文字,大约一眨眼之后才"啪"地变成正常样子。
这种一闪而过的东西很难复现,因为它只在网络慢、或者首屏是服务端渲染的时候才看得见。但它背后的问题是可以量清楚的:页面上的两套样式,各自是在什么时刻到达浏览器的? 先把这个问题拆成两个可以数的东西:
- 一份样式有多大(用户要下载多少字节);
- 一份样式什么时候存在(在用户第一眼看到内容的那一刻,它到底在不在)。
先停一下,你觉得这两个问题里,哪一个才是"闪一下"的原因?在往下翻之前先选一个。 选"太大"的话,你会发现本课的体积数字其实不小但也不致命;选"什么时候存在"的话,你会撞上这一章真正的主角:服务端渲染时,组件库的样式根本不在 HTML 里。
一、体积:import { Button } from "antd" 带进来什么
五种 import 组合,同一个打包器(esbuild,--bundle --format=esm --minify,NODE_ENV=production):
| 场景 | 产物 | gzip |
|---|---|---|
只 import { Button } from "antd" |
378872 B | 125861 B |
Button + ConfigProvider |
378916 B | 125879 B |
Button + Input + Table + ConfigProvider + StyleProvider |
843225 B | 272952 B |
深路径 import Button from "antd/es/button" |
378872 B | 125979 B |
只 react + react-dom/client(无组件库) |
222735 B | 68934 B |
三件事值得单独说:
第一,antd 的具名 import 和深路径 import,产物体积逐字节相同。 也就是说 import Button from "antd/es/button" 这种"按需导入"的老写法,在今天的打包器上没有任何收益(378872 vs 378872,一个字节都不差)。它的历史作用是绕开"整个 antd 都进包"的问题,而那个问题在 ESM + 摇树的打包器上已经不存在了。
第二,Button 本身不便宜。 扣掉 React 那 222735 B,Button 单独带进来约 156 KB(压缩后约 57 KB)。这个数字的主要成分是 cssinjs 运行时加按钮自己的样式逻辑——"样式是运行时算出来的"这件事,在体积上是要付账的。
第三,Table 才是真正的体积大户。 加上 Input + Table + StyleProvider 之后,产物从 378916 B 涨到 843225 B,多了约 464 KB。所以"按需导入"这件事真正该盯的不是 antd 还是 antd/es,而是"我这个页面到底用没用上 Table"。
二、Tailwind 那一侧的体积
Tailwind 的产物是构建时算出来的一个 CSS 文件,所以它的体积直接等于"你扫到了多少类":
| 产物 | 体积 | gzip |
|---|---|---|
扫 src/**/*(一个真实页面的用量) |
15308 B | 3514 B |
只 3 个类(bg-red-600 / text-blue-500 / w-40) |
5096 B | 1784 B |
| 没扫到源码(只有 theme + base) | 4165 B | 1402 B |
加了 @theme 自定义 token |
6467 B | 1933 B |
node_modules/antd/dist/reset.css(原样拷一份) |
3587 B | 1260 B |
注意第三行。 没扫到源码的产物是 4165 B —— 它不是空的。里面有 @layer theme 的变量表和 @layer base 的 preflight,也就是说页面看起来是有样式的(字体对了、间距重置了、标题大小正常了),只有原子类一个没产出。这就是"页面像有样式、组件全是裸的"这种最难排查的故障的成因:体检查不出来,因为产物文件确实存在、确实不为空、也确实加载成功了。
顺带一个容易忽略的账:antd/dist/reset.css 是 3587 B。如果你既引了它、又引了 Tailwind(它的 @layer base 里已经有一套 preflight),你就在做两遍同一件事,而且两份 reset 会按层叠规则互相盖。这不是字节数的问题,是可控性的问题。
三、什么时候存在:渲染时注入的那一瞬间
客户端渲染的场景下,antd 的样式是跟着组件一起到达的。上一章量过:一个三组件的最小页面,head 里会被插入 18 张 <style>,全部在 <link> 之前。
这意味着在浏览器里,DOM 和样式几乎是同步出现的——React 渲染组件的时候顺手把样式注入了,所以客户端渲染一般不会闪。
真正会闪的是另外两种情况:
- 样式表比内容晚到:内容来自 SSR 的 HTML(立刻可见),而样式来自 JS(还要等下载、解析、执行)。这段空档里用户看到的就是裸 HTML。
- 主题迟一步:如果暗色是运行时算出来的(
ConfigProvider theme.algorithm),而 HTML 是先渲染出来的,那么"先按亮色显示、再切成暗色"这一下也是闪。
第二种情况在第 06 章已经量过它的根源:antd 的暗色是 React 状态,不是 CSS 规则,所以服务端不可能在不知道客户端状态的情况下先把它算对——除非把状态也传到服务端去。这就是为什么"暗色模式在 SSR 下要先闪一下"是个普遍的、有明确成因的问题。
四、SSR:renderToString 的 HTML 里一个 <style> 都没有
把这件事量出来。服务端渲染一个 Button + Input + Table 的树:
import { renderToString } from "react-dom/server";
import { StyleProvider, createCache, extractStyle } from "@ant-design/cssinjs";
const cache = createCache();
const html = renderToString(<StyleProvider cache={cache}><App /></StyleProvider>);
const css = extractStyle(cache, true); // 第二个参数 true = 只要 CSS 文本,不要 <style> 标签
四组对照(开发态 / 生产态 × 带不带 layer):
| 环境 | renderToString 的 HTML |
HTML 里有 <style> 吗 |
extractStyle 抽出的 CSS |
gzip | 含 @layer |
|---|---|---|---|---|---|
开发态,不带 layer |
1043 B | 没有 | 162765 B | 16063 B | 否 |
开发态,带 layer |
1043 B | 没有 | 162916 B | 16101 B | 是(@layer antd) |
生产态,不带 layer |
918 B | 没有 | 140792 B | 15649 B | 否 |
生产态,带 layer |
918 B | 没有 | 140943 B | 15695 B | 是(@layer antd) |
四行里"没有"那一列全都是"没有"。 renderToString 交给你的是一段干干净净、一个 <style> 都没有的 HTML:
<button type="button"
class="ant-btn css-oc1rc0 css-var-root ant-btn-primary ant-btn-color-primary ant-btn-variant-solid bg-red-600">
类名都在,样式一条都没跟着来。这就是"首屏闪一下"的机制:服务端给的是骨架,样式要等客户端把 cssinjs 跑起来,往 head 里插那 18 张表之后才有。 而 extractStyle 就是那个"提前把样式捞出来"的钩子——它返回 140160 KB 的 CSS(gzip 后约 1516 KB),你把它塞进 SSR 输出的 <head> 里,首屏就一次到位。
三条派生结论:
extractStyle的产物是"这一屏用到的样式",不是"整个 antd 的样式"。 生产态 140792 B 对应的是这个页面上的三个组件;组件变多它就会变大。它不是一份可以永久缓存的固定资源,这一点和 Tailwind 那份构建产物正好相反。layer在 SSR 下也生效:带上layer之后抽出的 CSS 里有@layer antd(第 04 章的排层修法在 SSR 里同样要排,否则服务端抽出来、客户端重排一遍,两层规则又要打一次架)。代价是 151 字节(140792 → 140943)。- 生产态比开发态小约 22 KB(162765 → 140792)。省下来的主要是开发态才有的额外内容;下一条会讲它的另一半。
五、两条类名不一样:开发态有个前缀
把上面第二列的 class 拿出来单独看:
开发态:ant-btn css-dev-only-do-not-override-oc1rc0 css-var-root ant-btn-primary …
生产态:ant-btn css-oc1rc0 css-var-root ant-btn-primary …
同一个组件、同一份主题,类名不一样。
多出来的是 css-dev-only-do-not-override- 这个前缀。它的名字说明了一切:antd 在开发态特意给类名加这个前缀,是为了让你在开发时的覆盖规则不会"顺便"在生产里生效——反过来也成立:你在开发态写的一条依赖 .css-dev-only-do-not-override-oc1rc0 的覆盖,上线之后必然失效,而且失效得悄无声息(样式没报错、规则没消失,只是不再匹配任何元素)。
这解释了上一章"别写死 hash"那条纪律的另一半。第 05 章讲的是"hash 由主题 + 组件决定",这一章补上的是:它还是环境的函数。 两个变量一起作用,结论只有一个——任何把 .css-xxxxx 写进选择器的做法,都是把代码绑在了一个你无法控制、也无法预测的字符串上。
六、构建顺序:谁先谁后其实只影响一件事
走到这里,构建链上的顺序可以收成一条明确的规矩了:
- Tailwind 先产出 CSS(它要扫源码,和 antd 无关);
@layer的顺序语句必须在 Tailwind 产物里、且写在@import之前(第 04 章的@layer properties, theme, base, components, antd, utilities;);- antd 的样式在运行时或 SSR 时产出,并在
layer模式下进@layer antd那一格。
三者里唯一会互相影响的是第 2 步和第 3 步的第一次声明顺序:谁先声明 antd 这个名字,谁就决定它排第几。而因为 antd 是运行时注入的,你永远比它早——所以"排层"这个动作必须由你在自己的 CSS 里做,而且必须写在 @import "tailwindcss" 之前。
至于"构建产物和运行时产物哪个先到浏览器",它不影响层叠(层序由 CSS 文本里的声明顺序决定,与网络到达顺序无关),只影响首屏观感:构建产物是 <link>、可以预加载、可以缓存;运行时产物(或者 SSR 抽出来那份)是内联或后注入的,它到得早不早,决定了用户第一眼看的是什么。
本章脉络
flowchart TD
S["一个用了 antd + Tailwind 的页面"] --> B["构建期产物"]
S --> R["运行期产物"]
B --> B1["tailwind.css<br/>5-15 KB, gzip 1.4-3.5 KB"]
B --> B2["JS bundle<br/>Button 约 156 KB 净增, Table 再加约 464 KB"]
R --> R1["CSR: 18-31 张 style 注入 head"]
R --> R2["SSR: renderToString 的 HTML 里 0 个 style"]
R2 --> R3["extractStyle 捞出 140-160 KB<br/>gzip 约 15-16 KB"]
R3 --> F["补进 head 才不闪"]
R1 --> F2["客户端渲染通常不闪<br/>DOM 与样式同步出现"]
R --> E["开发态类名多 css-dev-only-do-not-override-<br/>生产态没有"]
图里在说什么。 左边是构建期、右边是运行期,这两条线对应两种不同的交付策略:构建期产物可以当静态资源预加载与缓存;运行期产物要么跟着 JS 一起到(CSR,DOM 与样式同步,一般不闪),要么必须靠 extractStyle 提前捞出来补进 HTML(SSR,不捞就是一闪)。最下面那个方框是这一章最容易造成"本地好好的、线上没生效"的东西:类名分两套,而且没有任何工具会在环境切换时提醒你。
生产边界
本课的体积数字全部来自最小复现,不是你的项目。 一个真实应用的账上有:路由级代码分割、Table/Select/DatePicker 这类重组件分别在哪些页面加载、cssinjs 的缓存复用、Tailwind 扫到的类远比本课多、以及 SSR 场景下 extractStyle 的产物要不要按页面缓存。这些都会让数字差出好几倍。
- 能拿走的是形状:
Button不是一个可以忽略的依赖、Table级别的组件才是真正的体积单位、antd/es/xxx深路径导入在 ESM 打包器上没有收益、extractStyle的产物是"这一屏的样式"而不是固定资源。 - 本课没有测:HTTP 层的压缩协商(本课的 gzip 是本地
zlib算的,不是 CDN 的 brotli)、缓存与版本策略、extractStyle产物的长尾(组件越多它越大)、以及流式 SSR 下样式抽取的时机。这几项要在真实链路上量。 - "首屏闪一下"本课没有用录像取证:所有结论是从"HTML 里有没有
<style>"这个可数的判据推出来的。要复核你自己的项目,最快的办法是curl一次 SSR 页面,然后grep -c "<style"—— 0 就是会闪。
动手:可观察结果
挑一个你手上真实项目,回答五个问题。每个答案都要来自命令输出。
- 我的 SSR 页面里有没有样式? ——
curl -s https://你的域名/ | grep -c "<style"。结果 0,就说明样式全靠客户端补,首屏一定会闪。 - 我有没有在 SSR 里调
extractStyle? —— 去搜服务端渲染那段代码。没有extractStyle(或者等价的自研抽取),第 1 题的结果就该是 0。 - 我引入了几个组件库的 reset? —— 数一下
reset.css/normalize.css/ Tailwind 的@layer base各来一份还是重复。重复的那些不是字节数问题,是层叠里互相盖。 - 我的 CSS 产物体积和它扫到的类成正比吗? —— 把
@source指到的目录删掉(或者改名)重新构建一次,看产物是不是从"一两万字节"掉到"四千字节左右"。掉了就说明那些字节确实来自你的原子类;没掉说明你的@source本来就没扫到东西。 - 我的类名依赖环境吗? —— 在产物里
grep -o "css-dev-only-do-not-override",以及grep -o "\.css-[a-z0-9]\{6\}"。任何一处出现,都代表有一份代码绑在了会变的字符串上。
完成标志:五题都有输出;并且你能说出"如果第 1 题是 0,我下一步会改哪一行代码"。
故障注入
| 注入 | 怎么做 | 观察什么 | 期望行为 |
|---|---|---|---|
把 @source 指到空目录 |
改 @source 的 glob,让一个源码文件都扫不到 |
产物体积 + 页面上原子类是否生效 | 产物掉到 4000 字节上下、且文件不为空;页面"看起来有样式"但组件全是裸的。这是最难查的一种 |
SSR 时去掉 extractStyle |
只 renderToString,不抽 CSS |
curl 出来的 HTML 里 <style> 的数量 |
0 个。首屏就是裸 HTML,等 JS 到了才有样式 |
| 在开发态写一条基于 hash 的覆盖 | 照抄 DevTools 里的选择器(带 css-dev-only-do-not-override-)写进 CSS |
生产构建后是否还生效 | 开发态生效,生产态静默失效——不报错、不警告、规则就躺在那儿 |
| 引两份 reset | 同时引 antd/dist/reset.css 和 Tailwind |
某个元素的 computed 是否符合预期 |
两份 reset 按层叠规则互相盖;computed 取决于层序与权重,而不是你的意图 |
第三种注入之后保留这个状态,然后问自己:我有没有任何一个环节,能发现"这条 CSS 规则在生产环境匹配不到任何元素"? 如果没有——那就是你项目里最该补的那个检查。
自测题
- 有人提议"为了减小体积,把
import { Button } from "antd"改成import Button from "antd/es/button""。请按本章的实测数据说明这个改动的收益,并指出真正该优化的地方在哪。 renderToString出来的 HTML 里类名齐全、<style>一个都没有。请解释这是设计如此还是配置漏了,并说出补上的那一行代码是什么。extractStyle抽出的 CSS 在生产态是 140792 B、开发态是 162765 B。这个差值主要来自什么?这个大小是固定的还是随页面变化的?- 一个页面在开发环境覆盖生效、上线后不生效。请给出至少两条互不重叠的可能原因,并说明各自用什么方法区分。
- 有人说"我引了 Tailwind,就不需要再引 antd 的
reset.css了"。请从"层叠规则"和"可控性"两个角度说出你的判断,并指出需要核实什么。 - 回到第 04 章:SSR 抽出来的 CSS 带
@layer antd(比不带多 151 字节)。请说明为什么这 151 字节值得花。
现在能解释什么
这门课从一个"加上了类名却不变色"的按钮开始,到"首屏闪一下"结束。回一遍这一路:
第 01 章的那个问题——"类名挂上了、权重也一样,为什么颜色不变"——现在你能拆成两条时间线:antd 的样式在组件渲染时才算出来、往 head 里插 18 张 <style>;Tailwind 的在构建时扫源码产出、来的时候是一个 `》。 而胜负的第一判据不是权重,是在不在层里。
第 02 章的那个问题——"我写了类名却没生成"——现在你知道 Tailwind v4 的扫描是文本级的:写下来过就生成(哪怕它出现在一句根本不作为类名用的字面量里),拼出来的不生成。@source 只覆盖 CSS 文件所在的那棵目录树;扫不到的时候产物不是空的(theme + base 还在),所以页面会以一种"像有样式"的方式坏掉。
第 03 章的那个问题——"改 token 为什么可靠"——现在你知道 antd 的颜色根本不在选择器里,而在 .css-var-root 那 381 个 --ant-* 变量里;改一个 token 不是覆盖旧变量,是多生成一整张 381 项的新表(css-var-_r_1_),这也是三套主题能同页共存的原因。而 :where(.css-oc1rc0).ant-btn 的权重被 :where() 压到 0,1,0,和 .bg-red-600 一模一样——antd 赢从来不是赢在权重上。
第 04 章的那个问题——"为什么加了 layer 却什么都没变"——现在你手上有一张完整的判定表:未分层 > 任何层;层间后声明的赢;!important 把层序整个反转(实测 @layer base 的 important 赢过 @layer utilities 的,权重 0,1,0 赢 0,0,1)。你也知道 StyleProvider layer 只包了一半——.css-var-root* 与 .anticon* 一共 13 条永远未分层。修法是排一个全序:@layer properties, theme, base, components, antd, utilities;,让 antd 赢过 preflight、输给工具类。而把 antd 排到最前,代价是主按钮变成 rgba(0, 0, 0, 0)。
第 05 章的那个问题——"往组件里塞样式有几种渠道"——现在你知道四种渠道的落点各不相同:token 改的是变量表、styles 落成内联样式(所以能赢)、classNames 落成类名(所以照样受层序摆布)、自写高权重选择器能赢但绕过了变量体系。而且类名里的 hash 是主题与环境的函数:开发态 css-dev-only-do-not-override-oc1rc0 ≠ 生产态 css-oc1rc0。
第 06 章的那个问题——"暗色为什么半边不跟着变"——现在你知道 <html class="dark"> 只驱动 Tailwind 的 dark: 变体(前提是写了 @custom-variant dark (&:where(.dark, .dark *)),否则它跟的是操作系统),而 antd 的暗色是一套 React 状态下的主题算法(产出新变量表 css-var-_r_2_,--ant-color-bg-container: #141414)。实测:加 class="dark" 之后页面肤色从 rgb(255,255,255) 变成 oklch(0.208 0.042 265.755),两个 antd 按钮的 background-color 纹丝不动。两套开关必须由同一个状态源同时喂。
以及最后这一章:renderToString 的 HTML 里没有任何 <style>,所以 SSR 必须自己把样式捞出来(extractStyle,140160 KB,gzip 约 1516 KB)。antd/es/button 这种深路径导入在 ESM 打包器上一个字节都不省;真正的大头是 Table 这一级的组件。
一个提醒收尾。这一门课量的所有东西——@layer 的顺序、13 条未分层规则、381 个变量、151 字节、css-oc1rc0——都是 antd 6.6.5 + tailwindcss 4.3.3 上的事实,换一个版本就可能变。 而这一行的工具确实换得快:Tailwind v4 把配置搬进了 CSS,antd 6 把类名改成了 color × variant 两段,StyleProvider layer 这种开关还在演进。
真正值钱的是那套做法,不是这些数字:打开真实浏览器、把候选规则连层名和出现序号一起列出来、代换 var()、和 computed 对账;在产物里数类的出现次数而不是相信配置;把"未分层 > 分层"当第一判据而不是权重。版本会变,能自己取证的能力不会。