Chakra UI Design Token 从 0 到 1:语义 Token、主题扩展与组件一致性治理

7959
2026-02-28 15:13
18 小时前

Chakra UI Design Token 从 0 到 1:语义 Token、主题扩展与组件一致性治理

这篇文章是一个 Chakra UI 的落地案例:如果你的 Web UI 技术栈是 Chakra UI, 怎么从“散装样式”起步,最后得到一套可复用、可演进、可验证的主题与组件体系。

这类工程的难点通常不在“能不能做出一套主题”,而在于:如何把主题变成可执行的协作契约。 让后续新增页面、改品牌、做暗黑、做 A/B 主题时,团队不会重新回到“每个页面自己写一份样式”的混乱状态。

TL;DR(30 秒讲清楚)

  • 先分层:global scales(色板/间距/圆角/字号)→ semantic tokens(bg/text/border/primary…)→ component variants(按钮/卡片/输入框)。
  • 再收敛:业务组件尽量只用 semantic/component token,不直接写 hex/px/rgba。
  • 最后加护栏:类型提示(token 名可补全)+ lint(阻断新散装值)+ 视觉回归(抽样页面截图对比)。

问题定义(Problem Statement)

在 Chakra UI 项目中建设一套可持续演进的主题系统:让颜色/间距/圆角/排版统一由 Token 驱动; 让暗黑模式与多主题演进只需要改语义映射而不是改业务页面; 让 Button/Card/Input 等核心组件通过 variants 收敛样式自由度; 并通过类型提示、lint 与回归验证把“一致性”固化成工程能力。

关键伪代码(读者可复现)

1) Theme 结构:foundations + semanticTokens + components overrides

Chakra 的落地形态非常清晰:你最终会得到一个 extendTheme 的入口。 关键不是“把值塞进去”,而是把你的体系按职责拆开:foundations 放 global scales,semanticTokens 放语义映射(含暗黑),components 放组件契约(variants)。

伪代码:theme 入口(结构示意)

import { extendTheme } from '@chakra-ui/react';
import colors from './foundations/colors';
import space from './foundations/space';
import radii, { semanticRadii } from './foundations/radii';
import { semanticShadows } from './foundations/shadows';
import textStyles from './foundations/textStyles';
import Button from './components/button';
import BaseCard from './components/baseCard';
import InputContent from './components/inputContent';

const semanticTokens = {
  colors: {
    // 语义化(示例):bg/text/border/primary...
  },
  shadows: semanticShadows,
  radii: semanticRadii,
};

export const theme = extendTheme({
  colors,
  space,
  radii,
  textStyles,
  semanticTokens,
  components: { Button, BaseCard, InputContent },
});
Chakra 主题结构图
图:把主题当成“系统模块”组织,后续维护才不会退化成随手改文件。

2) semantic token 的关键设计:把“交互色板”做成可参数化的 palette

UI 一致性最容易失控的点,是“交互状态”:hover/active/disabled/focus。 如果业务页面自己拼状态色,你一定会得到 N 套行为。

一个非常实用的做法是把“交互色板”抽象成语义 palette(例如 primary / secondary / gray), 每个 palette 固定提供一组槽位:fgcontrastsubtlemutedemphasizedsolidfocusRinghover

伪代码:semantic palette(示例)

export const semanticColors = {
  primary: {
    contrast: { default: 'blackAlpha.0', _dark: 'blackAlpha.0' },
    fg: { default: 'yellow.800', _dark: 'yellow.300' },
    subtle: { default: 'yellow.150', _dark: 'yellow.900' },
    muted: { default: 'yellow.200', _dark: 'yellow.800' },
    emphasized: { default: 'yellow.300', _dark: 'yellow.700' },
    solid: { default: 'yellow.500', _dark: 'yellow.500' },
    focusRing: { default: 'yellow.400', _dark: 'yellow.400' }
  },
  bg: {
    panel: { default: 'whiteAlpha.0', _dark: 'gray.950' },
    subtle: { default: 'gray.100', _dark: 'gray.880' }
  },
  text: {
    fg: { default: 'gray.960', _dark: 'gray.960' },
    fgMuted: { default: 'gray.900', _dark: 'gray.350' }
  }
};

