聊聊前端样式方案的演进与抉择

李建国
2026-07-07 15:06
阅读 781

坐标北京,每天挤一号线通勤一小时,从 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-titlefooter-titlemodal-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 生成。这意味着:

  1. 首次渲染慢:需要在 JS 执行时生成 CSS 字符串,再注入到 <style> 标签中
  2. 内存占用增加:需要维护样式缓存、主题上下文等
  3. 包体积增大: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-extractLinaria

它们的核心思想是:在构建时把 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 转过来的,这里有几个建议:

  1. 不要被 CSS 的"灵活"吓到。CSS 确实有很多反直觉的地方(比如垂直居中、BFC),但核心概念并不多。推荐看《CSS 权威指南》和 MDN。

  2. 善用浏览器 DevTools。这是前端开发的"Android Studio Layout Inspector",甚至比它更强大。学会用 Computed 面板看最终样式,用 Layout 面板看盒模型。

  3. 理解层叠上下文。这是 CSS 中最容易踩坑的地方,类似于 Android 中的 View 绘制层级。

  4. 关注性能。虽然前端没有 Android 那么严格的性能要求,但用户体验同样重要。避免过度使用 CSS-in-JS 的运行时特性,关注 Critical Rendering Path。

给前端老手的建议

  1. 不要盲目追新。CSS-in-JS 很好,但不是所有场景都需要。传统 CSS + CSS Modules 在很多场景下依然是最优解。

  2. 关注浏览器兼容性。我们做跨平台的时候,最头疼的就是兼容性问题。前端也一样,特别是 ToC 的项目,要考虑到各种低端机和旧版浏览器。

  3. 建立设计系统。无论用什么方案,一套统一的设计 Token 系统都是必要的。这比选择具体的 CSS 方案更重要。

  4. 性能预算。给样式方案设定性能预算,比如 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

最热最新
暂无评论
李建国Lv.1
0
影响力
0
文章
0
粉丝