聊聊前端样式方案的演进与抉择
坐标北京,每天挤一号线通勤一小时,从 Android 原生开发转战 Flutter 跨平台,现在又因为公司项目需要开始折腾前端样式方案。这篇文章,就是我在无数次样式冲突、打包爆炸、线上事故后的一些血泪总结。
从一个 Android 老兵的视角说起
先自我介绍一下,我是一个干了五年 Android 开发的老兵,去年因为公司业务调整,开始转 Flutter 做跨平台开发。你以为这就完了?上个月,我们团队接了一个跨端营销页面的需求,老板一句"前端也一起搞了吧",我就这么半推半就地又踏入了前端样式方案这片"深水区"。
说实话,刚接触前端样式的时候,我是懵的。在 Android 世界里有 XML 布局,在 Flutter 里有 Widget 树,样式和逻辑天然分离或者天然组合,都很清晰。但前端呢?CSS 这门"语言",说它是编程语言吧,它没有变量(好吧现在有了);说它不是吧,它又能搞出各种让你怀疑人生的布局效果。
每天通勤一小时,我就在地铁上刷各种技术文章,周末参加各种技术分享会,慢慢地,我对前端的样式方案有了一些自己的理解。今天这篇文章,就想跟大家聊聊 CSS-in-JS 和 传统 CSS 这两大阵营,到底该怎么选。
对了,这篇文章的灵感其实来源于上周五晚上的一次线上事故。我们一个 H5 活动页面上线后,样式全乱了,按钮跑到了页面底部,弹窗的遮罩层变成了半透明绿色(别问为什么是绿色,问就是设计稿的锅)。排查了两个小时,最后发现是一个全局 CSS 类名冲突导致的。当时真的想砸电脑。
传统 CSS:老兵不死,只是逐渐凋零?
传统 CSS 的那些事儿
传统 CSS,就是咱们最熟悉的 .css 文件,通过选择器来给元素加样式。写 Android 的时候,我觉得这玩意儿就跟全局的 styles.xml 差不多——简单、直接、好理解。
/* 一个再普通不过的按钮样式 */
.btn-primary {
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary:hover {
background-color: #40a9ff;
}
.btn-primary:active {
background-color: #096dd9;
}
看起来很美好对吧?但问题就出在这个"全局"上。
全局作用域:万恶之源
在传统 CSS 中,所有的类名都是全局的。这意味着,如果你在 header.css 里定义了一个 .title,在 footer.css 里也定义了一个 .title,它们就会互相影响。这就像你们公司所有部门共用一个微信群,产品经理在里面发个"今天加班",全公司都以为自己要加班。
我们团队之前有个项目,前端同学有七八个,大家各写各的 CSS,结果就是类名满天飞:
/* 张三写的 */
.container { ... }
.title { ... }
.btn { ... }
/* 李四写的 */
.container { ... } /* 跟张三的冲突了 */
.title { ... } /* 又冲突了 */
.my-btn { ... } /* 加了前缀,但迟早还会冲突 */
为了解决这个问题,大家开始加前缀:header-title、footer-title、modal-title……到最后,类名比代码还长,维护起来简直是噩梦。
BEM 命名规范:治标不治本
后来我们引入了 BEM(Block Element Modifier)命名规范:
/* BEM 风格 */
.card { ... }
.card__title { ... }
.card__content { ... }
.card--highlighted { ... }
.card__title--large { ... }
确实好了一些,类名有了层次感,冲突也少了。但说实话,这玩意儿全靠自觉。新人来了,不遵守规范,照样给你搞出一堆 .btn、.title。而且写起来真的很累,每次都要想这个元素属于哪个 Block,是 Element 还是 Modifier……
传统 CSS 的优势
不过公平地说,传统 CSS 也有它的优势:
| 优势 | 说明 |
|---|---|
| 零学习成本 | 会 HTML 就会 CSS,不需要额外学习 |
| 浏览器原生支持 | 不需要任何编译工具,直接跑 |
| 缓存友好 | CSS 文件可以被浏览器缓存,多页面复用 |
| 工具链简单 | 不需要 webpack、postcss 这些复杂的配置 |
| 调试方便 | 浏览器 DevTools 直接看,所见即所得 |
| 性能优秀 | 没有运行时开销,样式直接应用 |
对于小型项目、静态页面、或者团队前端经验不强的情况,传统 CSS 依然是很好的选择。
CSS-in-JS:前端界的"组件化"革命
什么是 CSS-in-JS?
第一次听到 CSS-in-JS 的时候,我是拒绝的。把 CSS 写到 JavaScript 里?这不是开历史倒车吗?在 Android 里我们好不容易把样式从代码里分离出来,你们前端又要合回去?
但当我真正用了之后,嗯……真香。
CSS-in-JS 的核心思想是:样式也是组件的一部分,应该跟组件绑定在一起。这样就不存在全局作用域的问题了,每个组件的样式都是局部的、隔离的。
目前主流的 CSS-in-JS 方案有很多:
| 方案 | 类型 | 特点 |
|---|---|---|
| styled-components | Runtime | 标签模板字符串,动态样式方便 |
| Emotion | Runtime | API 灵活,性能优秀 |
| vanilla-extract | Compile-time | 零运行时,类型安全 |
| Linaria | Compile-time | 类似 styled-components 的 API,编译时提取 |
| Tailwind CSS | Utility-first | 严格来说不算 CSS-in-JS,但理念相近 |
styled-components:我的入门之选
我们团队最先用的是 styled-components,因为它跟 React 的结合最自然:
import styled from 'styled-components';
// 定义一个带样式的按钮组件
const PrimaryButton = styled.button`
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background-color: #40a9ff;
}
&:active {
background-color: #096dd9;
}
// 支持 props 动态样式
${props => props.fullWidth && `
width: 100%;
`}
// 支持主题
background-color: ${props => props.theme.primaryColor};
`;
// 使用
function App() {
return (
<PrimaryButton fullWidth onClick={handleClick}>
提交
</PrimaryButton>
);
}
说实话,第一次看到这种写法的时候,我是有点震撼的。样式和组件真的融为一体了,不需要再想类名,不需要担心冲突,组件删了样式也跟着删了,干净利落。
动态样式的优雅
在 Flutter 里,我们经常需要根据状态动态改变样式。传统 CSS 做这件事很麻烦,要么加类名切换,要么用 inline style。而 CSS-in-JS 天然支持:
const Card = styled.div`
padding: 16px;
border-radius: 8px;
background-color: ${props => props.elevated ? '#fff' : '#f5f5f5'};
box-shadow: ${props => props.elevated
? '0 4px 12px rgba(0,0,0,0.15)'
: 'none'};
transform: ${props => props.elevated ? 'translateY(-2px)' : 'none'};
transition: all 0.3s ease;
`;
// 使用
<Card elevated={isHovered}>
<h3>卡片标题</h3>
<p>卡片内容</p>
</Card>
这种写法,让我想起了 Flutter 里的 AnimatedContainer,异曲同工。
主题系统:设计系统的基石
CSS-in-JS 的另一个强大之处是主题系统。我们后来做了一套设计系统,主题切换变得异常简单:
// 定义主题
const lightTheme = {
primaryColor: '#1890ff',
backgroundColor: '#ffffff',
textColor: '#333333',
borderColor: '#e8e8e8',
};
const darkTheme = {
primaryColor: '#177ddc',
backgroundColor: '#141414',
textColor: '#ffffff',
borderColor: '#434343',
};
// ThemeProvider 包裹
import { ThemeProvider } from 'styled-components';
function App() {
const [isDark, setIsDark] = useState(false);
return (
<ThemeProvider theme={isDark ? darkTheme : lightTheme}>
<MainLayout />
<ThemeToggle onChange={setIsDark} />
</ThemeProvider>
);
}
// 组件中使用主题
const Header = styled.header`
background-color: ${props => props.theme.backgroundColor};
color: ${props => props.theme.textColor};
border-bottom: 1px solid ${props => props.theme.borderColor};
`;
深水区:CSS-in-JS 的代价
运行时开销:性能的隐形杀手
用了一段时间后,我开始关注性能问题。毕竟在 Android 开发中,我们对性能是很敏感的,每一帧的渲染时间都要精打细算。
CSS-in-JS(特指 Runtime 类型的,如 styled-components、Emotion)的一个核心问题是:它在运行时做样式计算和 CSS 生成。这意味着:
- 首次渲染慢:需要在 JS 执行时生成 CSS 字符串,再注入到
<style>标签中 - 内存占用增加:需要维护样式缓存、主题上下文等
- 包体积增大:CSS-in-JS 的运行时库本身就有几十 KB
我用 Lighthouse 跑了一下我们的活动页面,发现 CSS-in-JS 的运行时开销导致首屏渲染时间增加了约 200ms。在低端安卓机上,这个数字可能更大。
这让我想起了在 Flutter 里用 CustomPainter 做复杂绘制时的性能问题——灵活性和性能,总是要做取舍的。
服务端渲染(SSR)的坑
我们有一个需求是做 SSR(服务端渲染),用于 SEO。这时候 CSS-in-JS 的问题就暴露出来了:
// SSR 需要特殊处理样式
import { ServerStyleSheet } from 'styled-components';
const sheet = new ServerStyleSheet();
try {
const html = renderToString(
sheet.collectStyles(<App />)
);
const styleTags = sheet.getStyleTags();
// 把 styleTags 注入到 HTML head 中
} finally {
sheet.seal();
}
每次服务端渲染都要收集样式、生成标签,这个过程不仅复杂,而且容易出错。我们之前就因为 SSR 样式丢失的问题,被产品经理追着问了三天。
调试体验:痛并快乐着
CSS-in-JS 生成的类名是 hash 值,比如 .sc-bdVaJa、.sc-gtsrce。在浏览器 DevTools 里看到这些类名,你根本不知道它对应哪个组件。
虽然后来有了 babel-plugin-styled-components 可以生成可读的类名(如 .PrimaryButton__sc-bdVaJa),但调试体验依然不如传统 CSS 直观。
这一点,让我很怀念 Flutter 的 Widget Inspector,一眼就能看到组件树和对应的样式。
Compile-time CSS-in-JS:鱼和熊掌的折中
编译时提取:性能与开发体验的平衡
在研究了各种方案后,我发现了一个折中之道:Compile-time CSS-in-JS。代表方案有 vanilla-extract 和 Linaria。
它们的核心思想是:在构建时把 CSS-in-JS 的样式提取成独立的 CSS 文件,运行时不需要任何额外开销。
以 vanilla-extract 为例:
// styles.css.ts
import { style, globalStyle } from '@vanilla-extract/css';
export const container = style({
padding: '16px',
borderRadius: '8px',
backgroundColor: '#ffffff',
});
export const title = style({
fontSize: '18px',
fontWeight: 'bold',
color: '#333333',
});
// 支持响应式
export const responsiveText = style({
fontSize: '14px',
'@media': {
'screen and (min-width: 768px)': {
fontSize: '16px',
},
},
});
// Component.tsx
import * as styles from './styles.css';
function Card() {
return (
<div className={styles.container}>
<h2 className={styles.title}>标题</h2>
<p className={styles.responsiveText}>内容</p>
</div>
);
}
构建后,这些样式会被提取成普通的 CSS 文件,运行时零开销。同时,由于是 TypeScript 文件,还有类型检查和自动补全。
vanilla-extract 的实战体验
我们团队后来在一个新项目中尝试了 vanilla-extract,体验确实不错:
// theme.css.ts
import { createTheme, style } from '@vanilla-extract/css';
export const [themeClass, vars] = createTheme({
color: {
primary: '#1890ff',
background: '#ffffff',
text: '#333333',
},
space: {
small: '8px',
medium: '16px',
large: '24px',
},
});
export const card = style({
padding: vars.space.medium,
backgroundColor: vars.color.background,
color: vars.color.text,
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
});
export const cardTitle = style({
fontSize: '18px',
fontWeight: 600,
marginBottom: vars.space.small,
color: vars.color.primary,
});
类型安全这一点,让我这个从强类型语言(Kotlin/Java)过来的人感到无比舒适。
一场关于 Stable Diffusion 的意外启发
说到样式方案的选择,我想起了一件有趣的事。
上个月参加一个技术分享会,有个哥们分享了他用 Stable Diffusion 生成 UI 设计稿的实践。他训练了一个模型,输入文字描述就能生成各种风格的界面设计。
这给了我一个启发:样式本质上是一种"视觉语言",而 CSS-in-JS 让这种视觉语言变得可编程、可组合、可复用。就像 Stable Diffusion 把图像生成变成了可参数化的过程一样,CSS-in-JS 把样式编写变成了一个工程化的过程。
当然,我不是说要用 AI 来写 CSS(虽然这确实是个趋势),而是说,CSS-in-JS 的思维方式——把样式当作代码来管理——跟 AI 生成内容的思路是相通的。都是通过参数化、模块化的方式,让"创作"变得更加可控和高效。
区块链思维:样式管理的去中心化
再扯一个看似不相关的话题:区块链。
区块链的核心思想是去中心化和不可篡改。在传统 CSS 中,样式是"中心化"的——所有的样式都在全局作用域里,谁都可以修改,谁都可能冲突。这就像是一个没有共识机制的系统,混乱是必然的。
CSS-in-JS 的思路,其实就是一种"去中心化":每个组件管理自己的样式,互不干扰。这跟区块链中每个节点独立维护自己状态的思路是类似的。
再进一步想,如果我们将样式定义存储在一个"链上"(比如一个统一的 design token 系统),所有组件都从这个"链"上获取样式变量,那不就实现了样式的"不可篡改"和"一致性"吗?
好吧,我承认这里有点强行关联了。但说实话,在地铁上刷各种技术文章的时候,脑子就是这么跳跃的。从 CSS-in-JS 想到 Stable Diffusion,再想到区块链,最后发现它们底层都有一种"工程化管理"的思想在里面。
实战对比:我们团队的选择
说了这么多理论,来看看我们团队在实际项目中的选择:
项目 A:C 端营销活动页
特点:首屏性能要求高、SEO 友好、多端适配
选择:传统 CSS + CSS Modules
/* Button.module.css */
.primary {
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
}
.primary:hover {
background-color: #40a9ff;
}
import styles from './Button.module.css';
function Button({ children }) {
return (
<button className={styles.primary}>
{children}
</button>
);
}
理由:
- 首屏性能敏感,不能有运行时开销
- 需要 SSR,CSS Modules 天然支持
- 页面相对简单,不需要复杂的动态样式
- CSS Modules 提供了局部作用域,解决了类名冲突
项目 B:B 端管理后台
特点:组件复杂、交互丰富、需要设计系统
选择:vanilla-extract + 设计 Token
理由:
- 组件复杂度高,需要强大的样式组合能力
- 需要完善的设计系统,主题切换是刚需
- 不需要 SSR,运行时开销可以接受
- TypeScript 支持好,类型安全
项目 C:跨端小程序
特点:平台限制多、样式能力有限
选择:传统 CSS + 预处理器(Sass)
理由:
- 小程序对 CSS-in-JS 支持不好
- 样式能力有限,不需要复杂的方案
- 预处理器提供了变量、混入等能力,够用了
我的选型决策树
经过这些项目的实践,我总结了一个简单的选型决策树:
需要样式方案?
├── 项目规模小 / 静态页面?
│ └── 传统 CSS
├── 需要 SSR / 首屏性能敏感?
│ ├── 需要动态样式?
│ │ └── Linaria / Compile-time 方案
│ └── 不需要?
│ └── CSS Modules / 传统 CSS
├── 复杂 SPA / 设计系统?
│ ├── 需要运行时动态样式?
│ │ └── styled-components / Emotion
│ └── 不需要?
│ └── vanilla-extract
└── 小程序 / 特殊平台?
└── 传统 CSS + 预处理器
当然,这只是我的经验总结,具体情况还要具体分析。就像在 Android 里选 RecyclerView 还是 ListView,没有绝对的对错,只有适不适合。
一些实用建议
给从其他端转过来的同学
如果你跟我一样,是从 Android 或 Flutter 转过来的,这里有几个建议:
不要被 CSS 的"灵活"吓到。CSS 确实有很多反直觉的地方(比如垂直居中、BFC),但核心概念并不多。推荐看《CSS 权威指南》和 MDN。
善用浏览器 DevTools。这是前端开发的"Android Studio Layout Inspector",甚至比它更强大。学会用 Computed 面板看最终样式,用 Layout 面板看盒模型。
理解层叠上下文。这是 CSS 中最容易踩坑的地方,类似于 Android 中的 View 绘制层级。
关注性能。虽然前端没有 Android 那么严格的性能要求,但用户体验同样重要。避免过度使用 CSS-in-JS 的运行时特性,关注 Critical Rendering Path。
给前端老手的建议
不要盲目追新。CSS-in-JS 很好,但不是所有场景都需要。传统 CSS + CSS Modules 在很多场景下依然是最优解。
关注浏览器兼容性。我们做跨平台的时候,最头疼的就是兼容性问题。前端也一样,特别是 ToC 的项目,要考虑到各种低端机和旧版浏览器。
建立设计系统。无论用什么方案,一套统一的设计 Token 系统都是必要的。这比选择具体的 CSS 方案更重要。
性能预算。给样式方案设定性能预算,比如 CSS-in-JS 的运行时开销不能超过多少毫秒。这跟我们在 Android 里做性能优化是一个思路。
写在最后
从 Android 到 Flutter,再到前端样式方案,这一路走来,我最大的感受是:技术选型没有银弹,只有权衡。
CSS-in-JS 和传统 CSS,就像是 Flutter 的 Widget 组合和 Android 的 XML 布局,各有优劣,各有适用场景。关键是要理解它们的本质,然后根据项目的实际需求做出选择。
每天通勤一小时,在地铁上看各种技术文章,参加各种技术分享会,让我意识到一个道理:技术的边界正在模糊。Android 开发者需要懂前端,前端开发者需要了解原生,Flutter 开发者需要理解两端。在这个跨端的时代,多了解一些不同领域的知识,总归是有益的。
最后,如果你也在做技术选型,或者对 CSS-in-JS 有疑问,欢迎在评论区交流。毕竟,程序员的成长,除了自己踩坑,还需要跟同行多交流。
对了,下周有个线下的技术分享会,我准备讲讲 Flutter 和前端在样式管理上的异同,有兴趣的同学可以来听听,咱们面基聊聊。
P.S. 写这篇文章的时候,产品经理又改需求了,按钮要从蓝色改成渐变色。幸好我们用了 CSS-in-JS,改起来只需要改一行代码。如果是传统 CSS……算了,不想了,改就改吧。
P.P.S. 上周那个线上事故后来查明,是因为两个不同业务线的 CSS 都用了 .modal-overlay 这个类名。我已经推动团队引入了 CSS Modules,希望以后不会再有这种低级错误了。


评论 0