安装压缩插件
从传统到 CSS-in-JS:一次前端样式方案的重构实战

我第一次系统性地去思考“该用哪种方式写样式”的时候,是去年在接手一个中大型 React 前端项目时遇到的问题。
项目本身已经发展了两年多,早期的结构还停留在比较传统的模式上 —— 每个页面或组件都有 .css 或 .scss 文件,并且采用的是类似 BEM 的命名规范。但在团队人数逐渐增加、需求迭代速度加快后,样式冲突、类名难维护、复用率低、调试困难等问题开始频繁暴露出来。
当时我们正处于一个版本大更新的前夜,业务需求和技术债务之间已经出现了明显的摩擦。于是我们决定,在下一个版本中尝试引入一种更现代化的样式管理方式:CSS-in-JS 方案。
这个过程并不是一帆风顺的,但从结果来看,它是值得的。这篇文章将结合我在那次重构中的真实经历,和你聊聊 传统 CSS 和主流 CSS-in-JS 方案之间的差异与取舍,并分享一些踩过的坑和学到的经验。
遇到的挑战:传统 CSS 在多人协作下的瓶颈

项目背景是一个典型的中后台管理系统,基于 React + TypeScript 构建,组件规模大约有三百多个,样式文件数量也基本匹配。整个项目初期设计良好,但随着人员流动和功能扩展,问题慢慢浮现:
样式污染严重:虽然使用了类似 BEM 的命名规范,但由于不同团队成员对命名习惯的理解不一致,加上部分组件嵌套较深,经常出现某个全局样式影响到其他页面或组件的情况。
组件样式难以抽象复用:比如一个按钮组件,不同的模块可能会需要不一样的颜色、间距、字体等,为了复用往往不得不写很多“条件类”或者通过
props控制类名拼接,代码可读性和维护性都很差。构建优化不足:样式表以全局形式打包,最终输出的一个 CSS 文件动辄几十 KB,加载慢、渲染延迟。尤其在首屏加载阶段,用户常常看到“裸 HTML 样式”,再突然应用完整的样式,造成体验上的跳变。
调试成本高:浏览器 DevTools 中显示的类名通常是压缩后的短名称(如
_a,_b),很难直接定位到对应的源码位置。有时候为了解决某个样式问题,要花大量时间在 Chrome 中“试错式”修改代码。
这些问题叠加在一起,导致我们的开发效率明显下降,测试和上线节奏也被拖慢。这时候我们意识到,是时候重新审视我们的样式管理方案了。
思路转变:为何考虑 CSS-in-JS?
其实我对 CSS-in-JS 的认识最早是在几年前看到 styled-components 这个库的时候,不过那时候并没有真正深入去用过它。直到这次项目遇到瓶颈,我才真正开始系统性地调研。
我们当时的几个核心目标是:
- 避免样式冲突
- 提升组件样式的封装性与可复用性
- 更好的动态样式支持
- 更优的性能表现与构建粒度
这正好是 CSS-in-JS 提出的核心理念:
把样式作为组件的一部分,按需加载,自动作用域隔离。
听起来非常符合我们需要解决的问题。于是我们做了技术选型对比,主要围绕以下几种主流方案展开:
| 方案 | 主流代表 | 优点 | 缺点 |
|---|---|---|---|
| 传统 CSS/SCSS | 手动 BEM 等命名规范 | 兼容性好,工具链成熟 | 类名冲突、复用困难 |
| CSS Modules | import styles from './style.module.css' |
自动作用域、构建优化 | 动态样式写法繁琐,调试仍然不便 |
| CSS-in-JS | styled-components / emotion / stitches / linaria 等 |
封装强、作用域天然隔离、动态样式方便 | 初期构建配置复杂、运行性能略差(部分) |
最终我们选择了 Emotion,主要原因有:
- 官方支持 React,并与
@emotion/styled和@emotion/css结合得很好 - 支持两种风格:既可以用
styled.xxx创建带样式的组件,也可以用内联对象的方式注入样式 - 社区活跃,文档清晰,插件生态丰富
- 构建优化方面,配合
@emotion/babel-plugin可以做到类名最小化,提升性能
实施过程:重构样式系统的三步走
第一步:小范围验证(PoC)
我们先在一个较小的功能模块里做了一次实验重构,目标包括:
- 使用 Emotion 替换原生 CSS 模块
- 将原本分散的类名样式封装成独立的组件
- 验证样式动态控制的能力(比如 props 控制主题、状态)
举个小例子,之前是一个带有多个条件判断的 className 组合:
// 旧版 button.js
import styles from './button.module.css';
function Button({ variant = 'primary', size = 'medium', children }) {
const className = `${styles.base} ${variant === 'primary' ? styles.primary : styles.secondary} ${styles[size]}`;
return <button className={className}>{children}</button>;
}
换成 Emotion 后变成了这样:
// 新版 button.jsx
import styled from '@emotion/styled';
const BaseButton = styled.button`
padding: 10px 20px;
border-radius: 4px;
font-weight: bold;
`;
const PrimaryButton = styled(BaseButton)`
background-color: #0078d4;
color: white;
`;
const SecondaryButton = styled(BaseButton)`
background-color: transparent;
color: #0078d4;
`;
function Button({ variant = 'primary', ...rest }) {
const Comp = variant === 'primary' ? PrimaryButton : SecondaryButton;
return <Comp {...rest} />;
}
这样的写法不仅语义清晰,而且可以完全封装到组件内部,不需要手动传类名,更重要的是:
不再担心样式冲突,每个组件的样式都自动命名唯一。
同时还可以很方便地动态生成样式,比如:
const DynamicText = styled.span(({ $color }) => ({
color: $color,
fontSize: '14px',
}));
只需要把 $color 作为一个 prop 传进来,就可以动态控制颜色 —— 这种能力在传统 CSS 中只能通过 JS 动态操作 DOM 来实现。
第二步:迁移已有组件
接下来我们按照模块优先级逐步替换原有 CSS 文件。
由于 Emotion 提供了良好的渐进迁移能力 —— 可以同时保留传统 CSS 引入方式,因此我们在过渡期间允许两者共存。
迁移过程中最大的难点其实是:
如何保持样式一致性,尤其是在视觉层面不发生变化的前提下。
为此我们采取了如下策略:
- 样式提取器:对于原有
.module.css文件,我们写了一个简单的脚本将其中的类名和样式规则提取成 JavaScript 对象格式,供后续参考。 - 可视化比对:每完成一个组件的替换,就在本地搭建一个“样式对比站”,两边并排展示新旧效果,确保没有偏移。
- 组件树梳理:有些样式是继承而来的,我们借助 React Developer Tools 查看组件结构和样式层级是否合理,防止重构之后出现布局塌陷。
整个迁移周期大概用了三周时间,覆盖了超过 60% 的核心组件,剩下的则在后续开发中逐步替换。
第三步:构建优化和工程配置升级
样式方案换了,构建流程自然也需要调整。
我们在 Webpack 配置中加入了:
// webpack.config.js
{
test: /\.module\.css$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
modules: true
}
},
]
},
{
test: /\.(ts|tsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
plugins: ['@emotion/babel-plugin'],
},
},
}
重点在于 @emotion/babel-plugin 插件的使用,它可以:
- 将 JSX 中的样式标签转译为高效的 class name
- 支持标签模板语法解析(如
styled.div) - 自动生成唯一的类名,避免冲突
此外,我们还启用了 SSR 支持,搭配 Next.js 的话还需要额外处理服务端渲染中的样式注入逻辑:
npm install @emotion/server @emotion/react
然后在 _document.tsx 里添加:
import { extractCritical } from '@emotion/server';
export default function Document() {
return (
<Html lang="en">
<Head>
{/* ... */}
<style dangerouslySetInnerHTML={{ __html: this.props.emotionStyleTags }} />
</Head>
{/* ... */}
</Html>
);
}
Document.getInitialProps = async (ctx) => {
const originalRenderPage = ctx.renderPage;
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: (App) => (props) =>
<CacheProvider value={createCache()}><App {...props} /></CacheProvider>,
});
const initialProps = await Document.getInitialProps(ctx);
const styles = extractCritical(initialProps.html);
return {
...initialProps,
html: styles.html,
emotionStyleTags: styles.css,
};
};
这样一来,服务端输出的 HTML 包含了完整的关键 CSS,避免了首次渲染的闪白问题。
踩过的主要坑和解决办法
虽然总体进展顺利,但也确实遇到了一些意想不到的状况:
🔧 1. 开发环境样式丢失?别忘了插件顺序!
刚开始的时候,开发环境下 Emotion 的样式根本没生效!控制台也没有报错。后来查资料发现:
Babel 插件一定要放在正确的顺序上!
我们原来的 .babelrc 是这样写的:
{
"presets": ["next/babel"],
"plugins": []
}
但如果你使用了 Next.js 这种集成了默认 preset 的框架,你需要在 next.config.js 中显式指定插件顺序:
// next.config.js
module.exports = {
babel: {
plugins: ['@emotion/babel-plugin'],
},
};
否则,Next 内部的默认 Babel 插件可能已经提前编译了 JSX,导致 Emotion 的插件无法生效。
🎯 2. 动态样式传值失败?注意变量写法
前面提到 Emotion 支持用 ({ theme }) => ({}) 的方式定义样式函数,但如果传值方式不对,样式就永远不会变。
举个例子:
const Box = styled.div`
width: ${props => props.width || '100%'};
`;
如果直接传递数字,就会出问题:
<Box width={100} /> // ❌ 最终变成 `width: 100`,没有单位
这时候要加一个单位转换函数:
const toPx = (val) => (typeof val === 'number' ? `${val}px` : val);
const Box = styled.div`
width: ${props => toPx(props.width)};
`;
当然,Emotion 有个官方建议是尽量使用 sx prop(如在 MUI 中),或者干脆用 Object 的写法:
const Box = styled('div')((props) => ({
width: props.width,
}));
后者会自动处理单位,更推荐。
📦 3. 包体积暴增?试试压缩构建
我们切换了样式方案以后,打包出来的文件大小反而增加了不少 —— 最初的 vendor chunk 多了快 5KB。这显然是不能接受的。
经过排查发现问题出在:
- Emotion 默认会带上 dev mode 的 source map
- 某些重复使用的 styled 组件没有被 deduplicate
解决方法也很简单:
npm install --save-dev terser-webpack-plugin optimize-css-assets-webpack-plugin
然后在 Webpack 配置中加入:
optimization: {
minimize: true,
minimizer: [
new TerserPlugin(),
new OptimizeCSSAssetsPlugin({})
],
},
并且设置环境变量为生产环境:
NODE_ENV=production
之后整体包体积回归正常,甚至因为拆分更细的 chunk 而略有减少。
效果总结:为什么说这次重构是成功的?
项目上线半年多以来,我们团队普遍反馈重构后的样式方案带来了显著提升:
| 方面 | 改善程度 |
|---|---|
| 样式冲突减少 | ✅✅✅✅✅ 几乎不再出现全局污染 |
| 组件封装更强 | ✅✅✅✅ 更容易封装和复用,样式即组件 |
| 调试效率提高 | ✅✅✅ 浏览器开发者工具直接看到组件来源 |
| 动态样式灵活 | ✅✅✅✅ 更好的状态驱动样式能力 |
| 新人上手更容易 | ✅✅✅✅ 直接看组件即可理解样式来源 |
另外,在性能方面我们也做了前后对比:
- 首屏加载速度 提升了约 12%
- 样式文件大小 减少约 18%(按 gzipped 计算)
- 关键路径渲染更加稳定,无白屏闪烁现象
可以说,这次重构不仅解决了老问题,也为未来的持续演进打下了良好基础。
经验分享:给正在纠结的你一些建议
作为一线开发者,经历过这次重构后,我想对还在犹豫要不要引入 CSS-in-JS 的朋友分享几点体会:
✅ 推荐使用 CSS-in-JS 的场景
- 你的项目是 React / Vue 这种组件化架构为主的
- 团队人多、协作频繁、风格不统一
- 需要大量动态样式(比如主题、交互反馈、状态变化)
- 你希望更快地上线新功能,而不是花大量时间调试样式
⚠️ 需要谨慎评估的情况
- 项目已经高度稳定,改动成本太大
- 对性能要求极高的页面(比如广告页、H5游戏)
- 团队中有资深 CSS 工程师,不愿意改变现有流程
- 技术栈老旧,升级代价较高
💡 我的建议是
- 从小处着手:哪怕只拿一个组件试水,也能快速看到收益
- 不要一刀切:过渡时期允许两种方式共存,逐步替换
- 关注构建配置:特别是 SSR、TypeScript 支持和打包优化
- 选择活跃社区的库:比如 Emotion、Stitches、Linaria 等
最后送大家一句话,也是我一直以来的信条:
“样式不是 UI 的附带品,而是交互的灵魂。”
选择合适的样式方案,不仅能提高开发效率,更能带来更好的用户体验。
后记:不只是样式的革新
写到这里,我发现这次关于样式的重构,其实本质上是一次 开发思维和协作模式的升级。
从以前“如何写好一个 CSS 文件”,到现在思考“如何让样式和组件一起生长”,这是一个认知跃迁的过程。
如果你现在也在面临类似的抉择,不妨大胆迈出第一步 —— 不管是 CSS-in-JS 还是 CSS Modules,关键是找到适合自己团队和项目的那一套方法论。
毕竟,只有不断适应变化的人,才能写出真正优雅的代码。
文 / 小北 | 一名热爱编码的前端工程师
原文发布于个人博客 blog.xiaobei.dev

评论 0