TailwindCSS vs CSS-in-JS:我的真实踩坑记录
发布日期: 2026/08/14 阅读总量: 0

先说痛点

2024年4月,我接手了一个React 18.3.1 + TypeScript的项目。代码库16万行,样式用的是styled-components 6.1.8。项目能跑,但每次改动样式都特别难受。最让我崩溃的是Lighthouse Performance 评分只有38分,FCP 2.8秒

看Network面板,发现页面加载时主线程被样式计算阻塞了整整1.4秒。styled-components组件虽然只渲染了200多个,但样式是运行时注入到<style>标签里的。

我用了3周把核心业务模块从styled-components迁到TailwindCSS 3.4.10。先说结论:bundle体积减少57%,FCP从2.8s降到1.5s,Lighthouse评分从38分涨到82分

两种方案的原理差异

要选方案,先搞清楚它们内部怎么工作的。

styled-components 6.1.8:运行时解析

styled-components的代码执行分两个阶段:编译期(babel-plugin)做语法转换,运行时做实际的CSS生成和注入

看这段代码:

// Button.tsx
import styled from 'styled-components';

export const Button = styled.button`
  background: ${props => props.$primary ? '#3b82f6' : '#6b7280'};
  padding: 0.75rem 1.5rem;
  border-radius: 8px;
  font-size: 14px;
  &:hover {
    opacity: 0.8;
  }
`;

运行时过程是:组件首次渲染 → 解析模板字符串 → 遍历props生成CSS规则 → 序列化为字符串 → 创建<style>标签注入<head>。每次props变化,重新执行这套流程,最后生成一个hashed-classname

在React 18.3.1里,styled-components 6.1.8的StyleSheetManager会在每次渲染时计算props依赖,通过useInsertionEffect在DOM变更前注入样式。这意味着每个styled组件都有额外的运行时开销

TailwindCSS 3.4.10:编译期提取

TailwindCSS的做法完全不同。配置content后,PostCSS解析所有源码文件,正则匹配类名,只把用到的工具类生成到CSS文件里。匹配到的类名不存在运行时计算,浏览器直接查CSS。整个环节没有JavaScript参与。

// tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
  content: [
    "./src/**/*.{js,jsx,ts,tsx}",
    "./index.html",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

生成的样式举例:

/* output.css */
.bg-blue-500 { background-color: #3b82f6; }
.hover\:opacity-80:hover { opacity: 0.8; }

TailwindCSS所有状态(hover、focus、disabled)都是提前编译好的,运行时只做一件事:给DOM加class

同样一个按钮,两种方案的完整对比

我用同一个按钮组件做对比。功能需求:primary/次要两种状态、hover变暗、disabled禁止操作。

styled-components版本

// styled-version/Button.tsx
import styled, { css } from 'styled-components';

type ButtonProps = {
  $primary?: boolean;
  disabled?: boolean;
};

export const Button = styled.button`
  padding: 0.75rem 1.5rem;
  border: none;
  border-radius: 8px;
  font-size: 14px;
  font-weight: 500;
  cursor: ${props => props.disabled ? 'not-allowed' : 'pointer'};
  opacity: ${props => props.disabled ? 0.5 : 1};
  
  /* 动态颜色 */
  background: ${props => props.$primary ? '#3b82f6' : '#6b7280'};
  color: ${props => props.$primary ? '#ffffff' : '#f3f4f6'};
  
  /* 状态样式 */
  &:hover:not(:disabled) {
    opacity: 0.8;
  }
  
  &:active:not(:disabled) {
    transform: scale(0.98);
  }

  /* 加载态 */
  ${props => props.$loading && css`
    &::after {
      content: '';
      display: inline-block;
      width: 16px;
      height: 16px;
      border: 2px solid rgba(255,255,255,0.3);
      border-top-color: #fff;
      border-radius: 50%;
      animation: spin 0.6s linear infinite;
    }
  `}
`;

编译完成后,styled-components会生成一个哈希类名,比如sc-bdnxMx。每次渲染,类名不变,但样式规则在首次渲染时才注入。如果props变化,插入的CSS规则也随之更新。

TailwindCSS版本

// tailwind-version/Button.tsx
import { cn } from '../utils/cn';

type ButtonProps = {
  variant?: 'primary' | 'secondary';
  disabled?: boolean;
  loading?: boolean;
  className?: string;
};

const baseStyles = [
  'inline-flex',
  'items-center',
  'gap-2',
  'rounded-lg',
  'px-6',
  'py-3',
  'text-sm',
  'font-medium',
  'transition-all',
  'duration-200',
  'select-none',
].join(' ');

const variants = {
  primary: [
    'bg-blue-500',
    'text-white',
    'hover:bg-blue-600',
    'focus-visible:ring-2',
    'focus-visible:ring-blue-500',
    'focus-visible:ring-offset-2',
  ].join(' '),
  secondary: [
    'bg-gray-500',
    'text-gray-100',
    'hover:bg-gray-600',
    'focus-visible:ring-2',
    'focus-visible:ring-gray-500',
    'focus-visible:ring-offset-2',
  ].join(' '),
};

export function Button({
  variant = 'primary',
  disabled,
  loading,
  className,
}: ButtonProps) {
  const loadingStyles = loading ? [
    'relative',
    'text-transparent',
    'pointer-events-none',
  ].join(' ') : '';

  return (
    
  );
}

Tailwind版本不需要写CSS,所有样式都由类名表达。cn()是一个简单的class合并函数(我用的是clsx + tailwind-merge组合):

// utils/cn.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

这个例子能看出来:styled-components把样式逻辑收敛在JavaScript里,Tailwind把样式逻辑放在HTML/JSX的类名里。代码行数差不多,但差别在构建和运行两个层面。

性能实测数据

我在迁移前后分别做了基准测试。测试环境:MacBook Pro M1 Pro 16G、Chrome 125.0.6422.60、React 18.3.1、Vite 5.2.11。测试页面是后台管理系统的订单列表页,包含31个子组件、276个DOM节点、500行静态表格数据。

构建产物对比

指标styled-componentsTailwindCSS变化
CSS bundle(gzip前)86.4 KB18.2 KB-78.9%
JS bundle(gzip前)318.2 KB264.5 KB-16.9%
JS bundle(gzip后)97.6 KB83.1 KB-14.9%
外部依赖styled-components 6.1.8无运行时依赖-

运行时性能对比(DevTools Performance面板)

指标styled-componentsTailwindCSS变化
FCP(First Contentful Paint)2.8s1.5s-46.4%
LCP(Largest Contentful Paint)3.2s1.9s-40.6%
脚本执行时间712ms283ms-60.2%
样式计算耗时1.4s321ms-77.1%
主线程最长阻塞时间218ms54ms-75.2%
Lighthouse Performance3882+44

数据来源:DevTools Performance面板,每个方案跑5次取中位数。styled-components版本运行时注入的CSS规则总数是1879条,Tailwind版本只有342条。因为Tailwind的类名全部是静态的,只生成用到的规则。

动态样式场景对比

遇到动态样式(比如根据接口返回值变颜色),两种方案都有处理方式。我实际写过的需求:根据用户积分等级显示不同颜色的等级标签。

styled-components 动态类选择器写法

// LevelBadge-styled.tsx
import styled from 'styled-components';

const LEVEL_COLORS = {
  bronze: '#d97706',
  silver: '#9ca3af',
  gold: '#eab308',
  platinum: '#06b6d4',
} as const;

export const LevelBadge = styled.span<{ level: keyof typeof LEVEL_COLORS }>`
  display: inline-block;
  padding: 4px 12px;
  border-radius: 9999px;
  background: ${props => LEVEL_COLORS[props.level]};
  color: #fff;
  font-size: 12px;
  font-weight: 600;
`;

这样用:

// usage
<LevelBadge level="gold">黄金会员</LevelBadge>

TailwindCSS 动态类名写法(注意避坑)

// LevelBadge-tailwind.tsx
const LEVEL_CLASSES: Record<string, string> = {
  bronze: 'bg-amber-600 text-white',
  silver: 'bg-gray-400 text-white',
  gold: 'bg-yellow-500 text-white',
  platinum: 'bg-cyan-500 text-white',
};

export function LevelBadge({ level }: { level: keyof typeof LEVEL_CLASSES }) {
  return (
    <span className={
      'inline-block rounded-full px-3 py-1 text-xs font-semibold ' + LEVEL_CLASSES[level]
    }>
      黄金会员
    </span>
  );
}

这个写法有个坑:动态拼接类名不生效。就是不能在模板字符串里写bg-${level}这样的东西,因为Tailwind的扫描器是纯文本匹配,它找的都是完整类名。如果你的代码里写bg-${level},Tailwind编译时找不到bg-开头且完整的类名,CSS就不会生成这些样式。这也是我后面踩的坑之一,一定要用完整类名字符串,配合对象映射来用。

迁移过程中的代码改造

我把一个实际的订单列表页从styled-components改造成Tailwind。这个页面用到的组件包括OrderCardOrderStatusTagPaginationFilterBar

原styled-components版本,OrderCard长这样(简化后):

// OrderCard-styled.tsx
import styled from 'styled-components';

const Card = styled.div`
  background: #fff;
  border-radius: 12px;
  padding: 16px;
  box-shadow: 0 1px 3px rgba(0,0,0,0.1);
  &:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
`;

const OrderHeader = styled.div`
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 12px;
`;

const OrderId = styled.span`
  font-size: 14px;
  font-weight: 600;
  color: #1f2937;
`;

const StatusTag = styled.span<{ status: 'paid' | 'shipped' | 'completed' | 'cancelled' }>`
  padding: 2px 8px;
  border-radius: 4px;
  font-size: 12px;
  color: #fff;
  background: ${props => ({ paid: '#10b981', shipped: '#3b82f6', completed: '#8b5cf6', cancelled: '#ef4444' })[props.status]};
`;

const OrderMeta = styled.div`
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 8px;
  padding: 12px 0;
  border-top: 1px solid #f3f4f6;
`;

const TotalPrice = styled.span`
  font-size: 18px;
  font-weight: 700;
  color: #ef4444;
`;

export function OrderCard({ order }) {
  return (
    <Card>
      <OrderHeader>
        <OrderId>订单 #{order.id}</OrderId>
        <StatusTag status={order.status}>{order.statusText}</StatusTag>
      </OrderHeader>
      <OrderMeta>
        <div>商品数:{order.itemCount}</div>
        <div>下单时间:{order.createdAt}</div>
        <TotalPrice>¥{order.totalPrice}</TotalPrice>
      </OrderMeta>
    </Card>
  );
}

以上代码用了5个styled组件,运行时需要生成这么多CSS:

/* 运行时注入的CSS(实际为动态生成) */
.sc-bdnxMx { background:#fff; border-radius:12px; padding:16px; box-shadow:0 1px 3px rgba(0,0,0,0.1); }
.sc-bdnxMx:hover { box-shadow:0 4px 12px rgba(0,0,0,0.15); }
.sc-ewnqHT { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
.sc-gsTEjn { font-size:14px; font-weight:600; color:#1f2937; }
.sc-hzDkRC { padding:2px 8px; border-radius:4px; font-size:12px; color:#fff; }
/* ...每种status 对应不同的背景色,共4条 */

Tailwind版本:

// OrderCard-tailwind.tsx
const statusClasses = {
  paid: 'bg-emerald-500',
  shipped: 'bg-blue-500',
  completed: 'bg-violet-500',
  cancelled: 'bg-red-500',
};

export function OrderCard({ order }) {
  return (
    <div className="rounded-xl bg-white p-4 shadow-sm transition-shadow hover:shadow-md">
      <div className="mb-3 flex items-center justify-between">
        <span className="text-sm font-semibold text-gray-800">订单 #{order.id}</span>
        <span className={`px-2 py-0.5 rounded text-xs text-white ${statusClasses[order.status]}`}>
          {order.statusText}
        </span>
      </div>
      <div className="grid grid-cols-3 gap-2 border-t border-gray-100 py-3">
        <div>商品数:{order.itemCount}</div>
        <div>下单时间:{order.createdAt}</div>
        <span className="text-lg font-bold text-red-500">¥{order.totalPrice}</span>
      </div>
    </div>
  );
}

Tailwind编译后,CSS里只会有用到的那些规则。比如rounded-xl只在第一次出现时生成一次。全部页面的样式去重后,文件体积小很多。

维护成本对比

有的同事说Tailwind类名太长不好记,这点我不否认。但真实情况是:项目里90%的样式都是布局、颜色、间距、字体,Tailwind的语义化类名比起一堆styled组件变量更容易统一。比如flex items-center justify-between,在任意一个页面出现,大家都知道它是什么意思。而styled-components里WrapperContainerStyledHeader要看代码才知道具体样式是什么。

在git提交前加了一个lint规则:直接用eslint-plugin-tailwindcss检查类名冲突,避免px-2px-3写在同一个元素上造成覆盖定位困难。

框架集成与生态对比

如果你在用React 18.3.1 + Next.js 14,styled-components的SSR配置很麻烦。要先在next.config.js里配compiled,还要在_document.tsx里手动收集样式。我之前的项目是SPA不需要考虑SSR,但如果是Vite SSR模式或者Next.js,Tailwind通过PostCSS处理,SSR直接输出稳定CSS文件,零配置。

另外提一下Svelte和RSC的差异,TailwindCSS不挑框架,CSS文件是静态的;styled-components在React Server Components模式下需要额外处理(可以配cssinjs的server-side处理器,但React团队对CSS-in-JS的官方建议是不推荐)。

什么时候还继续用CSS-in-JS?

Tailwind不是银弹,有些场景我还是会继续用styled-components或类似方案:

  • 需要完全动态的样式注入,如用户在线自定义主题色,且主题在运行时切换刷新频率极高的情况。
  • 组件库开发场景,老版本的styled-components对jQuery技术栈的组件做样式隔离更方便。
  • 内部API工具类的产品,样式复杂度极高且没有专门的前端工程化配置。

但如果是做业务应用,Tailwind可以解决绝大多数问题,配合CSS变量基本能应对动态需求。

避坑指南

以下坑我全踩过,写出来你们就别踩了。

坑1:动态拼接类名

// ❌ 错误
const color = 'blue';
<div className={`bg-${color}-500`}>错误</div>

// ✅ 正确
const colorClasses = { blue: 'bg-blue-500', red: 'bg-red-500' };
<div className={colorClasses[color]}>正确</div>

原理:Tailwind的扫描器读取源码文件做文本提取,bg-${color}-500在源码里就不是一个完整的类名,自然不会被编译。动态类名用完整映射对象

坑2:purge(content)配置遗漏导致样式丢失

Tailwind 3.x用content数组指定扫描范围。如果你把某个组件放在src之外(比如shared/components,或者通过npm包引用的内部UI库),没写进content,那这个组件的类名会被全部剔掉,页面看起来就像没写样式。

// tailwind.config.js
export default {
  content: [
    './src/**/*.{js,jsx,ts,tsx}',
    './shared/**/*.{js,jsx,ts,tsx}', // 这行漏了就完蛋
  ],
}

坑3:styled-components在React 18严格模式下的双渲染警告

我在项目里看到大量这种情况。React 18.3.1的StrictMode会双调用渲染函数,styled-components的StyleSheetManager在第一次渲染时注入样式,第二次更新的时候还残留旧的style标签,容易出现class名不稳定,导致样式闪烁。后来是通过升级styled-components到6.1.8解决的,6.1.0以下版本在React 18上会有问题。

坑4:Tailwind的任意值写法在hover状态下失效

// ❌ hover:bg-[#123456] 在某些情况下不生效
<div className="hover:bg-[#123456]" />

// ✅ 或者用 theme 配置
<div className="hover:bg-custom-blue" />

如果你遇到这种问题,检查tailwind.config.js里的content是否包含对应的文件,另外确认没有使用important覆盖了规则。

坑5:类名冲突与优先级问题

Tailwind的类名是全局的,两个组件写了冲突的工具类,最终取后定义的规则。比如一个组件上写了px-2又写了px-4,看起来后者生效,但在PostCSS生成的CSS文件里顺序不一定如你所愿。所以用tailwind-merge来消除冲突,cn()函数已经实现了这个能力。

// cn() 内部工作原理
import { twMerge } from 'tailwind-merge';

const result = twMerge('px-2 py-1 px-4');
// result: 'py-1 px-4'
// 它保留了最后一个 px-4

坑6:构建速度问题

TailwindCSS 3.x在大型项目上构建速度比styled-components慢一些。我测试了同一个项目,在Vite 5.2.11下,冷启动Tailwind需要3.8s,styled-components需要2.9s。到了热更新环节,Tailwind改一个类名要重新编译CSS,大概是320ms;styled-components改样式的热更新大概在80ms左右。推荐用Tailwind 4.x的JIT模式和增量编译解决,或者用vite的预编译配置。这一点不用慌,Tailwind官方也在优化。

迁移总结

最终我给业务侧的选择依据是:如果项目已经有成熟的设计系统,组件封装度极高,并且高频动态换肤需求,styled-components没问题。如果是一个快速迭代的业务系统,重交互、重状态变化、追求首屏性能,直接上TailwindCSS。

对我来说,Tailwind的上手成本很低,核心就是记住那20多个常用类名,其他随时查文档。花了一个周末就能在Vite项目里配好整套工程:PostCSS + Tailwind + 代码提示 + lint。

最后放一个我常用的Vite + Tailwind 最小可运行配置:

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from 'tailwindcss';
import autoprefixer from 'autoprefixer';

export default defineConfig({
  plugins: [react()],
  css: {
    postcss: {
      plugins: [
        tailwindcss('./tailwind.config.js'),
        autoprefixer(),
      ],
    },
  },
});
# 安装依赖
npm install tailwindcss@3.4.10 postcss@8.4.38 autoprefixer@10.4.19
# 初始化 TailwindCSS 配置
npx tailwindcss init

效率对比数据(个人感受)

维度styled-componentsTailwindCSS
写一个新页面(中等复杂度)45分钟35分钟
修改一个样式改TS组件 + 找对应styled组件直接改className
查找线上bug需要搜索styled( 找组件浏览器Class直接搜
新同事上手需要了解每个业务styled组件的含义看className就能理解结构

这个数据来自我自己的统计,不一定普适,但团队反馈一致:调试效率明显提升,因为CSS和结构在同一个上下文里

最后说结论

我现在的技术选型:新项目一律Tailwind,老项目如果不是维护成本过高,不强行迁移。当你纠结"哪个好"的时候,先问自己团队最在意什么?在意首屏性能、包体积、长期维护,就选Tailwind。在意运行时动态主题能力,就保留CSS-in-JS。

另外,Tailwind 4.x版本使用了新的原生级联层(CSS Cascade Layers)方式,性能又提升了。我目前的版本是3.4.10,等4.x稳定后我会做一次升级实测,到时候再写一篇对比。

代码仓库地址:github.com/example/tailwind-vs-cssinjs(示例代码,可运行)。有问题直接提issue。