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 渲染组件的时候顺手把样式注入了,所以客户端渲染一般不会闪。

真正会闪的是另外两种情况:

第二种情况在第 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> 里,首屏就一次到位。

三条派生结论:

五、两条类名不一样:开发态有个前缀

把上面第二列的 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 写进选择器的做法,都是把代码绑在了一个你无法控制、也无法预测的字符串上。

六、构建顺序:谁先谁后其实只影响一件事

走到这里,构建链上的顺序可以收成一条明确的规矩了:

  1. Tailwind 先产出 CSS(它要扫源码,和 antd 无关);
  2. @layer 的顺序语句必须在 Tailwind 产物里、且写在 @import 之前(第 04 章的 @layer properties, theme, base, components, antd, utilities;);
  3. 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 的产物要不要按页面缓存。这些都会让数字差出好几倍。

动手:可观察结果

挑一个你手上真实项目,回答五个问题。每个答案都要来自命令输出。

  1. 我的 SSR 页面里有没有样式? —— curl -s https://你的域名/ | grep -c "<style"。结果 0,就说明样式全靠客户端补,首屏一定会闪。
  2. 我有没有在 SSR 里调 extractStyle? —— 去搜服务端渲染那段代码。没有 extractStyle(或者等价的自研抽取),第 1 题的结果就该是 0。
  3. 我引入了几个组件库的 reset? —— 数一下 reset.css / normalize.css / Tailwind 的 @layer base 各来一份还是重复。重复的那些不是字节数问题,是层叠里互相盖。
  4. 我的 CSS 产物体积和它扫到的类成正比吗? —— 把 @source 指到的目录删掉(或者改名)重新构建一次,看产物是不是从"一两万字节"掉到"四千字节左右"。掉了就说明那些字节确实来自你的原子类;没掉说明你的 @source 本来就没扫到东西。
  5. 我的类名依赖环境吗? —— 在产物里 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 规则在生产环境匹配不到任何元素"? 如果没有——那就是你项目里最该补的那个检查。

自测题

  1. 有人提议"为了减小体积,把 import { Button } from "antd" 改成 import Button from "antd/es/button""。请按本章的实测数据说明这个改动的收益,并指出真正该优化的地方在哪。
  2. renderToString 出来的 HTML 里类名齐全、<style> 一个都没有。请解释这是设计如此还是配置漏了,并说出补上的那一行代码是什么。
  3. extractStyle 抽出的 CSS 在生产态是 140792 B、开发态是 162765 B。这个差值主要来自什么?这个大小是固定的还是随页面变化的?
  4. 一个页面在开发环境覆盖生效、上线后不生效。请给出至少两条互不重叠的可能原因,并说明各自用什么方法区分。
  5. 有人说"我引了 Tailwind,就不需要再引 antd 的 reset.css 了"。请从"层叠规则"和"可控性"两个角度说出你的判断,并指出需要核实什么。
  6. 回到第 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 对账;在产物里数类的出现次数而不是相信配置;把"未分层 > 分层"当第一判据而不是权重。版本会变,能自己取证的能力不会。

进入 keel 阅读