“减少前端资源体积”并不等于“所有文件都必须更小”。一个设计系统升级可能增加 CSS 下载量,却因为减少 JavaScript 执行、运行时样式计算和重复代码,让页面更早进入可交互状态。真正需要优化的不是某一种资源的字节数,而是浏览器完成页面渲染所付出的总成本。
这则案例更值得关注的地方,是设计系统如何在不破坏现有页面的前提下发布大规模变更。来源摘要没有披露具体实现细节,下面不把某种技术方案归因于原项目,而是整理一套可以复用的分析与迁移方法。
“更多 CSS”为什么可能更快
浏览器加载一个组件时,成本大致分成四类:
- 下载 HTML、CSS、JavaScript 和字体等资源;
- 解析 CSS,构建 CSSOM;
- 解析、编译并执行 JavaScript;
- 计算样式、布局、绘制,并在状态变化时重复其中一部分工作。
只比较压缩后的传输体积,容易遗漏后面三项。假设一次设计系统升级让压缩后的 CSS 增加 18 KB,但移除了 45 KB JavaScript,并且不再需要在运行时为每个组件生成样式,那么总体验仍可能改善。
CSS 尤其适合承接这些工作:
- 响应式布局交给媒体查询;
- 悬停、聚焦和禁用状态交给伪类与属性选择器;
- 主题值通过自定义属性传递;
- 组件变体通过稳定的类名或
data-*属性表达; - 通用规则进入可长期缓存的共享样式表。
相比之下,如果同样的逻辑依赖 JavaScript,浏览器不仅要下载代码,还要解析、执行,并可能等待框架完成初始化或水合。低性能设备通常会把这种 CPU 成本放大。
不过,“CSS 比 JavaScript 便宜”不是无条件成立。巨大的未使用样式表、复杂选择器、频繁失效的缓存,以及不断覆盖的高优先级规则,同样会拖慢页面。目标应当是把适合声明式表达的工作交给 CSS,而不是无节制地增加 CSS。
设计系统升级最难的是控制影响范围
设计系统不是普通依赖。一个基础按钮、间距变量或文本颜色发生变化,可能影响数百个页面。安全发布不能只依赖“改完后肉眼看几个页面”,而要把兼容性设计进样式架构。
用级联层明确优先级
@layer 可以把基础样式、组件样式和应用覆盖放在不同层中,避免团队继续通过更长的选择器或 !important 争夺优先级:
@layer reset, tokens, components, overrides;
@layer tokens {
:root {
--color-accent: #0969da;
--space-2: 0.5rem;
--radius: 0.375rem;
}
}
@layer components {
.Button {
padding: var(--space-2) 1rem;
border: 1px solid transparent;
border-radius: var(--radius);
background: var(--color-accent);
color: white;
}
}
@layer overrides {
.CheckoutButton {
width: 100%;
}
}
层的顺序是一份公开契约:产品代码可以覆盖组件,但不需要知道设计系统内部选择器的具体结构。这会显著降低后续重构的破坏面。
新旧实现短期共存
大规模迁移适合采用显式开关,而不是一次性替换所有页面。例如:
<body data-design-system="v2">
<button class="Button">保存</button>
</body>
.Button {
/* 仍受支持的旧版外观 */
border-radius: 3px;
}
[data-design-system="v2"] .Button {
/* 新版外观,可按页面或用户逐步启用 */
border-radius: var(--radius, 6px);
}
这种双轨模式会暂时增加 CSS,却换来了灰度发布、快速回滚和按页面迁移的能力。迁移完成后必须删除旧规则,否则临时代码会变成永久负担。
可复制实践:用 CSS 接管组件状态和响应式逻辑
下面是一个最小示例。这里的假设是:旧组件通过 JavaScript 计算布局和内联样式;改造后,JavaScript 只更新语义状态,具体视觉效果由 CSS 负责。该示例不是来源项目的实际代码,但可以直接运行并改造成自己的验证项目。
创建目录和文件:
mkdir css-first-demo
cd css-first-demo
touch index.html styles.css app.js
将以下内容写入 index.html:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CSS-first component demo</title>
<link rel="stylesheet" href="styles.css">
<script src="app.js" defer></script>
</head>
<body>
<main class="Page">
<article class="Card" data-expanded="false">
<h1 class="Card-title">构建状态</h1>
<p class="Card-summary">CSS 负责布局、主题和展开状态。</p>
<div class="Card-details">
测试通过,产物可以进入下一阶段。
</div>
<button class="Button" type="button" aria-expanded="false">
查看详情
</button>
</article>
</main>
</body>
</html>
将以下内容写入 styles.css:
@layer reset, tokens, components;
@layer reset {
* { box-sizing: border-box; }
body { margin: 0; }
button { font: inherit; }
}
@layer tokens {
:root {
color-scheme: light dark;
--canvas: #ffffff;
--surface: #f6f8fa;
--text: #1f2328;
--accent: #0969da;
--space: 1rem;
}
@media (prefers-color-scheme: dark) {
:root {
--canvas: #0d1117;
--surface: #161b22;
--text: #e6edf3;
--accent: #4493f8;
}
}
}
@layer components {
body {
background: var(--canvas);
color: var(--text);
font-family: system-ui, sans-serif;
}
.Page {
display: grid;
place-items: center;
min-height: 100vh;
padding: var(--space);
}
.Card {
width: min(100%, 34rem);
padding: calc(var(--space) * 1.5);
border: 1px solid color-mix(in srgb, var(--text) 20%, transparent);
border-radius: 0.75rem;
background: var(--surface);
}
.Card-details {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition: grid-template-rows 180ms ease, opacity 180ms ease;
}
.Card-details::before {
content: "";
min-height: 0;
}
.Card[data-expanded="true"] .Card-details {
grid-template-rows: 1fr;
opacity: 1;
margin-block: var(--space);
}
.Button {
min-height: 2.5rem;
padding-inline: 1rem;
border: 0;
border-radius: 0.375rem;
background: var(--accent);
color: white;
cursor: pointer;
}
.Button:focus-visible {
outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent);
outline-offset: 2px;
}
@media (max-width: 30rem) {
.Button { width: 100%; }
}
@media (prefers-reduced-motion: reduce) {
.Card-details { transition: none; }
}
}
将以下内容写入 app.js:
const card = document.querySelector('.Card');
const button = document.querySelector('.Button');
button.addEventListener('click', () => {
const expanded = card.dataset.expanded !== 'true';
card.dataset.expanded = String(expanded);
button.setAttribute('aria-expanded', String(expanded));
button.textContent = expanded ? '收起详情' : '查看详情';
});
启动本地服务器:
python3 -m http.server 8000
然后访问 http://localhost:8000。这个实现仍然使用少量 JavaScript处理交互语义,但深色主题、窄屏布局、焦点样式、动画偏好和展开后的视觉状态都由 CSS 完成。页面不需要在每次窗口变化时运行 JavaScript,也不需要为每个组件注入内联样式。
不要只看 bundle 大小
验证这类改造时,应同时观察网络成本、主线程成本和用户体验指标。至少记录以下数据:
- CSS 与 JavaScript 的压缩后传输体积;
- JavaScript 解析、编译和执行时间;
- 首次内容绘制(FCP)与最大内容绘制(LCP);
- 交互到下一次绘制(INP);
- 累积布局偏移(CLS);
- 样式重新计算和布局次数;
- 重复访问时的缓存命中率;
- 旧浏览器、低端设备和慢速网络下的表现。
可以在页面中加入一个很小的观测脚本,先收集实验数据:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(entry.entryType, entry.name, entry.startTime, entry.duration);
}
});
observer.observe({
type: 'longtask',
buffered: true
});
longtask 适合发现超过 50 毫秒的主线程任务,但它不能单独证明改造成功。实际项目还应通过真实用户监控比较新旧版本,并按设备性能、网络类型和页面类型分组,避免平均值掩盖低端设备上的退化。
发布前的决策清单
把更多逻辑迁移到 CSS 前,可以逐项确认:
- 新增 CSS 是否替代了可观数量的运行时 JavaScript;
- 样式表能否跨页面共享并长期缓存;
- 未使用 CSS 是否会被无差别发送给所有页面;
- 选择器是否稳定,优先级是否受控;
- 是否保留键盘焦点、减少动画和高对比度等可访问性能力;
- 是否具备按页面、用户或流量比例启用的开关;
- 是否有视觉回归测试和真实用户性能指标;
- 迁移结束后,谁负责删除旧样式与兼容分支。
“发送更多 CSS”不是性能技巧本身,而是一种资源重新分配:增加浏览器擅长解析、可缓存、声明式的样式规则,换取更少的脚本执行和运行时工作。只要用真实指标验证,并通过分层、灰度和回滚控制风险,它就可能成为大型设计系统升级中更稳妥的性能方案。