
一句话:设计系统的核心是「用 token 把『设计决策』抽成变量」——原始层(primitive)→ 语义层(semantic)→ 组件层(component) 三层 token 是一切的地基;而组件库的工程重点是「保留源码形态的构建产物(不打包依赖)+ 类型 + 文档 + 视觉回归 + 版本管理」。
一、分层:设计系统不只是「一堆组件」
⭐⭐ 设计系统的五层(越往下越「本地化」,越往上越「通用」):
① 【Design Tokens】颜色、间距、圆角、字号、阴影、动效时长 —— 最抽象的「设计决策」
② 【Primitives】Button / Input / Text / Icon / Stack 这类「原子」组件
③ 【Components】SearchBar / Pagination / Modal / DataTable —— 由原子组合
④ 【Patterns】「表单校验流程」「空状态规范」「加载态规范」—— 组合的**约定**
⑤ 【Pages / Templates】具体业务页面
⭐ 判据:一个「设计系统」如果只有第 ③ 层(组件),那它只是「组件库」——
真正的设计系统要能从第 ① 层「驱动」所有层(换 token 就能换肤)。
二、Design Tokens:三层变量
/* ✅ 第 1 层【原始层 primitive】:与语义无关的「调色板/刻度尺」 */
:root {
/* 颜色档位 */
--blue-50: #e6f4ff;
--blue-500: #1677ff;
--blue-600: #0958d9;
--gray-0: #ffffff;
--gray-50: #fafafa;
--gray-200: #e8e8e8;
--gray-600: #595959;
--gray-900: #1f1f1f;
--red-500: #f5222d;
/* 间距刻度(4 的倍数) */
--space-0: 0;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
/* 圆角 */
--radius-none: 0;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-full: 9999px;
/* 字号/行高 */
--font-size-xs: 12px;
--font-size-sm: 14px;
--font-size-md: 16px;
--font-size-lg: 20px;
--font-size-xl: 24px;
--line-height-tight: 1.25;
--line-height-normal: 1.5;
/* 阴影 */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, .06);
--shadow-md: 0 4px 12px rgba(0, 0, 0, .1);
/* 动效 */
--duration-fast: 120ms;
--duration-normal: 240ms;
--easing-standard: cubic-bezier(.2, .8, .2, 1);
}
/* ✅ 第 2 层【语义层 semantic】:描述「用途」——⭐ 主题切换只改这一层 */
:root,
[data-theme='light'] {
--color-primary: var(--blue-500);
--color-primary-hover: var(--blue-600);
--color-bg-page: var(--gray-50);
--color-bg-surface: var(--gray-0);
--color-bg-subtle: var(--gray-50);
--color-text-primary: var(--gray-900);
--color-text-secondary: var(--gray-600);
--color-text-inverse: var(--gray-0);
--color-text-danger: var(--red-500);
--color-border: var(--gray-200);
--color-border-strong: var(--gray-600);
--color-focus-ring: var(--blue-500);
/* 语义化的间距(用「用途」命名,而不是「大小」) */
--space-inline-sm: var(--space-1);
--space-inline-md: var(--space-2);
--space-stack-md: var(--space-4);
--space-inset-md: var(--space-4);
}
/* ⭐⭐ 暗色主题:只覆盖语义层(组件一行都不用改) */
[data-theme='dark'] {
--color-bg-page: #141414;
--color-bg-surface: #1f1f1f;
--color-bg-subtle: #262626;
--color-text-primary: #f0f0f0;
--color-text-secondary: #a6a6a6;
--color-text-inverse: #141414;
--color-text-danger: #ff7875;
--color-border: #303030;
--color-border-strong: #595959;
/* 暗色下用「更浅」的主色 + 「更弱」的阴影 */
--color-primary: #4096ff;
--color-primary-hover: #69b1ff;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, .4);
--shadow-md: 0 4px 16px rgba(0, 0, 0, .5);
}
/* ✅ 第 3 层【组件层 component】:只在「需要独立控制」时才建 */
:root {
--button-height-sm: 24px;
--button-height-md: 32px;
--button-height-lg: 40px;
--button-bg-primary: var(--color-primary);
--button-bg-primary-hover: var(--color-primary-hover);
--button-bg-default: var(--color-bg-surface);
--button-radius: var(--radius-md);
--button-font-size-md: var(--font-size-sm);
}
/* ⚠️ 组件层不要「重复定义一遍语义层的所有东西」——
那会让「改语义层」失去效果(等于把变量复制了一遍)。 */
⭐⭐⭐ 三层 token 的「为什么」(这是设计系统能否规模化的分水岭):
【为什么要分「原始层」和「语义层」?】
· 原始层回答「**有哪些可用的颜色/尺寸**」(调色板)
· 语义层回答「**这个东西该用什么颜色**」(用途)
⭐ 组件只能引用「语义层」——
于是「换主题 = 改语义层的映射」,组件一行不改。
❌ 反例:组件直接用 `var(--blue-500)`
→ 暗色主题下它还是亮蓝(瞎眼)→ 只能一个个改组件。
【为什么还要「组件层」?】
因为有些需求只属于「某个组件」(如「按钮的高度」),
放进语义层会污染全局(`--height-sm` 到底是行高还是按钮高?)。
⚠️ 但组件层的变量**必须有默认值指向语义层**,否则会成为「暗色模式的黑洞」。
【命名的两条规范】
① 用「**用途**」而不是「**外观**」:`--color-text-danger` 而不是 `--color-red`
(⭐ 因为「危险」在暗色下可能不是红色)
② 用「**层级/角色**」而不是「**具体值**」:`--space-stack-md` 而不是 `--space-16`
(改刻度时语义不变)
三、主题切换:三件事
// ✅ 一个完整的主题管理(含「跟随系统」+ 持久化 + 无闪烁)
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react';
type ThemeMode = 'light' | 'dark' | 'system';
interface ThemeContextValue {
/** 用户选择的模式(可能为 system) */
mode: ThemeMode;
/** 实际生效的主题(已解析 system) */
resolved: 'light' | 'dark';
setMode: (mode: ThemeMode) => void;
/** 品牌色覆盖(可选) */
setBrandColor: (color: string) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
const STORAGE_KEY = 'ui:theme-mode';
/** ⭐ 从系统偏好解析实际主题 */
function resolveTheme(mode: ThemeMode): 'light' | 'dark' {
if (mode !== 'system') return mode;
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export function ThemeProvider({ children, defaultMode = 'system' }: { children: React.ReactNode; defaultMode?: ThemeMode }) {
const [mode, setModeState] = useState<ThemeMode>(() => {
if (typeof window === 'undefined') return defaultMode;
return (localStorage.getItem(STORAGE_KEY) as ThemeMode | null) ?? defaultMode;
});
const [resolved, setResolved] = useState<'light' | 'dark'>(() => resolveTheme(mode));
/** ⭐ 把主题写到 <html> 的 data-theme 上(CSS 变量据此切换) */
useEffect(() => {
const next = resolveTheme(mode);
setResolved(next);
document.documentElement.dataset.theme = next;
// ⭐ 还要同步给浏览器(表单控件、滚动条、状态栏的颜色)
document.documentElement.style.colorScheme = next;
}, [mode]);
/** ⭐ 跟随系统:监听系统偏好变化(只在 mode === 'system' 时有效) */
useEffect(() => {
if (mode !== 'system') return;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => {
const next = mq.matches ? 'dark' : 'light';
setResolved(next);
document.documentElement.dataset.theme = next;
document.documentElement.style.colorScheme = next;
};
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, [mode]);
const setMode = useCallback((next: ThemeMode) => {
setModeState(next);
try { localStorage.setItem(STORAGE_KEY, next); } catch { /* 隐私模式 */ }
}, []);
/** ⭐ 品牌色覆盖(只改「语义层的映射」) */
const setBrandColor = useCallback((color: string) => {
document.documentElement.style.setProperty('--color-primary', color);
// ⚠️ hover 色通常需要「更深的同色系」→ 用 color-mix 计算(现代浏览器)
document.documentElement.style.setProperty('--color-primary-hover', `color-mix(in srgb, ${color} 85%, black)`);
}, []);
const value = useMemo<ThemeContextValue>(
() => ({ mode, resolved, setMode, setBrandColor }),
[mode, resolved, setMode, setBrandColor]
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('<ThemeProvider> 缺失:useTheme 必须在 ThemeProvider 内使用');
return ctx;
}
<!-- ⭐⭐⭐ 关键:在 HTML 里「阻塞式」设置主题,避免暗色用户看到「白屏闪一下」 -->
<script>
(function () {
try {
var mode = localStorage.getItem('ui:theme-mode') || 'system';
var resolved = mode === 'system'
? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: mode;
document.documentElement.dataset.theme = resolved;
document.documentElement.style.colorScheme = resolved;
} catch (e) { /* 忽略 */ }
})();
</script>
⭐⭐⭐ 这段「内联脚本」是「主题闪烁(FOUC)」的唯一正解:
问题:如果只靠 React 的 useEffect 设置主题:
① 浏览器先渲染「默认(亮色)」HTML
② JS 加载 → React 渲染 → effect 执行 → 改成暗色
→ ⭐ 暗色用户会看到「**白屏闪一下**」
⭐ 解法:在 `<head>` 里放一段「**同步执行的极小脚本**」,
在「首次绘制之前」就把 `data-theme` 写到 `<html>` 上。
⚠️ 注意:这段脚本必须「内联」(不能是外部文件,否则有网络延迟)
⭐ 另外两个细节:
· `color-scheme` 要一起设(影响表单控件、滚动条、<meta name="theme-color">)
· 服务端渲染时,在 `<html data-theme="...">` 里也带上(根据 cookie/header 判断)
四、组件库的构建:六条工程约束
// ✅ packages/ui/package.json —— 组件库的「构建产物」约定
{
"name": "@acme/ui",
"version": "3.2.1",
"sideEffects": ["*.css", "./src/styles/*"], // ⭐ 保护 CSS 不被 tree-shake 掉
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./styles.css": "./dist/styles.css",
"./package.json": "./package.json"
},
"peerDependencies": {
// ⭐⭐ 关键:React 必须是 peer(不能打进产物,否则「两份 React」→ Hooks 报错)
"react": ">=18",
"react-dom": ">=18"
},
"dependencies": {
// ⚠️ 尽量「零运行时依赖」;如果必须有,也要「小而稳」
},
"devDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
⭐⭐⭐ 组件库构建的六条约束(每一条都有真实事故):
① 【依赖必须是 peer,不能打包进来】
⚠️ 如果产物里打进了 `react` → 用户项目里会有「两份 React」→
**"Invalid Hook call"**(这是最常见的组件库事故,见 5.14)。
✅ `react`/`react-dom` 放 `peerDependencies`,并在构建配置里 `external`。
② 【产出 ESM(保留模块结构),不要只出 UMD/单文件】
⭐ 单文件会「破坏 tree-shaking」(用户 import 一个 Button,拉进整库)。
✅ 用 Rollup/Vite 的 library 模式 + `preserveModules`(或 tsup 的多入口),
让它「一个源文件 → 一个产物文件」。
③ 【`sideEffects` 要写对】
⚠️ 写了 `false` 但产物里有 CSS → **CSS 被删掉**(经典事故,见 5.14)。
✅ 数组形式列出 CSS 等有副作用的文件。
④ 【类型要跟着走】
`exports` 里每个入口都要有 `types` 字段(或用 `typesVersions` 兜底)。
⚠️ 「类型能解析到」与「运行时能解析到」必须一致(否则 TS 通过但运行时报错)。
⑤ 【样式有两种交付方式,要选清楚】
· **方式 A:产出「一个 styles.css」**(用户自己 import)
✅ 简单可控;⚠️ 用户容易忘(然后样式全丢)
· **方式 B:CSS-in-JS / CSS Modules 内联进组件**
✅ 用户不用管;⚠️ 运行时有成本(见第 6 条)
⭐ 推荐:**「CSS 变量(token)+ 独立 styles.css」**,
文档里明确写「必须 import 样式」。
⑥ 【样式方案的选择(性能视角)】
| 方案 | 运行时成本 | SSR | 动态主题 | 说明 |
| --- | --- | --- | --- | --- |
| **CSS Modules / plain CSS** | ⭐ 无 | 无需特殊处理 | ✅(CSS 变量) | 首选 |
| **Tailwind** | ⭐ 无 | 无 | ✅(CSS 变量) | 首选(配 token) |
| **vanilla-extract** | ⭐ 无(编译期) | 无 | ✅ | 类型安全的 CSS |
| **CSS-in-JS(运行时,如 emotion/styled)** | ⚠️ 有(每个组件渲染时要生成/插入样式) | ⚠️ 需要抽取 | ✅ | 灵活性高,代价是运行时 |
| **CSS-in-JS(编译期,如 Linaria/Compiled)** | ⭐ 无 | 无 | ✅ | 折中 |
⭐ 判据:「**能不能在构建期把样式定下来**」——能,就别付运行时成本(见 2.17)。
/* ✅ 用 token 写组件的正确方式(CSS 类 + 语义 token) */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-inline-sm);
border: 1px solid transparent;
border-radius: var(--button-radius);
font-size: var(--button-font-size-md);
line-height: var(--line-height-tight);
cursor: pointer;
transition:
background-color var(--duration-fast) var(--easing-standard),
border-color var(--duration-fast) var(--easing-standard),
color var(--duration-fast) var(--easing-standard);
}
/* ⭐ 尺寸变体 */
.btn--sm { height: var(--button-height-sm); padding: 0 var(--space-2); }
.btn--md { height: var(--button-height-md); padding: 0 var(--space-3); }
.btn--lg { height: var(--button-height-lg); padding: 0 var(--space-4); }
/* ⭐ 语义变体(全部走「语义层」token) */
.btn--primary {
background: var(--button-bg-primary);
color: var(--color-text-inverse);
}
.btn--primary:hover { background: var(--button-bg-primary-hover); }
.btn--default {
background: var(--button-bg-default);
color: var(--color-text-primary);
border-color: var(--color-border);
}
.btn--default:hover { border-color: var(--color-border-strong); }
/* ⭐ 无障碍:focus-visible 用 token(不要用 outline: none!) */
.btn:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
/* ⭐ 禁用态(用 opacity + cursor,而不是「改颜色」——保证对比度一致) */
.btn:disabled {
opacity: .5;
cursor: not-allowed;
pointer-events: none;
}
// packages/ui/src/Button.tsx
import { forwardRef, useMemo } from 'react';
import './Button.css'; // ⚠️ 记得在 sideEffects 里声明
export type ButtonVariant = 'primary' | 'default' | 'dashed' | 'text' | 'link' | 'danger';
export type ButtonSize = 'sm' | 'md' | 'lg';
export interface ButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'type'> {
variant?: ButtonVariant;
size?: ButtonSize;
/** 加载中(自动禁用 + 显示 spinner) */
loading?: boolean;
/** 图标(放内容前/后) */
icon?: React.ReactNode;
iconPosition?: 'start' | 'end';
/** 占满父容器宽度 */
block?: boolean;
/** 危险确认(仅视觉) */
danger?: boolean;
type?: 'button' | 'submit' | 'reset';
}
/** ⭐ 拼类名的极简工具(避免引 clsx 之类的依赖) */
function cx(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ');
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
variant = 'default',
size = 'md',
loading = false,
icon,
iconPosition = 'start',
block = false,
danger = false,
type = 'button',
className,
children,
disabled,
...rest
},
ref
) {
// ⭐ 变体类名(用 useMemo 避免每次渲染重建字符串——虽然成本很小,但可读性好)
const classes = useMemo(
() =>
cx(
'btn',
`btn--${size}`,
`btn--${danger && variant === 'default' ? 'danger' : variant}`,
block && 'btn--block',
loading && 'btn--loading',
className
),
[size, variant, danger, block, loading, className]
);
const isDisabled = disabled || loading;
return (
<button
{...rest}
ref={ref}
type={type}
className={classes}
disabled={isDisabled}
// ⭐ 无障碍:加载中要告诉辅助技术「忙碌」(而不是只画个圈)
aria-busy={loading || undefined}
aria-disabled={isDisabled || undefined}
>
{/* ⭐ 图标(占位用 aria-hidden,因为它只是装饰) */}
{icon && iconPosition === 'start' && <span className="btn__icon" aria-hidden>{icon}</span>}
{children != null && <span className="btn__content">{children}</span>}
{icon && iconPosition === 'end' && <span className="btn__icon" aria-hidden>{icon}</span>}
{/* ⭐ 加载指示(用 CSS 动画,只改 transform/opacity,见 5.17) */}
{loading && <span className="btn__spinner" aria-hidden />}
</button>
);
});
/* packages/ui/src/Button.css —— 承接上面的类名 */
.btn--block { width: 100%; }
.btn--danger {
background: var(--color-text-danger);
color: var(--color-text-inverse);
}
.btn--danger:hover { opacity: .88; }
.btn--dashed {
background: transparent;
border-style: dashed;
border-color: var(--color-border-strong);
color: var(--color-text-primary);
}
.btn--text, .btn--link {
background: transparent;
border-color: transparent;
padding-inline: var(--space-inline-sm);
}
.btn--link { color: var(--color-primary); text-decoration: none; }
.btn--link:hover { text-decoration: underline; }
.btn__icon { display: inline-flex; }
.btn--loading .btn__content,
.btn--loading .btn__icon { visibility: hidden; } /* ⭐ 保留宽度(避免按钮变窄) */
.btn__spinner {
position: absolute;
width: 1em;
height: 1em;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: var(--radius-full);
animation: btn-spin 600ms linear infinite;
/* ⭐ 只改 transform(走合成线程) */
will-change: transform;
}
@keyframes btn-spin {
to { transform: rotate(360deg); }
}
/* ⭐ 尊重用户的「减少动态效果」偏好(见 1.15) */
@media (prefers-reduced-motion: reduce) {
.btn__spinner { animation-duration: 1.6s; }
.btn { transition: none; }
}
// ✅ 用法
<div style={{ display: 'flex', gap: 'var(--space-stack-md)' }}>
<Button variant="primary">保存</Button>
<Button variant="default" icon={<span>+</span>}>新建</Button>
<Button variant="danger" loading={saving}>删除</Button>
<Button variant="link" size="sm">查看详情</Button>
</div>
⭐⭐ 这个 Button 演示了「设计系统级组件」的六个规范:
① 【类名用 BEM 风格 + 变体修饰符】(`btn--primary`/`btn__icon`)
⭐ 便于「用户用 className 覆盖」而不破坏内部结构
② 【所有视觉值都来自 token】(颜色/间距/圆角/时长)
→ 换主题「零改动」
③ 【`loading` 用「隐藏内容 + 绝对定位 spinner」而不是「替换文本」】
⭐ 这样「按钮宽度不变」(否则一加载就跳一下)
④ 【`aria-busy` + `aria-disabled`】
`disabled` 属性会让元素「失去焦点」(用户失去位置);
⭐ 更好的做法是「`aria-disabled` + 阻止点击」,这里两者都给上(保守)
⑤ 【`focus-visible` 而不是 `:focus`】
`:focus`(点击也会出现轮廓)会让鼠标用户觉得丑 → 很多人就 `outline: none`(❌ 破坏无障碍)
⭐ `:focus-visible` 只在「键盘导航」时出现轮廓 —— 既好看又无障碍
⑥ 【`prefers-reduced-motion` 降级】
动画是「装饰」,不该让不适的用户难受
六、质量与发布:四道关
⭐⭐⭐ 组件库的「四道质量关」(少一道就会在用户项目里出事故):
① 【单元/交互测试】Testing Library + Vitest/Jest
· 测「行为」(点击调用了 onChange、键盘能操作)而不是「实现」
· ⭐ 用 `role`/`label` 查询(这样「测试也顺便校验了无障碍」)
② 【视觉回归】Chromatic / Playwright 截图 / reg-suit
· ⭐ 组件库「改样式不改逻辑」的回归是最难被单测发现的
· 做法:对每个组件的「所有变体 × 尺寸 × 状态(hover/focus/disabled/loading)」截图比对
③ 【可访问性自动检查】jest-axe / axe-core
· 自动能查出「对比度不足、缺 label、role 错误」这类问题
· ⚠️ 自动检查只能覆盖约 30~50% 的问题 → 还要「键盘走一遍」+ 屏幕阅读器抽测
④ 【体积与依赖检查】size-limit + 「禁止引入新依赖」的 CI 规则
· ⭐ 组件库要「极度克制依赖」:用户引你的库时,也在引你的依赖
· 做法:把「产物体积」与「依赖数量」纳入 CI 门禁(见 5.14)
# ✅ 发布流程:changesets(语义化版本 + 变更日志)
# .changeset/config.json
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@acme/docs"]
}
⭐⭐ 发布的三条纪律:
① 【用 changesets 管理版本】
每个 PR 带一个 `.changeset/*.md`(说明「这次改了什么、是 patch/minor/major」)
→ 发版时自动「算版本号 + 生成 CHANGELOG + 打 tag」
② 【破坏性变更要有「迁移路径」】
· 旧 API 保留一个「大版本」,标 `@deprecated` + 开发期警告(见 1.11)
· 提供 codemod(`npx @acme/ui-codemod x-to-y`)—— ⭐ 这是「大版本升级」的最大善意
· CHANGELOG 里写「如何迁移」而不只是「改了什么」
③ 【内部包与外部包分开】
内部业务包(`@acme/internal-*`)可以不强求 semver;
对外发布的包必须严格 semver(用户依赖你的承诺)
七、本篇特有的坑
/* ① 组件直接用「原始层 token」(换主题时瞎眼) */
.button { background: var(--blue-500); } /* ❌ */
.button { background: var(--color-primary); } /* ✅ */
/* ② 只在「深色主题」里覆盖部分变量(导致「半明半暗」) */
[data-theme='dark'] { --color-bg-surface: #1f1f1f; } /* ⚠️ 忘了 --color-text-primary */
/* ✅ 语义层的**每一个**变量都要有「两套值」(或确保「未覆盖的」在两种主题下都可读) */
/* ③ 用 JS 读 token 再写内联 style(丧失 CSS 变量的优势) */
const primary = getComputedStyle(document.documentElement).getPropertyValue('--color-primary');
<button style={{ background: primary }} /> /* ❌ 主题切换后不会更新 */
/* ✅ 直接写 `style={{ background: 'var(--color-primary)' }}` 或用 CSS 类 */
/* ④ 主题切换「闪烁一下」(只在 React effect 里设主题) */
/* ✅ 在 `<head>` 里内联一段阻塞脚本(见上)+ `color-scheme` */
/* ⑤ 忘记同步 `color-scheme`(表单控件/滚动条还是亮的) */
/* ✅ `document.documentElement.style.colorScheme = theme` + CSS `color-scheme: light dark` */
/* ⑥ 用 `outline: none` 去掉焦点轮廓(破坏键盘无障碍) */
*:focus { outline: none; } /* ❌❌ */
/* ✅ 用 `:focus-visible` + 设置好看的 `outline` */
// ⑦ 组件库把 react 打进产物(用户项目两份 React → Hooks 报错)
{ "dependencies": { "react": "^19" } } /* ❌ */
{ "peerDependencies": { "react": ">=18" } } /* ✅ */
/* ⑧ `sideEffects: false` 但产物里有 CSS(CSS 被删掉) */
/* ✅ "sideEffects": ["*.css"] */
/* ⑨ 样式用「全局类名」(用户项目里撞名) */
.button { } /* ⚠️ 太通用 */
/* ✅ 加前缀(`.ac-btn`)或用 CSS Modules(`.btn__abc123`) */
/* ⑩ 组件的 `style` prop 直接覆盖内部样式(用户覆盖不了) */
<button style={{ ...internalStyles, ...props.style }} /> /* ⚠️ 用户的样式在后(这其实是对的) */
/* ⭐ 注意顺序:应该是「内部样式在前、用户 style 在后」(让用户能覆盖) */
/* ⑪ 用 `CSS-in-JS` 的运行时方案但没有「抽取」(SSR 首屏闪) */
/* ✅ 用编译期方案(vanilla-extract / Linaria)或「运行时 + 关键 CSS 抽取」 */
/* ⑫ 主题变量定义在「某个 React 组件」上(Portal 出来就丢了) */
.modal-wrapper { --color-bg-surface: ...; } /* ⚠️ Portal 出去的浮层拿不到 */
/* ✅ 定义在 `:root` 或 `html[data-theme]` 上(见 1.12) */
/* ⑬ 间距不统一(有人写 8px、有人写 var(--space-2)、有人写 0.5rem) */
/* ✅ 用 lint 规则禁止「裸数值」:stylelint 的 `declaration-property-value-allowed-list` 之类 */
// ⑭ 组件库「零测试」就发版(用户成了测试员)
// ✅ 四道关(单测/视觉回归/a11y/体积)
// ⑮ 组件的「受控/非受控」在两个组件里不一致(用户困惑)
// ✅ 统一(见 1.11)
// ⑯ 「内部包」与「外部包」混在一个 monorepo 但没有边界(改内部包意外破坏外部包)
// ✅ 用 `exports` 只暴露「公开入口」+ CI 检查「外部包不 import 内部包的私有路径」
// ⑰ 文档与实现不一致(文档里写的 prop 已经删了)
// ✅ Storybook 的「Controls/Autodocs」从类型生成 + CI 检查「每个组件都有 story」
// ⑱ 发布的包里带了「测试文件/源码/src」(体积变大)
"files": ["dist"] // ✅ 只发产物
// ⚠️ 但也有人「发 src 让用户自己编译」——那需要用户配 transpilePackages
// ⭐ 二选一,别「既发 dist 又发 src」让人困惑
// ⑲ peerDependencies 的版本范围太窄(用户升级 React 就装不上)
"peerDependencies": { "react": "19.0.0" } // ⚠️ 太严
"peerDependencies": { "react": ">=18" } // ✅ 宽松且合理
// ⑳ 「换肤」需求用「多份 CSS 文件切换」实现(切换时闪烁 + 体积翻倍)
/* ✅ 用 CSS 变量(同一份 CSS,只换变量值) */
// ⑬ 的完整处理(用 stylelint 限制「只能用 token」)
// .stylelintrc.json
{
"rules": {
// ⭐ 禁止在「颜色/间距」属性上写裸值(必须用 var())
"declaration-property-value-allowed-list": {
"/^(color|background|background-color|border-color|box-shadow)$/": ["/^var\\(--.*\\)$/", "/^transparent$/", "/^inherit$/", "/^none$/"],
"/^(margin|padding|gap|row-gap|column-gap)(-.*)?$/": ["/^var\\(--.*\\)$/", "/^0$/", "/^auto$/"]
}
}
}
八、面试延伸
- 「设计系统和组件库有什么区别?」
核心区别是「有没有 token 这一层」。组件库是「一堆组件」;设计系统要能从「设计决策」驱动所有层:① Design Tokens(颜色/间距/圆角/字号/阴影/动效)→ ② Primitives(Button/Input/Text)→ ③ Components(SearchBar/DataTable)→ ④ Patterns(表单校验流程、空状态规范)→ ⑤ Pages。⭐ 判据:「换 token 就能换肤吗」——如果换一个主题色需要改几十个组件文件,那它只是组件库。所以设计系统的工程重点是「把 token 抽出来并让组件只引用语义 token」。 -
「Design Tokens 为什么分三层?」
① 原始层(primitive) 回答「有哪些可用的颜色/尺寸」(--blue-500、--space-2),是「调色板与刻度尺」;② 语义层(semantic) 回答「这个东西该用什么颜色」(--color-primary、--color-text-danger、--color-bg-surface);③ 组件层(component) 只放「某个组件专属的值」(--button-height-md)。⭐ 关键约束是「组件只能引用语义层」——于是「换主题 = 改语义层的映射」,组件一行不改。❌ 反例:组件直接写var(--blue-500)→ 暗色主题下还是亮蓝,只能一个个改组件。命名两条规范:用「用途」而不是「外观」(--color-text-danger而不是--color-red——「危险」在暗色下未必是红的);用「角色」而不是「具体值」(--space-stack-md而不是--space-16)。 -
「主题切换怎么避免『白屏闪一下』?」
根因是「主题是在 JS(React effect)里设置的」:浏览器先渲染「默认(亮色)HTML」,等 JS 加载执行后才改成暗色,于是暗色用户看到白屏一闪。⭐ 正解是在<head>里放一段「内联、同步执行」的极小脚本——它在首次绘制之前就把data-theme写到<html>上,CSS 变量立刻就是正确的值。三个配套细节:① 脚本必须内联(外部文件有网络延迟);② 同时设置color-scheme(影响表单控件、滚动条、<meta name="theme-color">);③ SSR 时在<html data-theme="...">里直接带上(根据 cookie/header 判断),连内联脚本都不需要。另外「跟随系统」要监听matchMedia('(prefers-color-scheme: dark)')的change事件。 -
「组件库构建有哪些硬约束?」
六条:① ⭐react/react-dom必须是peerDependencies且构建时external——否则产物里打进一份 React,用户项目就「两份 React」→Invalid Hook call(最常见的组件库事故);② 产出 ESM 并保留模块结构(preserveModules/ 多入口)——单文件会破坏 tree-shaking;③ ⭐sideEffects要写对(["*.css"])——写成false会把 CSS 删掉;④ 类型要跟着exports走(每个入口都有types,且「类型解析」与「运行时解析」一致);⑤ 样式交付方式要明确(独立styles.css还是内联进组件);⑥ ⭐ 样式方案优先选「构建期」的(CSS Modules / Tailwind / vanilla-extract——零运行时),慎用运行时 CSS-in-JS(见 2.17)。 -
「组件库怎么做质量保障?」
四道关:① 交互测试(Testing Library)——⭐ 用role/label查询,这样「测试顺便也校验了无障碍」;② ⭐ 视觉回归(Chromatic / Playwright 截图)——组件库「改样式不改逻辑」的回归是单测发现不了的,应该对「所有变体 × 尺寸 × 状态(hover/focus/disabled/loading)」截图比对;③ 可访问性自动检查(jest-axe / axe-core)——能查「对比度、缺 label、role 错误」,但 ⚠️ 只能覆盖约 30~50%,还要「键盘走一遍 + 屏幕阅读器抽测」;④ 体积与依赖门禁(size-limit + 「禁止引入新依赖」的 CI 规则)——⭐ 组件库要极度克制依赖,因为「用户引你的库时也在引你的依赖」。 -
「为什么组件库的样式不要用运行时 CSS-in-JS?」
因为运行时成本是「每个组件渲染时都要生成/插入样式」:① 首屏要「JS 执行 → 生成 CSS → 插入」才能有样式(关键渲染路径变长);② 高频渲染的组件(列表/动画)会反复做「序列化 + 哈希 + 插入」;③ SSR 需要「抽取 + 注入」,多一层复杂度。⭐ 而 CSS Modules / Tailwind / vanilla-extract 这些「构建期」方案零运行时——样式已经在 CSS 文件里,浏览器直接解析。取舍:运行时方案灵活(能基于 props 动态生成样式),但组件库的样式通常是「有限变体的组合」(变体 × 尺寸 × 状态),完全可以在构建期枚举出来(如btn--primary)→ ⭐ 所以组件库应该用「构建期方案 + CSS 变量做主题」。例外:需要「使用者传入任意值」(如colorprop 传任意色)时,用「内联 CSS 变量」而不是「生成样式类」。 -
「组件库怎么管理版本与破坏性变更?」
三条纪律:① ⭐ 用 changesets 管理版本——每个 PR 带一个.changeset/*.md(说明「改了什么 + 是 patch/minor/major」),发版时自动「算版本号 + 生成 CHANGELOG + 打 tag」;② 破坏性变更要有迁移路径——旧 API 保留一个大版本并标@deprecated+ 开发期console.warn(见 1.11),⭐ 并尽量提供 codemod(npx @acme/ui-codemod),这是「大版本升级」最大的善意;③ CHANGELOG 写「如何迁移」而不只是「改了什么」。另外「内部包」与「对外包」要分开——对外包必须严格 semver(用户依赖你的承诺),内部包可以宽松(用workspace:*直接引用)。 -
「
:focus和:focus-visible有什么区别?」
:focus在「任何方式获得焦点」时都匹配(包括鼠标点击)——于是鼠标用户点按钮也会看到轮廓,很多人觉得丑就写outline: none,⭐ 这直接破坏了键盘无障碍(键盘用户看不到自己在哪)。:focus-visible只在「浏览器判断『应该显示焦点指示器』时」匹配——实践中就是「键盘导航(Tab)时匹配,鼠标点击时不匹配」。✅ 正确做法:outline: none可以写,但必须同时给:focus-visible一个清晰的焦点样式(用--color-focus-ringtoken +outline-offset)。⭐ 注意「:focus-visible的启发式规则不要依赖」(如在输入框上鼠标点击也会匹配,因为「输入框需要知道焦点在哪」)——所以不要用:focus-visible做「键盘/鼠标」的分支逻辑,它只是「何时显示轮廓」。
一句话速记
设计系统 = 「token 驱动的五层(Tokens → Primitives → Components → Patterns → Pages)」,⭐ 判据是「换 token 就能换肤」;token 分三层:原始层(调色板/刻度,
--blue-500)→ 语义层(用途,--color-primary/--color-text-danger)→ 组件层(--button-height-md),组件只能引用语义层,且命名用「用途」而不是「外观」;主题切换的防闪烁唯一正解是「<head>里内联同步脚本」(在首次绘制前写data-theme)+ 同步color-scheme+ SSR 直接输出;组件库六条硬约束:「react 必须 peer + external(否则两份 React → Invalid Hook call)、产出 ESM 保留模块结构、sideEffects写对(["*.css"],写false会删掉样式)、类型跟exports走、样式交付方式明确、优先构建期样式方案(零运行时)」;质量四道关:交互测试(用role查询顺便校验无障碍)、视觉回归(变体 × 尺寸 × 状态全截图)、a11y 自动检查(只覆盖 30~50%)、体积与依赖门禁;发布用 changesets +@deprecated过渡一个版本 + 尽量提供 codemod;焦点样式用:focus-visible而不是outline: none。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!