#React#TypeScript

设计系统与 Design Tokens

产品一大,UI 就乱——颜色字号各写各的、组件不统一。设计系统解决这个。本文讲清 Design Tokens(设计变量化的原子)、组件库分层(token→基础组件→业务组件)、主题切换、以及怎么让设计和开发用同一套语言,而不是各自为政。

本文要点

  • 主题聚焦:React、TypeScript
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年5月8日·更新于 2026年7月23日

设计系统与 Design Tokens:让 UI 不再各写各的

做过几个稍大点的产品,你一定见过这种场景:同一个"主色",首页用的是 #1677ff,个人中心用的是 #1890ff,后台又写成了 #2f54eb;按钮圆角,有的 4px、有的 6px、有的直接 rounded-full;设计师在 Figma 里改了一版主色,前端要全局搜索替换一整天,还总漏掉几个。

这不是谁不负责任的问题,是没有设计系统的问题。

产品规模一上来,UI 的混乱几乎是必然的:多人协作、多端多页面、历史代码堆积、设计师换了一拨又一拨。如果没有一套"单一的真相来源(single source of truth)",漂移就会发生。本文不讲虚的设计哲学,讲实战:怎么用 Design Tokens 把设计变量化、怎么分层组织组件库、怎么做主题切换、怎么让设计和开发真正用同一套语言。

一、产品大了,UI 为什么一定会乱

先承认问题。UI 混乱的根源通常有三个:

1. 值是"散落"的,而不是"定义"的。 颜色、字号、间距、圆角这些值,直接写在每个组件里。改一个品牌色,要在代码库里搜出几百处硬编码。这种叫值漂移(value drift)——同一个语义,在不同地方长得不一样。

2. 组件没有分层。 一个 Button 既要管基础样式,又要管"提交订单"的业务逻辑,还要内置登录态。结果就是谁都不敢改这个 Button,改一处影响一片。基础组件和业务组件搅在一起,复用成了奢望。

3. 设计和开发各说各话。 设计师在 Figma 里定义的 color/primary/500,到了代码里叫 --brand;设计师改了 Figma,开发不知道;开发自己加了个 color/primary/600,设计师也没同步。两套 token,永远对不上。

设计系统(Design System)就是来解决这三件事的。它不是 UI Kit,不是组件库,而是一套从设计决策到代码实现的、有约束力的共同语言。核心抓手就是 Design Tokens。

二、Design Tokens:把设计变成"原子"

Design Tokens 是设计系统的原子层。它的本质就一句话:把所有视觉决策(颜色、字号、间距、圆角、阴影、动效时长)抽成有名字的变量,而不是直接写值。

先看反例:

tsx
// 反模式:值散落,漂移的开始
<button style={{ background: '#1677ff', padding: '8px 16px', borderRadius: '6px' }}>
  提交
</button>

再看正例。先定义 tokens(以 CSS 变量为例):

css
:root {
  /* 颜色 token:语义化命名,而不是 color-blue-500 */
  --color-brand-primary: #1677ff;
  --color-brand-primary-hover: #4096ff;

  /* 间距 token:用 t-shirt 尺寸,可扩展 */
  --spacing-sm: 8px;
  --spacing-md: 16px;

  /* 圆角 */
  --radius-md: 6px;

  /* 字号 */
  --font-size-body: 14px;
  --font-size-heading-lg: 24px;

  /* 阴影 */
  --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(0, 0, 0, 0.08);
}

然后组件里只引用 token,绝不写裸值:

tsx
<button className="btn-primary">提交</button>
css
.btn-primary {
  background: var(--color-brand-primary);
  padding: var(--spacing-sm) var(--spacing-md);
  border-radius: var(--radius-md);
  font-size: var(--font-size-body);
}
.btn-primary:hover {
  background: var(--color-brand-primary-hover);
}

这里有个命名的关键原则:token 要语义化,而不是具象化。

  • 具象(不好):--color-blue-500、--spacing-16
  • 语义(好):--color-brand-primary、--spacing-md、--color-text-subtle

