Chakra UI 组件设计解读
Chakra UI v3 是一次彻底重写:Modal 改叫 Dialog 并拆成带命名空间的零件,spacing 换成 gap,colorScheme 换成 colorPalette。但它没有像传闻里那样丢掉 Emotion。
把一段 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 的包清单:
{ "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/styled 和 framer-motion —— v2 要求你把这两个也装上,v3 不再需要:动画改用 CSS 实现了。
那个 @pandacss/is-valid-prop 容易造成误会,它只是一张「这个 prop 该不该往 DOM 上传」的名单,不是 Panda 的编译器。Panda 影响的是主题 API 的形状(下面会说到的 recipe),不是打包方式。官方在迁移文档里提 Panda 时,用的词也是 inspired by。
Button 的子组件里,有一个从来不存在
这篇的旧版本写过这样一段结构,说它是 Button 的源码组成:
<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.tsx、button-group.tsx、close-button.tsx、icon-button.tsx,spinner 和 icon 那两个文件都没了。
这里有个比「单一职责」更具体的教训:Button 从来不是靠让你拼子组件来工作的。 它对外只暴露 props,内部才决定要不要渲染 spinner。把内部文件拆得细,和把拆分暴露成 API,是两件事 —— 前者是实现细节,随时可以像 v3 这样合并掉;后者一旦暴露就得一直兼容。真正把拆分做成 API 的是 Modal 那一类,因为它的各个部分需要用户自己排列组合。
Modal 在 v3 里叫 Dialog,零件挂到了命名空间下
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 |
<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 的 ModalOverlay 和 ModalContent 是靠隐式 context 找到自己属于哪个 Modal 的,光看 JSX 看不出来;Dialog.Root 把这层归属摆到了明面上,也顺手解决了「同名前缀一长串」的问题。
二是定位被拎成了单独一层。v2 里遮罩和内容的定位藏在 ModalContent 内部,v3 多出来的 Dialog.Positioner 就是把那件事显式化 —— 加上外层必须自己写的 Portal,渲染到哪儿、怎么居中,都从组件内部挪到了调用方手里。多写三行,换的是这三行终于可以改。
ModalHeader 存在的一半理由是生成一个 id
v2 的 Modal 文档把无障碍行为逐条列了出来,这在组件库里不算常见:
- 打开时焦点被困在弹窗内部;
- 焦点自动落到第一个可聚焦元素,或者
initialFocusRef指定的那个; - 关闭时焦点回到打开它之前的元素,或者
finalFocusRef; ModalContent上带aria-modal="true";ModalContent的aria-labelledby指向ModalHeader的 id;ModalContent的aria-describedby指向ModalBody的 id。
后两条解释了一件平时想不到的事:ModalHeader 和 ModalBody 不只是排版容器。 它们存在的理由有一半是生成 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)、initialFocusEl、finalFocusEl。
样式 props 没变,变的是主题怎么定义
bg、w、p、color 这些简写在 v3 里原封不动,仍然是官方推荐的写法。它们不是魔法,是 preset-base.ts 里一条条注册出来的:backgroundColor 的 shorthand 是 ["bg"],width 是 ["w"]、取值走 sizes 这套 token,padding 是 ["p"]、走 spacing,borderRadius 还多一个 ["rounded"]。
<Box bg="tomato" w="100%" p={4} color="white"> This is the Box</Box>真正断掉的是主题那一侧。v2 的 extendTheme 加 <ChakraProvider theme={theme}>,在 v3 里变成 defineConfig 加 createSystem,provider 的 prop 也从 theme 改成了 value:
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时就会落到你指定的那一组。Stack的spacing是删掉,不是废弃。 v3 的stack.tsx里StackOptions只剩align、justify、wrap、direction、separator,间距统一走样式层的gap。也就是说继续写spacing不会得到间距,它已经不对应任何东西了。- 布尔 prop 去掉了
is前缀,isOpen变成open,其余同理。
顺带纠正一个常见的说法:useBreakpointValue 不是 render props。它就是一个普通 hook,内部读 context 和 useMediaQuery,返回当前断点对应的那个值。render props 指的是把一个返回 JSX 的函数当 prop 传进去,Chakra 里 Dialog.Context 和 asChild 那套才沾边。v3 保留的 hook 一共就五个:useBreakpointValue、useCallbackRef、useDisclosure、useControllableState、useMediaQuery。
v2 没有下架
手上是 v2 项目的,不用急着迁。npm i @chakra-ui/react@v2-latest 仍然装得到 2.10.10,v2 的文档也完整留在 v2.chakra-ui.com。真要迁的话,成本不在改名 —— 改名可以搜索替换 —— 而在 Dialog.Positioner 和 Portal 这类新增的结构层:它们要求你重新想一遍每个弹窗渲染在哪儿、由谁定位,而这件事在 v2 里是组件替你决定的。
这篇归在 React 与生态下。同一主题里最近的另外两篇是 Jotai v2:React 状态管理的新篇章和 React 18 完结:最佳实践与注意事项。