Chakra UI 组件设计解读

Chakra UI v3 是一次彻底重写:Modal 改叫 Dialog 并拆成带命名空间的零件,spacing 换成 gap,colorScheme 换成 colorPalette。但它没有像传闻里那样丢掉 Emotion。

预计
7 分钟

把一段 Chakra UI 的 Modal 代码粘进一个新建的项目,很可能连编译都过不去:v3 里没有 Modal 这个导出了。

所以读 Chakra 的组件设计,第一件事是确认版本。@chakra-ui/react@3.0.0 发布于 2024 年 10 月 22 日,官方的说法是 complete rewrite —— 不是加功能,是重写。写这篇的时候 npm 上的 latest 是 3.36.1,v2 停在 2.10.10(dist-tag v2-latest),没有 v4。文档也跟着分了家:chakra-ui.com 现在是 v3 的,v2 的文档搬去了 v2.chakra-ui.com

网上大量讲 Chakra「组件设计」的文章写的是 v2,包括这篇的旧版本。下面凡是标了 v2 的写法,在 v3 里都不成立。

v3 没有丢掉 Emotion,丢掉的是 @emotion/styled

关于 v3 传得最广的一句话是「它去掉了运行时 CSS-in-JS,换成了 Panda 那样的静态提取」。这句话是错的,而且不需要读源码就能证伪 —— 看一眼 @chakra-ui/react@3 的包清单:

@chakra-ui/react 3.x · package.json(节选)
{
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18",
"@emotion/react": ">=11"
},
"dependencies": {
"@ark-ui/react": "…",
"@emotion/serialize": "…",
"@emotion/utils": "…",
"@pandacss/is-valid-prop": "…"
}
}

@emotion/react 还挂在 peerDependencies 上,样式仍然是运行时生成的。真正被拿掉的是 @emotion/styledframer-motion —— v2 要求你把这两个也装上,v3 不再需要:动画改用 CSS 实现了。

那个 @pandacss/is-valid-prop 容易造成误会,它只是一张「这个 prop 该不该往 DOM 上传」的名单,不是 Panda 的编译器。Panda 影响的是主题 API 的形状(下面会说到的 recipe),不是打包方式。官方在迁移文档里提 Panda 时,用的词也是 inspired by。

Button 的子组件里,有一个从来不存在

这篇的旧版本写过这样一段结构,说它是 Button 的源码组成:

这段是编的,Chakra 里没有这个写法
<Button>
<ButtonSpinner />
<ButtonIcon />
<ButtonLabel />
</Button>

去 v2 的 packages/components/src/button/ 目录里对一遍,实际情况是三个不同的答案:

  • button-spinner.tsx 存在,ButtonSpinner 还从 index.ts 导出,是公开 API。
  • button-icon.tsx 存在,但 ButtonIcon 没有被 index.ts 再导出 —— 它只在 button.tsx 内部给 leftIcon / rightIcon 用,你 import 不到。
  • ButtonLabel 全仓库搜不到,任何版本都没有过这个文件。button.tsx 里放 label 的位置是一个匿名的 <span>,从来没有被命名成组件。

v3 更干脆,button/ 目录只剩 button.tsxbutton-group.tsxclose-button.tsxicon-button.tsx,spinner 和 icon 那两个文件都没了。

这里有个比「单一职责」更具体的教训:Button 从来不是靠让你拼子组件来工作的。 它对外只暴露 props,内部才决定要不要渲染 spinner。把内部文件拆得细,和把拆分暴露成 API,是两件事 —— 前者是实现细节,随时可以像 v3 这样合并掉;后者一旦暴露就得一直兼容。真正把拆分做成 API 的是 Modal 那一类,因为它的各个部分需要用户自己排列组合。

v2 的 Modal 是一组平铺的兄弟组件,v3 换成了 Dialog.Root 打头的复合组件:

v2 v3
Modal Dialog.Root
—— Dialog.Trigger
ModalOverlay Dialog.Backdrop
—— Dialog.Positioner(外面还要包 Portal
ModalContent Dialog.Content
ModalHeader Dialog.Header,标题另有 Dialog.Title
ModalBody Dialog.Body
ModalFooter Dialog.Footer
ModalCloseButton Dialog.CloseTrigger
v3 · Dialog 的组装方式
<Dialog.Root>
<Dialog.Trigger asChild><Button>打开</Button></Dialog.Trigger>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header><Dialog.Title>标题</Dialog.Title></Dialog.Header>
<Dialog.Body>正文</Dialog.Body>
<Dialog.Footer><Dialog.CloseTrigger asChild><Button>取消</Button></Dialog.CloseTrigger></Dialog.Footer>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>

改名之外,有两处结构变化值得单独看。

一是边界写进了名字。v2 的 ModalOverlayModalContent 是靠隐式 context 找到自己属于哪个 Modal 的,光看 JSX 看不出来;Dialog.Root 把这层归属摆到了明面上,也顺手解决了「同名前缀一长串」的问题。

二是定位被拎成了单独一层。v2 里遮罩和内容的定位藏在 ModalContent 内部,v3 多出来的 Dialog.Positioner 就是把那件事显式化 —— 加上外层必须自己写的 Portal,渲染到哪儿、怎么居中,都从组件内部挪到了调用方手里。多写三行,换的是这三行终于可以改。

ModalHeader 存在的一半理由是生成一个 id

v2 的 Modal 文档把无障碍行为逐条列了出来,这在组件库里不算常见:

  • 打开时焦点被困在弹窗内部;
  • 焦点自动落到第一个可聚焦元素,或者 initialFocusRef 指定的那个;
  • 关闭时焦点回到打开它之前的元素,或者 finalFocusRef
  • ModalContent 上带 aria-modal="true"
  • ModalContentaria-labelledby 指向 ModalHeader 的 id;
  • ModalContentaria-describedby 指向 ModalBody 的 id。

后两条解释了一件平时想不到的事:ModalHeaderModalBody 不只是排版容器。 它们存在的理由有一半是生成 id、再把 id 接到 ModalContent 的 aria 属性上。图省事换成两个 <div>,视觉上一模一样,但读屏用户听到的弹窗从此没有名字,也没有描述 —— 而且不会有任何报错提醒你。

v3 把这套逻辑整个交了出去。packages/react/src/components/dialog/dialog.tsx 的第一行是 import { Dialog as ArkDialog, useDialogContext } from "@ark-ui/react/dialog",Chakra 只在外面套一层 slot recipe 负责样式。Ark UI 底下是 Zag.js 的状态机(@zag-js/dialog),焦点、按键、aria-* 都由它管。对应的开关也改了名:trapFocus(默认 true)、initialFocusElfinalFocusEl

样式 props 没变,变的是主题怎么定义

bgwpcolor 这些简写在 v3 里原封不动,仍然是官方推荐的写法。它们不是魔法,是 preset-base.ts 里一条条注册出来的:backgroundColorshorthand["bg"]width["w"]、取值走 sizes 这套 token,padding["p"]、走 spacingborderRadius 还多一个 ["rounded"]

这段在 v2 和 v3 里都能跑
<Box bg="tomato" w="100%" p={4} color="white">
This is the Box
</Box>

真正断掉的是主题那一侧。v2 的 extendTheme<ChakraProvider theme={theme}>,在 v3 里变成 defineConfigcreateSystem,provider 的 prop 也从 theme 改成了 value

v3 · 定义主题并交给 provider
import { createSystem, defaultConfig, defineConfig, ChakraProvider } from "@chakra-ui/react"
const config = defineConfig({
// v3 的每个 token 值都要包一层 { value: … },v2 是直接写字符串
theme: { tokens: { colors: { brand: { 500: { value: "tomato" } } } } },
})
export const system = createSystem(defaultConfig, config)
// <ChakraProvider value={system}>…</ChakraProvider>

跟着一起改的还有三处,都是照着 v2 的文章写 v3 代码时最先撞上的:

  • colorScheme 改叫 colorPalette 官方给的理由很具体:colorScheme 和 HTML 元素上原生的 colorScheme 属性重名了。改名之后它还顺便变强了 —— v2 的 colorScheme 只有认识它的组件才响应,v3 的 colorPalette 可以设在任意元素上,后代解析 colors.colorPalette.500 时就会落到你指定的那一组。
  • Stackspacing 是删掉,不是废弃。 v3 的 stack.tsxStackOptions 只剩 alignjustifywrapdirectionseparator,间距统一走样式层的 gap。也就是说继续写 spacing 不会得到间距,它已经不对应任何东西了。
  • 布尔 prop 去掉了 is 前缀isOpen 变成 open,其余同理。

顺带纠正一个常见的说法:useBreakpointValue 不是 render props。它就是一个普通 hook,内部读 context 和 useMediaQuery,返回当前断点对应的那个值。render props 指的是把一个返回 JSX 的函数当 prop 传进去,Chakra 里 Dialog.ContextasChild 那套才沾边。v3 保留的 hook 一共就五个:useBreakpointValueuseCallbackRefuseDisclosureuseControllableStateuseMediaQuery

v2 没有下架

手上是 v2 项目的,不用急着迁。npm i @chakra-ui/react@v2-latest 仍然装得到 2.10.10,v2 的文档也完整留在 v2.chakra-ui.com。真要迁的话,成本不在改名 —— 改名可以搜索替换 —— 而在 Dialog.PositionerPortal 这类新增的结构层:它们要求你重新想一遍每个弹窗渲染在哪儿、由谁定位,而这件事在 v2 里是组件替你决定的。

这篇归在 React 与生态下。同一主题里最近的另外两篇是 Jotai v2:React 状态管理的新篇章React 18 完结:最佳实践与注意事项