为什么?因为具象名字绑定的是"它现在长什么样",改主题、改品牌色时你就尴尬了——--color-blue-500 在暗色主题下其实是橙色,这名字就误导了。语义名字绑定的是"它代表什么意思",主题怎么切都不冲突。

跨平台的关键:token 应该有一份"中性来源"。 CSS 变量只解决 Web。如果你的产品还有 App、小程序,token 应该以 JSON 作为单一来源,再用工具(Transformer / Style Dictionary)编译成各端格式:Web 出 CSS 变量、iOS 出 Swift 常量、Android 出 XML、Tailwind 出 config。

json
{
  "color": {
    "brand": {
      "primary": { "value": "#1677ff", "type": "color" },
      "primaryHover": { "value": "#4096ff", "type": "color" }
    }
  },
  "spacing": {
    "sm": { "value": "8", "type": "dimension", "unit": "px" },
    "md": { "value": "16", "type": "dimension", "unit": "px" }
  }
}

这份 JSON 就是设计系统的宪法,所有端的视觉都从它派生。改一处,全平台生效。

如果你用 Tailwind,token 应该直接驱动 Tailwind config,而不是在 config 里又写一遍值:

ts
// tailwind.config.ts —— 从 tokens 派生,别手动抄
import tokens from './design-tokens.json'

export default {
  theme: {
    extend: {
      colors: {
        brand: {
          primary: tokens.color.brand.primary.value,
          'primary-hover': tokens.color.brand.primaryHover.value,
        },
      },
      spacing: {
        sm: `${tokens.spacing.sm.value}px`,
        md: `${tokens.spacing.md.value}px`,
      },
    },
  },
}

三、分层架构:tokens → 基础组件 → 业务组件 → 页面

光有 tokens 还不够。设计系统必须分层,否则组件库会变成一锅粥。我推荐的四层架构:

text
┌─────────────────────────────────────┐
│  4. 页面 (Page)                     │  ← 组装业务组件,处理布局
├─────────────────────────────────────┤
│  3. 业务组件 (Business Component)   │  ← ProductCard / OrderTable
├─────────────────────────────────────┤
│  2. 基础组件 (Primitive Component)  │  ← Button / Input / Modal
├─────────────────────────────────────┤
│  1. Tokens(原子)                   │  ← 颜色/字号/间距/圆角
└─────────────────────────────────────┘

约束是:上层可以依赖下层,下层绝不依赖上层。 Tokens 不知道有 Button,Button 不知道有 ProductCard。这是单向依赖,违反这条规则,系统就会腐化。

看基础组件怎么写。它只消费 tokens,不带任何业务:

tsx
// Button.tsx —— 基础组件,纯展示,无业务
import { type ButtonHTMLAttributes } from 'react'

type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
  variant?: 'primary' | 'secondary' | 'ghost'
  size?: 'sm' | 'md' | 'lg'
}

export function Button({ variant = 'primary', size = 'md', className = '', ...props }: ButtonProps) {
  const base = 'inline-flex items-center justify-center font-medium transition-colors'
  const variants = {
    primary: 'bg-brand-primary text-white hover:bg-brand-primary-hover',
    secondary: 'bg-transparent border border-brand-primary text-brand-primary',
    ghost: 'bg-transparent text-brand-primary hover:bg-gray-100',
  }
  const sizes = {
    sm: 'h-8 px-spacing-sm text-sm rounded-md',
    md: 'h-10 px-spacing-md rounded-md',
    lg: 'h-12 px-6 text-lg rounded-lg',
  }
  return <button className={`${base} ${variants[variant]} ${sizes[size]} ${className}`} {...props} />
}

业务组件则消费基础组件,叠加业务语义:

tsx
// AddToCartButton.tsx —— 业务组件,组合基础组件 + 业务逻辑
import { Button } from '@/components/ui/Button'
import { useCart } from '@/hooks/useCart'

export function AddToCartButton({ productId }: { productId: string }) {
  const { add, isLoading } = useCart()
  return (
    <Button
      variant="primary"
      size="lg"
      loading={isLoading}
      onClick={() => add(productId)}
    >
      加入购物车
    </Button>
  )
}

页面只做组装,不写样式细节:

