设计系统与 Design Tokens
产品一大,UI 就乱——颜色字号各写各的、组件不统一。设计系统解决这个。本文讲清 Design Tokens(设计变量化的原子)、组件库分层(token→基础组件→业务组件)、主题切换、以及怎么让设计和开发用同一套语言,而不是各自为政。
本文要点
- 主题聚焦:React、TypeScript
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
设计系统与 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 是设计系统的原子层。它的本质就一句话:把所有视觉决策(颜色、字号、间距、圆角、阴影、动效时长)抽成有名字的变量,而不是直接写值。
先看反例:
// 反模式:值散落,漂移的开始
<button style={{ background: '#1677ff', padding: '8px 16px', borderRadius: '6px' }}>
提交
</button>
再看正例。先定义 tokens(以 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,绝不写裸值:
<button className="btn-primary">提交</button>
.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。
{
"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 里又写一遍值:
// 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 还不够。设计系统必须分层,否则组件库会变成一锅粥。我推荐的四层架构:
┌─────────────────────────────────────┐
│ 4. 页面 (Page) │ ← 组装业务组件,处理布局
├─────────────────────────────────────┤
│ 3. 业务组件 (Business Component) │ ← ProductCard / OrderTable
├─────────────────────────────────────┤
│ 2. 基础组件 (Primitive Component) │ ← Button / Input / Modal
├─────────────────────────────────────┤
│ 1. Tokens(原子) │ ← 颜色/字号/间距/圆角
└─────────────────────────────────────┘
约束是:上层可以依赖下层,下层绝不依赖上层。 Tokens 不知道有 Button,Button 不知道有 ProductCard。这是单向依赖,违反这条规则,系统就会腐化。
看基础组件怎么写。它只消费 tokens,不带任何业务:
// 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} />
}
业务组件则消费基础组件,叠加业务语义:
// 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>
)
}
页面只做组装,不写样式细节:
export default function ProductDetailPage({ product }) {
return (
<Page>
<ProductCard product={product} />
<AddToCartButton productId={product.id} />
</Page>
)
}
这套分层的好处:基础组件可以在任何业务里复用,业务组件聚焦业务逻辑,页面只管编排。改 Button 的样式,全站所有按钮跟着变;改"加入购物车"的逻辑,只动业务组件。变更的爆炸半径是可控的。
四、主题与暗色:一套 token 切多主题
主题切换是设计系统的经典场景,而它之所以能成立,完全靠 tokens 驱动。思路是:token 名字不变,值随主题变。
/* 亮色主题 */
: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;
}
切换主题只需要改一个属性:
// 切暗色
document.documentElement.dataset.theme = 'dark'
组件代码一行都不用改,因为它引用的是 var(--color-bg-base),这个 token 在不同主题下解析到不同的值。这就是 tokens 的威力——把"多主题"这个复杂问题,降维成了"换一张变量表"。
注意几个实战细节:
- 永远用语义 token(
bg-base、text-primary),而不是引用原始色(gray-900)。 否则暗色模式下,原本是浅底深字的地方,会变成深底深字——看不清。 - 避免在组件里硬编码颜色到主题判断(
theme === 'dark' ? '#fff' : '#000')。这种条件分支一旦多了就维护不动,应该全部交给 token 解析。 prefers-color-scheme自动跟随系统:
@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 编译流水线。
// 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 上跑。
// 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 修复,不影响 APIminor:新增功能,向后兼容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),只能有一个来源。
小结
- 设计系统不是组件库,是设计与开发的共同语言。 它解决的是值漂移、分层混乱、协同扯皮这三件事,核心抓手是 Design Tokens。
- Token 是设计系统的原子。 把颜色、字号、间距、圆角、阴影全部变量化,用语义化命名,以 JSON 作为跨平台的单一来源,再编译成各端格式。
- 组件库必须分层。 tokens → 基础组件 → 业务组件 → 页面,单向依赖。上层可依赖下层,下层绝不依赖上层。变更的爆炸半径要可控。
- 主题切换靠 token,不靠条件判断。 token 名字固定,值随主题变;组件代码对主题无感知,多主题就被降维成"换一张变量表"。
- 协同的关键是单一来源 + 自动同步。 Figma token 和代码 token 不能各抄一套,用 Tokens Studio + Style Dictionary 让"改一处,全平台生效"。
金句收尾:写死一个颜色只要 10 秒,但改掉它在全站的几百处散落值,要花你三天——Design Tokens 的全部意义,就是把这三天的债,提前在 10 秒里还掉。
设计系统不是一蹴而就的项目,是持续演进的契约。它最大的敌人不是技术复杂度,而是"这次先硬编码,下次再改"的侥幸。从第一个 token 开始,把"单一来源"立起来,剩下的会自然长出来。
Related