CSS-in-JS vs 传统CSS:现代样式方案选择指南

♀许洋
2025-12-12 17:13
阅读 350

上个月我刚从德国回来,硕士读完在柏林一家小 startup 干了三年多前端,现在在国内疯狂投简历、面试、聊 offer。说实话,国内前端生态变化快得让我有点晕——Webpack 已经被 Vite 按在地上摩擦,React 还是那个 React,但写法早就不一样了。更别说样式这一块,CSS-in-JS 都快成标配了,而我当年在学校写的还是 .container { margin: 0 auto; }

回国后第一轮面试就被一个大厂的 Tech Lead 问:“你们项目用什么方案管理样式?CSS Modules?Styled-components?还是 Tailwind?”我当场有点懵,心想:这玩意儿不就是写个 class 名的事吗?结果回去翻了翻 GitHub 上最近 star 的几个开源项目,发现清一色全是 styled.div 或者 css 标签模板字面量,传统 CSS 文件几乎绝迹。

于是上周五晚上(没错,又是周五!产品经理临下班前说“这个交互效果能不能再丝滑一点”),我决定好好研究一下 CSS-in-JS 到底是不是真的香,还是纯属前端圈的新一轮“内卷表演”。这篇文章就是我踩坑、掉头发、debug 到凌晨三点后的血泪总结,希望能帮到和我一样想跳槽/转技术栈/搞清现代前端样式的同学。


事情是怎么开始的?

我们公司(暂且叫它“某电商业务中台”)去年双11期间上线了一个商品详情页改版。需求很简单:加个“悬浮购物车按钮”,滚动时固定在右下角,点击弹出 mini 购物车动画。UI 设计稿里还特别强调:“要有 spring 动画,不要 linear!”。

我一开始用老办法:写了个 .floating-cart-btn 的 class,配合 position: fixedtransform,动画用 transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)。本地跑得好好的,一上测试环境,QA 小姐姐直接甩来一张截图:按钮在 Safari 里抖动像帕金森,Chrome 下滚动卡顿到 30fps。

当时我真的想砸电脑。后来发现是传统 CSS 无法动态响应组件状态(比如滚动位置、是否已加入购物车),硬写 media query 和 JS 控制 class 切换又脏又难维护。更要命的是,我们的组件库是微前端架构,不同子应用之间 class 名冲突频发——有一次因为两个团队都用了 .btn-primary,导致支付按钮变成了粉色,被老板在站会上点名批评。

那一刻我意识到:纯 CSS 在复杂交互场景下已经不够用了。尤其是当我看到隔壁组用 @emotion/react 写的动画组件丝滑如德芙,心里那个酸啊……


折腾 CSS-in-JS:从“真香”到“真坑”

第一步:选型,光这就够喝一壶

GitHub 上搜 CSS-in-JS,star 数高的有 styled-components、Emotion、Linaria、Vanilla Extract……每个都说自己“零运行时”、“高性能”、“支持 SSR”。我花了两天时间搭了四个 demo 项目,对比下来:

方案 是否需要 Babel 插件 运行时依赖 支持 SSR 动态样式性能 学习成本
styled-components 中等
Emotion 可选 可无(使用 css prop)
Linaria 极高
Vanilla Extract 极高

我司项目用的是 Next.js + TypeScript,对 SSR 友好是刚需;另外后端同事对 bundle size 很敏感(运维天天吐槽“前端打包比 Java 服务还大”)。所以最终我锁定了 Emotion —— 它既能用 css prop 写动态样式,又能通过 @emotion/babel-plugin 提取静态 CSS,做到“运行时最小化”。

📌 小贴士:如果你项目用的是 Vite,Emotion 的配置会稍微麻烦点,记得装 @emotion/babel-plugin 并在 vite.config.ts 里配 esbuild: { jsxInject: 'import React from "react"' },否则 css prop 会报错。


第二步:写代码,才发现“动态”没那么简单