tsx
export default function ProductDetailPage({ product }) {
  return (
    <Page>
      <ProductCard product={product} />
      <AddToCartButton productId={product.id} />
    </Page>
  )
}

这套分层的好处:基础组件可以在任何业务里复用,业务组件聚焦业务逻辑,页面只管编排。改 Button 的样式,全站所有按钮跟着变;改"加入购物车"的逻辑,只动业务组件。变更的爆炸半径是可控的。

四、主题与暗色:一套 token 切多主题

主题切换是设计系统的经典场景,而它之所以能成立,完全靠 tokens 驱动。思路是:token 名字不变,值随主题变。

css
/* 亮色主题 */
:root {
  --color-bg-base: #ffffff;
  --color-text-primary: #1f1f1f;
  --color-bg-elevated: #fafafa;
  --color-border-subtle: #e5e5e5;
}

/* 暗色主题 —— 同名 token,换值 */
:root[data-theme='dark'] {
  --color-bg-base: #0a0a0a;
  --color-text-primary: #f0f0f0;
  --color-bg-elevated: #1a1a1a;
  --color-border-subtle: #2a2a2a;
}

切换主题只需要改一个属性:

tsx
// 切暗色
document.documentElement.dataset.theme = 'dark'

组件代码一行都不用改,因为它引用的是 var(--color-bg-base),这个 token 在不同主题下解析到不同的值。这就是 tokens 的威力——把"多主题"这个复杂问题,降维成了"换一张变量表"。

注意几个实战细节:

  1. 永远用语义 token(bg-base、text-primary),而不是引用原始色(gray-900)。 否则暗色模式下,原本是浅底深字的地方,会变成深底深字——看不清。
  2. 避免在组件里硬编码颜色到主题判断(theme === 'dark' ? '#fff' : '#000')。这种条件分支一旦多了就维护不动,应该全部交给 token 解析。
  3. prefers-color-scheme 自动跟随系统:
css
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    /* 默认暗色 token */
  }
}

让用户没显式设置时,跟随操作系统。

五、设计与开发协同:别再各写一套 token

设计系统失败的最大原因,不是技术,是协同。设计师在 Figma 里维护一份 token,前端在代码里又抄一份,两边永远不同步。改一处要两边手动改,谁能保证不漏?

正确的做法是单一来源 + 自动化同步。主流方案有两个:

1. Tokens Studio(原 Figma Tokens)插件。 设计师在 Figma 里定义 token,插件导出 JSON,这份 JSON 直接进代码仓库。前端用 CI 跑 Style Dictionary 编译成各端格式。设计师改 Figma → PR 自动生成 → 前端 review → 合并。token 的变更走代码评审流程,有 diff、有历史、可回滚。

2. Style Dictionary 编译流水线。

js
// style-dictionary.config.js
import StyleDictionary from 'style-dictionary'

StyleDictionary.extend({
  tokens: './design-tokens.json',
  platforms: {
    web: {
      transformGroup: 'css',
      buildPath: 'build/css/',
      files: [{ destination: 'tokens.css', format: 'css/variables' }],
    },
    ios: {
      transformGroup: 'ios-swift',
      buildPath: 'build/ios/',
      files: [{ destination: 'Tokens.swift', format: 'ios-swift/class.swift' }],
    },
    tailwind: {
      transformGroup: 'js',
      buildPath: 'build/tw/',
      files: [{ destination: 'tailwind-tokens.js', format: 'javascript/module' }],
    },
  },
}).buildAllPlatforms()

一份 JSON,编译出 Web CSS、iOS Swift、Tailwind config——真正一处改,处处生效。

这里有个组织上的关键:token 的所有权归谁? 我的建议是设计 + 前端共同所有,但入库走前端。因为 token 最终是代码生效的,代码仓库是唯一的真相来源。Figma 只是设计端的编辑器,导出的 JSON 必须经过代码评审才能合并。这条边界划不清,协同就会变成扯皮。

六、组件库实践:Storybook、版本化、按需引入

设计系统不是写完组件就完了,它是个产品,要有文档、有版本、有分发机制。

Storybook 是事实标准。 每个组件配套写 stories,展示各种 variant、状态、边界情况。这不仅是文档,更是组件的测试床——视觉回归、可访问性测试都挂在 Storybook 上跑。

