安装压缩插件

变量命名困难户
2025-06-18 15:12
阅读 219

从传统到 CSS-in-JS:一次前端样式方案的重构实战

从传统到 CSS-in-JS:一次前端样式方案的重构实战

我第一次系统性地去思考“该用哪种方式写样式”的时候,是去年在接手一个中大型 React 前端项目时遇到的问题。

项目本身已经发展了两年多,早期的结构还停留在比较传统的模式上 —— 每个页面或组件都有 .css.scss 文件,并且采用的是类似 BEM 的命名规范。但在团队人数逐渐增加、需求迭代速度加快后,样式冲突、类名难维护、复用率低、调试困难等问题开始频繁暴露出来。

当时我们正处于一个版本大更新的前夜,业务需求和技术债务之间已经出现了明显的摩擦。于是我们决定,在下一个版本中尝试引入一种更现代化的样式管理方式:CSS-in-JS 方案。

这个过程并不是一帆风顺的,但从结果来看,它是值得的。这篇文章将结合我在那次重构中的真实经历,和你聊聊 传统 CSS 和主流 CSS-in-JS 方案之间的差异与取舍,并分享一些踩过的坑和学到的经验。


遇到的挑战:传统 CSS 在多人协作下的瓶颈

遇到的挑战:传统 CSS 在多人协作下的瓶颈

项目背景是一个典型的中后台管理系统,基于 React + TypeScript 构建,组件规模大约有三百多个,样式文件数量也基本匹配。整个项目初期设计良好,但随着人员流动和功能扩展,问题慢慢浮现:

  • 样式污染严重:虽然使用了类似 BEM 的命名规范,但由于不同团队成员对命名习惯的理解不一致,加上部分组件嵌套较深,经常出现某个全局样式影响到其他页面或组件的情况。

  • 组件样式难以抽象复用:比如一个按钮组件,不同的模块可能会需要不一样的颜色、间距、字体等,为了复用往往不得不写很多“条件类”或者通过 props 控制类名拼接,代码可读性和维护性都很差。

  • 构建优化不足:样式表以全局形式打包,最终输出的一个 CSS 文件动辄几十 KB,加载慢、渲染延迟。尤其在首屏加载阶段,用户常常看到“裸 HTML 样式”,再突然应用完整的样式,造成体验上的跳变。

  • 调试成本高:浏览器 DevTools 中显示的类名通常是压缩后的短名称(如 _a, _b),很难直接定位到对应的源码位置。有时候为了解决某个样式问题,要花大量时间在 Chrome 中“试错式”修改代码。

这些问题叠加在一起,导致我们的开发效率明显下降,测试和上线节奏也被拖慢。这时候我们意识到,是时候重新审视我们的样式管理方案了。


思路转变:为何考虑 CSS-in-JS?

其实我对 CSS-in-JS 的认识最早是在几年前看到 styled-components 这个库的时候,不过那时候并没有真正深入去用过它。直到这次项目遇到瓶颈,我才真正开始系统性地调研。

我们当时的几个核心目标是:

  1. 避免样式冲突
  2. 提升组件样式的封装性与可复用性
  3. 更好的动态样式支持
  4. 更优的性能表现与构建粒度

这正好是 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 引入方式,因此我们在过渡期间允许两者共存。

迁移过程中最大的难点其实是:

如何保持样式一致性,尤其是在视觉层面不发生变化的前提下。

为此我们采取了如下策略:

  1. 样式提取器:对于原有 .module.css 文件,我们写了一个简单的脚本将其中的类名和样式规则提取成 JavaScript 对象格式,供后续参考。
  2. 可视化比对:每完成一个组件的替换,就在本地搭建一个“样式对比站”,两边并排展示新旧效果,确保没有偏移。
  3. 组件树梳理:有些样式是继承而来的,我们借助 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

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