陌上人如玉
公子世无双

10.3 样式方案

10.3 样式方案

一句话:主流方案有四种——CSS Modules(无运行时、作用域隔离)、Tailwind(原子化、无运行时)、CSS-in-JS(有运行时开销)、普通 CSS/Sass(需自己管命名);新项目的主流选择是「CSS Modules 或 Tailwind」,CSS-in-JS 因运行时开销在 React 18 并发下逐渐退潮。

一、四种方案对比

维度 CSS Modules Tailwind CSS-in-JS(styled-components/emotion) 普通 CSS/Sass
运行时开销 无 无 有(运行时生成样式) 无
作用域隔离 ✅(编译时加 hash) ✅(原子类) ✅ ❌(需 BEM/命名约定)
动态样式 ⚠️(需 CSS 变量) ⚠️(需条件类名) ✅(直接写 JS 表达式) ⚠️
类型安全(TS) ⚠️(需生成 d.ts) ✅(插件给类型) ✅ ❌
产出体积 小(按需) 小(PurgeCSS) 大(运行时 + 样式) 小
学习成本 低 中(要记类名) 低 低
SSR 支持 ✅ ✅ ⚠️(需额外配置,避免 FOUC) ✅
调试体验 一般(类名是 hash) 一般(类名很长) 好(类名可读) 好
设计系统契合 中 好(token 化) 好 中
现状 主流 主流 ⚠️ 退潮(运行时开销) 老项目
⭐ 一句话选型:
① 团队有设计系统 / 喜欢原子化 → Tailwind
② 想要「普通 CSS 的写法 + 作用域隔离」 → CSS Modules
③ 需要「样式与逻辑强耦合、动态样式极多」 → CSS-in-JS(但要接受代价)
④ 老项目 / 简单页面 → 普通 CSS + BEM

二、CSS Modules

/* Button.module.css */
.button {
  padding: 8px 16px;
  border-radius: 6px;
  border: 1px solid transparent;
}

.primary {
  background: #2563eb;
  color: white;
}

/* 组合(复用其它类) */
.danger {
  composes: button;
  background: #dc2626;
}
// Button.tsx
import styles from './Button.module.css';
import clsx from 'clsx';

interface Props {
  variant?: 'primary' | 'danger';
  className?: string;
}