tsx
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { Button } from './Button'

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  tags: ['autodocs'],
}
export default meta

export const Primary: StoryObj<typeof Button> = {
  args: { variant: 'primary', children: '提交' },
}

export const AllVariants: StoryObj<typeof Button> = {
  render: () => (
    <div className="flex gap-4">
      <Button variant="primary">Primary</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="ghost">Ghost</Button>
    </div>
  ),
}

版本化是必须的,用语义化版本(semantic versioning)。 组件库一旦被多个业务消费, breaking change 就是大事。major.minor.patch 要严肃对待:

  • patch:bug 修复,不影响 API
  • minor:新增功能,向后兼容
  • major:破坏性变更,消费方要改代码

major 版本发布前,一定要有 deprecation 期——先在旧 API 上打 @deprecated 警告,跑一两个版本,再在下个 major 删掉。直接删 API 是对消费方的背叛。

按需引入。 组件库别打成一坨让业务方全量 import。Tree-shaking 要做好,每个组件独立 export,业务方 import { Button } from '@myorg/ui' 只打包 Button。包体积是组件库被采用的关键指标,没人愿意为一个按钮多打 200KB。

七、反模式:这些坑别踩

反模式一:没有 tokens,直接写值。

这是最普遍的坑。图省事在组件里写 color: '#1677ff',短期快,长期是债。三个月后品牌升级,你 grep 出 400 处硬编码,每一处都要判断"这真的是主色吗还是别的蓝"。判断成本远高于一开始抽 token 的成本。 经验法则:一个视觉值在代码里出现超过 2 次,就必须抽成 token。

反模式二:组件库什么都往里塞。

基础组件库塞进了"用户头像组件"、"支付按钮"、"公司 Logo 组件"——这些都是业务,不该进基础库。判断标准很简单:这个组件脱离当前公司/产品,还能在别的项目里用吗? 不能,就别进基础库,放业务组件层。基础库臃肿的后果是:发版慢、breaking change 多、没人敢升级,最后被弃用。

反模式三:token 命名具象化。

前面提过,--color-blue-500 这种名字是定时炸弹。暗色主题一上,名字和实际颜色对不上,代码可读性崩塌。永远用语义命名。

反模式四:主题用条件判断,不用 token 切换。

{theme === 'dark' ? 'bg-black' : 'bg-white'} 写得到处都是——这是把"多主题"复杂度散布到了每个组件里。正确做法是 token 名字固定,值随主题切换,组件无感知。

反模式五:设计和开发各维护一套 token。

这个前面讲协同时强调了。只要存在两套手抄的 token,就一定不同步。要么设计师的 Figma token 是来源(代码 CI 拉取),要么代码 token 是来源(反向同步回 Figma),只能有一个来源。

小结

  1. 设计系统不是组件库,是设计与开发的共同语言。 它解决的是值漂移、分层混乱、协同扯皮这三件事,核心抓手是 Design Tokens。
  2. Token 是设计系统的原子。 把颜色、字号、间距、圆角、阴影全部变量化,用语义化命名,以 JSON 作为跨平台的单一来源,再编译成各端格式。
  3. 组件库必须分层。 tokens → 基础组件 → 业务组件 → 页面,单向依赖。上层可依赖下层,下层绝不依赖上层。变更的爆炸半径要可控。
  4. 主题切换靠 token,不靠条件判断。 token 名字固定,值随主题变;组件代码对主题无感知,多主题就被降维成"换一张变量表"。
  5. 协同的关键是单一来源 + 自动同步。 Figma token 和代码 token 不能各抄一套,用 Tokens Studio + Style Dictionary 让"改一处,全平台生效"。

金句收尾:写死一个颜色只要 10 秒,但改掉它在全站的几百处散落值,要花你三天——Design Tokens 的全部意义,就是把这三天的债,提前在 10 秒里还掉。

设计系统不是一蹴而就的项目,是持续演进的契约。它最大的敌人不是技术复杂度,而是"这次先硬编码,下次再改"的侥幸。从第一个 token 开始,把"单一来源"立起来,剩下的会自然长出来。

Related

相关推荐