我兴冲冲地把原来的 .floating-cart-btn 改成了:

import { css } from '@emotion/react';

const FloatingCartButton = ({ isScrolled, isInCart }) => {
  return (
    <button
      css={css`
        position: fixed;
        bottom: 20px;
        right: 20px;
        transform: ${isScrolled ? 'scale(1)' : 'scale(0)'};
        opacity: ${isScrolled ? 1 : 0};
        background: ${isInCart ? '#4CAF50' : '#FF5722'};
        transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94),
                    opacity 0.2s ease-in;
      `}
    >
      购物车
    </button>
  );
};

本地开发一切完美!动画丝滑,颜色随状态变化,连产品经理都夸“这次终于不像 PPT 了”。结果一 build 到生产环境,Lighthouse 性能评分直接掉到 60 分——原来每次 isScrolled 变化,Emotion 都会生成新的 <style> 标签插入 head,导致 reflow。

坑点来了:CSS-in-JS 的“动态”不是免费的。如果你频繁更新 props(比如监听 scroll 事件每 16ms 触发一次),就会不断创建新样式规则,浏览器要重新计算 layout,性能反而不如传统 CSS + class 切换。

解决方案?把动态部分拆出来

// 静态样式提前定义
const baseStyle = css`
  position: fixed;
  bottom: 20px;
  right: 20px;
  transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94),
              opacity 0.2s ease-in;
`;

const FloatingCartButton = ({ isScrolled, isInCart }) => {
  // 动态 class 单独处理
  const dynamicClass = css`
    transform: ${isScrolled ? 'scale(1)' : 'scale(0)'};
    opacity: ${isScrolled ? 1 : 0};
    background: ${isInCart ? '#4CAF50' : '#FF5722'};
  `;

  return <button css={[baseStyle, dynamicClass]}>购物车</button>;
};

虽然代码多了两行,但至少避免了高频更新时的性能雪崩。不过说实话,这种写法已经有点“为了用新技术而写”的味道了——如果只是简单状态切换,传统 CSS + className={isActive ? 'active' : ''} 其实更清爽。


第三步:调试?DevTools 直接给你上刑

传统 CSS 最爽的是啥?打开 DevTools,元素面板里直接看到所有 class,点进去就能改值、看盒模型、调动画曲线。但 CSS-in-JS 生成的 class 名是哈希(比如 .css-1a2b3c),你根本不知道哪个样式对应哪段逻辑。

有一次线上有个按钮 hover 效果失效,我在 DevTools 里找了半小时,最后发现是因为 Emotion 的 css 对象写法漏了个逗号:

// 错误写法(JS 对象)
css({
  backgroundColor: 'red'
  hover: { color: 'white' } // 这里少了个逗号,JS 报错但构建没拦住!
})

构建工具居然没报错,只在控制台打印了个 warning,结果 hover 样式整个没了。后来我学会了用 Emotion 的 DevTools 插件(浏览器扩展),它能在元素上显示原始的 css 内容,还能跳转到源码位置——但前提是你的 source map 配置正确,而我们项目的 webpack config 是后端大哥三年前写的,source map 路径全乱……

💡 调试技巧:在开发环境强制开启 Emotion 的 label 功能:

// .babelrc
{
  "plugins": [
    ["@emotion", { "labelFormat": "[filename]__[local]" }]
  ]
}

这样生成的 class 名会变成 .FloatingCartButton__button,至少知道是哪个文件的锅。


和后端同事的“友好”合作

说到后端,他们对我们前端“折腾样式”一直颇有微词。有一次 PR review,后端老哥留言:“你们前端能不能别整天换方案?上次 Less,这次 CSS Modules,现在又 Emotion,我们 Nginx 缓存策略都要重写。”

其实他说得对。传统 CSS 文件可以单独缓存(比如 styles.[hash].css),但 CSS-in-JS 的样式是打包进 JS bundle 的。如果 JS 文件更新了(哪怕只是改了个 console.log),整个样式缓存就失效了。

