陌上人如玉
公子世无双

10.8 组件库与设计系统

10.8 组件库与设计系统

一句话:设计系统的核心是「用 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;
}

五、完整实现:一个「token 驱动」的 Button(200 行)

// 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$/"]
    }
  }
}

八、面试延伸

  1. 「设计系统和组件库有什么区别?」
    核心区别是「有没有 token 这一层」。组件库是「一堆组件」;设计系统要能从「设计决策」驱动所有层:① Design Tokens(颜色/间距/圆角/字号/阴影/动效)→ ② Primitives(Button/Input/Text)→ ③ Components(SearchBar/DataTable)→ ④ Patterns(表单校验流程、空状态规范)→ ⑤ Pages。⭐ 判据:「换 token 就能换肤吗」——如果换一个主题色需要改几十个组件文件,那它只是组件库。所以设计系统的工程重点是「把 token 抽出来并让组件只引用语义 token」。

  2. 「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)。

  3. 「主题切换怎么避免『白屏闪一下』?」
    根因是「主题是在 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 事件。

  4. 「组件库构建有哪些硬约束?」
    六条:① ⭐ 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)。

  5. 「组件库怎么做质量保障?」
    四道关:① 交互测试(Testing Library)——⭐ 用 role/label 查询,这样「测试顺便也校验了无障碍」;② ⭐ 视觉回归(Chromatic / Playwright 截图)——组件库「改样式不改逻辑」的回归是单测发现不了的,应该对「所有变体 × 尺寸 × 状态(hover/focus/disabled/loading)」截图比对;③ 可访问性自动检查(jest-axe / axe-core)——能查「对比度、缺 label、role 错误」,但 ⚠️ 只能覆盖约 30~50%,还要「键盘走一遍 + 屏幕阅读器抽测」;④ 体积与依赖门禁(size-limit + 「禁止引入新依赖」的 CI 规则)——⭐ 组件库要极度克制依赖,因为「用户引你的库时也在引你的依赖」。

  6. 「为什么组件库的样式不要用运行时 CSS-in-JS?」
    因为运行时成本是「每个组件渲染时都要生成/插入样式」:① 首屏要「JS 执行 → 生成 CSS → 插入」才能有样式(关键渲染路径变长);② 高频渲染的组件(列表/动画)会反复做「序列化 + 哈希 + 插入」;③ SSR 需要「抽取 + 注入」,多一层复杂度。⭐ 而 CSS Modules / Tailwind / vanilla-extract 这些「构建期」方案零运行时——样式已经在 CSS 文件里,浏览器直接解析。取舍:运行时方案灵活(能基于 props 动态生成样式),但组件库的样式通常是「有限变体的组合」(变体 × 尺寸 × 状态),完全可以在构建期枚举出来(如 btn--primary)→ ⭐ 所以组件库应该用「构建期方案 + CSS 变量做主题」。例外:需要「使用者传入任意值」(如 color prop 传任意色)时,用「内联 CSS 变量」而不是「生成样式类」。

  7. 「组件库怎么管理版本与破坏性变更?」
    三条纪律:① ⭐ 用 changesets 管理版本——每个 PR 带一个 .changeset/*.md(说明「改了什么 + 是 patch/minor/major」),发版时自动「算版本号 + 生成 CHANGELOG + 打 tag」;② 破坏性变更要有迁移路径——旧 API 保留一个大版本并标 @deprecated + 开发期 console.warn(见 1.11),⭐ 并尽量提供 codemod(npx @acme/ui-codemod),这是「大版本升级」最大的善意;③ CHANGELOG 写「如何迁移」而不只是「改了什么」。另外「内部包」与「对外包」要分开——对外包必须严格 semver(用户依赖你的承诺),内部包可以宽松(用 workspace:* 直接引用)。

  8. 「:focus 和 :focus-visible 有什么区别?」
    :focus 在「任何方式获得焦点」时都匹配(包括鼠标点击)——于是鼠标用户点按钮也会看到轮廓,很多人觉得丑就写 outline: none,⭐ 这直接破坏了键盘无障碍(键盘用户看不到自己在哪)。:focus-visible 只在「浏览器判断『应该显示焦点指示器』时」匹配——实践中就是「键盘导航(Tab)时匹配,鼠标点击时不匹配」。✅ 正确做法:outline: none 可以写,但必须同时给 :focus-visible 一个清晰的焦点样式(用 --color-focus-ring token + 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。

赞(0) 打赏
未经允许不得转载:陌上寒 » 10.8 组件库与设计系统

评论 抢沙发

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

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

微信扫一扫

支付宝扫一扫