有了这个 palette,你的按钮/输入框/卡片就可以“参数化”:同一套组件逻辑,通过传入 colorPalette 切换不同的色彩风格, 而不是复制粘贴一份样式。

伪代码:通用按钮(基于 colorPalette)

export const ShareButtonStyle = {
  variants: {
    solid: (props) => ({
      bg: `${props.colorPalette}.solid`,
      color: `${props.colorPalette}.contrast`,
      _hover: { bg: `${props.colorPalette}.focusRing` }
    }),
    subtle: (props) => ({
      bg: `${props.colorPalette}.subtle`,
      color: `${props.colorPalette}.fg`,
      _hover: { bg: `${props.colorPalette}.muted` }
    })
  }
};

3) textStyles:用“可读的组合键”统一排版,并让业务只选 token

排版(字号/行高/字重/字体)是另一个容易失控的点。Chakra 的 textStyle 非常适合做排版 Token:业务侧只需写一个键名即可复用整套排版配置。

伪代码:textStyles(示例)

export const textStyles = {
  'title-xl-bold': { fontSize: 'xl', lineHeight: 'xl', fontWeight: 'bold', fontFamily: 'heading' },
  'body-md-medium': { fontSize: 'md', lineHeight: 'md', fontWeight: 'medium', fontFamily: 'body' },
  'body-sm-normal': { fontSize: 'sm', lineHeight: 'sm', fontWeight: 'normal', fontFamily: 'body' }
};

业务消费:不要再手写 fontSize="14px" 这类散装值

<Text textStyle="body-sm-normal" color="text.fgMuted">
  Subtext
</Text>

4) cssVarsRoot:别忘了 portals 与预览容器,否则“弹层样式会漂移”

Token 工程经常遇到一个非常隐蔽的坑:页面主体用 token 没问题,但 Menu/Popover/Modal 这类组件是 portal 渲染的, 如果你的 CSS variables 只挂在某个容器下,它们就拿不到正确的 token。

Chakra 提供了一个非常工程化的能力:cssVarsRoot。 你可以把变量根同时挂到主应用根节点、预览容器(例如主题编辑器的 .custom-theme)、以及 portal 容器上。

伪代码:ChakraProvider 注入(示例)

<ChakraProvider
  theme={theme}
  cssVarsRoot={['#__next', '.custom-theme', '.chakra-portal']}
>
  {children}
</ChakraProvider>

5) 类型提示:让 token 名“写错就报错”,而不是上线才发现

只要你的 token 体系足够大,“拼写错误”就会成为真实成本。尤其是 textStyles 这种长键名:写错一个字符,UI 就会悄悄回退到默认样式。

一个可落地做法是:在 CI 或本地命令里生成 Chakra 主题的类型定义,让 IDE 对 token 名有提示(并且让错误更早暴露)。

伪代码:生成主题类型(示例)

# 从 theme 生成 token types(示意)
npx @chakra-ui/cli tokens your-project/src/styles/theme/index.ts \
  --out your-project/node_modules/@chakra-ui/.../theming.types.d.ts

真实链路:一次“从散装到 token”的迁移怎么推进?

  1. 盘点:先用搜索把散装值找出来(hex/rgba/px),列出 Top N 高频值与使用场景。
  2. 定标:把颜色/间距/圆角/字号整理成 scales(global tokens),先保证“值统一”。
  3. 语义化:用 semantic tokens 把“页面语义”抽出来(bg/text/border + 交互 palette)。
  4. 组件收敛:先收敛 Button/Input/Card 这类高复用组件(variants),让业务不再拼交互状态。
  5. 护栏上线:加类型提示生成与 lint 门禁,阻断新的散装值进入。
  6. 回归验证:抽样 Light/Dark、移动/桌面、关键页面截图对比;灰度上线后观察一致性缺陷与反馈。