为了解决这个问题,我们做了两件事:

  1. 提取关键路径 CSS:用 @emotion/server 在 SSR 时把首屏样式 inline 到 HTML,减少 FOUC(Flash of Unstyled Content)。
  2. 拆分 vendor chunk:把 Emotion runtime 和核心组件样式单独打包,减少主 bundle 体积。

配置起来不难,但在 Next.js 里要搞懂 getInitialPropsgetServerSideProps 的区别,差点把我这个海归整不会了。最后还是靠 GitHub 上一个 issue 里的示例代码搞定的——感谢开源社区!


到底该选谁?我的结论

经过三个月实战(包括一次线上事故回滚 😭),我对两种方案的看法如下:

适合用传统 CSS 的场景:

  • 项目简单,页面少,交互固定(比如后台管理系统)
  • 团队有专职 UI 工程师,设计系统稳定
  • 对 bundle size 极度敏感(比如嵌入式 H5)
  • 需要强缓存策略,且运维不希望前端频繁改构建配置

适合用 CSS-in-JS 的场景:

  • 组件高度动态,样式依赖 props/state(比如数据可视化、交互动画)
  • 使用 React/Vue 等组件化框架,追求“样式局部作用域”
  • 团队接受新工具链,愿意投入学习成本
  • 需要主题切换、暗黑模式等运行时样式变更

🎯 个人建议:不要为了用而用。我见过太多团队盲目跟风上 styled-components,结果写出来的代码全是 styled.div 嵌套十层,维护起来比 jQuery 时代还痛苦。


给想跳槽的同学一点真心话

我回国面试时,有家公司让我现场用 CSS-in-JS 实现一个带 loading 状态的按钮。我一开始写得很炫,用 keyframes 做旋转动画,结果面试官问:“如果用户禁用了动画(prefers-reduced-motion)呢?” 我当场愣住——传统 CSS 里我肯定会写 @media (prefers-reduced-motion: reduce),但在 CSS-in-JS 里怎么处理?

后来查文档才知道 Emotion 支持:

css`
  @media (prefers-reduced-motion: reduce) {
    animation: none;
  }
`

但很多新人根本不知道这个细节。技术选型不是炫技,而是解决问题。CSS-in-JS 的优势在于“逻辑与样式共存”,但代价是增加了复杂度。如果你的项目不需要动态样式,硬上它只会让代码更难读、性能更差、协作更痛苦。


最后:别信教程,信数据

网上那些“CSS-in-JS 性能碾压传统 CSS”的教程,基本都是作者自己跑的 micro benchmark,根本不考虑真实场景。我用 Lighthouse 对比了两种方案在商品详情页的表现:

指标 传统 CSS Emotion (优化后)
FCP 1.2s 1.3s
TTI 2.1s 2.4s
Bundle Size +8KB CSS +12KB JS
开发体验 一般 优秀(热更新快)

结论很清晰:性能上传统 CSS 依然略优,但开发体验 CSS-in-JS 更爽。选哪个,取决于你团队当前最痛的点是什么。


写这篇文章的时候已经是凌晨两点,窗外北京还在堵车(也不知道是谁半夜开车)。回想在柏林的日子,每天八点起床写代码,咖啡配柏林熊软糖,虽然工资不高但节奏舒服。现在回国找工作,感觉每天都在“技术选型焦虑”中度过。

但话说回来,前端这行就是这样——永远有新东西要学,永远有坑等着你跳。CSS-in-JS 不是银弹,传统 CSS 也没过时。真正重要的,是你能不能根据业务需求、团队能力和用户体验,做出最合适的选择

希望这篇踩坑文能帮你少熬几个夜。如果你也在纠结样式方案,欢迎留言讨论——或者,如果你司缺个会写动画的前端(React + 交互动效是我的强项),简历请砸过来!毕竟,我还在找工作ing…… 😅

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