export function Button({ variant = 'primary', className, ...rest }: Props & React.ButtonHTMLAttributes<HTMLButtonElement>) {
  return (
    <button
      className={clsx(styles.button, styles[variant], className)}   // ⭐ 合并外部 className
      {...rest}
    />
  );
}
/* ✅ 与 CSS 变量结合做「动态样式」 */
.button {
  background: var(--btn-bg, #2563eb);
  color: var(--btn-fg, white);
}
// 通过 CSS 变量传动态值(避免「为每个值生成一个类」)
<button style={{ '--btn-bg': color } as React.CSSProperties} className={styles.button} />;
⭐ CSS Modules 的三个要点:
① 「编译时」就把类名变成「唯一 hash」→ 天然作用域隔离
② 支持 `composes` 复用(比 Sass 的 @extend 更可控)
③ 动态样式用「CSS 变量 + 内联 style」而不是「动态生成类名」

⚠️ TS 支持:需要类型声明(否则 `styles.button` 报错)
// src/types/css-modules.d.ts(或用「vite-plugin-sass-dts」自动生成)
declare module '*.module.css' {
  const classes: Record<string, string>;
  export default classes;
}

三、Tailwind

// ✅ 原子类直接写在 className 里
export function Button({ variant = 'primary', ...rest }: ButtonProps) {
  return (
    <button
      className={clsx(
        'inline-flex items-center rounded-md px-4 py-2 text-sm font-medium transition',
        variant === 'primary' && 'bg-blue-600 text-white hover:bg-blue-700',
        variant === 'danger' && 'bg-red-600 text-white hover:bg-red-700',
      )}
      {...rest}
    />
  );
}
// tailwind.config.js —— 把「设计 token」写进主题(避免魔法值)
export default {
  content: ['./index.html', './src/**/*.{ts,tsx}'],
  theme: {
    extend: {
      colors: {
        brand: {
          50: '#eff6ff',
          500: '#3b82f6',
          600: '#2563eb',
          700: '#1d4ed8',
        },
      },
      spacing: { 18: '4.5rem' },
      borderRadius: { xl: '0.75rem' },
    },
  },
  plugins: [],
};
⭐ Tailwind 的取舍:

✅ 优点:
· 无运行时(编译时生成 CSS)
· 产出小(只保留用到的类)
· 「设计约束」——间距/颜色都来自主题(不易出现魔法值)
· 改样式不用「起名」(也就不用纠结命名)
· TS 插件能提示类名

❌ 缺点:
· className 很长(可读性差)
· 「多条件样式」需要 clsx/cva 之类的工具
· 团队需要统一约定(否则会「各写各的」)
· 学习成本(要记住常用类名)

✅ 缓解方式:
· 用 `cva`(class-variance-authority)管理「变体」 → 见下
· 用 `@apply` 抽取「重复的组合」(但要克制)
// ✅ 用 cva 管理「变体」(Tailwind 项目的常见实践)
import { cva, type VariantProps } from 'class-variance-authority';

const button = cva('inline-flex items-center rounded-md font-medium transition', {
  variants: {
    variant: {
      primary: 'bg-blue-600 text-white hover:bg-blue-700',
      danger: 'bg-red-600 text-white hover:bg-red-700',
      ghost: 'bg-transparent hover:bg-gray-100',
    },
    size: {
      sm: 'px-2 py-1 text-xs',
      md: 'px-4 py-2 text-sm',
      lg: 'px-6 py-3 text-base',
    },
  },
  defaultVariants: { variant: 'primary', size: 'md' },
});

interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof button> {}

export function Button({ variant, size, className, ...rest }: ButtonProps) {
  return <button className={button({ variant, size, className })} {...rest} />;
}

四、CSS-in-JS 的原理与代价

// styled-components / emotion 的写法
import styled from '@emotion/styled';

const Button = styled.button<{ $variant: 'primary' | 'danger' }>`
  padding: 8px 16px;
  border-radius: 6px;
  background: ${(p) => (p.$variant === 'primary' ? '#2563eb' : '#dc2626')};
  color: white;
`;

// 使用
<Button $variant="primary">确定</Button>;
⭐ CSS-in-JS 的价值(为什么曾经流行):
① 样式与组件「同文件」(就近维护)
② 动态样式「直接写 JS 表达式」(不需要类和 CSS 变量的间接)
③ 类名「可读」(便于调试)
④ 天然作用域隔离
⚠️ 但它的三类代价(为什么现在退潮):

① 运行时开销
   · 每次渲染都要「序列化样式 + 生成/插入 <style>」
   · 样式的「hash 计算」也在运行时
   → 首屏与大列表场景有明显成本

② React 18 并发下的问题
   · 渲染可中断 → 样式插入的时机需要专门处理
   (这就是 React 18 引入 useInsertionEffect 的原因——给 CSS-in-JS 库用)

③ SSR 的复杂度
   · 需要「收集样式 → 注入 HTML」→ 避免 FOUC(样式闪烁)
   · 需要额外的配置与 API(如 emotion 的 extractCritical)
// ✅ 如果仍要用 CSS-in-JS,React 18 要求库用 useInsertionEffect
// (库作者的事,但你要知道「为什么有 useInsertionEffect 这个 Hook」)
function useCSS(rule: string) {
  useInsertionEffect(() => {
    const style = document.createElement('style');
    style.textContent = rule;
    document.head.appendChild(style);
    return () => style.remove();
  }, [rule]);
}
⭐ 「零运行时」的 CSS-in-JS(现代替代):
① 编译时提取——如 vanilla-extract、Linaria、Panda CSS
   → 写法仍是「样式与逻辑同文件」,但构建时产出静态 CSS(无运行时)
② 这是「CSS-in-JS 的体验 + 静态 CSS 的性能」的折中
// vanilla-extract 示例(编译时产出 CSS)
// button.css.ts
import { style, styleVariants } from '@vanilla-extract/css';

export const base = style({ padding: '8px 16px', borderRadius: 6 });

export const variant = styleVariants({
  primary: [{ background: '#2563eb', color: 'white' }],
  danger: [{ background: '#dc2626', color: 'white' }],
});

五、选型建议

决策树:

① 团队已有「设计系统 + token」?
   ├─ 是 → Tailwind(token 直接映射到主题)或 CSS Modules + CSS 变量
   └─ 否 → 继续

② 需要「极致的动态样式」(如「颜色由用户输入决定且数量无限」)?
   ├─ 是 → CSS-in-JS(或「零运行时」的 vanilla-extract)
   └─ 否 → 继续

③ 团队偏好「原子化」还是「语义化」?
   ├─ 原子化 → Tailwind
   └─ 语义化 → CSS Modules(或 Sass)

④ 有 SSR?
   → 都支持;CSS-in-JS 需要额外配置(避免 FOUC)

⭐ 经验:
· 新项目大多数场景 → Tailwind 或 CSS Modules(都是「无运行时」)
· 组件库 → CSS Modules / vanilla-extract / 自带设计 token
· 老项目/简单页面 → 保持原样(普通 CSS + BEM)
· 避免「同一项目里混用三四种方案」
⚠️ 「混用」的常见做法(合理):
· Tailwind 做「工具类与布局」 + CSS Modules 做「复杂组件样式」
· 全局「设计 token」用 CSS 变量(`:root { --color-brand: ... }`)
· 主题切换用 CSS 变量 + data-attribute(无运行时)
/* ✅ 用 CSS 变量做主题(运行时切换无需重新生成样式) */
:root {
  --color-bg: #ffffff;
  --color-fg: #111827;
  --color-brand: #2563eb;
}

[data-theme='dark'] {
  --color-bg: #0f172a;
  --color-fg: #e5e7eb;
  --color-brand: #60a5fa;
}

.card {
  background: var(--color-bg);
  color: var(--color-fg);
}
// 切换主题只需改一个属性(甚至只用一个 <script> 就能在「首屏前」设置)
function toggleTheme() {
  const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
  document.documentElement.dataset.theme = next;
  localStorage.setItem('theme', next);
}

六、其它常见问题

① Sass/Less 还需要吗?
   · 现代 CSS 已有「嵌套、变量、:is/:where」(Sass 的部分能力已被原生吸收)
   · Sass 的价值主要剩「mixin / function / @use 模块化」
   · 新项目可以只用原生 CSS + PostCSS(autoprefixer / postcss-preset-env)

② PostCSS 做什么?
   · autoprefixer(自动加浏览器前缀)
   · postcss-preset-env(把「未来的 CSS 语法」编译成兼容语法)
   · Tailwind 本身就是一个 PostCSS 插件

③ 「样式冲突」怎么排查?
   · 用 DevTools 看「应用的规则来源」
   · 优先用「作用域方案」(Modules/Tailwind)从源头避免
   · 检查「层级/权重」与「加载顺序」

④ 「样式被打包重复」怎么办?
   · 检查是否「多处引入同一个样式文件」
   · Tailwind 的 content 配置要正确(否则扫描不到类名 → 样式丢失)

七、常见坑

// ① 用 Tailwind 但 content 配置不全 → 生产环境「样式丢失」
content: ['./src/**/*.{ts,tsx}'];                     // ⚠️ 漏了某些文件类型/目录
// ② Tailwind 用「动态拼接类名」→ 扫描不到 → 样式丢失
className={`text-${color}-500`}                       // ❌ 无效
// ✅ 用完整的类名映射
const colors = { red: 'text-red-500', blue: 'text-blue-500' };
className={colors[color]}                             // ✅

// ③ CSS Modules 里用「全局类名」却忘了 :global
:global(.ant-btn) { }                                 // ✅
.ant-btn { }                                          // ❌ 被 hash 化(选择不到)

// ④ CSS Modules 的类名「动态拼」导致 TS 报错/取不到
styles[`btn-${variant}`]                              // ⚠️ 类型不友好
// ✅ 用「显式映射」或 camelCase 命名

// ⑤ CSS-in-JS 在「大列表」里每行都生成样式(运行时开销爆炸)
// ⑥ CSS-in-JS 的 SSR 配置不全 → FOUC(样式闪烁)
// ⑦ 混用多种方案导致「权重/顺序」难以预测
// ⑧ 用「内联 style」写「大量静态样式」(失去缓存与复用)
<div style={{ padding: 16, color: 'red', fontSize: 14, ... }} />   // ⚠️
// ⑨ 忘记「优先级 / 层叠」问题(!important 满天飞)
// ⑩ 用「样式隔离方案」但仍有「全局 reset 冲突」
// ⑪ 主题切换用「重新生成样式」(应该有 CSS 变量 + data-attribute)
// ⑫ 忘记「图片/字体等资源的路径」(CSS 里的 url() 要能被构建处理)
// ⑬ Tailwind 的「任意值语法」用太多(失去设计约束的意义)
className="w-[317px] text-[#a1b2c3]"                  // ⚠️ 偶尔可以,滥用则失控
// ⑭ 用「100vh」而不用「100dvh」(移动端地址栏导致高度跳变)
// ⑮ 忽略「CSS 体积监控」(未使用的样式随版本累积)
// ② 的正确写法
// ❌
<div className={`bg-${tone}-100 text-${tone}-700`} />

// ✅ 用「完整的类名常量」
const toneClasses = {
  success: 'bg-green-100 text-green-700',
  warning: 'bg-amber-100 text-amber-700',
  error: 'bg-red-100 text-red-700',
} as const;

<div className={toneClasses[tone]} />

八、面试延伸

  1. 「React 项目有哪些样式方案?怎么选?」
    四种:① CSS Modules——无运行时、编译时作用域隔离、写法就是普通 CSS(主流);② Tailwind——原子化、无运行时、产出小、天然与设计 token 契合(主流);③ CSS-in-JS(styled-components/emotion)——样式与逻辑同文件、动态样式直接写 JS,但有运行时开销 + SSR 复杂度(近年退潮);④ 普通 CSS/Sass——需自己管命名(老项目)。新项目推荐前两种,关键判断是「是否有设计系统」与「是否需要极致动态样式」。

  2. 「CSS-in-JS 有什么代价?」
    三类:① 运行时开销——每次渲染都要「序列化样式、计算 hash、生成/插入 <style>」(首屏与大列表场景明显);② React 18 并发下的问题——渲染可中断,样式插入时机需要专门处理(这也是 React 18 引入 useInsertionEffect 的原因,它是专门给 CSS-in-JS 库用的 Hook);③ SSR 复杂——需要「收集样式并注入 HTML」以避免 FOUC,配置与 API 更繁琐。所以现在出现了「零运行时」的编译时方案(vanilla-extract、Linaria、Panda CSS)。

  3. 「Tailwind 有什么优缺点?」
    优点:① 无运行时(编译时生成 CSS);② 产出小(只保留用到的类);③ 设计约束(间距/颜色都来自主题,避免魔法值);④ 不用起名(省去命名成本)。缺点:① className 很长(可读性差);② 多条件样式需要 clsx/cva 之类工具;③ 需要团队统一约定;④ 有学习成本。关键坑是「动态拼接类名」——Tailwind 靠静态扫描,text-${color}-500 这类拼接会被漏掉(必须用完整的类名映射)。

  4. 「CSS Modules 怎么处理动态样式?」
    不推荐「动态生成类名」(会让类型与构建都变复杂)。推荐两条路:① CSS 变量 + 内联 style——在 CSS 里写 background: var(--btn-bg),在 JSX 里用 style={{ '--btn-bg': color }} 传值(类型上需要断言为 CSSProperties);② 条件类名——用 clsx 在两个预定义的类之间切换(适合「有限个变体」)。核心原则是「类名保持静态、动态值走 CSS 变量」。

  5. 「主题切换怎么做?」
    最佳实践是「CSS 变量 + data-* 属性」:① 在 :root 定义「浅色」的变量,在 [data-theme='dark'] 覆盖为「深色」;② 切换主题时只改 document.documentElement.dataset.theme(让 CSS 自动生效,不需要重新生成或加载样式);③ 持久化到 localStorage;④ 避免首屏闪烁(FOUC)——在 HTML 里内联一段 <script>,在「页面渲染前」就根据 localStorage 设置 data-theme(或由服务端从 cookie 读出并注入 class,后者更适合 SSR)。

  6. 「为什么会有 FOUC?怎么避免?」
    FOUC(Flash of Unstyled Content)指「页面先以无样式状态显示,然后样式才生效」——表现为「闪一下」。两种典型原因:① CSS-in-JS 的 SSR——服务端渲染的 HTML 里没有对应样式,客户端 JS 执行后才插入 <style>;② 主题由客户端脚本设置——首屏渲染时用的默认主题,JS 执行后才切成用户主题。解法:① CSS-in-JS 要配置「样式提取并注入 HTML」(如 emotion 的 extractCritical);② 主题在「首屏渲染前」确定(内联脚本或服务端读 cookie 注入)。

  7. 「样式冲突怎么排查/避免?」
    避免:① 用「作用域方案」(CSS Modules 的编译时 hash、Tailwind 的原子类、CSS-in-JS 的运行时隔离)从源头避免重名;② 全局样式只放「reset、CSS 变量、极少数工具类」;③ 组件样式不写在「全局选择器」里(如 ul li 这类会污染全局)。排查:① DevTools 的 Styles 面板会显示「哪些规则命中了这个元素、来自哪个文件、有没有被覆盖」;② 检查「加载顺序」与「选择器权重」;③ 如果是第三方组件库的样式被覆盖,用「提高权重」或「作用域包裹」(但优先考虑「官方定制的 API」,如 antd 的 ConfigProvider)。

  8. 「CSS 体积怎么优化?」
    四个方向:① 避免未使用的样式——Tailwind(PurgeCSS 只保留用到的类)、CSS Modules(按需引入);② 避免重复——检查多个入口是否重复引入同一份样式、组件库是否整包引入(改成按需);③ 减少「运行时代价」——CSS-in-JS 换「零运行时」(vanilla-extract);④ 压缩与拆分——生产压缩(cssnano)、关键 CSS 内联(首屏)、其余异步加载。此外要注意「不要用 @import 串行引入」(会阻塞),改用构建工具的合并。

一句话速记

四种方案:CSS Modules(无运行时、编译时 hash 隔离、写普通 CSS,动态值走 CSS 变量)、Tailwind(无运行时、原子类、与设计 token 契合,坑是「动态拼类名导致样式丢失」,用 cva 管变体)、CSS-in-JS(样式与逻辑同文件但有运行时开销 + SSR 复杂 + 并发下需要 useInsertionEffect,已被「零运行时」的 vanilla-extract 类方案替代)、普通 CSS/Sass(需自己管命名);选型看「有没有设计系统 + 是否需要极致动态样式」;主题切换用「CSS 变量 + data-theme 属性」(零运行时、无 FOUC,配合内联脚本或服务端读 cookie);FOUC 的两个来源是「CSS-in-JS 的 SSR 未提取样式」与「主题由客户端脚本设置」;避免样式冲突的根本办法是「作用域方案 + 全局只放 reset 与变量」。

赞(0) 打赏
未经允许不得转载:陌上寒 » 10.3 样式方案

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