指标与验证(最小闭环)

  • 散装值数量:代码库 raw hex/rgba/px 的出现次数(目标是持续下降)。
  • Token 覆盖率:核心组件是否主要使用 bg.*/text.*/border.*/primary.* 等语义 token。
  • 视觉回归结果:抽样页面在迁移前后差异是否符合预期(最好能自动化像素 diff)。
  • 一致性缺陷数:UI 不一致类 bug/反馈的数量是否下降。

通过标准(建议):

  • 可发现:token/types 生成成功,IDE 可补全,减少拼写错误与散装值。
  • 可约束:新增散装值(hex/rgba/px)能被检索或 lint 发现,且数量趋势下降。
  • 可回归:抽样关键页面(Light/Dark、Mobile/Desktop、Marketing/App)无明显视觉回退。

最小可复现检查清单(示例)

# 1) 搜索散装值(示例:hex + rgba + px)
rg -n \"#[0-9a-fA-F]{3,8}\\b|rgba\\(|\\b\\d+px\\b\" your-project/src

# 2) 生成 token 类型提示(示例)
npm run generate-theme-types

# 3) 抽样验证:至少覆盖一组 portal 组件(Menu/Popover/Modal)
npm run dev

预期与判定(建议):

  • 散装值:能稳定找出新增散装值位置;推荐门禁口径是“新增散装值 = 0(或白名单)”,并让总量趋势持续下降。
  • 类型提示:generate-theme-types 成功;常用 token/textStyles 在 IDE 可补全,重命名能被类型报错兜住。
  • portal 抽样:Menu/Popover/Modal 的背景/边框/阴影与页面主体同口径,不出现“弹层像另一个产品”的主题漂移。

不通过先查:portal 容器是否被主题变量根覆盖(例如 cssVarsRoot/portal root);散装值 lint 是否真的在 CI 阻断; types 生成脚本是否在构建/开发链路里跑过、产物是否被正确引入。


FAQ

Q1:为什么 semantic token 里要分 bg/text/border,还要做 primary 这种 palette?

bg/text/border 解决的是“页面语义一致”,palette 解决的是“交互状态一致”。如果只做其中一个,UI 一致性仍会在 hover/disabled/focus 上崩掉。

Q2:业务代码完全禁止写 hex/px 会不会太极端?

建议“默认禁止、允许白名单”。真正必要的散装值(例如第三方组件临时兼容)可以允许,但要能被审计:在哪些地方、为什么存在、什么时候迁移掉。

Q3:dark mode 一定要一开始就做吗?

不一定。但 semantic token 的设计要为它留出空间:至少用 default/_dark(或等价机制)表达未来映射, 避免后面要做暗黑时把业务页面全部重写。

Q4:为什么要关心 portal?

因为 portal 组件(Menu/Popover/Modal)往往承载最“显眼”的交互与视觉层级。一旦 token 在 portal 里拿不到,你会看到最尴尬的现象: 页面主体样式统一,但弹层像另一个产品。

Q5:textStyles 的键名很长,怎么避免难用?

  • 键名可读:body-sm-normal 这种“语义-尺寸-字重”的组合键。
  • 类型提示:让 IDE 能补全,减少记忆负担。
  • 减少变体:不要为每个页面创建新 textStyle;优先复用,新增必须有理由。

Q6:组件 variants 会越积越多怎么办?

把 variants 当成“公共 API”:新增要评审、要文档示例、要说明使用场景。能用 token 组合解决的,不要新增 variant;能复用的,不要新增语义上重复的 variant。

Q7:怎么分阶段迁移,避免一次性“大爆炸”?

建议按影响面从小到大推进:先统一 foundations(global scales),再补 semantic tokens(先覆盖 bg/text/border 等高频语义), 然后优先收敛 Button/Input/Card 这类核心组件(variants),最后才是业务页面扫尾。迁移期允许 alias/兼容层存在,但要配合 lint 把“新增散装值”挡住。

Q8:lint/门禁怎么落,才能不把仓库一夜打红?

经验做法是“先基线、再阻断新增”:先统计现有散装值基线(存档数字与 Top 文件),然后在 CI 里只阻断“新增散装值”进入; 对确实需要临时散装的地方走白名单/注释豁免,并要求写清原因与清理期限。