昨天 InfoQ 报道了 Linear 的一个里程碑:他们把 React 应用里的 styled-components 全部换成了 Meta 的 StyleX,工程量 1000 多个 PR、约 5 个月,今年 8 月初收尾。官方给的收益数字:视图重的页面主线程工作量降 20% 到 35%,中端机器上页面导航快了约 30%,切换页面时新注入的 CSS 规则是零。
"大厂换了 X 快了 30%"这种句式我一般直接划走。但这次有两个细节让我停下来了。一是主导迁移的工程师 Kenneth Skovhus 压根没手改样式,而是写了个确定性 codemod,前后跑出 500 多个 PR、动过约 10 万行代码;二是 Linear 挑 StyleX 的理由里有一条跟性能无关------styled(Button) 这种模式让"从组件外面改它的样式"太容易了,团队想把这个口子故意焊死,理由写得很直白:写代码的越来越多是 agent,没人管得住它往你的 Button 上堆什么。
这条理由我没办法不当真。性能收益可以辩论,agent 乱改样式是我每天都在处理的事。所以我决定不搬运报道,直接把两边的机制拆开验一遍:那 30% 的差距到底从哪来的,StyleX 的"限制"到底有多硬。环境是 Node v22.23.2,styled-components 装的当前最新 6.5.3,StyleX 是 0.19.1,React 18.3.1------正好是 Linear 当年升级后痛感变强的那个版本。
先复现 styled-components 的运行时账单
styled-components 的机制一句话就能说完:样式定义是带插值函数的模板字符串,真正的 CSS 要等组件渲染的时候才生成,生成完注入页面的 <style> 标签里。同一个组件渲染一百次,如果 props 组合各不相同,理论上就要生成一百份 CSS。理论归理论,我还是想看到数字。写一个带三个动态插值的 Button:
css
jsconst Button = styled.button`
padding: ${(p) => p.$pad || 8}px;
background: ${(p) => p.$bg || "#eee"};
color: ${(p) => p.$fg || "#111"};
border-radius: 6px;
`;
然后在 SSR 里渲染两组按钮:一组 10 个完全相同的 props,一组 200 个不同组合(20 种 padding × 100 种背景色),统计渲染耗时、唯一类名数、注入的 CSS 字节数。类名和注入内容从 styled-components 的内部 mainSheet 里捞,脚本完整贴在文末。结果比我想象的更极端:
ini
A.10个相同props首次: 唯一类名=1 渲染耗时=5.1ms 注入CSS=74B
B.10个相同props再跑: 唯一类名=1 渲染耗时=0.4ms 注入CSS=74B
C.200种不同组合首次: 唯一类名=199 渲染耗时=19.4ms 注入CSS=17380B
D.200种不同组合再跑: 唯一类名=199 渲染耗时=4.1ms 注入CSS=17380B
逐行解读一下。相同 props 的一组,10 个实例共享 1 个类名------styled-components 对同组合有进程内缓存,所以 B 组再跑只要 0.4ms。但 200 种不同组合的一组,首次渲染生成了 199 个互不相同的 hash 类名,往页面里塞了 17.4KB 的 CSS。随手抓两条注入的规则看:
css
css.bAkkBP{padding:8px;background:#eee;color:#111;border-radius:6px;}/*!sc*/
.cGxFCN{padding:8px;background:hsl(0 70% 50%);color:#111;border-radius:6px;}/*!sc*/
padding:8px、border-radius:6px` 这些一模一样的声明,在每个类里都完整重复一遍。CSS 体积跟"组合数"成正比,而不是跟"出现的样式值"成正比。更微妙的是耗时分布:首渲 19.4ms,缓存命中后再跑只要 4.1ms------也就是说这部分开销集中在每次新组合第一次出现的那个瞬间,用户第一次点到那个状态时,卡的就是这一下。
跑 C 组的时候 styled-components 还在控制台里自己报警了:
sql
Over 200 classes were generated for component styled.button
with the id of "sc-bdvwhi".
Consider using the attrs method, together with a style object
for frequently changed styles.
官方文档自己都承认这个模式有问题,给的缓解方案(attrs + style object)本质是把样式从 CSS 挪成内联 style。一个库要靠"劝你别多用我核心功能"来救性能,这个信号挺说明问题的。
换条路:样式在构建期就全部算完
带着上面的预期去看 StyleX,我原以为会看到一套类似的 API 加一个更快的实现。完全不是。StyleX 里没有 styled 这个概念,样式定义在编译期就得写死:
js
import * as stylex from '@stylexjs/stylex';
const styles = stylex.create({
base: { color: '#111', borderRadius: 6 },
pad0: { padding: 8 },
// ...pad1 到 pad19 bg0: { backgroundColor: 'hsl(0 70% 50%)' },
// ...bg1 到 bg9
});
stylex.create里的每个值必须是编译期常量,插值函数、运行时计算统统不行。动态变化靠 stylex.props(styles.base, styles.pad13, styles.bg7) 这种组合现成样式的方式来表达。所以我构造了同样的 200 种组合场景:20 种 padding 定义加 10 种背景色定义,200 个实例各取一种组合。
然后是 StyleX 和 styled-components 路线分岔的地方:这个文件要过一遍 Babel 插件编译。我在这里踩了今天唯一的坑------编译器报缺 @babel/preset-env,装上才过。这就是两条路线的成本结构差异:styled-components 开箱即用(代价运行时付),StyleX 的构建链路你得自己搭(代价提前付,但一定要付清楚)。编译完成后跑同样的统计:
makefile
StyleX: 200实例(200种组合) 唯一原子类=32 props耗时首次=0.67ms 再跑=0.36ms
运行时注入CSS: 0(编译产物中 stylex.create 残留 0 处,动态样式函数残留 0 处)
200 个实例、200 种组合,最终只剩 32 个原子类 ------2 个 base、20 个 padding、10 个背景色,每个类只在构建产出的 stylex.css 里出现一次,整个文件 1892 字节。运行时做的事只剩一件:按顺序把类名字符串拼起来,0.67ms。所谓"页面切换零 CSS 注入"在机制层面就是这么回事:没有可以注入的东西,样式在 build 的时候已经全量产出成静态 CSS 文件了。
编译产物本身值得看一眼。源文件里那段 stylex.create({...}) 编译后变成了:
js
const styles = {
base: { color: 'x1votaj3', borderRadius: 'x1kogg8i', $$css: true },
pad13: { padding: 'xp9y95v', $$css: true },
// ...
};
样式值没了,只剩类名字符串。构建期把该算的都算完了,运行时想慢都没有空间。对比实验 1 里那 199 个 hash 类,这就是 Linear 说的"运行时 CSS-in-JS 让用户为样式生成买单"的具体形状,17.4KB 对 1.9KB、19.4ms 对 0.67ms,差出来的就是这两段。
"太严格"是特性,不是妥协
性能解释得通,但还没到非迁不可的地步------毕竟 17KB 的 CSS 对多数应用不算致命。真正让我理解 Linear 动机的是第三个实验。我把 Linear 团队抱怨的场景直接怼给 StyleX 编译器:在 stylex.create 里尝试写 styled-components 里司空见惯的选择器。
js
// 四种写法,只有第一种能活
{ padding: 8, ':hover': { backgroundColor: '#ddd' } } // ✅ 编译通过
{ padding: 8, '& > span': { color: 'red' } } // ❌ Invalid pseudo or at-rule
{ padding: 8, '& span': { color: 'red' } } // ❌ Invalid pseudo or at-rule
{ padding: 8, 'span': { color: 'red' } } // ❌ Invalid pseudo or at-rule
子代组合器、后代通配、标签选择器,全部在编译期被拒,报错都是同一个 Invalid pseudo or at-rule。:hover 这类伪类是官方支持的,仅此而已。
这意味着在 StyleX 里,"从外面改里面"这条路从语法层面就不存在。styled-components 的 styled(Button) 本质上靠后代选择器穿透组件边界,好用,也正因为它好用------一个 AI agent 拿到你的设计系统,最省事的路径就是在外面套一层 styled 把所有孙子元素重排一遍,编译器不会说一个不字。StyleX 把这条路焊死了:想改样式,要么改组件自己的 stylex.create,要么从外面传 props 类名进来,全部有据可查。
Syntax 播客里 Wes Bos 和 Scott Tolinski 讨论这波 StyleX 迁移潮时有个说法,大意是这种僵化对人类开发者很不友好,但 agent 反而如鱼得水。我跑完这三个实验后的感受更具体一点:StyleX 真正的产出其实是约束------约束恰好是现在这种"代码大半由 agent 生成"的工作流里最稀缺的东西。Yahoo 出身的 Reid Burke 在这场讨论里也提醒过:styled-components 那个图灵完备的开放 API,恰恰是自动化迁移难以施展的原因,Linear 能写 codemod 十万行机械替换,前提是目标语言(StyleX)足够死板。
这笔账怎么算
先说我的局限:全程是 Node SSR 环境,测的是样式生成和注入的机制成本,Linear 那 20%-35% 的主线程收益发生在真实浏览器里,我没有复现条件,这个数字只能存疑照录。另外 200 种组合是个构造出来的极端场景,你的页面如果样式组合数很少,styled-components 的进程内缓存会让两边差距小得多。
但机制层面的结论我认为是站得住的:styled-components 的成本随"组合数"线性涨,且集中在用户第一次触达的瞬间;StyleX 的成本全部前置到构建期,运行时只剩字符串拼接。如果你的应用页面重、样式组合爆炸、团队里 AI 生成代码的占比越来越高,这 1000 个 PR 花得不冤。反过来,一个几十个页面的内部系统,为了这个迁移去搭 StyleX 的构建链路(你得配 Babel 插件,这是我自己刚踩过的坑),大概率是给自己找事。styled-components 进入维护模式这件事是真实的背景压力,但它没死,存量项目不必恐慌。我自己那个小项目还没决定迁。不过这次的实验脚本都贴在文末了,哪天样式组合数真的炸了,跑一遍就知道该不该动。
实验脚本与原始输出 (Node v22.23.2,styled-components 6.5.3,@stylexjs/stylex 0.19.1。先 npm i styled-components react react-dom @stylexjs/stylex @babel/core @stylexjs/babel-plugin)
实验1:styled-components 运行时行为与统计(exp1_styled_components.js,node 直接跑)
ini
// 实验1(完整版):styled-components 运行时样式生成 + 注入
const React = require("react");
const { renderToString } = require("react-dom/server");
const styled = require("styled-components").default;
const priv = require("styled-components").__PRIVATE__;
const Button = styled.button`
padding: ${(p) => p.$pad || 8}px;
background: ${(p) => p.$bg || "#eee"};
color: ${(p) => p.$fg || "#111"};
border-radius: 6px;
`;
function bench(label, variants) {
// 清掉上一次的全局缓存,模拟"冷启动的页面"
priv.mainSheet.clearRules && priv.mainSheet.names && priv.mainSheet.clearNames();
const t0 = process.hrtime.bigint();
const els = variants.map((v, i) => React.createElement(Button, { key: i, ...v }, "ok"));
const html = renderToString(React.createElement(React.Fragment, null, els));
const t1 = process.hrtime.bigint();
// 统计唯一实例类名
const classes = [...html.matchAll(/class="sc-\w+ (\w+)"/g)].map((m) => m[1]);
const unique = new Set(classes).size;
// 用 getGroup 捞出注入的规则文本
let cssText = "";
const tag = priv.mainSheet.tag;
for (let g = 0; g < 64; g++) {
try { const r = tag.getGroup(g); if (r) cssText += r; } catch (e) { break; }
}
console.log(
`${label}: 实例=${variants.length} 唯一类名=${unique} 渲染耗时=${((Number(t1 - t0)) / 1e6).toFixed(1)}ms 注入CSS=${Buffer.byteLength(cssText)}B`
);
return { html, cssText };
}
const mk = (n, vary) =>
Array.from({ length: n }, (_, i) =>
vary ? { $pad: 8 + (i % 20), $bg: `hsl(${i * 2} 70% 50%)`, $fg: "#111" } : { $pad: 8, $bg: "#eee", $fg: "#111" }
);
const r1 = bench("A.10个相同props首次", mk(10, false));
const r2 = bench("B.10个相同props再跑", mk(10, false));
const r3 = bench("C.200种不同组合首次", mk(200, true));
const r4 = bench("D.200种不同组合再跑", mk(200, true));
console.log("\n--- 实例类名抽样(A组前3个)---");
console.log((r1.html.match(/class="sc-[^"]+"/g) || []).slice(0, 3).join(" | "));
console.log("\n--- 注入规则文本抽样(C组,截400字节)---");
console.log(r3.cssText.slice(0, 400));
实验2:StyleX 版组件(stylex_bench.js,需先经 @stylexjs/babel-plugin 编译再运行------正文说"你得配 Babel 插件"的坑就是它)
css
// 实验2:StyleX ------ 样式在构建期编译成原子类,运行时只做类名拼接
import * as stylex from '@stylexjs/stylex';
const styles = stylex.create({
base: { color: '#111', borderRadius: 6 },
pad0: { padding: 8 },
pad1: { padding: 9 },
pad2: { padding: 10 },
pad3: { padding: 11 },
pad4: { padding: 12 },
pad5: { padding: 13 },
pad6: { padding: 14 },
pad7: { padding: 15 },
pad8: { padding: 16 },
pad9: { padding: 17 },
pad10: { padding: 18 },
pad11: { padding: 19 },
pad12: { padding: 20 },
pad13: { padding: 21 },
pad14: { padding: 22 },
pad15: { padding: 23 },
pad16: { padding: 24 },
pad17: { padding: 25 },
pad18: { padding: 26 },
pad19: { padding: 27 },
bg0: { backgroundColor: 'hsl(0 70% 50%)' },
bg1: { backgroundColor: 'hsl(10 70% 50%)' },
bg2: { backgroundColor: 'hsl(20 70% 50%)' },
bg3: { backgroundColor: 'hsl(30 70% 50%)' },
bg4: { backgroundColor: 'hsl(40 70% 50%)' },
bg5: { backgroundColor: 'hsl(50 70% 50%)' },
bg6: { backgroundColor: 'hsl(60 70% 50%)' },
bg7: { backgroundColor: 'hsl(70 70% 50%)' },
bg8: { backgroundColor: 'hsl(80 70% 50%)' },
bg9: { backgroundColor: 'hsl(90 70% 50%)' },
});
// 200个实例 = 200种不同组合(pad 0..19 x bg 0..9),模拟一页里颜色/间距各异的按钮
export function buildEls() {
const t0 = process.hrtime.bigint();
const els = [];
for (let i = 0; i < 200; i++) {
const props = stylex.props(styles.base, styles[`pad${i % 20}`], styles[`bg${i % 10}`]);
els.push(props);
}
const t1 = process.hrtime.bigint();
return { els, ms: Number(t1 - t0) / 1e6 };
}
实验3:选择器限制验证(exp3_restrict.js,node 直接跑)
css
// 实验3:StyleX 的选择器限制 ------ Linear 团队要的"故意变难"
// 试三种写法:伪类(官方支持)、子代组合器(受限)、后代通配(应该被拒)
const tests = {
"hover伪类": { padding: 8, ':hover': { backgroundColor: '#ddd' } },
"子代组合器": { padding: 8, '& > span': { color: 'red' } },
"后代通配": { padding: 8, '& span': { color: 'red' } },
"标签选择器": { padding: 8, 'span': { color: 'red' } },
};
const babel = require('@babel/core');
const fs = require('fs');
for (const [name, obj] of Object.entries(tests)) {
const src = `import * as stylex from '@stylexjs/stylex';\nexport const styles = stylex.create({ box: ${JSON.stringify(obj).replace(/":/g, '": ').replace(/,"/g, ', "') } });\n`;
fs.writeFileSync('tmp_case.js', src);
let ok = false, msg = '';
try {
babel.transformSync(src, { filename: 'tmp_case.js', babelrc: false, configFile: false, plugins: [['@stylexjs/babel-plugin', { dev: false, runtimeInjection: false, genCSS: true, styleResolution: 'application-order' }]] });
ok = true; msg = '编译通过';
} catch (e) {
msg = (e.message || '').split('\n').slice(0, 2).join(' ').slice(0, 120);
}
console.log(`${name}: ${ok ? '✅ 编译通过' : '❌ 被拒 -> ' + msg}`);
}
原始输出(exp1_output.txt):
css
A.10个相同props首次: 实例=10 唯一类名=1 渲染耗时=5.1ms 注入CSS=74B
B.10个相同props再跑: 实例=10 唯一类名=1 渲染耗时=0.4ms 注入CSS=74B
style: {
},
C.200种不同组合首次: 实例=200 唯一类名=199 渲染耗时=19.4ms 注入CSS=17380B
D.200种不同组合再跑: 实例=200 唯一类名=199 渲染耗时=4.1ms 注入CSS=17380B
--- 实例类名抽样(A组前3个)---
class="sc-bdvwhi bAkkBP" | class="sc-bdvwhi bAkkBP" | class="sc-bdvwhi bAkkBP"
--- 注入规则文本抽样(C组,截400字节)---
.bAkkBP{padding:8px;background:#eee;color:#111;border-radius:6px;}/*!sc*/
.cGxFCN{padding:8px;background:hsl(0 70% 50%);color:#111;border-radius:6px;}/*!sc*/
.blQrLW{padding:9px;background:hsl(2 70% 50%);color:#111;border-radius:6px;}/*!sc*/
.ibusMI{padding:10px;background:hsl(4 70% 50%);color:#111;border-radius:6px;}/*!sc*/
.diQgpT{padding:11px;background:hsl(6 70% 50%);color:#111;border-radius:6
(exp2_output.txt)
lua
StyleX: 200实例(200种组合) 唯一原子类=32 props耗时首次=0.67ms 再跑=0.36ms
--- className 抽样(前3个实例)---
x1votaj3 x1kogg8i xe8ttls x1q5ffdr | x1votaj3 x1kogg8i xskserf x10za3gn | x1votaj3 x1kogg8i x7z7khe x13qear8
编译产物中 stylex.create 调用残留: 0 处
编译产物中箭头函数动态求值样式残留: 0 处
(exp3_output.txt)
javascript
hover伪类: ✅ 编译通过
子代组合器: ❌ 被拒 -> /root/stylex_lab/tmp_case.js: Invalid pseudo or at-rule.
后代通配: ❌ 被拒 -> /root/stylex_lab/tmp_case.js: Invalid pseudo or at-rule.
标签选择器: ❌ 被拒 -> /root/stylex_lab/tmp_case.js: Invalid pseudo or at-rule.
实验2构建产物 stylex.css(全文 1892 字节):
css
x1votaj3 { ltr: .x1votaj3{color:#111}; rtl: null; }
x1kogg8i { ltr: .x1kogg8i{border-radius:6px}; rtl: null; }
xe8ttls { ltr: .xe8ttls{padding:8px}; rtl: null; }
xskserf { ltr: .xskserf{padding:9px}; rtl: null; }
x7z7khe { ltr: .x7z7khe{padding:10px}; rtl: null; }
xaqq2fw { ltr: .xaqq2fw{padding:11px}; rtl: null; }
xc7ga6q { ltr: .xc7ga6q{padding:12px}; rtl: null; }
xh073nz { ltr: .xh073nz{padding:13px}; rtl: null; }
x1gnqi22 { ltr: .x1gnqi22{padding:14px}; rtl: null; }
x6w2896 { ltr: .x6w2896{padding:15px}; rtl: null; }
x1tamke2 { ltr: .x1tamke2{padding:16px}; rtl: null; }
xcaa3tu { ltr: .xcaa3tu{padding:17px}; rtl: null; }
x1dypa6k { ltr: .x1dypa6k{padding:18px}; rtl: null; }
x6jh44v { ltr: .x6jh44v{padding:19px}; rtl: null; }
x1qhigcl { ltr: .x1qhigcl{padding:20px}; rtl: null; }
x131h7f2 { ltr: .x131h7f2{padding:21px}; rtl: null; }
xp9y95v { ltr: .xp9y95v{padding:22px}; rtl: null; }
x19soz2t { ltr: .x19soz2t{padding:23px}; rtl: null; }
xggk2y7 { ltr: .xggk2y7{padding:24px}; rtl: null; }
xp9dabg { ltr: .xp9dabg{padding:25px}; rtl: null; }
xor4a8r { ltr: .xor4a8r{padding:26px}; rtl: null; }
x1179rhn { ltr: .x1179rhn{padding:27px}; rtl: null; }
x1q5ffdr { ltr: .x1q5ffdr{background-color:hsl(0 70% 50%)}; rtl: null; }
x10za3gn { ltr: .x10za3gn{background-color:hsl(10 70% 50%)}; rtl: null; }
x13qear8 { ltr: .x13qear8{background-color:hsl(20 70% 50%)}; rtl: null; }
x1srw8zz { ltr: .x1srw8zz{background-color:hsl(30 70% 50%)}; rtl: null; }
xwwom7w { ltr: .xwwom7w{background-color:hsl(40 70% 50%)}; rtl: null; }
x89yzdq { ltr: .x89yzdq{background-color:hsl(50 70% 50%)}; rtl: null; }
xco7pv2 { ltr: .xco7pv2{background-color:hsl(60 70% 50%)}; rtl: null; }
x1iu9uxe { ltr: .x1iu9uxe{background-color:hsl(70 70% 50%)}; rtl: null; }
xqr8tve { ltr: .xqr8tve{background-color:hsl(80 70% 50%)}; rtl: null; }
xgy0tcm { ltr: .xgy0tcm{background-color:hsl(90 70% 50%)}; rtl: null; }