全景:UI 库的版图与选型
在挑库之前先回答三个问题:一个「组件库」到底替你干了什么活、今天分成哪几个流派、以及什么时候根本不该用库。后面每一章都是这张地图的放大。
「用不用组件库」难答,是因为大多数人只把它看成「省了写 CSS 的时间」。实际上一个 Dialog 替你扛下的是四层完全不同的工作,其中最难自己补齐的那层恰恰不是样式。
拆开一个「对话框」,里面装着什么
- ① 行为:开合状态、点外部关闭、Esc 关闭、锁住页面滚动、嵌套弹窗的层级;
- ② 无障碍:
role="dialog"、aria-*关联、焦点陷阱与关闭后焦点归位——这层最难自己补齐; - ③ 样式:视觉、主题、暗色模式;④ 组合接口:能不能替换内部结构、受控还是非受控。
四层能力的不同组合,就是三个流派
- 成套库(MUI、Ant Design、Element Plus):四层全给,装上就有一套完整产品;
- 无头库(Radix、Headless UI、React Aria):只给 ①②④,样式一行不给;
- copy-in 脚手架(shadcn/ui):把源码复制进你的仓库,从此归你改。
// 同一个「对话框」,四层工作在代码里的位置
// ① 自己写:只有开合,其余三层全缺
const [open, setOpen] = useState(false);
{open && <div className="modal">…</div>} // 键盘能 Tab 出去、读屏器读不到、Esc 没反应
// ② 无头库:行为 + 无障碍 + 组合接口,样式归你
<Dialog.Root>
<Dialog.Trigger>打开</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/40" /> // 样式自己写
<Dialog.Content className="fixed left-1/2 top-1/2 …">
<Dialog.Title>标题</Dialog.Title> // 自动接上 aria-labelledby
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
// ③ 成套库:四层全包,代价是「长得像它」
<Dialog open={open} onClose={close}>
<DialogTitle>标题</DialogTitle> // 视觉、动画、响应式都自带
</Dialog>「哪个库好」没有答案,「在我的框架里、我的流派下有哪些候选」才有。下面这张表是今天的全部候选池,17 章会逐格展开每个库的性格。
主流候选池
| 框架 | 无头 / 行为层 | 成套 / 有视觉 | copy-in 脚手架 |
|---|---|---|---|
| React | Radix UI、Base UI、Headless UI、React Aria Components、Ark UI | MUI、Ant Design、Mantine、Chakra UI、HeroUI | shadcn/ui |
| Vue | Reka UI、Headless UI | Element Plus、Naive UI、Vuetify、Ant Design Vue | shadcn-vue |
| Svelte | Bits UI、Melt UI | Skeleton、Flowbite Svelte | shadcn-svelte |
| Solid | Kobalte、Ark UI | — | solid-ui |
| 跨框架 | Ark UI(Zag 状态机) | Web Components 系(Shoelace / Wired) | — |
近两年改过名字的,别搜错
- Radix Vue → Reka UI(
radix-vue停更);NextUI → HeroUI(@nextui-org/react已 deprecated); - 还有一类候选是「不装库」:原生
<dialog>+popover已自带焦点管理、Esc 关闭与顶层渲染,简单场景真的不需要库(07 章)。
# 现查版本永远比记忆可靠:写代码前先跑这一行
$ npm view radix-ui version # 1.6.7
$ npm view @base-ui/react version # 1.6.0
$ npm view element-plus version # 2.14.3
# 已改名的包,registry 会直接告诉你
$ npm view @nextui-org/react deprecated
This package has been deprecated. Please use @heroui/react instead.
$ npm view @base-ui-components/react deprecated
Package was renamed to @base-ui/reactradix-vue 已改名 reka-ui、@nextui-org/react 已 deprecated 提示改用 @heroui/react,旧包名却仍占着搜索结果前排。npm view <pkg> time.modified);② 它自己依赖了谁(npm view <pkg> dependencies)——第二条能看出它是真无头还是套壳。这一页不是「各家库的说明书」——说明书官网上有,更新得比任何教程都快。这一页讲的是换一个库也仍然成立的那部分:交互契约、无障碍要求、样式与主题的组织方式。
需要的前置知识
- 必须有:HTML / CSS 基础与现代 JavaScript;这两块还虚就先看本站的 Web UI 与 JS/TS 两页;
- 最好有:一个前端框架的组件心智(props、状态、组合)。示例以 React 为主、Vue 为辅。
跳读建议
- 只想把眼前的后台做完:01 → 02 → 14(体积)就够;
- 要建公司自己的设计系统:04 令牌 → 05 无障碍 → 16 组合模式 → 18 自建与发布;
- 被交互 bug 缠住:直接查对应组件章。
// 本页的章节地图(id 就是页面锚点)
00 全景 01 上手 02 三大流派 03 样式方案
04 设计令牌 05 无障碍 06 浮层与定位 07 对话框与焦点
08 表单 09 选择器族 10 表格与虚拟化 11 反馈与导航
12 动画 13 SSR/水合 14 体积与性能 15 国际化与 RTL
16 深水区 17 生态速查 18 自建组件库 19 测试与质量
20 路线图上手:把两种流派各跑一遍
这一章不讲理论,只做一件事:在半小时内把「无头库」和「成套库」各跑通一次,看清它们的手感差异。跑完你会明白为什么无头库第一眼像坏了,也会明白成套库为什么当天就能交付。
两条路线共用同一个起点:一个空的 Vite 项目。分岔发生在第二步——无头路线要先把样式方案装好,成套路线只需注册一次。
共同的第一步
npm create vite@latest my-ui -- --template react(Vue 换--template vue)→npm install→npm run dev。
然后分岔
- 路线 A:无头 + Tailwind——装
tailwindcss @tailwindcss/vite radix-ui,vite 配置里加插件,CSS 入口只需一行@import "tailwindcss";(Tailwind 4 起不再需要 config 文件); - 路线 B:成套库——装
element-plus,app.use(ElementPlus)一行注册,立刻就有完整视觉。
// vite.config.js —— 路线 A 的完整配置就这么多
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "node:path";
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: { "@": path.resolve(import.meta.dirname, "./src") }, // shadcn 默认要这个别名
},
});
/* src/index.css —— Tailwind 4 的全部引入代码 */
@import "tailwindcss";@tailwind base; 三行。它的表现不是「完全不生效」而是「一部分好使、一部分静默消失」——最难查的那种。v4 只要一行 @import "tailwindcss";。npm create vite 生成的项目不会遇到问题(模板自带依赖),但从老项目手工升级时要自己补上。把右边这段抄进 App.jsx,你会看到无头库的两个第一印象:它一开始丑得像没写样式(因为确实没有),但键盘和读屏器已经全对了。
它替你做了什么
- 弹窗上自动出现
role="dialog"、aria-labelledby/aria-describedby,触发按钮同步变成aria-expanded="true"; - 焦点被关进弹窗、Esc 能关、关闭后焦点自动回到触发按钮——这三件事自己写要几十行,而且很难写全。
为什么它看起来「坏了」
Dialog.Content默认就是文档流里的一个div,会出现在页面底部而不是屏幕中央——居中要自己写;- 这不是 bug,是无头库的定义:它只给行为与无障碍,样式一行不给。
路线 B 的手感完全相反:一行注册就有像样的界面
- 成套库第一步永远是「注册 + 引样式」,代价在体积:同一个「按钮 + 对话框」页面,全量注册 347.7 KB、具名引入 51.4 KB(gzip),差约 6.8 倍;
- 而具名引入与插件按需的产物字节数完全一致——插件省的是打字不是体积,它真正的用处是自动 import 并顺带引入每个组件的样式(漏了这步的表现是「组件在但没样式」)。
import { Dialog } from "radix-ui";
export default function App() {
return (
<Dialog.Root>
<Dialog.Trigger className="rounded bg-slate-900 px-4 py-2 text-white">
打开对话框
</Dialog.Trigger>
<Dialog.Portal> {/* 渲染到 body 下,逃开父级的 overflow / transform */}
<Dialog.Overlay className="fixed inset-0 bg-black/40" />
<Dialog.Content
className="fixed left-1/2 top-1/2 w-80 -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white p-6 shadow-xl"
>
<Dialog.Title className="text-lg font-semibold">确认删除</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-slate-600">
删除后不可恢复。
</Dialog.Description>
<input className="mt-4 w-full rounded border p-2" placeholder="输入项目名以确认" />
<Dialog.Close className="mt-4 rounded border px-3 py-1">取消</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}Dialog.Portal 不是装饰:不套它时祖先上只要有 overflow: hidden 或 transform,弹窗就会被裁掉,且 z-index 无解。上手阶段的坑就那么三类,认得出来能省掉几小时——而最危险的那一类什么都不说。
① 会大声报错的:缺 Provider
- Radix 的 Tooltip 必须包在
Tooltip.Provider里,忘了直接抛错。这类报错是好消息——库明确告诉了你缺什么。
② 不报错只是看着不对 ③ 什么都不说
- 成套库漏引 CSS:组件渲染出来了但完全是浏览器默认样式。判据是 DOM 里 class 名都在,Styles 面板里查不到对应规则;
- 最危险的第三类:无障碍缺陷——对话框没有可访问名、按钮只有图标没有文字,页面看着完全正常,只有读屏器用户撞得上。
// ① 缺 Provider —— 会直接抛
<Tooltip.Root>…</Tooltip.Root> // Error: `Tooltip` must be used within `TooltipProvider`
// 正确:整个应用包一层,通常放在根组件
<Tooltip.Provider delayDuration={200}>
<App />
</Tooltip.Provider>
// ③ 缺 Title —— 不报错,但读屏器读到一个无名对话框
<Dialog.Content>
<p>确定要删除吗?</p> // 没有 Dialog.Title
</Dialog.Content>
// 修法之一:标题不想显示时,用视觉隐藏而不是删掉
<Dialog.Title className="sr-only">确认删除</Dialog.Title> // 屏幕上不可见,读屏器能读到display: none 或 visibility: hidden 去「藏起来但留给读屏器」——这两个属性会把元素从无障碍树里一并删掉,等于没写。要视觉隐藏而语义保留,用 sr-only 这类工具类。这条在整个无障碍话题里高频到值得背下来。@axe-core/react(或浏览器扩展 axe DevTools),在开发环境跑起来,它会把「对话框没有可访问名」「按钮没有文字」这类静默缺陷直接打到控制台。上手阶段就装上,比事后补便宜十倍——19 章给的是把同一套检查放进 CI 的做法。三大流派:成套、无头、复制进项目
同样叫「UI 库」,三个流派卖给你的其实是三种不同的东西:一套成品、一套行为、一份源码。这一章给出可操作的判据——包括各家无头库的性格差异(含本机上的体积与行为对照)。
成套库被鄙视得有点过头了。当「像它就行」成立时,它是三个流派里唯一理性的选择——真正要判断的是「像它就行」这句话对你的项目成不成立。
三个「选成套库」的强信号
- 组件目录深度决定交付速度:需要可编辑表格、树选择、日期区间、穿梭框、上传队列、级联选择——这些自己写要几周,antd / Element Plus / Vuetify 里全是现成的。后台系统的组件需求恰恰集中在这一类;
- 团队里没有专职前端或没有设计师:默认视觉直接可用,省掉「谁来定义按钮长什么样」这个会开三次都开不完的问题;
- 生命周期短或改版频率低:内部工具、运营后台、原型——五年后它长什么样并不重要。
定制上限:三个层次,越往下越难
- ① 令牌层(几乎都支持):主色、圆角、间距、字号、暗色模式。改法是配置对象或 CSS 变量,这一层随便改;
- ② 组件变体层(部分支持):某个组件的默认 props、默认样式覆盖。MUI 的
components.MuiButton.styleOverrides、antd 的ConfigProvider+theme.components、Mantine 的theme.components——能改,但要读它的插槽命名; - ③ 结构层(基本不支持):改 DOM 层级、改交互流程、改无障碍属性。到这一层就只剩「fork 或换库」。选型时把设计稿里最刁钻的那两三个组件拿出来,先判断它落在哪一层,比读十篇对比文章有用。
成套库的三个常见误判
- 「体积大」被高估也被低估:高估在于——现代成套库都能 tree-shake,你不会把整个库打进去;低估在于——它的固定成本很高:antd 6.5 里只用一个 Button,产物就比空白 React 应用多 37.9 KB gzip(样式引擎与主题上下文是固定开销),而 Radix 的一个 Checkbox 只多 5.0 KB;
- 「可以按需引入样式」在 CSS-in-JS 库上不成立:antd v5 起、Chakra、Mantine 的一部分都把样式放在 JS 里,没有独立 CSS 文件可裁;
- 「以后能换掉」几乎总是假的:见 00 章那条 pitfall。真要留退路,就从第一天起做薄封装(01 章第 5 卡)。
// 三家成套库的「令牌层」定制,写法各不相同但意思一样
// MUI 9:createTheme + ThemeProvider
const theme = createTheme({
palette: { primary: { main: "#7c3aed" }, mode: "dark" },
shape: { borderRadius: 10 },
components: { MuiButton: { defaultProps: { disableElevation: true } } },
});
// Ant Design 6:ConfigProvider + theme token
<ConfigProvider theme={{ token: { colorPrimary: "#7c3aed", borderRadius: 10 } }}>
<App />
</ConfigProvider>
// Element Plus:直接覆盖 CSS 变量,连 JS 都不用改
:root {
--el-color-primary: #7c3aed;
--el-border-radius-base: 10px;
}overrides.css:库升级后类名一变,你的覆盖有一半静默失效(不报错,只是某些地方变丑),另一半还在生效但已经没有对应元素。正确顺序是先找主题令牌 → 再找组件级 styleOverrides / classNames → 最后才考虑写 CSS,而且写 CSS 时用 @layer 明确优先级(03 章)。classNames / slots 这类插槽 API。有插槽 API 说明作者预留了定制口子;只有一堆哈希类名说明「改样式靠猜」。五家无头库都能给你一个「能用键盘操作的对话框」,但它们在 DOM 上留下的痕迹、默认行为的取舍、以及体积差得相当明显。下面这张表是本机在真 Chrome 里逐条测出来的(React 19.2.8 + Vite 8 生产构建)。
同一个「Dialog + 触发按钮」的对照
| 维度 | Radix 1.6.7 | Base UI 1.6.0 | Headless UI 2.2.10 | React Aria 1.19.0 |
|---|---|---|---|---|
| gzip 增量(相对空白 React 应用) | +12.0 KB | +19.1 KB | +17.9 KB | +21.9 KB |
| 状态属性 | data-state="open" | data-open | data-headlessui-state + data-open | data-rac + data-focused 等 |
| Portal 落点 | 直接挂到 body | 自建一个 div 挂 body | #headlessui-portal-root | 自建 div 挂 body |
| 打开后焦点落在 | 弹窗内第一个可聚焦元素 | 弹窗内第一个可聚焦元素 | 对话框容器本身 | 对话框元素本身 |
aria-modal | 不用 | 不用 | true | 不用 |
| 屏蔽外部内容的方式 | aria-hidden | aria-hidden | inert + aria-hidden | inert |
| 滚动锁的落点 | body:overflow:hidden + data-scroll-locked + 补 padding | body overflow + html 的 scrollbar-gutter | html:overflow + 补 padding | html:overflow + scrollbar-gutter |
| 点外部默认关闭 | 是 | 是 | 是 | 否(要 isDismissable) |
| Esc 关闭 / 焦点归还触发器 | 是 / 是 | 是 / 是 | 是 / 是 | 是 / 是 |
怎么读这张表
- 四家在「该做对的事」上全部做对了——Esc、焦点陷阱、焦点归还、屏蔽外部内容,无一例外。差异在实现手段而不是正确性;
aria-modal只有 Headless UI 用。这不是谁错了:把外部内容inert或aria-hidden掉是更彻底的做法,读屏器天然出不去,aria-modal反而是历史包袱较重的属性;- React Aria 默认不点外关闭是个真实差异,不是 bug——它认为模态对话框应当强制用户做出选择。迁移时最容易撞上这一条;
- 状态属性的命名差异决定你的 CSS 怎么写:Radix 是
[data-state=open],Base UI 是[data-open],写 Tailwind 时分别是data-[state=open]:…与data-open:…(03 章展开)。
五家的性格速判
- Radix UI:生态最大(shadcn 建立在它之上)、体积最小、API 最像「HTML 的加强版」。短板是新组件出得慢,日期选择器至今没有官方实现;
- Base UI:MUI 团队做的新一代无头库,已发 1.x 正式版。API 干净、内置
Positioner分层清晰、滚动锁用scrollbar-gutter更现代。生态还年轻; - Headless UI:Tailwind 官方出品,组件数量最少但和 Tailwind 配合最顺(
data-*全套 +transition属性)。只覆盖最常用的十来个组件; - React Aria Components:Adobe 出品,无障碍做得最狠(内置多语言字符串、国际化日期、拖拽、虚拟化协作),代价是体积最大、概念最多、默认行为最「有主张」;
- Ark UI:基于状态机(Zag.js),同一套逻辑同时提供 React / Vue / Svelte / Solid 四个框架的绑定——跨框架团队的唯一现实选项,Chakra 3 就建立在它之上。
// 同一件事,四家的写法差异(都是「点开一个对话框」)
// Radix:Root/Trigger/Portal/Overlay/Content 五件套
<Dialog.Root><Dialog.Trigger/><Dialog.Portal><Dialog.Overlay/><Dialog.Content/></Dialog.Portal></Dialog.Root>
// Base UI:Backdrop / Popup 的命名不同,多一层 Positioner(浮层类才有)
<Dialog.Root><Dialog.Trigger/><Dialog.Portal><Dialog.Backdrop/><Dialog.Popup/></Dialog.Portal></Dialog.Root>
// Headless UI:状态自己管,open/onClose 是必填
<Dialog open={open} onClose={setOpen}><DialogPanel>…</DialogPanel></Dialog>
// React Aria:DialogTrigger 包住触发器与 Modal,默认不点外关闭
<DialogTrigger>
<Button>打开</Button>
<Modal isDismissable> // 不加这个属性,点遮罩不会关
<Dialog>…</Dialog>
</Modal>
</DialogTrigger>data-* 属性,样式表也能复用。代价是包很碎(依赖数 67 个)且抽象层更厚,调试时你面对的是状态机而不是普通组件。shadcn/ui 是过去几年里前端最有影响力的「反常识」设计:它不是一个 npm 包,你装不了它。npx shadcn add button 干的事情是把一个按钮的源码复制到你的 components/ui/button.jsx 里,从此那份代码归你,改随便改,升级也不关它的事。
它的三块拼图
- 行为来自 Radix(少数组件来自 cmdk、vaul、embla 等专项库);
- 样式用 Tailwind 写死在复制过来的源码里,用
cva组织变体; - 分发靠 CLI + registry:一份 JSON 描述「这个组件由哪些文件、哪些依赖、哪些注册表项组成」,CLI 按图索骥拉下来。registry 是开放的——任何人都能发布自己的组件注册表,公司内部也能建一个私有 registry 给全组用。
真正的取舍
- 赚到的:没有版本锁、没有主版本迁移、想改哪一行改哪一行;组件代码在你仓库里,code review 能看见;AI 辅助改代码时上下文完整;
- 赔进去的:没有自动升级。上游修了一个焦点管理的 bug,你不会知道,也不会自动拿到——除非你重新
add一次并手工 diff; - 被低估的成本:一旦复制了三十个组件,你就拥有了一个内部组件库,它需要维护、需要文档、需要有人负责。很多团队没意识到自己已经在 18 章的处境里了;
- 被高估的成本:「不能升级」其实没那么可怕——Radix 那层依赖仍然是正常的 npm 包,安全修复照样到手;不自动更新的只是那层样式与组装代码。
用之前先确认三件事
- 你的项目得有 Tailwind。不用 Tailwind 就别硬上 shadcn——复制过来的全是 Tailwind 类名;
- CLI 包名是
shadcn(4.16.0),老教程里的shadcn-ui已停更在 0.9.5; - 它默认要求
@/别名与一个components.json配置文件,路径不对时生成的 import 会指向不存在的位置。
# 初始化:生成 components.json,问你几个路径与主题问题
$ npx shadcn@latest init
# 加组件:源码直接落进 src/components/ui/
$ npx shadcn@latest add button dialog select
# 从任意 registry 加(公司内部私有源也一样)
$ npx shadcn@latest add https://example.com/r/our-table.json
# 想跟上游对齐时:重新 add,CLI 会问是否覆盖,然后自己 git diff
$ npx shadcn@latest add button --overwrite
$ git diff src/components/ui/button.tsx # 人工判断哪些改动要留cva、clsx、tailwind-merge、有时还有 sonner / cmdk / vaul)。这层工具函数本身要 9.7 KB gzip,其中 tailwind-merge 一家就占 8 KB。如果你的项目只需要两三个组件,直接用 Radix 自己写样式更轻;shadcn 的收益要在组件多、且需要统一视觉时才体现出来。components/ui/ 当成「我们自己的代码,只是初始版本是别人写的」来对待:第一次 add 之后立刻提交一个干净的 commit,之后所有本地修改都在其上。这样将来重新 add 时,git diff 能清楚区分「上游改了什么」和「我们改了什么」——没有这个基线 commit,半年后你根本分不清。现实项目很少是纯的:主力用 Element Plus,但表格换成 TanStack Table;主力用 shadcn,但日期选择器用 React Aria。混用本身没问题,出事的永远是那三个「全局资源」。
三条必须协调的全局资源
- ① 层级(z-index 与 top layer):两个库各有自己的浮层层级约定(antd 默认 1000 起、Element Plus 2000 起、Radix 完全不设 z-index 交给你)。把所有浮层的 z-index 收进一张自己的表,别让两套约定各自为政。用原生
<dialog>/popover的库会进 top layer,永远压在普通 z-index 之上——这也是一种要提前知道的差异; - ② 焦点与滚动锁:两个库同时想「锁住页面滚动」时会互相踩(一个在
body上设 overflow、另一个在html上设,关闭顺序不同就会残留一个锁不掉的页面)。症状是「关掉弹窗后页面滚不动了」——排查时直接看html与body的行内样式; - ③ 样式作用域:成套库的全局重置样式(reset / normalize)会影响另一个库的组件。用
@layer把「库的样式」和「我们的样式」分层,能让优先级从「谁在后面谁赢」变成「按层排序」,这是 03 章的重点。
混用的合理姿势
- 按「组件族」划界,不要按「页面」划界:所有对话框归一家、所有下拉归一家。同一族里混两个库,用户会立刻感到不一致(动画时长、关闭行为、键盘习惯都不一样);
- 专项库优先:表格(TanStack Table)、虚拟化(TanStack Virtual)、定位(Floating UI)、通知(Sonner)、命令面板(cmdk)这些「单点最强」的库,混进任何成套库都很自然,因为它们不抢全局资源;
- 两个成套库不要并存。这是唯一一条硬规则——两套设计语言 + 两套重置样式 + 两套主题变量,收益为负。
/* 把层级收成一张表,两个库都听你的 */
:root {
--z-dropdown: 1000;
--z-sticky: 1020;
--z-overlay: 1030;
--z-modal: 1040;
--z-toast: 1060;
}
/* 让第三方库的样式永远在自己的样式之下 */
@layer vendor, app;
@layer vendor {
@import "element-plus/dist/index.css";
}
@layer app {
.my-button { border-radius: 10px; } /* 不用 !important 也能赢 */
}onPointerDownOutside / onInteractOutside 里判断 event.target 并 preventDefault(),或者把内层弹层的 appendTo / getPopupContainer 指回外层容器。浮层嵌浮层时这个问题几乎必然出现,06 章有完整讨论。transform、filter、backdrop-filter、will-change、contain 或非 static 的定位——这些都会创建新的层叠上下文,让子元素的 z-index 被困在里面。找到之后正确的解法通常是「用 Portal 把弹窗挪出去」,而不是把 z-index 调到 99999。选型时最该问但最少被问的问题是:「三年后想换掉它,要付多少?」这一卡把这个数字拆开——它比你想的更依赖于第一天的目录约定,而不是库本身。
换库的成本由三部分组成
- ① 触点数量:有多少文件直接 import 了那个库。做了薄封装是十几个,没做是几百个。这一项在你的控制之下,而且只在第一天有机会控制;
- ② API 形状差异:受控/非受控的默认值、事件名、插槽结构。同为无头库,Radix 的
<Select.Item value>与 React Aria 的<ListBoxItem id>连「值放哪个属性」都不同;成套库之间差得更远(antd 的<Table columns dataSource>与 Element Plus 的<el-table :data>+ 子组件列定义,是两种完全不同的心智); - ③ 视觉回归:这一项最容易被忽略,也最难自动化。像素级不一致会被产品和设计一条条提出来,19 章的视觉回归测试就是为这一步准备的。
哪些迁移是「便宜」的
- 无头库之间:如果你的样式是自己写的(Tailwind 类名在你手上),换 Radix → Base UI 主要是改标签名与
data-*选择器,视觉可以保持一致。这是无头流派最被低估的好处; - 成套库 → 成套库:最贵。视觉全变、API 全变、组件目录还对不齐(对方没有的组件要自己补);
- 成套库 → 无头:贵但可分批。可以「新页面用新方案、老页面不动」,因为两者不抢同一套样式令牌——前提是你在 04 章的意义上先把令牌统一了。
一个实用的「退出演练」
- 选型阶段花两小时,用候选库 A 和候选库 B 各实现同一个真实页面(挑项目里最复杂的那个表单或表格);
- 然后统计:写了多少行、遇到几个「文档里没写」的问题、DevTools 里的 DOM 是否可控、键盘走一遍有没有断点;
- 这两小时的信息量,大于任何对比文章。而且这个页面之后可以当成模板。
// 薄封装让「换库」变成改一个文件
// src/components/ui/dialog.jsx —— 换库前(Radix)
import { Dialog as Rx } from "radix-ui";
export const Dialog = Rx.Root;
export const DialogTrigger = Rx.Trigger;
export function DialogContent({ children, ...rest }) { /* Portal + Overlay + Content */ }
// 换库后(Base UI):业务代码一行不用改
import { Dialog as Bui } from "@base-ui/react/dialog";
export const Dialog = Bui.Root;
export const DialogTrigger = Bui.Trigger;
export function DialogContent({ children, ...rest }) { /* Portal + Backdrop + Popup */ }
// 前提:业务代码从来只写这一行 import
import { Dialog, DialogTrigger, DialogContent } from "@/components/ui/dialog";asChild、antd Form 的 name 路径、MUI 的 sx 属性,一旦在业务里用开,换库时照样要逐处改。薄封装能收敛 import,收敛不了 API 风格——所以真正想留退路时,封装层要连这些概念一起挡在外面(代价是封装变厚,又违反了 01 章那条「别写厚封装」)。这个矛盾没有完美解,只能按项目寿命权衡。no-restricted-imports 禁止业务目录直接 import 组件库,只允许 @/components/ui/*。规则写十分钟,能守住三年——否则总有人在赶工期时直接 import 一下,一年后就是几百个触点。样式方案:把视觉接到组件上
无头库把样式的决定权还给了你,随之而来的是一个必须回答的问题:这些样式写在哪、怎么被覆盖、怎么随状态变化。这一章比较五种落地方式,并讲清楚 data 属性驱动这套现代无头库的通用写法。
这几年样式方案的格局有过一次明显的换代:运行时 CSS-in-JS 退潮,Tailwind 与「零运行时」方案上位,直接原因是服务端渲染与 React Server Components 对「运行时生成样式」很不友好。
五种方案的定位
| 方案 | 代表 | 样式在哪生成 | 适合 |
|---|---|---|---|
| 原子类 | Tailwind 4 | 构建期扫描类名生成 CSS | 与无头库配合、追求「样式随组件走」 |
| CSS Modules | Vite / Next 内置 | 构建期,类名加哈希 | 喜欢写常规 CSS、要作用域隔离 |
| 运行时 CSS-in-JS | Emotion、styled-components | 浏览器里运行时插入 | 存量项目;新项目要慎重 |
| 零运行时 CSS-in-JS | vanilla-extract、Panda CSS、StyleX | 构建期把 TS 编译成 CSS | 要类型安全又不想要运行时开销 |
| 原生 CSS 变量 + 常规 CSS | 无需库 | 不生成,直接写 | 组件库自身、跨框架分发 |
选择的两个判据
- ① 你的渲染方式:用 RSC(React Server Components)就别选运行时 CSS-in-JS——它依赖 React Context 与浏览器 API,在服务端组件里根本跑不起来,这是 Chakra 3、MUI 等库这几年大改样式引擎的直接原因(13 章展开);
- ② 谁来写样式:如果样式是设计师给的规范、要跨多个仓库复用,底座应该是 CSS 变量(04 章),上层用什么写法都行;如果只有前端自己维护、追求改起来快,Tailwind 的「就地写类名」体验最好。
成套库自己用的方案,会传染给你
- MUI 9 用 Emotion(运行时);antd 6 用自研的 cssinjs(运行时,带缓存);Chakra 3 换成了 Panda(零运行时);Mantine 9 用普通 CSS + CSS 变量(要引
styles.css);Element Plus 是 SCSS 编译出的普通 CSS; - 这决定了两件事:你能不能在 RSC 里用它、以及它的样式能不能被裁剪。Mantine 的
styles.css是整库样式(未压缩 225 KB / gzip 33 KB),无论你只用一个按钮还是用了八个组件,这个数字一模一样——因为它就是整个库的 CSS。
/* Tailwind 4:配置写在 CSS 里,@theme 定义的变量同时就是 CSS 变量 */
@import "tailwindcss";
@theme {
--color-brand-500: oklch(0.62 0.19 275);
--radius-card: 12px;
}
/* 于是这两种写法等价,且能被任何非 Tailwind 的代码复用 */
.card { border-radius: var(--radius-card); } /* 普通 CSS */
// <div className="rounded-card bg-brand-500"> // 工具类无头库不给你样式,但会给你一套可以挂样式的钩子:把组件的每个状态写成 DOM 属性。理解这一套之后,「怎么给无头组件写样式」就变成了一个纯 CSS 问题。
各家的属性命名(抓的真实 DOM)
| 状态 | Radix | Base UI | Headless UI | React Aria |
|---|---|---|---|---|
| 展开 / 收起 | data-state="open" / "closed" | data-open(关闭时属性消失) | data-open + data-headlessui-state | data-open="true" |
| 选中 | data-state="checked" | aria-selected="true" | data-selected | data-selected="true" |
| 键盘高亮项 | data-highlighted | data-highlighted(并把 tabindex 设为 0) | 靠 aria-activedescendant 指向 | data-focused / data-focus-visible |
| 禁用 | data-disabled | data-disabled | data-disabled | data-disabled="true" |
| 浮层方位 | data-side="top" | data-popup-side="bottom" | — | data-placement |
为什么用属性而不是 class
- 属性天然是「状态」,class 天然是「样式」。库不该替你决定 class 名,但状态必须让 CSS 能看见;
- 属性可以携带值(
data-side="top"),一个属性表达多个互斥状态; - 它对任何样式方案都通用:普通 CSS 写
[data-state=open] {},Tailwind 写data-[state=open]:opacity-100,CSS Modules 也一样。这是无头库能同时服务五种样式方案的原因。
三个容易踩的细节
- Base UI 用「属性有无」而不是「属性值」:关闭时
data-open整个消失,所以选择器是[data-open]而不是[data-open="false"]——照抄 Radix 的写法会全都不生效; - Tailwind 4 对无值属性有简写:
data-open:opacity-100(v3 里要写data-[open]:);带值的仍是data-[state=open]:; - 别用
:hover代替data-highlighted:键盘用户没有 hover。两者都要写,而且高亮项的样式应当由库的属性决定,否则鼠标与键盘会各高亮一个。
/* 普通 CSS:跟着库的属性写 */
[data-state="open"] .dropdown-panel { opacity: 1; transform: none; }
[data-highlighted] { background: var(--color-accent-soft); }
[data-disabled] { opacity: 0.5; pointer-events: none; }
[data-side="top"] .arrow { rotate: 180deg; }
// Tailwind 4:同一件事写在类名里
<DropdownMenu.Content
className="data-[state=open]:animate-in data-[state=closed]:animate-out
data-[side=top]:slide-in-from-bottom-2"
/>
// Base UI 是无值属性,写法不同(照抄 Radix 会失效)
<Select.Popup className="data-open:opacity-100 data-closed:opacity-0" />data-[state=closed]:animate-out 看起来很美好,但如果库在状态变成 closed 的同一帧就卸载了节点,动画根本没机会播。各家给了不同的解法:Radix 的 forceMount + 自己控制、Base UI 的 keepMounted、Headless UI 的 transition 属性、以及通用的 animate-presence 方案。12 章整章在讲这道坎,它是无头库里最常见的「样式写了没效果」。「我传了 className,但样式没生效」是无头库用户最常问的问题。它其实是三个不同的问题穿着同一件外套。
题一:组件到底透不透传
- 无头库基本都透传
className与其余 DOM 属性到它渲染的那个元素上,但多层结构的组件只有部分节点能被你摸到(比如 Select 的 Trigger / Content / Item 各是一个可传参的组件,中间的 Viewport / Positioner 有时不暴露); - 成套库则各有各的插槽 API:MUI 的
sx/slotProps、antd 的classNames与styles、Mantine 的classNames。先查它有没有插槽 API,再考虑写 CSS 选择器往里钻——靠后代选择器钻进第三方内部结构,是最脆的一种写法。
题二:两个 Tailwind 类打架时谁赢
- CSS 里胜负由规则在样式表中的先后决定,与你在
class属性里写的顺序无关。所以<Button className="p-8">覆盖组件自带的p-4时,赢的可能是p-4; - 这正是
tailwind-merge存在的理由:它识别出p-8与p-4属于同一「冲突组」,直接把前者删掉,从而让「后传入的赢」这件事成立; - 代价见 01 章:约 8 KB gzip。如果你的组件不接受外部覆盖(内部设计系统常见),完全可以不引它。
题三:和第三方样式表打架
- 用
@layer显式定义层序,是今天最干净的解法:层的优先级高于选择器特异性,靠后的层里一个.foo能压过靠前的层里的#id.a.b; - Tailwind 4 自己就把
theme/base/components/utilities组织成了层,所以工具类天然压得住组件层的样式; - 只有当所有相关样式都在层里时,这套排序才成立——没进任何层的样式排在所有层之后(也就是优先级最高)。这一条反直觉,是
@layer最常见的困惑来源。
// cn():clsx 负责拼接,tailwind-merge 负责让后来者赢
import clsx from "clsx";
import { twMerge } from "tailwind-merge";
export const cn = (...a) => twMerge(clsx(a));
cn("p-4 text-sm", "p-8"); // => "text-sm p-8"(p-4 被判定冲突并删掉)
clsx("p-4 text-sm", "p-8"); // => "p-4 text-sm p-8"(谁赢取决于 CSS 顺序)
/* @layer:把胜负从「特异性」改成「层序」 */
@layer reset, vendor, components, utilities;
@layer vendor { .el-button { border-radius: 4px; } }
@layer components { .btn { border-radius: 12px; } } /* 靠后的层赢,哪怕选择器更弱 */"text-" + color)在构建期扫不出来,产物里根本没有那条规则——表现是「本地开发好好的,某些颜色上线后没样式」(因为开发时可能被别的文件里的同名类救了)。正确做法是把完整类名写成常量映射:const map = { red: "text-red-500", blue: "text-blue-500" }。@layer。先看「我的规则出现了没有」——没出现是选择器不匹配或没被 Tailwind 扫描到;出现但被划掉才是优先级问题。这两类原因的修法完全不同,混为一谈会白折腾很久。这一卡讲的问题不报错、不影响本地开发、只在生产构建后偶尔出现,因此排查成本极高:CSS 规则的最终顺序,不完全由你的 import 顺序决定。
顺序是怎么乱的
- 代码分割:组件被拆进不同的 chunk 后,各自的 CSS 也被拆开,加载顺序取决于运行时哪个 chunk 先到——于是「谁在后面谁赢」变成了不确定的;
- 懒加载:
React.lazy的组件第一次渲染时才插入它的样式,这时它排在所有已有样式之后,可能反过来压住你的覆盖; - 库的样式在你之后加载:动态 import 一个成套库的组件时,它的 CSS 后到,把你精心写的覆盖全盖了。
三个可靠的对策
- ① 用
@layer:层序在 CSS 解析时就确定了,与到达顺序无关。这是唯一一个「结构性」的解法; - ② 把第三方样式集中在入口引一次,不要散落在各组件里,减少不确定性;
- ③ 用 CSS 变量而不是覆盖规则:改
--el-color-primary这类变量不涉及优先级竞争,变量取值只看层叠后的最终值,天然稳定。这也是 04 章推荐「一切走令牌」的工程理由。
顺带一提:样式抖动(FOUC)
- SSR 页面上,如果样式是运行时插入的(Emotion / antd cssinjs),首屏可能先看到没有样式的裸 HTML,然后突然变好看——这就是 FOUC;
- 各框架都提供了「样式提取」方案把关键 CSS 内联进 HTML,但配置繁琐且容易随框架升级失效。构建期出 CSS 的方案(Tailwind、CSS Modules、零运行时)从根上没有这个问题,这是它们这几年上位的另一个原因;
- 暗色模式还有一个专属的抖动来源(先白后黑),04 章给了那段必须内联的脚本。
/* 一个稳定的层序骨架,放在 CSS 入口最上面 */
@layer reset, vendor, tokens, components, utilities;
@layer reset { /* normalize / preflight */ }
@layer vendor { /* 第三方库样式 */ }
@layer tokens { :root { --brand: oklch(0.62 0.19 275); } }
@layer components { .btn { background: var(--brand); } }
/* 提醒:不在任何层里的样式,优先级高于所有层 */
.legacy-hack { background: red; } /* 这条会赢过上面所有层里的规则 */@layer 与 chunk 拆分,别去看代码逻辑。@layer 之后再构建一次对比。这个检查十分钟,能省掉「上线后某个页面样式怪怪的」这类幽灵问题的整个排查周期。设计令牌与主题化
主题不是「换个主色」。这一章讲清楚三层令牌模型、用 CSS 变量落地的标准做法、暗色模式那段必须内联的脚本,以及多品牌切换与设计工具对接——这是一个组件库能不能被长期维护的分水岭。
设计令牌(design token)就是把设计决策写成有名字的值。新手常见的做法是定义一堆 --blue-500 即可,然后在暗色模式或第二个品牌上撞墙——因为缺了中间那一层。
三层各自回答什么问题
- ① 原始层(primitive / global):「我们有哪些颜色」。
--blue-500: oklch(0.62 0.19 250)、--space-4: 16px。这一层不带含义,也不该被组件直接使用; - ② 语义层(semantic / alias):「这个颜色是干什么的」。
--color-primary: var(--blue-500)、--color-surface、--color-text-muted、--color-danger。暗色模式只改这一层——原始色板不变,语义指向变了; - ③ 组件层(component):「这个组件的这个部位用什么」。
--button-bg: var(--color-primary)、--card-radius。这一层可选,但一旦要让第三方在不改样式表的前提下定制单个组件,它就是接口。
没有语义层会怎样
- 暗色模式变成逐个组件加
.dark覆盖,规则数量翻倍且极易漏; - 第二个品牌要「把所有
--blue-500换成绿色」,于是原始层被污染成了语义层——从此--blue-500可能是绿的,难以分辨; - 设计师说「把警告色调深一点」,你得全局搜哪些地方用了那个黄色,而不是改一个变量。
命名的三条经验
- 语义层按用途命名,不按外观命名:
--color-danger而不是--color-red;--color-surface-raised而不是--color-white; - 成对出现的要成对命名:
--color-primary与--color-on-primary(放在主色上的文字色)。少了后者,暗色模式下按钮文字对比度必出错; - 别把「状态」做成独立令牌:hover / active 用颜色函数或透明度叠加更好维护(
color-mix(in oklch, var(--color-primary) 90%, black)),否则令牌数量指数级膨胀。
/* ① 原始层:只有值,没有含义 */
:root {
--blue-500: oklch(0.62 0.19 250);
--blue-300: oklch(0.78 0.12 250);
--gray-900: oklch(0.21 0.01 260);
--gray-50: oklch(0.98 0.00 260);
--space-4: 1rem;
}
/* ② 语义层:亮色下的指向 */
:root {
--color-primary: var(--blue-500);
--color-on-primary: var(--gray-50);
--color-surface: var(--gray-50);
--color-text: var(--gray-900);
}
/* ② 语义层:暗色只重新指向,原始层一个字都不动 */
[data-theme="dark"] {
--color-primary: var(--blue-300);
--color-on-primary: var(--gray-900);
--color-surface: var(--gray-900);
--color-text: var(--gray-50);
}
/* ③ 组件层:对外的定制接口 */
.btn {
--btn-bg: var(--color-primary);
background: var(--btn-bg);
color: var(--color-on-primary);
}background: var(--blue-500),你的暗色模式就有一个洞——它不会跟着语义层变。这类洞的症状很典型:暗色下某个角落还是亮的。防守办法是把规矩变成可检查的:写一条 stylelint 规则,禁止在组件样式里出现原始层的变量名前缀(--blue-、--gray-),只允许语义层。L 是感知均匀的——oklch(0.62 …) 的蓝色和 oklch(0.62 …) 的黄色看起来一样亮,而 HSL 里 hsl(240 100% 50%) 的蓝比 hsl(60 100% 50%) 的黄暗得多。这直接决定了「同一档次的颜色在暗色模式下会不会有的刺眼有的看不见」。今天所有主流浏览器都支持 oklch(),Tailwind 4 的默认调色板就是 OKLCH。令牌的载体今天基本已经收敛到一个答案:原生 CSS 变量。它的三个性质刚好卡住了所有需求——运行时可改、可继承、任何技术栈都认。
为什么是 CSS 变量而不是 JS 主题对象
- 运行时切换零成本:改一个属性,整棵子树重新取值,不需要重渲染任何组件。JS 主题对象要靠 Context 传播,切主题时整棵树重渲染;
- 作用域天然存在:在某个容器上重新定义变量,只影响那一块——这就是「局部暗色区块」「某个模块用另一套品牌色」的实现方式,一行 CSS 搞定;
- 跨技术栈:同一套变量能同时喂给 React 组件、Vue 组件、老的 jQuery 页面、以及第三方库(大多数成套库的主题就是一组 CSS 变量:
--el-color-primary、--mantine-color-*)。
Tailwind 4 把两件事合成了一件
- 在
@theme里定义的东西,既是 CSS 变量、又会生成对应的工具类:写--color-brand-500,你同时得到var(--color-brand-500)和bg-brand-500/text-brand-500; - 这解决了 v3 时代的一个长期割裂:JS 配置文件里的主题和 CSS 变量是两套东西,要手工同步;
- 注意区分
@theme与普通:root:只有@theme里的才生成工具类。运行时才需要变化的值(比如用户选的主题色)应当放在:root,或用@theme inline让工具类引用一个可被覆盖的变量。
和成套库对接
- 成套库的主题变量名是它自己的,做法是让它的变量指向你的语义层,而不是反过来;
- 这样「换品牌」只改你自己的语义层一处,第三方库自动跟随。凡是需要在两个地方各改一次的方案,长期一定会走偏。
/* Tailwind 4:@theme 里的令牌同时是变量与工具类 */
@import "tailwindcss";
@theme {
--color-brand-500: oklch(0.62 0.19 275);
--color-brand-600: oklch(0.55 0.20 275);
--radius-card: 12px;
}
/* 语义层放 :root,运行时可被 JS 改写 */
:root {
--color-primary: var(--color-brand-500);
--color-surface: white;
}
/* 让第三方库跟着你的语义层走,而不是各改各的 */
:root {
--el-color-primary: var(--color-primary); /* Element Plus */
--mantine-primary-color-filled: var(--color-primary); /* Mantine */
}
// 运行时换主题:一行,不触发任何组件重渲染
document.documentElement.style.setProperty("--color-primary", userColor);@media 查询条件里不能用。@media (min-width: var(--bp-md)) 是无效的——变量在层叠计算阶段才被解析,而媒体查询在更早的阶段就要求确定值。断点必须写成常量(或者靠构建工具生成)。同理,变量也不能用在 @import 的 URL、选择器名里。「变量到处都能用」这个直觉在这几个位置会失效,且报错方式是「整条规则被静默忽略」。<div data-theme="dark"> 包一层就完成了,不需要任何 JS,也不影响页面其余部分。暗色模式的技术含量不在配色,而在「第一帧就得是对的」。做不到这一点,用户每次刷新都会看到一道白光——这是暗色模式最常见也最招骂的缺陷。
三种触发方式
- ① 纯跟随系统:
@media (prefers-color-scheme: dark)。最简单,没有闪烁问题,代价是用户不能单独给你的站选主题; - ② class 开关:
html.dark。Tailwind 的传统做法,可手动切换; - ③ 属性开关:
html[data-theme="dark"]。和 ② 等价,但能表达三态以上(light/dark/sepia/ 品牌 A / 品牌 B),多主题场景建议直接用这个。
② 和 ③ 都要处理「用户选择」的持久化,于是就有了闪烁问题:CSS 已经渲染了,JS 还没跑,页面先按默认(通常是亮色)画了一帧。
解法只有一个:在 <head> 里同步内联一段脚本
- 它必须是同步的、内联的、放在样式表之后但在 body 之前——任何异步加载(
defer、模块脚本、打包产物)都来不及; - 逻辑就三行:读
localStorage→ 没有就问系统偏好 → 把结果写到<html>上; - 别忘了同时设置
color-scheme:它决定滚动条、表单控件、<input>的默认外观跟不跟着变。只改自己的颜色而不设它,会得到「深色页面 + 亮色滚动条 + 白底下拉框」的拼贴感。
三个常被忽略的细节
- 图片与图表要单独处理:白底透明 PNG 在暗色下会变成一块白斑。用
<picture>+media提供两套图,或给图加浅色底; - 阴影在暗色下几乎不可见:暗色模式的层次感应该靠「表面提亮」(
--color-surface-raised比背景亮一点)而不是投影; - SSR 时服务端不知道用户偏好:所以服务端渲染的 HTML 只能是某个默认值,靠上面那段内联脚本在客户端「纠正」。如果你的组件里有依赖主题的条件渲染,就会撞上水合不匹配(13 章)。
<!-- 放进 <head>,必须同步内联执行 -->
<script>
(function () {
var saved = localStorage.getItem("theme");
var sysDark = matchMedia("(prefers-color-scheme: dark)").matches;
var theme = saved || (sysDark ? "dark" : "light");
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme; // 滚动条与原生控件跟着变
})();
</script>
/* CSS 侧:语义层按主题重新指向即可 */
:root { color-scheme: light; --color-surface: oklch(0.98 0 0); --color-text: oklch(0.21 0 0); }
[data-theme="dark"] { color-scheme: dark; --color-surface: oklch(0.21 0 0); --color-text: oklch(0.98 0 0); }
/* 想同时支持「跟随系统」:把系统查询也写一份 */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) { color-scheme: dark; --color-surface: oklch(0.21 0 0); }
}transition 是个陷阱。看起来很高级,实际上会让页面上成百上千个元素同时进入过渡,低端设备上直接卡住半秒;更糟的是首屏加载时那段过渡也会被触发,看起来像是页面在自己变色。要做过渡,就只给少量关键元素加,或者用 View Transitions API,并且在切换的那一帧临时禁用过渡(切换前加一个 .no-transition 类,下一帧移除)。color-scheme 这个属性单独就值一个功能点:设成 dark 后,浏览器会自动把滚动条、复选框/单选框的原生外观、<select> 的下拉面板、日期选择器的原生弹层、以及未指定背景色的元素默认背景全部切到深色。很多「暗色模式做了但就是有几处白的」,答案就是这一行。「同一套代码,不同客户不同视觉」是 B 端产品的常见需求。做对了是改几个变量,做错了是维护 N 套样式表。关键在于:品牌差异必须全部落在语义层,一个字节都不落进组件。
三种规模,三种做法
- ① 只差主色(最常见):运行时改一个变量即可。用户选的主色存起来,进页面时
setProperty写上去。用color-mix()从主色派生出 hover / active / 浅底色,不需要让用户选七个颜色; - ② 差一整套色板与圆角字体:为每个品牌写一段
[data-brand="acme"] { … },全部是语义层变量的重新指向。切换就是改<html>上的属性; - ③ 差组件结构(某客户的表格要长得完全不同):这已经不是主题问题了,应该走组件的 slot / 渲染函数,或者干脆分叉出一个专用组件。硬塞进主题系统会让令牌层变成一个什么都往里扔的垃圾桶。
从一个主色派生一整套
color-mix(in oklch, var(--brand) 90%, black)得到按下态;混白得到浅底;在 OKLCH 空间里混合能保持色相稳定,在 sRGB 里混合容易发灰;- 对比度不能靠感觉:文字色用
oklch(from var(--brand) …)的相对颜色语法,或者干脆给两个候选并用light-dark()/ 手工指定; - 无障碍红线:正文对背景至少 4.5:1,大字与图标 3:1。用户自选主色时必须有兜底——检测对比度不足就自动改用深色/浅色文字,否则总有人选一个亮黄色然后说「按钮上的字看不见」。
SSR 与多品牌
- 品牌通常由域名或登录信息决定,服务端知道——所以应该在服务端就把
data-brand写进 HTML,而不是等 JS 来加,这样没有闪烁; - 用户自选的主色则相反(存在浏览器里),要走上一卡那段内联脚本的路子。两类主题的来源不同,处理方式也不同,混在一起写会得到一个两头不讨好的实现。
/* 一套语义层,多个品牌各自重新指向 */
[data-brand="acme"] { --brand: oklch(0.62 0.19 275); --radius-card: 12px; }
[data-brand="globex"] { --brand: oklch(0.70 0.15 145); --radius-card: 2px; }
/* 派生:不要让用户选七个颜色 */
:root {
--color-primary: var(--brand);
--color-primary-hover: color-mix(in oklch, var(--brand) 88%, black);
--color-primary-soft: color-mix(in oklch, var(--brand) 12%, white);
}
// 用户自选主色时的对比度兜底(简化版判据)
function onPrimary(l) { // l = OKLCH 的亮度分量 0~1
return l > 0.65 ? "var(--gray-900)" : "var(--gray-50)";
}color-mix() / 相对颜色语法,JS 只负责写入那一个源头变量——这条边界划清楚,换肤功能的复杂度会掉一个数量级。令牌真正的价值在跨越设计与工程的边界。如果设计稿里的变量和代码里的变量是两套人手工同步的东西,它们一定会漂移——问题只是多久。
今天可行的链路
- Figma Variables:Figma 原生支持变量与模式(modes),一个变量可以在「亮色/暗色」「品牌 A/品牌 B」下取不同值——这正好对应上面的语义层与多品牌;
- 导出:通过 Figma 插件(Tokens Studio 等)或 REST API 把变量导成 JSON,格式向 W3C Design Tokens 规范(
$value/$type)靠拢; - 转换:
style-dictionary把这份 JSON 编译成各平台产物——Web 的 CSS 变量、iOS 的 Swift、Android 的 XML、以及给 Tailwind 用的@theme片段; - 消费:前端只 import 生成出来的 CSS,不手写任何原始层令牌。
让它别漂移的三条工程约束
- ① 生成物进仓库并提交:不要在构建时现拉 Figma API(网络故障就构建失败,且历史不可追溯)。生成物提交进 git,改动在 PR 里看得见;
- ② 命名映射在转换层做:设计侧叫
Color/Brand/Primary,代码侧要--color-primary——这个转换规则写在 style-dictionary 的配置里,只有一处; - ③ 加一条 CI 检查:跑一遍生成,如果产物与仓库里的不一致就失败。这条能挡住「有人手改了生成文件」,那是所有代码生成方案的头号死因。
什么时候不值得上这套
- 团队里没有专职设计师,或者令牌总数不到几十个——直接手写 CSS 变量,把
tokens.css当成单一事实来源就够了; - 只有一个品牌、一个平台(纯 Web)——多平台产出是 Style Dictionary 最大的价值,用不上就是纯负担;
- 判据很朴素:当「改一个颜色要动三个以上的地方」时,才该上工具链。
// tokens/color.json —— 向 W3C Design Tokens 规范靠拢的写法
{
"color": {
"brand": { "500": { "$type": "color", "$value": "oklch(0.62 0.19 275)" } },
"primary": { "$type": "color", "$value": "{color.brand.500}" }
}
}
// style-dictionary 配置:一份源,多份产物
export default {
source: ["tokens/**/*.json"],
platforms: {
css: {
transformGroup: "css",
buildPath: "src/styles/",
files: [{ destination: "tokens.css", format: "css/variables" }],
},
},
};
/* 产物 src/styles/tokens.css(提交进仓库,别手改) */
:root {
--color-brand-500: oklch(0.62 0.19 275);
--color-primary: var(--color-brand-500);
}npm run tokens 生成,请勿手改」,并把它加进 .github/CODEOWNERS 或 lint 的忽略名单。成本一分钟,能避免最典型的那种事故:有人在生成文件里手改了一个颜色,下次生成时被覆盖,然后没人知道为什么颜色变回去了。无障碍:键盘与 ARIA 的契约
这一章是判断「一个组件做得对不对」的唯一硬标准。不谈情怀,只列契约:键盘该怎么响应、可访问名从哪来、焦点怎么走、弹窗打开时外面的世界该怎么消失——每一条都能在 DevTools 里当场验证。
键盘可用性不是「额外的照顾」,它是一切辅助技术的地基——读屏器、语音控制、开关设备全都建立在焦点模型之上。下面五条是 WAI-ARIA 实践里最硬的部分,任何组件库做不到就是有 bug。
契约本体
- ① Tab 只在「组件之间」移动,方向键在「组件内部」移动。一个有十个选项的菜单,Tab 应该一下子跳过整个菜单,而不是按十次。做到这一点的技术叫 roving tabindex(只有一个项
tabindex="0",其余-1)或aria-activedescendant; - ② Enter 与 Space 都要能激活。原生
<button>免费得到这条;用 div 模拟按钮就得自己实现两个键,而且 Space 还要preventDefault()阻止页面滚动——这是「能用原生标签就用原生标签」最实际的理由; - ③ Esc 关闭当前层,且只关一层。弹窗里开了下拉,按 Esc 应该只关下拉;
- ④ 焦点必须可见。删掉
outline而不给替代样式,是无障碍事故里最常见的一条; - ⑤ 关闭浮层后焦点归还触发器。做不到的话,键盘用户会被扔回页面顶部,等于重新导航一次。
两种「组件内导航」的实现,对照
- roving tabindex(真焦点移动):Radix Select、Base UI Select、React Aria Select 都是这一派——按下方向键后
document.activeElement真的变成了role="option"的那个元素,Base UI 还会把高亮项的tabindex从 -1 改成 0; - aria-activedescendant(焦点不动,指针移动):Headless UI 的 Listbox 是这一派——焦点始终留在列表容器上,靠
aria-activedescendant属性指向当前项的 id; - 两种都合规,但组合框(可输入的下拉)只能用后者:焦点必须留在
<input>上才能继续打字。Headless UI Combobox 与 React Aria ComboBox 的输入框上都出现了aria-activedescendant,与它们各自 Select 的实现方式并不相同——同一个库在不同组件上用不同策略,是正常且正确的。
自查的最低成本方法
- 把鼠标拿开,用 Tab 走一遍你的页面。二十秒就能发现绝大多数问题:焦点看不见、Tab 陷进某个组件出不来、弹窗关了焦点丢了;
- 重点关注三处:自定义下拉、模态框、以及任何用 div 做的可点击元素;
- 「Tab 走一遍」的信息量超过任何自动化工具——axe 之类的工具查得出缺属性,查不出「焦点顺序很怪」。
// roving tabindex 的最小实现(理解原理用;实际请用库)
function onKeyDown(e) {
const items = [...list.querySelectorAll('[role="option"]')];
const i = items.indexOf(document.activeElement);
let next = i;
if (e.key === "ArrowDown") next = Math.min(i + 1, items.length - 1);
if (e.key === "ArrowUp") next = Math.max(i - 1, 0);
if (e.key === "Home") next = 0;
if (e.key === "End") next = items.length - 1;
if (next !== i) {
e.preventDefault(); // 阻止方向键滚动页面
items[i].tabIndex = -1; // 只有一个项参与 Tab 序列
items[next].tabIndex = 0;
items[next].focus();
}
}
/* 焦点可见:别删 outline,换一个更好看的 */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}disabled 的按钮不能被聚焦,因此读屏器用户可能根本发现不了它的存在,也听不到「为什么不能点」。更好的做法在很多设计系统里是 aria-disabled="true" + 保留可聚焦 + 拦掉点击行为:焦点能到、读屏器会读出「已禁用」、还能配一句说明为什么。这是无障碍里少数「原生属性反而不是最佳解」的地方——React Aria 与 Ark UI 都提供了这种「focusable disabled」模式。:focus-visible 而不是 :focus 写焦点样式:浏览器只在「用户可能需要焦点提示时」(键盘操作、辅助技术)才匹配它,鼠标点击不会触发。这解决了那个促使无数人写 outline: none 的老矛盾——点一下按钮不会留下一个难看的框,键盘用户却仍然看得见焦点。每个交互元素都有一个「可访问名(accessible name)」——读屏器念出来的那句话。它不等于你看到的文字,而是由一套优先级规则算出来的。这套规则不熟,就会出现「明明写了文字,读屏器却读别的」。
优先级(从高到低)
- ①
aria-labelledby:指向其它元素的 id,取那些元素的文本。它会覆盖后面所有来源; - ②
aria-label:直接写字符串; - ③ 原生关联:
<label for>、<fieldset><legend>、表格的<caption>、<img alt>; - ④ 元素自身的文本内容(按钮、链接等);
- ⑤
title属性——兜底,别依赖它。
组件库怎么用这套规则
- Radix / Base UI 的
Dialog.Title会自动生成一个 id,并把aria-labelledby指过去——这就是「必须放 Title」的机制原因; - 不放 Title 时,两家的对话框都没有
aria-labelledby属性,Chrome 无障碍树里算出来的可访问名是空字符串,而且控制台一句警告都没有(radix-ui 1.6.7 的 dialog 产物里没有任何 console 调用); - 结论很直接:「没有报错」不等于「有名字」。要验证只能自己看——DevTools 的 Elements → Accessibility 面板里有 Computed Properties,Name 那一行就是答案。
三个高频错误
aria-label覆盖了可见文字:按钮上写着「保存」,却加了aria-label="submit",于是语音控制用户说「点击保存」时点不动它。可见文字与可访问名必须一致或包含关系(WCAG 的 label-in-name 要求);- 只有图标的按钮忘了名字:一个
<button><TrashIcon /></button>的可访问名是空的。给按钮加aria-label="删除",并给图标加aria-hidden="true"; aria-labelledby指向了不存在的 id:条件渲染的标题被卸载后最容易发生,结果是名字直接消失,且没有任何提示。
<!-- 图标按钮:名字 + 图标对读屏器隐藏 -->
<button aria-label="删除这一行">
<svg aria-hidden="true" …></svg>
</button>
<!-- 想视觉隐藏标题但保留语义:用 sr-only,别用 display:none -->
<Dialog.Title className="sr-only">确认删除订单</Dialog.Title>
<!-- 表单控件:三种合规写法 -->
<label for="email">邮箱</label><input id="email" /> <!-- 最好,点标签能聚焦输入框 -->
<label>邮箱<input /></label> <!-- 包裹式,也行 -->
<input aria-label="邮箱" /> <!-- 无可见标签时才用 -->
// 自查:在控制台里读出某个元素的可访问名
$0.getAttribute("aria-label") ?? document.getElementById($0.getAttribute("aria-labelledby"))?.textContent;placeholder 不是标签。它在某些浏览器/读屏器组合下会被当成可访问名的兜底来源,于是「看起来能用」,但用户一开始输入,提示就消失了——认知负担全部转嫁给用户,而且对比度通常也不达标。规则很简单:每个输入框都要有真正的 <label>,视觉上不想要就用 sr-only 藏起来。模态框的核心不是「盖了一层灰」,而是「外面的东西对所有输入方式都不再存在」。视觉上有遮罩,键盘上要有焦点陷阱,读屏器上要屏蔽——三者缺一,模态就是假的。
三种技术手段
inert属性(今天的首选):加在元素上,它和它的所有后代同时失去焦点能力、点击能力、并从无障碍树中消失。一个属性解决三件事,浏览器原生支持;aria-hidden="true":只从无障碍树里移除,焦点仍然能 Tab 进去——所以必须搭配 JS 的焦点陷阱一起用。历史上是主流做法;aria-modal="true":告诉读屏器「这是模态,别越界」。它依赖读屏器实现,且不影响键盘焦点,因此不能单独使用。
四家库的实际选择
- Radix / Base UI:给外部内容加
aria-hidden,自己实现焦点陷阱,不设aria-modal; - Headless UI:外部
inert+aria-hidden,并且是四家里唯一设了aria-modal="true"的; - React Aria:外部
inert,不设aria-modal; - 四家的焦点陷阱都有效:连按六次 Tab,焦点一直在弹窗内的两个可聚焦元素之间循环,一次都没跑出去。
原生 <dialog> 把这些都包了
dialogEl.showModal()会:把元素提升到 top layer(永远压在所有 z-index 之上)、自动做焦点陷阱、Esc 自动关闭、外部内容自动 inert、::backdrop提供遮罩;- 它不做的两件事:滚动锁(body 仍然能滚,要自己加
overflow:hidden)、以及退场动画(需要配合@starting-style与transition-behavior: allow-discrete); - 所以简单模态用原生
<dialog>完全够,07 章会把两种方案并排比较。
<!-- 现代做法:外面 inert,一个属性搞定三件事 -->
<div id="app" inert>…页面正文…</div>
<div role="dialog" aria-labelledby="t">
<h2 id="t">确认删除</h2>
</div>
// 原生 dialog:焦点陷阱、Esc、top layer、backdrop 全都免费
const dlg = document.querySelector("dialog");
dlg.showModal(); // 注意:show() 是非模态,没有以上任何一条
dlg.addEventListener("close", () => restoreFocus());
/* 遮罩用伪元素,不需要额外 DOM */
dialog::backdrop { background: rgb(0 0 0 / 0.4); }<body> 或包含弹窗自身的祖先加 aria-hidden / inert。常见写法是「打开弹窗时给 #root 加 aria-hidden」,而弹窗恰好也 portal 在 #root 里——结果是整个弹窗对读屏器一起消失了,视觉上完全正常,只有辅助技术用户遇到「什么都读不到」。这也是为什么各家库都把弹窗 portal 到 body 的直接子节点:要屏蔽的和被保留的必须是兄弟关系。inert 是排查焦点问题的利器:在 DevTools 里给某个容器手动加上 inert,如果焦点仍然能进去,说明那个元素在另一棵树里(多半被 portal 到了别处)。反过来,如果你的弹窗自己不小心被 inert 的祖先包住,表现是「点什么都没反应」——看起来像 JS 坏了,实际是一个 HTML 属性。ARIA 是一套「补语义」的机制,它只改变辅助技术看到的东西,不改变任何行为。这个事实是所有 ARIA 误用的根源——写了 role="button" 不会让 div 变得能按回车。
ARIA 第一规则
- 能用原生元素就别用 ARIA。
<button>自带:可聚焦、Enter/Space 激活、正确的 role、禁用语义、表单提交行为。用<div role="button" tabindex="0">复刻这些,至少要写三个事件处理与两个属性,还容易漏; - 推论:看到一个组件库大量使用原生元素,那是好迹象。中四家无头库的 Trigger 渲染出来都是真正的
<button type="button">,不是 div。
三类高频误用
- ① 冗余 role:
<nav role="navigation">、<button role="button">——无害但多余;真正有害的是覆盖式误用,比如给<ul>加role="tablist"却没给<li>加role="tab",导致列表语义被破坏而 tab 语义又不完整; - ② role 与结构不匹配:
role="listbox"的子元素必须是role="option",中间不能塞别的容器(塞了要用role="presentation"抹掉)。这是自研下拉最常见的破绽; - ③ 状态属性忘了同步:
aria-expanded写死成false、aria-selected从不更新。比不写更糟——不写只是缺信息,写错是主动误导。
动态内容要「说出来」
- 页面上出现一条 Toast、表单校验失败、搜索结果数量变了——视觉用户一眼看见,读屏器用户什么都不知道,除非放进 live region;
aria-live="polite":等用户说完话再播报,用于大多数状态更新;assertive:打断当前播报,只用于真正紧急的错误;- live region 必须在内容变化之前就存在于 DOM 里——先插入一个空的容器,再往里写文字。整块一起插入时读屏器往往不播报,这是 Toast 组件最常见的实现错误(11 章展开)。
<!-- ① 原生优先:这两行等价,但左边免费得到所有键盘行为 -->
<button type="button" onclick="…">保存</button>
<div role="button" tabindex="0" onclick="…" onkeydown="…">保存</div> <!-- 还要处理 Enter/Space -->
<!-- ② 状态属性要真的跟着状态走 -->
<button aria-expanded="true" aria-controls="panel-1">详情</button>
<div id="panel-1">…</div>
<!-- ③ live region:容器先在,内容后填 -->
<div id="status" aria-live="polite" class="sr-only"></div>
// 之后:
document.getElementById("status").textContent = "已保存 3 条记录";浮层与定位:下拉、气泡、提示
下拉菜单、tooltip、popover、下拉选择——它们共用同一套定位与关闭逻辑,也共用同一批 bug。这一章把 Floating UI 的四个中间件、层叠上下文、以及嵌套浮层的「点外部」误伤讲清楚。
今天几乎所有主流库的浮层定位都基于 Floating UI(Radix、Base UI、Headless UI、Chakra、Ark UI、Mantine 全在用它,Vue 的 Reka UI 与 Element Plus 亦然)。它的设计是一条中间件流水线,理解四个核心中间件就理解了 90% 的定位行为。
四个中间件各自解决什么
offset:浮层与触发器之间留多少间距(库里通常叫sideOffset/alignOffset);flip:放不下就翻到对面。下方空间不够时从 bottom 翻到 top;shift:放得下但会溢出边缘时,沿着边平移回来,保证浮层完整可见;size:把可用空间算出来交给你,用于「最多这么高,超了就内部滚动」。
它们真的在工作
- 把一个 Popover 放在页面中部:
data-side="bottom",浮层在触发器正下方——默认行为; - 把同样的 Popover 放到贴近视口底部:
data-side自动变成"top",浮层出现在触发器上方——这就是flip; - 把它放到贴近右缘(触发器右边只剩十几像素):
data-side仍是"bottom",但浮层的左边界被往左推了几十像素,右边界正好压在可用宽度上——这就是shift; - Radix 还把算出来的空间以 CSS 变量暴露出来:
--radix-popper-available-height/-available-width、--radix-popover-trigger-width、以及用于动画的--radix-popper-transform-origin。这几个变量是写「下拉最大高度」「下拉宽度等于触发器」的正确姿势。
三个常见的定位问题
- 下拉超长、撑出屏幕:不是库的错,是你没用
size给的变量。加一句max-height: var(--radix-popover-content-available-height); overflow: auto;就解决; - 下拉宽度想和触发器一样:用
--radix-popover-trigger-width(Select 有对应的--radix-select-trigger-width),别去 JS 里量宽度; - 滚动时浮层不跟随:Floating UI 默认不持续跟踪,要显式开启
autoUpdate(多数库已经替你开了)。自己直接用computePosition时最容易漏这一步。
/* 用库暴露的变量写「不撑破屏幕的下拉」 */
.dropdown-content {
max-height: var(--radix-popover-content-available-height);
overflow-y: auto;
width: var(--radix-popover-trigger-width); /* 宽度对齐触发器 */
transform-origin: var(--radix-popover-content-transform-origin); /* 动画从正确的角落展开 */
}
// 直接用 Floating UI 时的最小配置(自研组件才需要)
import { computePosition, offset, flip, shift, size, autoUpdate } from "@floating-ui/dom";
autoUpdate(trigger, panel, () => {
computePosition(trigger, panel, {
placement: "bottom-start",
middleware: [
offset(8),
flip(), // 放不下就翻面
shift({ padding: 8 }), // 贴边时平移,留 8px 余量
size({
apply({ availableHeight, elements }) {
elements.floating.style.maxHeight = availableHeight + "px";
},
}),
],
}).then(({ x, y }) => Object.assign(panel.style, { left: x + "px", top: y + "px" }));
});position: absolute + 手算 offsetTop 自己定位浮层。它在四种情况下必错:祖先有 transform(定位基准变了)、页面横向滚动、触发器在可滚动容器里、以及窗口 resize。这些恰好是真实页面的常态。Floating UI 用 position: fixed + transform 的组合正是为了绕开这些——Radix 生成的定位容器就是 position: fixed; left: 0; top: 0; transform: translate(224px, 346px) 这种写法(用 transform 而不是 left/top 是为了避免亚像素模糊与重排)。flip 与 shift 的顺序很重要,而且是「先 flip 后 shift」:先决定放哪一面,再在那一面上微调位置。反过来写会得到诡异的抖动(平移之后空间变了,又触发翻面,再平移……)。库里已经排好了顺序,只有自己拼中间件时才需要注意——Floating UI 的中间件是有序流水线,不是一组开关。「弹窗被卡住一半」「z-index 加到 9999 还是被挡」——这两个问题的答案几乎总是同一个:浮层被困在了某个祖先创建的层叠上下文或滚动容器里。
谁会困住你的浮层
overflow: hidden / auto / scroll:超出部分被裁掉。表格容器、卡片、抽屉内容区全是重灾区;transform/filter/backdrop-filter/perspective/will-change/contain:这些属性会创建新的层叠上下文,子元素的 z-index 从此只在这个小世界里排序,无论多大都压不过外面的兄弟;position: fixed的祖先带 transform:更隐蔽——此时 fixed 的定位基准会变成那个祖先而不是视口,浮层「跟着页面滚走了」。
三种解法,从好到差
- ① Portal(传送):把浮层渲染到
body直属子节点,彻底逃出所有困局。这就是各家库都提供Portal的原因——Radix 直接挂 body,Base UI 与 React Aria 各自建一个 div 挂 body,Headless UI 挂在#headlessui-portal-root; - ② top layer(原生):
<dialog>.showModal()与popover属性会把元素提升到浏览器的顶层,连 z-index 都不需要,天然在所有内容之上; - ③ 调 z-index:只在「没有额外层叠上下文」时有效,是最不可靠的一种,却往往是第一个被尝试的。
Portal 的代价,别只看好处
- 事件冒泡路径变了:DOM 上浮层已经在 body 下,但 React 的合成事件仍沿组件树冒泡——这个差异会让「点击外部关闭」的实现变复杂,也是「在 portal 里点击却触发了外层的 onClick」的原因;
- CSS 继承断了:浮层不再继承原来父级的字体、颜色、以及局部主题变量。局部暗色区块里的下拉突然变成亮色,就是这个原因——解法是把主题变量定义在
:root或给 portal 容器也加上data-theme; - 表单关系断了:portal 出去的
<input>不再属于原来的<form>,提交时拿不到值。要用form="表单id"属性显式关联(08 章)。
// 症状与判据:在 DevTools 控制台里找出困住浮层的祖先
let n = $0; // $0 = 当前选中的浮层元素
while ((n = n.parentElement)) {
const s = getComputedStyle(n);
if (
s.transform !== "none" || s.filter !== "none" ||
s.contain !== "none" || s.willChange !== "auto" ||
s.overflow !== "visible" || (s.position !== "static" && s.zIndex !== "auto")
) {
console.log("嫌疑祖先:", n, s.transform, s.overflow, s.zIndex);
}
}
// 解法:套 Portal(各库写法)
<Popover.Portal>…</Popover.Portal> // Radix / Base UI
<el-select :teleported="true" /> // Element Plus(默认就是 true)
<Select getPopupContainer={() => document.body} /> // antdoverflow: hidden 会顺带创建一个「滚动容器」,即使没有滚动条。于是一个只为了裁圆角而写的 overflow: hidden,就能让里面的所有 tooltip 被切掉一半。这类 CSS 往往写在很远的地方(某个通用卡片类),排查时根本想不到。判据:把浮层的 position 临时改成 fixed 看它是否恢复正常——恢复了就是被祖先困住,接着往上找。<dialog> 里,portal 到 body 反而会跑到对话框下面去)。各家都留了口子:Radix 的 <Portal container={ref.current}>、antd 的 getPopupContainer、Element Plus 的 :teleported="false" 或 append-to。「浮层跑到对话框后面」时先检查这个,而不是调 z-index。浮层的交互设计有一套约定俗成的规矩,各家库的默认值基本一致。违反这些规矩的自研组件,用起来会有一种说不上来的别扭——这一卡把它们说明白。
三种触发方式的适用场景
- hover(tooltip 专用):只用于纯提示、无交互内容的浮层。里面有链接或按钮就不能用 hover,因为触屏与键盘用户永远够不着;
- click(菜单、popover、select):任何含交互内容的浮层都该用 click。触屏上 hover 不存在,click 是唯一可靠的;
- focus(输入型):组合框、日期输入。要注意区分
focus与focus-visible,鼠标点击输入框也会触发 focus。
hover 类浮层的三个细节
- 打开要延迟,关闭也要延迟:打开延迟(约 300–700ms)避免鼠标划过时一路弹窗;关闭延迟(约 100–300ms)给用户时间把鼠标移进浮层;
- 「安全三角」:从触发器移向浮层时,鼠标可能短暂离开两者——好的实现会计算一个三角形区域,在其中移动不算离开。Radix 的 Tooltip / DropdownMenu 与 Base UI 都实现了这类保护;
- 组内共享延迟:工具栏上一排图标按钮,移到第一个要等 500ms,但从第一个移到第二个应该立刻显示。这就是
Tooltip.Provider的作用之一(skipDelayDuration),也是它必须包在外层的原因。
关闭的五个触发点,缺一个都会被投诉
- Esc 键(必须);点击外部(除模态确认框外基本都要);选中某一项后(菜单/选择器);焦点移出(键盘用户 Tab 走了);路由跳转——最后这条最容易漏,症状是「点了菜单里的链接,页面变了但菜单还挂在那里」;
- 关闭之后焦点必须回到触发器(05 章),这条对菜单尤其重要,否则连续操作时每次都要重新 Tab 一遍。
// Tooltip 的延迟配置:全局一次,别每个都写
<Tooltip.Provider delayDuration={500} skipDelayDuration={200}>
<App /> // 首次悬停等 500ms;200ms 内移到下一个则立即显示
</Tooltip.Provider>
// 路由跳转时关闭浮层(最容易漏的一条)
useEffect(() => { setOpen(false); }, [pathname]);
// 需要拦住「点外部关闭」时(比如点到了自己的另一个浮层里)
<Popover.Content
onInteractOutside={(e) => {
if (e.target.closest("[data-my-datepicker]")) e.preventDefault();
}}
/>title 属性当 tooltip 是个陷阱。它看起来免费,实际问题一堆:显示延迟由系统决定(约 1–2 秒,无法配置)、样式完全不可控、触屏设备上根本不显示、部分读屏器会把它和可访问名混在一起读两遍。它唯一合适的场景是给已有可访问名的元素补充「额外说明」,而不是当作提示的主要载体。下拉里放选择器、对话框里开菜单、菜单里再展开子菜单——嵌套浮层是 bug 密度最高的地方,因为每一层都以为自己是最外面那一层。
三类典型故障
- ① 点内层,外层关了:内层被 portal 到 body,落在外层的「外部」判定里。这是嵌套浮层的头号问题;
- ② 按一次 Esc,两层全关:两层各自监听了 document 的 keydown,事件被两边都收到;
- ③ 焦点归还错位:关掉内层后焦点跳回了最外层的触发器,用户在菜单里操作一次就被踢出来。
库怎么解决,你怎么解决
- 同一个库内部的嵌套通常已经处理好了:各家都维护一个「浮层栈」,只有栈顶响应 Esc 与外部点击。Radix 的
DismissableLayer、Base UI 的层管理都是干这个的——Base UI 的对话框上有个--nested-dialogs变量,就是嵌套层数计数; - 跨库嵌套必然出问题(02 章那条 pitfall),因为两个栈互不知情。解法是显式告诉外层「这块也算里面」:
onInteractOutside/onPointerDownOutside里判断目标并preventDefault(); - 或者干脆不 portal 内层:把内层渲染在外层容器内部(
container/appendTo/getPopupContainer指回去),DOM 包含关系一恢复,外部判定自然就对了。代价是又要面对 overflow 裁剪——两害相权。
子菜单(submenu)的额外规矩
- 子菜单靠 hover 展开,但必须允许鼠标斜着移向子菜单(经过父菜单的其它项也不能关)——这就是前面说的安全三角,自己实现的话这是最容易做砸的一处;
- 键盘上:
→打开子菜单并聚焦第一项,←关闭并回到父项。这两个键是 ARIA 菜单模式的硬要求; - 子菜单的
flip方向是左右而不是上下(placement: "right-start"),贴到右缘时整个翻到左边。
// 跨库嵌套的标准修法:把内层的 DOM 也算作「内部」
<Popover.Content
onPointerDownOutside={(e) => {
const t = e.target;
// 内层日期面板被 portal 到了 body,这里手动放行
if (t.closest(".el-picker__popper, .ant-picker-dropdown")) e.preventDefault();
}}
onFocusOutside={(e) => e.preventDefault()} // 焦点跑进内层时也别关
>
<DatePicker />
</Popover.Content>
// 另一条路:让内层别 portal 出去(各库的写法)
<el-date-picker :teleported="false" />
<DatePicker getPopupContainer={(node) => node.parentElement} />document.addEventListener("click") 是错的。三个原因:① 点击事件在 mousedown 到 mouseup 之间如果 DOM 变了就可能丢失;② 在浮层内按下鼠标、拖到外面松开会被误判为外部点击(选中文本时高频发生);③ 触屏与手写笔走的是 pointer 事件。正确做法是监听 pointerdown 并检查 event.composedPath()(这样才能穿透 Shadow DOM)——各家库都是这么做的,自研时务必抄对。onInteractOutside={(e) => e.preventDefault()}),看剩下的行为是否正常。这一步能立刻把问题分成两类:关掉之后就好了 → 是外部判定的问题;关掉还是不对 → 是层级或焦点的问题。不做这个二分,两类问题的现象会混在一起,越查越乱。浏览器正在把浮层这件事收进平台。popover 属性已经可以用了,CSS 锚点定位(anchor positioning)则在 Chromium 系里可用、其它引擎跟进中——这一卡讲清楚今天能用到什么程度。
popover 属性给了什么
- 给元素加
popover,再给按钮加popovertarget="面板id",不写一行 JS 就有了「点击展开/收起」; - 它自动提供:提升到 top layer(不需要 z-index、不受祖先 overflow 与层叠上下文影响)、Esc 关闭、点外部关闭(
popover="auto"时)、轻量的层级栈(嵌套的 popover 会正确地一层层关); - 它不提供:定位(要配合锚点定位或自己算)、焦点陷阱(它本来就不是模态)、动画(需要
@starting-style)。
CSS 锚点定位
- 给触发器
anchor-name: --my-btn,给浮层position-anchor: --my-btn+position-area: bottom center,浏览器自己算位置; position-try-fallbacks提供备选位置,相当于原生版的flip;- 状态:Chromium 系已支持,Safari 与 Firefox 处于不同的实现阶段。写之前必须现查一次支持度(caniuse 上搜 anchor positioning),并准备好回退。
今天该怎么选
- 简单、非模态、内容不复杂的浮层(一个小菜单、一个提示气泡):
popover+ 锚点定位已经够用,且没有任何 JS 体积; - 要严格的键盘契约、跨浏览器一致、复杂交互(组合框、多级菜单、虚拟列表下拉):仍然用库。原生方案目前解决的是「定位与层级」,不解决「ARIA 与键盘」;
- 可以混用:库负责行为,容器用原生
popover拿 top layer。已经有库在这么做了(部分实现把popover=""加在浮层根节点上)。
<!-- 零 JS 的浮层:popover + anchor positioning -->
<button popovertarget="menu" style="anchor-name: --btn">菜单</button>
<div id="menu" popover>
<a href="#">设置</a>
</div>
<style>
#menu {
position-anchor: --btn;
position-area: bottom span-right; /* 锚点下方、向右展开 */
position-try-fallbacks: flip-block; /* 放不下就翻到上面 —— 原生版 flip */
margin: 6px 0 0;
}
/* 进场动画:popover 从 display:none 变过来,需要这两样 */
#menu { opacity: 0; transition: opacity 0.15s, display 0.15s allow-discrete; }
#menu:popover-open { opacity: 1; }
@starting-style { #menu:popover-open { opacity: 0; } }
</style>popover 当模态框用。它不做焦点陷阱、不屏蔽外部内容、不锁滚动——popover="auto" 只是「点外面会关」而已。需要模态语义就用 <dialog>.showModal()(07 章)或库。两者名字接近、行为差得远,把提示气泡的实现直接套给确认框,会得到一个键盘用户可以随意 Tab 出去的「假模态」。popover 有两种模式:popover="auto"(默认,点外部会关、同时只能开一个同级的)与 popover="manual"(只能用代码开关,适合 Toast 这类不该被点外部关掉的东西)。选错模式的表现是「Toast 一点页面就没了」或「菜单点外面不关」,而两者都不报错。对话框:焦点、滚动锁与层级
对话框是所有组件里契约最严、也最容易做出「看起来对、用起来不对」的一个。这一章拆开模态的四件套、三种滚动锁实现的差异,并把原生 dialog 与库方案并排比较。
「模态」不是视觉概念,是输入焦点的独占。这四件事同时成立,对话框才是真的模态;缺任何一件,键盘或读屏器用户就能「穿墙」。
四件套与结果
- ① 初始焦点进入:打开时焦点必须移进对话框。Radix 与 Base UI 把焦点放到弹窗内第一个可聚焦元素,Headless UI 放到对话框容器,React Aria 放到对话框元素本身——三种都合规,差别在于用户按第一次 Tab 时会到哪;
- ② 焦点陷阱:Tab 到最后一个元素后循环回第一个。四家连按六次 Tab,焦点全程没有离开弹窗;
- ③ 关闭后归还:焦点回到触发它的那个元素。四家都做到了,包括用键盘(Enter 打开、Esc 关闭)的完整来回;
- ④ 屏蔽外部:
inert或aria-hidden(05 章的对比表)。
初始焦点该给谁:一条实用规则
- 默认给「最安全的那个元素」——不是「确认删除」按钮,而是「取消」或对话框本身。危险操作绝不能成为默认焦点,否则用户习惯性按空格就删了数据;
- 表单类对话框给第一个输入框;纯确认类给取消;内容很长的给对话框容器(让读屏器从标题开始读);
- 各库都提供
autoFocus或initialFocus一类的口子,需要时显式指定,别依赖默认。
三个容易漏的边角
- 对话框里的内容是异步加载的:打开时里面还没有可聚焦元素,焦点无处可去。解法是先把焦点给容器,加载完再移;
- 对话框内容比屏幕高:要让对话框内部滚动而不是页面滚动,否则滚动锁与内容滚动会打架;
- 嵌套对话框:第二层打开时第一层要变成「外部」。同一个库内部会处理,跨库不会(06 章)。
// 指定初始焦点:确认类对话框应当聚焦「取消」而不是「删除」
<Dialog.Content
onOpenAutoFocus={(e) => {
e.preventDefault(); // 阻止默认的「第一个可聚焦元素」
cancelRef.current?.focus();
}}
>
<Dialog.Title>删除这个项目?</Dialog.Title>
<button ref={cancelRef}>取消</button>
<button className="danger">删除</button>
</Dialog.Content>
// 关闭后想把焦点给别处(比如刚新建的那一行)
<Dialog.Content onCloseAutoFocus={(e) => { e.preventDefault(); newRowRef.current?.focus(); }} /><body>,键盘用户被送回页面最顶端。解法是显式指定归还目标(onCloseAutoFocus 里聚焦表格容器或下一行)。凡是「操作会让触发器消失」的对话框,都要单独处理这一条。对话框打开时页面不该还能滚。实现这件事有三种做法,它们在「布局会不会跳一下」这个细节上表现不同——这是用户能感知到但说不清的那类瑕疵。
为什么会跳
- 桌面浏览器的滚动条占据布局宽度(约 15px)。给
overflow: hidden之后滚动条消失,页面可用宽度突然变宽,所有内容向右挪了十几像素; - 所以锁滚动必须同时补偿这个宽度。两种补法:加 padding-right(传统)或
scrollbar-gutter: stable(现代,提前预留出槽位)。
四家库的实际做法
| 库 | 锁在哪 | 补偿方式 | 留下的痕迹 |
|---|---|---|---|
| Radix | body | 给 body 加 padding-right | data-scroll-locked="1"、pointer-events: none |
| Base UI | body overflow | html 上 scrollbar-gutter: stable | 行内 overflow: hidden |
| Headless UI | html | 给 html 加 padding-right | 行内 overflow + padding |
| React Aria | html | scrollbar-gutter: stable | 行内 overflow |
四家都清理得很干净:关闭之后 body / html 上的行内样式与自定义属性都被移除了。
iOS Safari 的老问题
- 移动端 Safari 上
overflow: hidden锁不住 body 的滚动(历史行为),常见的兜底是position: fixed+ 记录并还原scrollTop; - 这个兜底自己有副作用:还原时如果算错,页面会跳回顶部;输入法弹起时还会引发视口变化;
- 所以移动端优先考虑「不要模态」——用整页路由、底部抽屉(配合原生滚动)代替居中弹窗,能绕开一整类问题。
/* 自己实现时的最小版本:锁 + 补偿 */
function lockScroll() {
const gap = window.innerWidth - document.documentElement.clientWidth; // 滚动条宽度
document.body.style.overflow = "hidden";
if (gap > 0) document.body.style.paddingRight = gap + "px";
return () => { // 解锁:把改过的都还原
document.body.style.overflow = "";
document.body.style.paddingRight = "";
};
}
/* 更现代的做法:一开始就给页面留出滚动条槽位,锁的时候就不会跳 */
html { scrollbar-gutter: stable; }
/* 固定定位的元素(顶栏、悬浮按钮)也要一起补偿,否则只有它们会跳 */
body[data-scroll-locked] .fixed-header { padding-right: var(--removed-scrollbar-size, 0); }body 加了 pointer-events: none。这在绝大多数情况下没问题(对话框自己会把 pointer-events 打开),但如果你有渲染在 body 下、又不属于对话框的浮层(比如另一个库的 Toast、地图控件、第三方客服挂件),它们会在对话框打开期间整个不能点,而且不报任何错。判据:打开对话框后某些东西点不动、关掉就好了。解法是给那些元素显式加 pointer-events: auto。html 加一句 scrollbar-gutter: stable 是个一劳永逸的小优化:不只对话框,任何「内容从一屏变成两屏」的时刻(加载完数据、展开手风琴)都不会再跳一下。代价是短内容页面右侧永远留一条空槽。大多数应用型页面都值得开,内容型站点按设计取舍。原生 <dialog> 今天已经全面可用,而且免费给了模态最难的那几件事。先搞清楚它给了什么、缺了什么,再决定要不要上库。
对照表
| 能力 | 原生 <dialog> | 组件库 |
|---|---|---|
| 焦点陷阱 | 自带(showModal()) | 自带 |
| Esc 关闭 | 自带(触发 cancel 事件) | 自带 |
| 外部内容屏蔽 | 自带(浏览器级 inert) | 自带(inert / aria-hidden) |
| 层级 | top layer,永远最上 | 靠 z-index,要自己排 |
| 遮罩 | ::backdrop 伪元素 | 一个真实 DOM 节点 |
| 点遮罩关闭 | 要自己写 | 默认或一个 prop |
| 滚动锁 | 没有,要自己加 | 自带 |
| 进出场动画 | 要 @starting-style + allow-discrete | 自带或一个 prop |
| 受控(React/Vue 状态驱动) | 命令式,要用 ref 调方法 | 声明式 open 属性 |
| 标题关联(aria-labelledby) | 要自己写 | 放个 Title 组件即可 |
结论很清楚
- 用原生:确认框、简单表单弹窗、纯展示;尤其是不用框架或不想加依赖的场景。它的 top layer 特性还能彻底绕开 z-index 战争;
- 用库:需要声明式受控、需要动画、需要滚动锁与移动端适配、或者对话框是设计系统的一部分要统一行为;
- 混合:用库的行为层 + 原生
<dialog>作为容器拿 top layer。这条路越来越常见,Base UI 与部分 Vue 库已经在做类似的事。
<!-- 原生对话框的完整可用版本 -->
<dialog id="dlg" aria-labelledby="dlg-title">
<h2 id="dlg-title">确认删除</h2>
<form method="dialog"> <!-- method="dialog" 的按钮会直接关闭 -->
<button value="cancel">取消</button>
<button value="ok">删除</button>
</form>
</dialog>
// 打开、读返回值、点遮罩关闭(后者要自己写)
dlg.showModal();
dlg.addEventListener("close", () => console.log(dlg.returnValue)); // "ok" / "cancel"
dlg.addEventListener("click", (e) => {
if (e.target === dlg) dlg.close(); // 点到 dialog 本身 = 点在了 backdrop 上
});
/* 遮罩与动画 */
dialog::backdrop { background: rgb(0 0 0 / 0.4); }
dialog { opacity: 0; transition: opacity .2s, overlay .2s allow-discrete, display .2s allow-discrete; }
dialog[open] { opacity: 1; }
@starting-style { dialog[open] { opacity: 0; } }dialog.show() 与 dialog.showModal() 差一个词,行为差一个世界:show() 是非模态——没有焦点陷阱、没有 ::backdrop、不进 top layer、Esc 也不关。它看起来「能弹出来就是对的」,但键盘用户可以直接 Tab 到后面的页面上去。需要模态就必须用 showModal(),这是原生 dialog 最常见的误用。<form method="dialog"> 是个被严重低估的特性:表单里的按钮点击后自动关闭对话框,并把按钮的 value 写进 dialog.returnValue,一行 JS 都不用写就完成了「确认/取消」的全部逻辑。写原生确认框时应当默认用它,比自己绑两个 onClick 干净得多。这三个都是对话框的变体,共享同一套焦点契约,但各有额外的规矩。把它们当成「对话框 + 若干附加条件」来理解,比把它们当成三个独立组件更容易做对。
抽屉(drawer / sheet)
- 本质是「贴边的对话框」,焦点契约完全一样;
- 移动端专属的额外要求:手势关闭(下拉关闭)、吸附点(半开/全开)、以及和输入法的配合。Vaul 就是专门做这个的(React 侧的事实标准,配 Radix Dialog 使用);
- 侧边导航抽屉不一定要模态:桌面端常驻的侧栏应该是普通布局元素,只有窄屏下才变成模态抽屉。用同一个组件的两种模式,而不是两套代码。
确认框(confirm)
- 它是对话框里唯一不该允许「点外部关闭」的一类——用户必须做出选择。React Aria 的 Modal 默认就不允许,Radix 里要给 Content 传
onInteractOutside拦掉; - 标题要写清楚后果,按钮文字用动词(「删除」而不是「确定」)——这条不是 UI 库的事,但它对可用性的贡献比任何交互细节都大;
- 危险操作的按钮不能是初始焦点(本章第 1 卡)。
命令面板(command palette)
- 结构上是「对话框 + 组合框」:一个输入框 + 一个 listbox,焦点留在输入框,靠
aria-activedescendant指向高亮项(05 章的第二种模式); - React 侧的常用实现是
cmdk(shadcn 的 Command 组件就是它),Vue / Svelte 侧多用各自无头库的 Combobox 拼; - 三个必须做对的细节:输入时列表要实时过滤但高亮项要重置到第一项;
↑↓在列表里移动而不是移动光标;Enter执行当前高亮项而不是提交表单。
// 确认框:禁止点外部关闭(Radix)
<AlertDialog.Content
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()} // 需要时连 Esc 也拦(慎用)
>
// Radix 专门提供了 AlertDialog:它与 Dialog 的差别正是「确认语义」
// role="alertdialog"、必须有 Action 与 Cancel、默认焦点在 Cancel 上
// 抽屉的响应式模式:窄屏模态、宽屏常驻
const isNarrow = useMediaQuery("(max-width: 768px)");
return isNarrow
? <Drawer open={open} onOpenChange={setOpen}>{nav}</Drawer>
: <aside className="w-64 border-r">{nav}</aside>;window.confirm() 代替确认框看似省事,代价很硬:它会阻塞整个 JS 主线程(动画停住、定时器不跑)、样式完全不可控、移动端上有些浏览器会显示「阻止此页面创建更多对话框」的复选框——用户勾了之后你后续所有确认框都被静默跳过,而代码里 confirm() 直接返回 false。生产代码里不该出现 confirm / alert / prompt。Dialog 与 AlertDialog 分成两个组件,不是为了凑数:role="alertdialog" 会让读屏器在打开时立刻朗读内容(普通 dialog 只读标题),而且它强制要求有明确的确认与取消动作。破坏性操作用 AlertDialog,其余用 Dialog——这个区分在各家库里名字不同但普遍存在(Element Plus 是 ElMessageBox,antd 是 Modal.confirm)。表单:受控、校验与提交
业务代码里一半的时间花在表单上。这一章讲清楚受控与非受控的真正分界、组件库的表单控件怎么和原生 form 打通、校验该放在哪一层,以及错误提示怎么写才对读屏器有效。
这对概念被讲滥了,但真正的判据只有一句:状态的唯一事实来源在组件外面(你手里)还是在组件内部(DOM 或它自己的 state)里。
三种形态
- 非受控:只给
defaultValue,值存在 DOM 里,提交时用FormData或 ref 读。性能最好(每次输入不触发 React 重渲染),代码最少; - 受控:
value+onChange成对出现,每次输入都过一遍你的状态。需要联动时才必要(一个字段影响另一个、实时格式化、跨组件同步); - 混合(组件库的常见做法):库内部维护状态,同时接受可选的
value—— 传了就受控,不传就自己管。Radix 系的open/defaultOpen、value/defaultValue全是这个模式,16 章会讲它是怎么实现的。
三个高频错误
- ①
value传了但没给onChange:输入框变成只读且用户不知道为什么打不进字。React 会警告一次(You provided a value prop to a form field without an onChange handler),Vue 里:value不配@input同理但没有警告; - ② 受控与非受控之间来回切换:初始
value={undefined}(数据还没加载)后来变成有值,React 会警告A component is changing an uncontrolled input to be controlled。修法是给个空字符串兜底,或者数据没到就不渲染表单; - ③ 在受控组件上用
defaultValue:完全不生效且不报错——这两个属性是互斥的两条路。
该选哪个:一条实用判据
- 默认非受控。表单的 90% 是「填完提交」,中途没人需要知道那个值;
- 出现下面任一情况再改受控:字段之间有联动、要实时校验并显示、输入要格式化(手机号分段、金额千分位)、要把值同步到 URL 或别的组件;
- React Hook Form 之所以流行,正是因为它让你在「非受控的性能」下拿到「受控的能力」——它用 ref 收集值,只在需要时订阅局部更新。
// 非受控:值在 DOM 里,提交时一把读出来
<form onSubmit={(e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.currentTarget));
save(data); // { email: "...", plan: "pro" }
}}>
<input name="email" defaultValue="" />
</form>
// 受控:需要联动时才这么写
const [country, setCountry] = useState("");
<select value={country} onChange={(e) => { setCountry(e.target.value); setCity(""); }}>…</select>
// 混合模式的库组件:传了就受控,不传自己管(16 章讲实现)
<Select.Root defaultValue="pro"> // 非受控
<Select.Root value={plan} onValueChange={setPlan}> // 受控onChange 里把值发给服务端或做了防抖,状态回来得晚一拍,React 用旧值重渲染,浏览器把光标放到了字符串末尾——用户在中间插字时每输入一个字符光标就跳一次。判据:只在「输入中间位置」时出现。解法是输入框的值必须同步更新(异步的事情另存一份状态),或改用非受控 + 防抖读取。v-model 展开就是 :model-value + @update:model-value,本质是受控。想要非受控效果(不想每次输入都进响应式系统)在 Vue 里反而要刻意为之——直接用 ref 拿 DOM 或者用 v-model.lazy(change 时才同步)。Vue 生态里表单性能问题比 React 少,代价是默认就走了受控这条路。无头库的 Select、Checkbox、Switch 渲染出来的都是 div 与 button,它们不是原生表单控件,默认不会出现在 FormData 里。各家用同一个办法解决:偷偷渲染一个隐藏的原生 input。
机制
- 给库组件传
name属性后,它会在 DOM 里额外渲染一个<input type="hidden">(或视觉隐藏的原生select/checkbox),值随组件状态同步; - 于是
new FormData(form)能拿到值、表单重置能生效、浏览器自动填充也可能生效; - 没传
name就没有这个隐藏控件——「为什么提交上去少一个字段」的答案通常就在这里。
Portal 会切断表单归属
- HTML 规定:控件属于包含它的那个
<form>。浮层被 portal 到body之后,里面的输入框在 DOM 上已经不在表单内了; - 后果:提交拿不到值、回车不再触发提交、表单校验不覆盖它;
- 解法是
form="表单的id"属性——它能让任意位置的控件显式归属某个表单,是 HTML5 就有但极少被用到的特性。多数库把隐藏 input 渲染在原地(不 portal)来避开这个问题,但你自己拼组件时要注意。
提交按钮的三个细节
<button>在表单里默认type="submit"——一个用来「添加一行」的按钮忘了写type="button",点一下就把整个表单提交了。这是最经典的 HTML 陷阱之一;- 回车提交依赖「表单里有提交按钮」:如果你把提交按钮放在表单外面(比如对话框底部的操作区),回车就不再提交——用
form="id"把它关联回去; - 提交中要禁用按钮并给出状态,否则双击会发两次请求。禁用的同时要保留可访问名,别把文字换成一个转圈图标了事。
// 给无头组件传 name,它才会渲染隐藏的原生控件
<Select.Root name="plan" defaultValue="pro">…</Select.Root>
<Checkbox.Root name="agree" value="yes">…</Checkbox.Root>
// 对话框底部的提交按钮在 form 外面:用 form 属性关联回去
<form id="profile-form" onSubmit={onSubmit}>…</form>
<Dialog.Footer>
<button type="button">取消</button>
<button type="submit" form="profile-form">保存</button> // 关键:form="…"
</Dialog.Footer>
// 表单里的非提交按钮,一定要写 type="button"
<button type="button" onClick={addRow}>添加一行</button>autocomplete 属性写错,比不写更糟。密码管理器与浏览器自动填充完全依赖它:注册表单的新密码要写 autocomplete="new-password",登录用 current-password,一次性验证码用 one-time-code。写成 off 在多数现代浏览器上会被忽略(因为被滥用过),而写错类型会导致密码管理器把新密码填成旧密码。这一层组件库管不了,必须你自己传对。<form> 包住你的表单,哪怕你完全用 JS 提交。白拿三样东西:回车提交、浏览器自动填充(尤其是密码管理器,它认的是 form 与 autocomplete 属性)、以及 FormData 这个免费的取值方式。用 <div> 包表单是新手最容易犯又最难察觉的退步——功能都在,只是用户体验少了一大截。一个字段的校验规则可能出现在四个地方:HTML 属性、前端 schema、后端接口、数据库约束。重复是必然的,问题是怎么让它们不打架。
三层各自的职责
- ① 原生约束校验(
required/type="email"/pattern/min):零成本、无 JS 也生效、移动端会调出合适的键盘。缺点是提示文案与样式几乎不可控(各浏览器不同、无法本地化到位); - ② 前端 schema(zod / valibot / yup):真正干活的一层。一份 schema 同时提供类型推导与运行时校验,还能被后端复用。今天 React 侧的主流组合是
react-hook-form+zod(zod 已是 4.x); - ③ 服务端校验:唯一不可省略的一层。前端校验是体验,服务端校验才是正确性——绕过前端只需要一个 curl。
让三层不打架的做法
- schema 是单一事实来源,前端从它派生 UI 提示,后端直接复用同一份(同构项目)或对齐字段规则;
- 原生属性只保留「无害的那部分」:
type(决定键盘)、inputmode、autocomplete、maxlength。required要不要保留取决于你是否接受浏览器的默认气泡——多数设计系统会加noValidate关掉浏览器提示,改用自己的; - 错误文案集中管理:zod 4 支持在 schema 上直接写错误消息与本地化,别散落在组件里。
校验时机比校验规则更影响体验
- 别在用户第一次输入时就报错——刚打了一个字符就说「邮箱格式不正确」是最招人烦的交互。业界共识是 blur 时首次校验,之后每次输入实时更新(RHF 的
mode: "onTouched"); - 提交时把所有错误一次列出,并把焦点移到第一个出错的字段(下一卡);
- 异步校验(用户名是否已被占用)要防抖 + 竞态处理:先发的请求后到会覆盖正确结果,用 AbortController 取消旧请求。
// zod 4 + react-hook-form:schema 一份,类型与校验都从它来
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z.object({
email: z.email("请填写有效的邮箱"),
age: z.coerce.number().int().min(18, "必须年满 18 岁"),
});
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm({
resolver: zodResolver(schema),
mode: "onTouched", // 失焦后才首次校验,之后实时更新
});
<form onSubmit={handleSubmit(save)} noValidate> // noValidate:关掉浏览器默认气泡
<input {...register("email")} type="email" autoComplete="email" />
{errors.email && <p role="alert">{errors.email.message}</p>}
</form>type="number" 是个陷阱,尤其在需要精确输入时。它会:允许 e / + / -(因为要支持科学计数法)、在部分浏览器里滚轮滚过时静默改值、超长数字精度出问题、value 在非法输入时直接变成空字符串(于是你拿不到用户实际打了什么)。金额、手机号、身份证一律用 type="text" + inputmode="numeric" + 自己校验。type 与 inputmode:它们决定移动端弹出哪种键盘(type="email" 给 @ 键、inputmode="numeric" 给数字盘、type="tel" 给拨号盘)。这一条对移动端填写效率的提升,比任何前端校验框架都大,而成本是一个属性。红色边框和红字对视觉用户是清晰的信号,对读屏器用户等于不存在。三个属性把它补齐,成本很低但极少被写全。
三件套
aria-invalid="true":告诉辅助技术「这个字段现在是错的」。字段恢复正确时要改回false或去掉;aria-describedby指向错误文本的 id:读屏器聚焦到该字段时会把错误一并读出来。同一个字段可能同时有帮助文字与错误文字——aria-describedby可以写多个 id,空格分隔;- 错误文本容器加
role="alert"(或放进 live region):出现时立刻播报,不用等焦点过去。
提交失败时的完整流程
- ① 把焦点移到第一个出错的字段——这是最重要的一步,键盘用户否则要自己找;
- ② 在表单顶部放一个错误摘要(「有 3 处需要修改」+ 跳转链接),长表单尤其需要;
- ③ 不要只靠颜色:红色边框要配图标或文字,色觉障碍用户看不出红绿差异(WCAG 1.4.1)。
组件库替你做了多少
- 成套库的 Form 组件(antd 的
Form.Item、Element Plus 的el-form-item、Mantine 的表单输入)通常会自动接上aria-invalid与错误文本的关联,这是它们的一大价值; - 无头库里,Radix 有
Form基元、React Aria 的TextField系列内建了完整关联;但你如果只用<input>自己拼,这三个属性就得自己写; - 验证方法:DevTools 里聚焦到出错字段,看 Accessibility 面板的 Description 一栏有没有错误文案。没有就是没接上。
<!-- 完整的一个字段:标签、帮助文字、错误、三个 aria 属性 -->
<label for="pwd">新密码</label>
<input
id="pwd"
type="password"
autocomplete="new-password"
aria-invalid={hasError}
aria-describedby="pwd-help pwd-err" <!-- 可以指向多个 id -->
/>
<p id="pwd-help">至少 12 位,含数字与符号</p>
{hasError && <p id="pwd-err" role="alert">密码太短了</p>}
// 提交失败:焦点移到第一个错误字段(RHF 内置了这个能力)
useForm({ shouldFocusError: true }); // 默认就是 true,别关掉
// 自己实现时
const first = document.querySelector("[aria-invalid='true']");
first?.focus();role="alert" 不要一开始就渲染在页面上。它是个隐式的 live region,只有当内容「变化」时才播报。如果页面加载时错误容器就已经带着文字存在,读屏器可能一个字都不说;反过来,如果你把整个 <p role="alert"> 连同文字一起插入 DOM,某些读屏器也会漏掉。最稳的写法是容器常驻、文本内容后填(05 章那条 live region 的规矩,这里同样适用)。表单一旦超过二十个字段、带上「可增删的明细行」,两类问题会同时出现:输入卡顿与状态管理失控。这一卡讲这两件事的成因与对策。
为什么会卡
- 受控表单里,每敲一个字符都会让持有状态的那个组件重渲染。如果状态放在表单根组件上,整棵子树(可能是几百个节点)跟着重算;
- 三种解法,效果依次递增:① 把状态下沉到最小的子组件;② 用非受控 + ref 收集(RHF 的路线);③ 订阅式状态(只有用到某个字段的组件才重渲染,RHF 的
useWatch、TanStack Form 的选择器都是这个思路); - 判据:打开 React DevTools 的 Profiler,输入一个字符,看有多少组件被重渲染。超过十几个就该动手了。
数组字段(明细行)
- key 必须稳定且唯一,绝不能用数组下标——删除中间一行后所有后续行的下标都变了,React 会把「删第 2 行」渲染成「把后面每行的内容往前挪一格」,结果是输入框里的值错位;
- 正确做法是给每行生成一个稳定 id(RHF 的
useFieldArray返回的field.id就是干这个的),或用业务主键; - 删除行之后焦点要有归属:删掉当前行后把焦点移到下一行的同名字段,否则键盘用户每删一行就丢一次焦点(07 章那条「触发器消失」的同类问题)。
提交状态的四态
- idle / submitting / success / error——四种状态都要有可见反馈,且submitting 时要禁用重复提交;
- 乐观更新(先假设成功、失败再回滚)只适合「失败概率极低且可撤销」的操作,不要用在支付、删除这类地方;
- 提交成功后的焦点去向也要想好:留在原地、跳到列表、还是关闭对话框。「提交后什么都不发生」是最常见的体验漏洞——用户不知道成功了没有,于是又点一次。
// 数组字段:key 用稳定 id,不要用下标
const { fields, append, remove } = useFieldArray({ control, name: "items" });
{fields.map((field, i) => (
<div key={field.id}> // ← field.id 稳定;写 key={i} 会错位
<input {...register(`items.${i}.name`)} />
<button type="button" onClick={() => remove(i)}>删除</button>
</div>
))}
// 只订阅需要的字段,避免整表重渲染
const country = useWatch({ control, name: "country" }); // 只有这里会因 country 变化而重渲染
// 提交四态
<button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}>
{isSubmitting ? "保存中…" : "保存"}
</button>.pick()),提交时再用完整 schema 校验一次。这样既避免了「一次报二十个错」的挫败感,又保证了最终数据的完整性。选择器族:Select、Combobox、日期
下拉选择看着简单,实际是无障碍要求最细的一类组件——而且「能不能输入」这一个差别,就决定了它必须换一套完全不同的 ARIA 模式。这一章讲清楚三种选择器的分界、大数据量的做法,以及为什么日期选择器是最难的那个。
「点开一个列表选一项」和「一边打字一边选」在 ARIA 里是两个不同的模式,而且不能互换。分界线只有一条:用户能不能在触发器里输入文字。
两套模式的对照
| Select(不可输入) | Combobox(可输入) | |
|---|---|---|
| 触发器 | <button role="combobox"> 或原生 select | <input role="combobox"> |
| 焦点在哪 | 可以移进选项(roving) | 必须留在 input 上 |
| 高亮怎么表达 | 真焦点,或 aria-activedescendant | 只能 aria-activedescendant |
| 列表 | role="listbox" + role="option" | 同左 |
| Radix Select | 焦点真的移到 role="option" 上,高亮项带 data-highlighted | — |
| Base UI Select | 同上,且高亮项 tabindex 变成 0 | — |
| Headless UI Listbox | 焦点留在列表容器,靠 aria-activedescendant 指向 | 输入框上出现 aria-activedescendant |
| React Aria | 焦点移到选项上 | 输入框上出现 aria-activedescendant,且列表自带 aria-label(跟随浏览器语言) |
三条实用规则
- 选项超过约 10 个就该给搜索——也就是改用 Combobox。让用户在 200 个选项里靠滚动找,是设计失误而不是技术问题;
- Combobox 必须明确「能不能填自定义值」:能填叫
autocomplete="both"(可编辑),只能选叫list(辅助筛选)。这个区别要在交互上表达清楚,否则用户以为自己打的字被接受了; - 别把 Select 的触发器做成
<input readonly>:读屏器会读成输入框,用户以为能打字。这是自研下拉最常见的语义错误。
<!-- Select:按钮触发,焦点可以进列表 -->
<button role="combobox" aria-expanded="true" aria-controls="lb" aria-haspopup="listbox">专业版</button>
<div id="lb" role="listbox">
<div role="option" aria-selected="true" tabindex="0">专业版</div>
<div role="option" aria-selected="false" tabindex="-1">企业版</div>
</div>
<!-- Combobox:焦点必须留在 input,用 activedescendant 指高亮项 -->
<input
role="combobox"
aria-expanded="true"
aria-controls="lb2"
aria-autocomplete="list"
aria-activedescendant="opt-2" <!-- 指向当前高亮的那一项 -->
/>
<div id="lb2" role="listbox">
<div id="opt-2" role="option" aria-selected="false">企业版</div>
</div>aria-activedescendant:DOM 焦点不动,只用属性告诉辅助技术「当前高亮的是哪一项」。反过来,不可输入的 Select 用哪种模式都行——这就是为什么各家库在两类组件上会做出不同选择,而这并不是不一致。aria-label,内容是当前浏览器语言下的「建议」二字(中文环境就是「建议」)。这说明它内置了一整套本地化字符串表——对国际化项目是实打实的省事,15 章会展开这一点。在一片自定义下拉里,<select> 常常被当成「土」的代名词。但它有三个别人给不了的东西,而且移动端的差距特别大。
原生 select 的三个优势
- ① 移动端调起系统选择器:iOS 的滚轮、Android 的原生列表——大屏手指操作友好、支持系统级的辅助功能、不会被输入法遮住。自定义下拉在移动端的体验普遍不如它;
- ② 零成本的正确性:键盘、读屏器、表单集成、首字母跳转全部免费且永远正确;
- ③ 零体积:没有任何 JS。
它的真实限制
- 选项内容只能是纯文本:放不了图标、两行描述、头像。这是最常见的换库理由;
- 弹出层样式不可控:
<option>的样式支持极其有限(各浏览器不同); - 不能搜索、不能多选出标签(
multiple的原生外观基本不能用于产品)。
一个正在变化的事实
- CSS 正在推进可定制的 select(
appearance: base-select与可样式化的::picker(select)),Chromium 已开始落地。如果它按预期铺开,「为了样式而重写 select」的理由会消失一大半; - 今天的建议是:移动端优先、选项简单、在表单里的场景,直接用原生
<select>;需要富内容或搜索时才上库; - 响应式做法也可行:窄屏渲染原生 select,宽屏渲染自定义下拉——两者共享同一份数据与
name,切换成本很低。
<!-- 原生:简单场景的最优解 -->
<label for="plan">套餐</label>
<select id="plan" name="plan">
<option value="free">免费版</option>
<option value="pro" selected>专业版</option>
</select>
/* 只改触发器外观,仍然是原生 select(今天普遍可用的程度) */
select {
appearance: none; /* 去掉系统箭头 */
background: url("data:image/svg+xml,…") no-repeat right 8px center;
padding-right: 28px;
}
// 响应式:窄屏用原生,宽屏用自定义
{isNarrow ? <select name="plan">…</select> : <Select.Root name="plan">…</Select.Root>}<div> 重写下拉时,最容易丢掉的是「首字母跳转」。原生 select 上连按 b 会在所有 b 开头的选项间循环,这是键盘用户选长列表的主要手段(叫 typeahead)。好的库都实现了它(Radix、Base UI、React Aria 的 Select 都支持),自研的几乎都会漏。检查方法:打开你的下拉,按一个字母,看会不会跳。<optgroup>,长列表用 <datalist> 做输入建议——这两个原生元素几乎无人使用,但它们把「分组下拉」和「带建议的输入框」这两个常见需求以零代码解决了。<datalist> 的限制是样式不可控且行为各浏览器略有差异,适合「锦上添花的建议」而不是「必须选中某项」。选项从几十变成几万时,问题从「怎么选」变成「怎么渲染」和「怎么找」。这两件事的解法互相牵制:虚拟化和「跳到选中项」「首字母跳转」天生打架。
虚拟化在下拉里的三个额外难点
- ① 打开时要滚动到已选项:而已选项可能在第 8000 位,尚未渲染。要先算出它的偏移再滚过去(虚拟化库都提供
scrollToIndex); - ② 键盘导航要驱动滚动:按
↓到未渲染区域时,先滚动再高亮,否则焦点落到一个不存在的 DOM 上; - ③ 无障碍语义会被打断:
role="listbox"下只渲染了 20 个option,读屏器会以为总共就 20 项。解法是给容器加aria-setsize(总数)与给每项加aria-posinset(序号)——这一条几乎所有自研实现都会漏。
异步搜索的四件套
- 防抖(约 200–300ms)避免每敲一个字符发一次请求;
- 取消旧请求:用
AbortController,否则先发后到的响应会覆盖正确结果——症状是「搜索结果闪回上一次的内容」; - 加载态要可播报:给列表容器加
aria-busy="true",或用 live region 说「找到 12 条」; - 已选项回显:只搜到当前页的选项时,已选中的那一项可能不在结果里。要么单独接口按 id 拿回显文本,要么把已选项缓存下来——这是异步下拉最常见的产品缺陷(编辑页面打开时下拉里空空如也)。
什么时候不该用下拉
- 候选项上万且没有天然的层级 → 应该用独立的搜索页/弹窗,而不是塞进一个下拉;
- 选项有明确层级(省市区、部门树)→ 用级联选择或树选择,扁平化成一万条是在为难用户;
- 「能不能装下」不是判据,「用户能不能找到」才是。
// 异步搜索:防抖 + 取消 + 回显缓存
const ctrl = useRef(null);
async function search(q) {
ctrl.current?.abort(); // 取消上一次,避免竞态
ctrl.current = new AbortController();
try {
const res = await fetch("/api/users?q=" + encodeURIComponent(q), { signal: ctrl.current.signal });
setOptions(await res.json());
} catch (e) {
if (e.name !== "AbortError") setError(e); // 取消不算错误
}
}
<!-- 虚拟化列表的无障碍补丁:告诉读屏器真实总数 -->
<div role="listbox" aria-busy={loading}>
{visible.map((item, i) => (
<div role="option" aria-setsize={total} aria-posinset={item.index + 1} key={item.id}>…</div>
))}
</div>filter 实时过滤」在几千条以上会明显卡顿——但真正的瓶颈通常不是过滤,而是渲染。先确认瓶颈再优化:在 onChange 里打个 performance.now() 量过滤耗时,几千条字符串的 includes 过滤通常在 1ms 量级,而渲染几千个 DOM 节点是几百毫秒。结论几乎总是「先虚拟化,别急着优化过滤算法」——这个判断顺序反了会白干很多活。日期选择器是所有组件里唯一一个「实现难度主要不在 UI」的:它的复杂度来自时间本身。这也是为什么 Radix 至今没有官方 DatePicker,而 React Aria 把它当成招牌能力。
四层复杂度
- ① 时区:用户选的是「本地的 3 月 5 日」,服务端存的可能是 UTC 时间戳。跨时区时同一个瞬间在两地是不同的日期——「生日」「截止日」这类无时区的日期不应该存成时间戳,应该存
2026-03-05这样的纯日期; - ② 历法与本地化:一周从周一还是周日开始(各国不同)、月份名、周末在哪两天(中东是周五周六)、非公历(伊斯兰历、佛历、日本年号)。这些不是「翻译文案」,是日历网格本身要变;
- ③ 输入解析:用户手打
3/5/2026在美国是 3 月 5 日,在欧洲是 5 月 3 日。所以现代实现倾向于用「分段输入框」(年/月/日各一段)而不是自由文本; - ④ 键盘与无障碍:日历网格要用
role="grid",方向键在日期间移动、PageUp/PageDown 换月、Home/End 到周首尾,还要正确朗读「2026年3月5日 星期四」。
今天的选择
- React Aria 的 DatePicker 是功能上限最高的:基于
@internationalized/date,原生支持多历法、时区、分段输入,键盘契约完整; - 成套库自带的(antd、Element Plus、MUI X、Vuetify)功能足够且视觉一致,本地化程度取决于它用的日期库(dayjs / date-fns / luxon);
- 自己拼:只在需求极简(选一个日期、不跨时区、只有中文)时才值得。一旦出现「区间选择 + 禁用日期 + 快捷选项 + 时间部分」,工作量会超出所有人的估计。
三条能省掉大量麻烦的约定
- 纯日期用字符串
YYYY-MM-DD传输与存储,别用Date对象或时间戳; - 需要精确瞬间时用 ISO 8601 带时区偏移(
2026-03-05T09:00:00+08:00),别用「本地时间 + 另一个字段说明时区」; - 展示层才做本地化:用
Intl.DateTimeFormat而不是手拼格式串。它免费处理了语言、历法、时区转换。
// 展示:交给 Intl,别手拼
new Intl.DateTimeFormat("zh-CN", {
dateStyle: "long", timeStyle: "short", timeZone: "Asia/Shanghai",
}).format(new Date(iso)); // "2026年3月5日 09:00"
// 一周从哪天开始,也别写死(各地区不同)
new Intl.Locale("en-US").getWeekInfo?.().firstDay; // 7(周日);zh-CN 是 1(周一)
// 纯日期字段:字符串进、字符串出,别经手 Date 对象
{ birthday: "2001-03-05" } // 对
{ birthday: new Date("2001-03-05") } // 错:会被解释成 UTC 午夜,东八区显示成 3 月 5 日 08:00,
// 在西半球的用户那里会变成 3 月 4 日disabled,于是键盘用户按方向键时焦点会跳过它们——但 ARIA 的 grid 模式要求所有单元格都可达,跳过会让用户搞不清日历结构(「怎么从 10 号一下跳到 15 号了」)。规范做法是可聚焦但不可选(aria-disabled="true" 而非 disabled),并在朗读时说明为什么不可选。好的库会这么做,自研的基本都做成前者。new Date("2001-03-05") 与 new Date("2001/03/05") 的解析规则不一样:带横杠的 ISO 格式按 UTC 解析,带斜杠的按本地时区解析。这个差异是「日期差一天」类 bug 的头号来源,而且只有部分时区的用户会遇到,本地测试很难发现。需要纯日期时根本不要构造 Date 对象——这是最彻底的解法。多选把「选择」变成了「编辑一个集合」,于是多出一组交互:已选项要能被看见、被逐个移除、被键盘操作。这一组交互的实现质量,是判断一个组件库细不细致的好指标。
四条必须做对的键盘规则
- ① 光标在输入框最左端时按
Backspace:删除最后一个已选标签。这是标签输入的行业惯例,缺了会显得很不专业; - ② 标签本身可聚焦:用
←/→在标签之间移动,按Delete/Backspace删除当前标签; - ③ 删除标签后焦点有归属:删中间的标签焦点给下一个,删最后一个焦点回输入框。不要让焦点掉回 body;
- ④ 已选项在列表里要标记为已选(
aria-selected="true"),并决定「再点一次是取消还是无操作」——两种设计都有,但必须一致。
展示与容量
- 选了 30 个标签时不能让输入框撑成半屏:常见做法是「显示前 N 个 + 「其余 25 项」的折叠」,点折叠标签展开一个浮层;
- 折叠标签的可访问名要说清楚(
aria-label="还有 25 项已选"),别只写一个+25; - 全选 / 清空要有明确入口,且清空是破坏性操作——大量已选时值得二次确认。
与表单的对接
- 多选的值是数组,提交时要么用同名多个 hidden input(
FormData.getAll("tags")能拿到数组),要么序列化成 JSON 放一个字段; - 成套库的 Form 组件通常已经处理好;无头库自己拼时记得给每个已选项都渲染一个 hidden input——只渲染一个只会提交最后一个值。
// 多选的隐藏 input:每个已选项一个,同名
{selected.map((v) => <input key={v} type="hidden" name="tags" value={v} />)}
// 服务端 / FormData 侧:
new FormData(form).getAll("tags"); // ["react", "vue"]
// 输入框最左端按退格删最后一个标签
function onKeyDown(e) {
if (e.key === "Backspace" && e.target.selectionStart === 0 && e.target.selectionEnd === 0) {
removeLast();
}
}
<!-- 每个标签是一个可聚焦的元素,删除按钮要有名字 -->
<span role="option" aria-selected="true" tabindex="-1">
React
<button type="button" aria-label="移除 React">×</button>
</span>onOpenChange、antd 的 open 受控),但默认值不一定符合你的场景,务必自己试一遍。表格与虚拟化
表格是后台系统的主角,也是最容易被组件库绑架的地方。这一章把表格拆成数据层、DOM 层、样式层三块——拆开之后你会发现,真正难的部分和「哪个 UI 库」几乎没关系。
「表格组件」这个词把三件不同的事捆在了一起。拆开之后,选型问题会变得清晰得多:你可能想要成套库的样式,但需要另一个库的数据能力。
三层各自负责什么
- ① 数据层:排序、过滤、分组、聚合、分页、列显隐、列顺序、行选择、展开。这一层完全与 UI 无关,纯逻辑;
- ② DOM 层:渲染成什么标签(
<table>还是 div 网格)、虚拟化、粘性表头、横向滚动; - ③ 样式层:斑马纹、密度、边框、悬停高亮。
两条路线
- 成套表格(antd Table、Element Plus el-table、MUI X DataGrid、Vuetify Data Table):三层全包,配置驱动。后台系统的默认选择,几行配置就有排序分页;限制是深度定制(自定义单元格编辑、复杂分组、超大数据)会开始别扭;
- 无头数据层 + 自己渲染(TanStack Table 8.x):只给你一个「表格状态机」,连一行 DOM 都不渲染。你拿到的是行模型、列模型、排序状态,渲染完全自由。它是跨框架的(React / Vue / Svelte / Solid / Angular / Lit 都有绑定),也因此成为「自建设计系统里的表格」的事实标准;
- 判据:需求在「排序 + 分页 + 简单筛选」以内 → 成套表格;出现「行内编辑、树形数据、列固定 + 虚拟化、自定义聚合」→ 数据层与渲染分离会更省心。
「服务端还是客户端」是先要回答的问题
- 数据量小(几百行以内)→ 全量拉到前端,排序过滤分页都在客户端做,交互最快;
- 数据量大 → 排序、过滤、分页全部交给服务端,前端只负责把状态转成查询参数。此时表格库的「自动排序」功能全部要关掉(TanStack 的
manualSorting/manualPagination、antd 的受控pagination); - 最常见的错误是混着来:分页在服务端、排序在客户端——结果是「只对当前这一页排序」,用户看到的顺序完全不对,而且不报任何错。
// TanStack Table:只有状态与模型,DOM 全归你
const table = useReactTable({
data, columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(), // 客户端排序
manualPagination: true, // 分页交给服务端
state: { sorting, pagination },
onSortingChange: setSorting,
});
<table>
<thead>{table.getHeaderGroups().map((hg) => (
<tr key={hg.id}>{hg.headers.map((h) => (
<th key={h.id} aria-sort={h.column.getIsSorted() === "asc" ? "ascending" : "none"}>
<button onClick={h.column.getToggleSortingHandler()}>{h.column.columnDef.header}</button>
</th>
))}</tr>
))}</thead>
<tbody>{table.getRowModel().rows.map((row) => (
<tr key={row.id}>{row.getVisibleCells().map((c) => <td key={c.id}>{render(c)}</td>)}</tr>
))}</tbody>
</table>accessorKey、格式化函数、排序方式写在一个和渲染无关的数组里,UI 只负责消费它。这样同一份列定义可以同时喂给表格、导出 Excel、以及移动端的卡片列表——「表格」和「列表」在移动端往往要换一种呈现,共享列定义能省掉一整套重复代码。虚拟化的原理一句话:只渲染可视区域内的那几十行,用一个撑高的占位元素维持滚动条长度。原理简单,坑集中在「行高不确定」和「其它功能与它打架」。
四个坑
- ① 动态行高:最麻烦的一个。虚拟化需要提前知道每行多高才能算偏移,而内容决定高度。现代库(TanStack Virtual)用「先估算 → 渲染后测量 → 修正」的方案,代价是滚动时可能出现轻微抖动,尤其是快速拖动滚动条时;
- ② 与「Ctrl+F 页内搜索」不兼容:没渲染的内容浏览器搜不到。这是虚拟化的固有代价,只能靠提供自己的搜索来补偿;
- ③ 无障碍语义断裂:读屏器看到的是 20 行而不是 10000 行。要用
aria-rowcount(表格总行数)与每行的aria-rowindex补上(对应下拉列表里的aria-setsize/aria-posinset); - ④ 表格语义与虚拟化冲突:
<tbody>里放一个撑高的占位<div>是非法的 HTML,浏览器会把它挪走(foster parenting)。解法是用transform: translateY()平移一个真实的<tr>容器,或干脆用 div 网格 +role="grid"代替 table 标签。
什么时候才需要虚拟化
- 先量再上:几百行的表格在现代设备上通常不需要虚拟化,上了反而引入上面四个问题;
- 判据是「渲染耗时超过一帧(约 16ms)」或滚动明显掉帧。用 Performance 面板录一段滚动就能看出来;
- 先考虑更简单的替代:分页(最简单且对无障碍友好)、无限滚动(要注意「回不去了」的问题)、或减少每行的 DOM 数量。后者往往一下子就够了——把每个单元格里的三层 div 减成一层,几千行也能流畅。
// TanStack Virtual:估高 + 动态测量
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 44, // 先给个估计值
measureElement: (el) => el.getBoundingClientRect().height, // 渲染后修正
overscan: 8, // 上下各多渲染几行,减少空白闪烁
});
<div ref={parentRef} style={{ height: 600, overflow: "auto" }} role="grid" aria-rowcount={rows.length}>
<div style={{ height: rowVirtualizer.getTotalSize(), position: "relative" }}>
{rowVirtualizer.getVirtualItems().map((v) => (
<div
key={v.key}
role="row"
aria-rowindex={v.index + 1} // 告诉读屏器真实序号
style={{ position: "absolute", top: 0, transform: `translateY(${v.start}px)` }}
>…</div>
))}
</div>
</div>height: 100% 而祖先链上有一环没有确定高度,容器会塌成 0,虚拟化算出「可视区域 0 行」,结果是表格完全空白且不报错。这是虚拟化最常见的「怎么什么都没有」。排查方法:在 DevTools 里看那个滚动容器的实际高度是不是 0。overscan(多渲染几行)是最有效的一个旋钮:设为 0 时快速滚动会看到空白,设太大又失去虚拟化的意义。经验值是 5–10 行,或者按「一屏行数的 20%」算。移动端可以稍大一些,因为惯性滚动的速度更快。这三件事都是纯 CSS 问题,但它们互相牵制,而且组件库的实现差异很大——知道原理才能判断「这个库的表格能不能满足我的需求」。
粘性表头
position: sticky加在<th>上(不是<thead>上——旧浏览器对thead的 sticky 支持不一致,今天两者都可以但th更保险);- sticky 只在「最近的滚动祖先」内生效:如果表格外面套了
overflow: hidden的容器,粘性会失效或粘错位置。这是「表头不粘」的头号原因; - 粘性元素需要不透明背景,否则滚上来的内容会透过去。
固定列(左右冻结)
- 同样是
position: sticky+left: 0/right: 0,但要逐列计算偏移(第二个固定列的left是第一列的宽度); - 层级要排:左上角那个单元格既是表头又是固定列,z-index 要高于两者;
- 阴影提示:固定列与滚动区之间需要一道阴影告诉用户「这里还能横向滚动」,而且滚到头时应该消失——这个细节区分了「能用」和「好用」。
移动端:横向滚动往往是错的答案
- 八列的表格在手机上横向滚动,用户看不到全貌也记不住列名;
- 更好的做法是换布局:每行变成一张卡片,列名与值成对显示。可以用 CSS 做(给每个
<td>加data-label,窄屏时用::before显示列名)而不需要另写一套组件; - 确实要保留表格时,横向滚动容器必须能用键盘滚动——给它加
tabindex="0"与可访问名,否则键盘用户永远看不到右边的列。这一条几乎所有实现都漏。
/* 粘性表头 + 左固定列 */
.table-wrap { overflow: auto; max-height: 70vh; } /* 滚动容器 */
th { position: sticky; top: 0; z-index: 2; background: var(--color-surface); }
.col-fixed { position: sticky; left: 0; z-index: 1; background: var(--color-surface); }
th.col-fixed { z-index: 3; } /* 左上角要压过两者 */
/* 横向可滚动时给个阴影提示,滚到头自动消失 */
.table-wrap { background:
linear-gradient(to right, var(--color-surface) 30%, transparent),
linear-gradient(to right, rgb(0 0 0 / .12), transparent 12px) 0 0 / 12px 100% no-repeat;
background-attachment: local, scroll;
}
/* 窄屏堆叠:不用换组件,用 data-label 显示列名 */
@media (max-width: 640px) {
thead { display: none; }
tr { display: block; margin-bottom: 12px; }
td { display: flex; justify-content: space-between; }
td::before { content: attr(data-label); font-weight: 600; }
}tabindex="0" 是一个真实的无障碍缺陷(WCAG 的键盘可达要求):容器本身不可聚焦时,键盘用户无法滚动它,右侧的列就永远看不到。加了之后还要给它一个可访问名(role="region" aria-label="订单表格"),否则读屏器会读到一个没有名字的可滚动区域。这是本章最容易被忽略、又最容易修的一条。position: sticky 失效时按这个顺序查:① 祖先有没有 overflow: hidden/auto(sticky 会粘到那个容器上而不是视口);② 有没有写 top/left(不写等于没启用);③ 父元素是不是 display: flex 且子元素被拉伸;④ 表格上有没有 border-collapse: collapse(部分浏览器上它会让 th 的 sticky 失效,改用 border-spacing: 0 + 单边框)。这四条覆盖了绝大多数情况。表格有两种 ARIA 角色,选错会让读屏器用完全不同的方式对待它。判据是:这个表格里的单元格能不能被聚焦和操作。
table 还是 grid
role="table"(或原生<table>):静态数据展示,单元格不可聚焦。读屏器提供「表格阅读模式」(按行列方向键浏览);role="grid":单元格可交互(可选中、可编辑、含按钮)。此时你必须实现完整的网格键盘导航:方向键在单元格间移动、Home/End、Ctrl+Home 到左上角;- 选了 grid 就得兑现键盘契约,只加 role 不实现导航,比用 table 更糟(05 章那条 ARIA 纪律)。
原生表格标签值得保留
<caption>给表格一个名字(可以视觉隐藏);<th scope="col">/scope="row"建立行列关联——读屏器读某个单元格时会自动带上它的行头与列头,这是 div 网格很难复刻的能力;- 用 div 做表格时必须手工补
role="table"/"row"/"columnheader"/"cell"全套,漏一个整棵语义树就断了; - 结论:能用
<table>就用,只有虚拟化等确实做不到时才退到 div 网格。
三个交互契约
- 排序:排序按钮放在
<th>里(而不是让整个 th 可点),当前排序列用aria-sort="ascending" / "descending" / "none"。同一时刻只有一列能有非 none 的值; - 行选择:复选框要有可访问名(
aria-label="选择第 3 行 张三",不能只是一个空复选框);表头的全选框要正确表达「部分选中」状态(indeterminate); - 行操作:整行可点击时要额外提供一个显式的可聚焦元素(第一列的链接),否则键盘用户无法触发行点击。「整行 onClick」对鼠标友好、对键盘完全不可用。
<!-- 语义完整的表格骨架 -->
<table>
<caption class="sr-only">2026 年 3 月订单列表,共 128 条</caption>
<thead>
<tr>
<th scope="col">
<input type="checkbox" aria-label="全选本页" />
</th>
<th scope="col" aria-sort="ascending">
<button>下单时间</button> <!-- 可聚焦的是按钮,不是 th -->
</th>
</tr>
</thead>
<tbody>
<tr>
<td><input type="checkbox" aria-label="选择订单 A1024" /></td>
<th scope="row">A1024</th> <!-- 行头:读单元格时会带上它 -->
</tr>
</tbody>
</table>
// 全选框的三态
checkbox.indeterminate = selected.length > 0 && selected.length < rows.length;<caption> 与 <th scope> 被删掉,通常是「为了样式」。然后为了补回语义,又加上一堆 aria-label。这是净亏损:原生标签给的是行列关联,aria-label 只能给名字,读屏器读到第 5 行第 3 列时无法自动说出「金额 128 元」。样式问题应该用 CSS 解决(caption 可以 sr-only、th 可以去掉粗体和居中),而不是换标签。反馈与导航:Toast、Tabs、菜单
这一组组件的共同点是「状态变化要让人知道」——视觉上很容易,播报与焦点管理上全是细节。这一章逐个讲它们各自的契约,以及最容易被忽略的那几条。
Toast 看起来只是「右下角冒个条」,实际上它是无障碍要求最微妙的组件之一:它必须被读屏器播报,但绝不能抢走焦点——这两个要求同时成立才算做对。
四条契约
- ① 用 live region 播报,不移动焦点:用户正在填表时弹出「保存成功」,如果焦点被抢走,用户的输入就断了。正确做法是
role="status"(等价于aria-live="polite"),错误提示可以用role="alert"; - ② 容器必须先存在:live region 要在页面初始化时就渲染一个空容器,Toast 内容后填。整块一起插入 DOM 时读屏器经常不播报(05 章讲过的同一条规矩,这里是最典型的受害者);
- ③ 自动消失的要能被暂停:鼠标悬停、聚焦、以及用户开启了「减少动态效果」或延长超时的辅助设置时都应暂停。WCAG 对「自动消失的内容」有明确要求(可暂停、可延长、可关闭);
- ④ 要能用键盘到达:Toast 里有「撤销」按钮时,用户必须能 Tab 到它。各家的做法是一个特殊快捷键(Radix Toast 用 F8)把焦点跳进 Toast 区域。
今天的常用实现
- Sonner(React):API 极简(
toast("已保存")),零依赖,shadcn 已把它作为默认 Toast 方案; - Radix Toast:更底层,提供 swipe 关闭与 F8 焦点跳转;
- 成套库自带的(antd
message/ Element PlusElMessage)用起来最省事,但要留意它们是否满足上面第 ①③④ 条——命令式 API 很容易漏掉播报语义。
产品层面的三条
- 错误不该用会自动消失的 Toast:用户可能正好没看屏幕。错误要么内联在出错的地方,要么用不自动消失的通知;
- 不要堆叠超过三条:更多的应该合并(「3 个文件上传失败」);
- 「撤销」比「确认」体验好得多:与其每次删除都弹确认框,不如直接删并给一个 5 秒的撤销 Toast——前提是撤销必须真的能撤销。
<!-- 容器常驻,内容后填(这一条决定了会不会被播报) -->
<div id="toast-region" role="status" aria-live="polite" aria-atomic="true"></div>
// Sonner:三行接入
import { Toaster, toast } from "sonner";
<Toaster richColors closeButton /> // 放在应用根部,只放一次
toast.success("已保存", { action: { label: "撤销", onClick: undo } });
// 悬停暂停计时(自己实现时别忘了)
onMouseEnter: () => clearTimeout(timer.current),
onMouseLeave: () => { timer.current = setTimeout(dismiss, remaining); },
/* 尊重系统的「减少动态效果」设置 */
@media (prefers-reduced-motion: reduce) {
.toast { animation: none; }
}<body> 下但没管 pointer-events,会在对话框打开时变得不可点。07 章那条 Radix 滚动锁的 pitfall 在这里现形:Radix 打开对话框时给 body 加了 pointer-events: none,你的 Toast(属于另一个库)就整个点不动了,但看得见。症状是「弹窗开着的时候 Toast 上的撤销按钮点不了」,解法是给 Toast 容器显式加 pointer-events: auto。Tabs 的 ARIA 模式很成熟,规矩也很少,但「用方向键还是 Tab 键切换」这一条几乎所有自研实现都做反了。
三条键盘规矩
- ① Tab 键只进出整个 tablist,不在标签间移动:焦点进入 tablist 时落在当前选中的那个标签上,再按 Tab 就跳到面板内容里;
- ② 方向键在标签间移动(水平 tablist 用 ←→,垂直用 ↑↓),Home / End 跳首尾;
- ③ 自动激活 vs 手动激活:方向键移动时是立刻切换内容(自动),还是移动焦点、按 Enter/Space 才切(手动)?内容加载慢或很重时必须用手动,否则用户按住方向键会连续触发几次加载。各库都提供了这个开关(Radix 的
activationMode="manual")。
属性关联
role="tablist"→role="tab"(带aria-selected与aria-controls)→role="tabpanel"(带aria-labelledby指回 tab);- 未选中的 tab 要
tabindex="-1",选中的tabindex="0"——这就是 roving tabindex(05 章); - 面板本身要可聚焦(
tabindex="0")如果它的内容不含可聚焦元素,否则键盘用户读不到面板内容。
被忽略的两个后果
- SEO 与页内搜索:未激活的面板如果不渲染,内容对搜索引擎和 Ctrl+F 都不存在。内容型页面(文档、商品详情)慎用 Tabs——把内容分成几段小标题往往更好;要用就考虑把所有面板都渲染、只用 CSS 隐藏(代价是首屏体积);
- 移动端 Tabs 与手势冲突:横向滑动切 tab 会和浏览器的「侧滑返回」打架,也会和横向滚动的内容打架。移动端优先用可点击的分段控件,别默认加滑动手势。
<!-- 完整的属性关联 -->
<div role="tablist" aria-label="账户设置">
<button role="tab" id="t1" aria-selected="true" aria-controls="p1" tabindex="0">资料</button>
<button role="tab" id="t2" aria-selected="false" aria-controls="p2" tabindex="-1">安全</button>
</div>
<div role="tabpanel" id="p1" aria-labelledby="t1" tabindex="0">…</div>
// 内容重时用手动激活,避免方向键连续触发加载
<Tabs.Root activationMode="manual">…</Tabs.Root>
// 把当前 tab 同步到 URL,刷新与分享才不丢状态
<Tabs.Root value={tab} onValueChange={(v) => setSearchParams({ tab: v })}>这三个组件最常见的错误是「角色用错」:把导航做成菜单、把折叠面板做成手风琴,语义一错,读屏器给用户的心智模型就全错了。
menu 与「导航链接列表」不是一回事
role="menu"是给「应用式操作菜单」用的(右键菜单、编辑器的文件菜单):里面是动作(role="menuitem"),键盘用方向键移动,读屏器会切到应用模式;- 网站的导航栏是链接列表:
<nav><ul><li><a>,用 Tab 键遍历,不要加role="menu"。加了之后读屏器会告诉用户「这是一个菜单,用方向键」,而实际上方向键没反应; - 判据一句话:点了之后是「跳转到别处」还是「执行一个操作」——前者是链接列表,后者才是 menu。
手风琴(Accordion)与 Disclosure
- Disclosure(单个展开区):一个
<button aria-expanded aria-controls>+ 一块内容。原生<details><summary>直接就是这个,零 JS、可被 Ctrl+F 搜到(浏览器会自动展开); - Accordion:多个 Disclosure 的集合,可能互斥。互斥(同时只开一个)要慎用——用户对比两段内容时会很烦;
- 标题层级要正确:每个
<button>应当包在对应层级的<h2>/<h3>里,这样读屏器的「标题导航」才能跳转。
导航的三个必备
- 当前页要标记:
aria-current="page"(不只是加个高亮 class)。多个导航区要各自有aria-label(「主导航」「页脚导航」); - 跳过导航链接:页面第一个可聚焦元素应该是一个「跳到主内容」的链接(视觉上隐藏、聚焦时显示)。没有它,键盘用户每翻一页都要 Tab 过几十个导航项;
- 移动端汉堡菜单打开时要是模态:焦点陷阱、Esc 关闭、外部 inert——它就是一个抽屉(07 章)。
<!-- 导航:链接列表,不是 menu -->
<nav aria-label="主导航">
<ul>
<li><a href="/docs" aria-current="page">文档</a></li>
<li><a href="/blog">博客</a></li>
</ul>
</nav>
<!-- 跳过导航:页面最前面,聚焦时才显示 -->
<a href="#main" class="skip-link">跳到主内容</a>
<!-- Disclosure:原生就够了,还能被 Ctrl+F 搜到 -->
<details>
<summary>运费怎么算?</summary>
<p>满 99 元包邮…</p>
</details>
/* 跳过链接的标准写法 */
.skip-link { position: absolute; left: -9999px; }
.skip-link:focus { left: 8px; top: 8px; z-index: 100; }aria-current="page" 一个属性就解决,而且它还能当 CSS 选择器用([aria-current="page"] { font-weight: 600 }),比额外加一个 class 更省事——语义与样式同一个来源,永远不会不同步。<details> 现在可以做出真正的手风琴:给同组的多个 details 加同一个 name 属性,它们就会互斥(打开一个自动关掉其它)。零 JavaScript 的手风琴,还自带 Ctrl+F 自动展开。配合 ::details-content 与 allow-discrete 甚至能做展开动画——写之前查一下目标浏览器的支持度,但这是这两年最实用的一个原生新特性。动画与过渡
无头库最常见的「样式写了没效果」,八成出在退场动画上——因为元素在动画播完之前就被卸载了。这一章讲清楚这道坎的四种解法、性能红线,以及必须尊重的「减少动态效果」。
这是无头库使用者的头号困惑:打开时的动画好好的,关闭时元素「啪」地就没了。原因不在 CSS,在于 DOM 节点在过渡开始前就已经被移除。
四种解法
- ① 库自带的 presence 机制:Radix 的组件会等待 CSS 动画结束再卸载——前提是你用
animation而不是transition(它监听的是animationend)。这是最省事的一条,也是最多人不知道的一条; - ②
forceMount/keepMounted:让节点始终挂载,自己用data-state控制显示。代价是内容一直在 DOM 里(首屏体积与无障碍都要注意,隐藏时要inert); - ③ 动画库接管:Motion(原 Framer Motion)的
AnimatePresence、Vue 的<Transition>、Svelte 的transition:指令——它们都在框架层解决了「延迟卸载」; - ④ 原生 CSS:
@starting-style+transition-behavior: allow-discrete,让display: none与 top layer 的进出也能过渡。这是最新也最干净的一条路,不需要任何 JS。
Vue 与 Svelte 的对应写法
- Vue:
<Transition>包住条件渲染的元素,用v-enter-from/v-leave-to等六个类名。多元素列表用<TransitionGroup>——它还免费提供了「其它元素平滑让位」的 FLIP 动画; - Svelte:
transition:fade一个指令,进出都管;animate:flip处理列表重排; - React 没有内置,这就是为什么 React 生态需要 AnimatePresence 这类方案,也是各无头库要自己实现 presence 的原因。
用库暴露的变量把动画做对
- 浮层的动画原点应当在触发器那一侧(从下方展开 vs 从上方展开),Radix 暴露了
--radix-popper-transform-origin,直接拿来当transform-origin; - 方位靠
data-side区分:data-[side=top]:slide-in-from-bottom-2; - 不做这一步的表现是:浮层翻到上方时,动画还是从上往下展开,看起来「从触发器里长出来的方向反了」——很多人感觉别扭但说不出哪里不对。
/* ① Radix presence:必须用 animation 而不是 transition */
[data-state="open"] { animation: fadeIn 150ms ease-out; }
[data-state="closed"] { animation: fadeOut 120ms ease-in; } /* 库会等它播完再卸载 */
@keyframes fadeIn { from { opacity: 0; transform: scale(.96); } }
@keyframes fadeOut { to { opacity: 0; transform: scale(.96); } }
/* ④ 原生:连 display 与 top layer 的进出都能过渡 */
dialog {
opacity: 0;
transition: opacity .2s, display .2s allow-discrete, overlay .2s allow-discrete;
}
dialog[open] { opacity: 1; }
@starting-style { dialog[open] { opacity: 0; } } /* 进场的起始值 */
// ③ React + Motion
<AnimatePresence>
{open && <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} />}
</AnimatePresence>transition 还是 animation 时,有个简单判据:元素会不会被卸载。不卸载(只是改 class / 属性)→ transition 更简单;会被卸载 → 用 animation(配合库的 presence)或交给动画库。Radix 的文档里那句「用 animation」不是风格建议,是它的实现要求。动画性能只有一条硬规则:只动 transform 与 opacity。这两个属性可以完全在合成线程完成,不触发布局与绘制;其它属性都会把主线程拖下水。
代价从低到高
transform/opacity:只需合成,60fps 轻松;color/background/box-shadow:触发重绘(paint),中等;width/height/top/margin:触发重排(layout),最贵——而且会连累兄弟元素;- 典型替换:动
width→ 改用transform: scaleX();动top→ 改用translateY();手风琴展开的高度动画是最难替换的一个,库通常用 CSS 变量把测量到的高度传给keyframes(Radix 的--radix-accordion-content-height就是干这个的)。
prefers-reduced-motion 是必须做的
- 系统里开启「减少动态效果」的用户,可能是前庭功能障碍——大幅位移与缩放会让他们真的感到眩晕恶心,这不是偏好问题;
- 正确的响应不是「关掉所有动画」,而是把「移动/缩放」换成「淡入淡出」,保留状态变化的可感知性;
- 写法就一个媒体查询,建议做成全局兜底,再对少数关键动画单独处理。
三个隐蔽的性能坑
will-change滥用:给几百个元素加上它会占用大量显存,反而更卡。只在动画开始前加、结束后移除,或者干脆别加(现代浏览器对 transform 动画已经自动优化);- 动画期间读取布局属性(
offsetHeight、getBoundingClientRect)会强制同步布局,把合成线程的优势全部抵消; - 大面积
backdrop-filter(毛玻璃遮罩)在中低端设备上非常贵,全屏遮罩配它经常直接掉到 20fps。移动端慎用。
/* 全局兜底:把位移换成淡入淡出,而不是一刀切关掉 */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
scroll-behavior: auto !important;
}
}
/* 手风琴的高度动画:库把测量结果放进 CSS 变量 */
@keyframes slideDown {
from { height: 0; }
to { height: var(--radix-accordion-content-height); }
}
[data-state="open"] .accordion-content { animation: slideDown 180ms ease-out; }
// JS 侧也要能读到这个设置(比如决定要不要做滚动动画)
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
element.scrollIntoView({ behavior: reduce ? "auto" : "smooth" });prefers-reduced-motion 的重点对象,但它们通常不是用 CSS 动画实现的,所以上面那段全局兜底管不到。JS 驱动的动画必须自己查询这个媒体查询。另外 WCAG 对「自动播放超过 5 秒的动效」有明确要求:必须提供暂停手段。轮播图不给暂停按钮是最常见的违规项之一。SSR、RSC 与水合
同一个组件库,在纯客户端项目里岁月静好,搬进 Next.js / Nuxt 就开始报水合错误、样式闪一下、或者干脆编译不过。这一章讲清楚这三类问题的成因与判据。
水合(hydration)是「服务端产出的 HTML」与「客户端首次渲染的结果」对齐的过程。两边只要有一个字符对不上,框架就会报警告甚至丢弃整棵子树重渲染。组件库是这类问题的高发区,因为它会生成随机 id。
来源一:自动生成的 id
- 无头库要把
aria-labelledby之类的属性接起来,就必须生成唯一 id。如果服务端和客户端各生成一次,两边必然不同; - 现代框架给了稳定方案:React 的
useId()、Vue 的useId()(3.5 起)——它们保证同一个组件在两端得到相同的 id。Radix 生成的 id 形如radix-_r_1_、Base UI 是base-ui-_r_a_,中间那段就是框架的useId产物; - 所以:自研组件里不要用
Math.random()/Date.now()/ 自增计数器生成 id,用框架的useId。
来源二:只有浏览器才知道的东西
window.matchMedia(媒体查询)、localStorage(主题、语言偏好)、navigator、时区、随机数——服务端全都没有;- 典型现场:「窄屏渲染抽屉、宽屏渲染侧栏」(09 章那种响应式切换)在 SSR 时服务端只能猜一个,猜错就水合不匹配;
- 三种解法:① 首屏用 CSS 媒体查询而不是 JS 判断(最优,两端都不需要知道);② 两边都渲染、用 CSS 隐藏其一;③ 客户端挂载后再切换(
useEffect里设 state),代价是会闪一下。
来源三:条件渲染与浮层
- Portal 内容在服务端没有落点(
document.body不存在),各库的处理是服务端不渲染浮层内容——这通常是对的,因为浮层默认是关的; - 但如果你写了
defaultOpen的浮层,或者用forceMount常驻挂载,就要留意两端是否一致; - 判据:React 19 的水合错误信息会直接打印出服务端与客户端的差异片段,比以前的「Text content did not match」有用得多。先读那个 diff,再回头找哪个值是环境相关的。
// 稳定 id:用框架的,别自己造
const id = useId(); // React 18+ / Vue 3.5+
<label htmlFor={id}>邮箱</label><input id={id} />
// 响应式切换:优先用 CSS,而不是 JS 判断
<div className="md:hidden"><Drawer /></div> // 两端渲染一致,靠 CSS 决定谁可见
<div className="hidden md:block"><Sidebar /></div>
// 不得不用 JS 时:先渲染服务端版本,挂载后再切
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return <SkeletonOrServerVersion />; // 代价:会闪一下suppressHydrationWarning 压掉警告是掩耳盗铃。它只让警告消失,两端不一致的事实还在——客户端会用自己的版本覆盖,于是首屏内容闪一下变成另一个。它唯一的正当用途是「明知不可能一致」的场景(比如显示当前时间)。看到自己在加这个属性时,先问一句「这个值为什么两端不同」。data-theme 写到 <html> 上,而 <html> 的属性不参与 React 的水合比对,所以既不闪也不报错。React Server Components 把组件分成两类:在服务端跑、不下发 JS 的,和在浏览器里跑、要下发 JS 的。UI 库里的交互组件几乎全属于后者,这就带来了一整套新的组织方式。
哪些库组件必须是客户端组件
- 用了
useState/useEffect/useContext、事件处理、浏览器 API 的——基本上所有交互组件; - 好消息:主流无头库都已经在自己的入口文件里标了
"use client",你直接 import 就能用,不需要自己包一层; - 要注意的:某些库把整个包标成客户端,于是连纯展示的部分也进了客户端包。判断方法是看构建产物里那个组件有没有出现。
组织方式:把边界往下推
- 不要在页面顶层写
"use client"——那会让整棵树都变成客户端组件,RSC 的意义全部消失; - 正确做法是让交互组件成为叶子:服务端组件负责取数与布局,把内容作为
children传给客户端组件; - children 可以是服务端组件——这是最容易被忽略的一条:客户端组件的
children在服务端渲染好之后作为「已完成的元素」传进去,不会被拖进客户端包。所以「一个客户端的 Tabs 包着服务端渲染的内容」是完全可行的。
样式方案的兼容性
- 运行时 CSS-in-JS 在服务端组件里不可用:Emotion / styled-components 依赖 React Context 与浏览器 API。这就是 03 章说的换代压力的直接来源;
- 可用的:Tailwind、CSS Modules、vanilla-extract、Panda、StyleX、以及普通 CSS —— 共同点是构建期产出 CSS;
- 成套库在这件事上的处境各不相同:Chakra 3 换成了 Panda(零运行时)、MUI 提供了专门的 RSC 适配层、antd 需要额外的注册器组件。用 RSC 框架选成套库时,务必先查它的官方 App Router 指南,这个兼容性是硬约束,不是「配一配就好」。
// ❌ 错:整页变成客户端组件
"use client";
export default async function Page() { … }
// ✅ 对:边界下沉到叶子,children 仍然是服务端渲染的
// app/page.jsx(服务端组件)
import { Tabs } from "@/components/ui/tabs"; // 里面有 "use client"
export default async function Page() {
const data = await db.query(…); // 服务端取数,不下发到浏览器
return (
<Tabs defaultValue="a">
<TabPanel value="a">
<HeavyServerRenderedTable data={data} /> // 作为 children 传入,仍在服务端渲染
</TabPanel>
</Tabs>
);
}"use client" 是「边界」而不是「标记」:它下面的整棵子树默认都是客户端组件。很多人把它理解成「这个文件在客户端跑」,于是在一个客户端组件里 import 了一个「服务端组件」并期待它在服务端渲染——实际上那个组件会被当成客户端组件一起打包。唯一能把服务端内容送进客户端组件的方式是通过 props(通常是 children)传入已经渲染好的元素。@next/bundle-analyzer 看具体是哪个包贡献的体积。「我以为它在服务端」是 RSC 项目里最常见的误判,而这个检查一分钟就能做完。Vue 的 SSR 面对的是同一批问题,但工具与叫法不同。把 React 侧的经验平移过来时,这几处对应关系值得记住。
对应表
| 问题 | React / Next | Vue / Nuxt |
|---|---|---|
| 稳定 id | useId() | useId()(Vue 3.5+) |
| 只在客户端渲染 | 挂载后再切 state | <ClientOnly>(Nuxt 内置) |
| 浮层落点 | Portal | <Teleport>,SSR 时要注意目标存在与否 |
| 只在服务端跑的逻辑 | 服务端组件 | server/ 目录、useAsyncData |
| 关掉某组件的 SSR | 动态 import + ssr: false | 文件名后缀 .client.vue |
Vue 侧特有的两个坑
<Teleport to="body">在 SSR 时:服务端没有真实 DOM,Vue 会把传送内容单独收集起来由框架注入。用disabled属性可以在服务端关掉传送,多数 UI 库已经处理好了(Element Plus 的teleported属性);- 组件样式的注入顺序:SFC 的
<style>在 SSR 时会被收集进 HTML,顺序与客户端可能不同,导致「服务端渲染的首屏样式和水合后不一样」。用@layer把优先级从顺序里解耦(03 章那条),是这里同样有效的解法。
一个跨框架都成立的判据
- 「这段逻辑需要知道浏览器的什么」——窗口尺寸、存储、时区、用户偏好、随机数——只要答案不是「什么都不需要」,它就是水合风险点;
- 处理顺序永远是:① 能不能用 CSS 解决 → ② 能不能把值放进 HTML 属性(服务端已知的,比如登录用户的语言)→ ③ 才考虑客户端二次渲染;
- 这个顺序在 React、Vue、Svelte、Solid 上完全一致,因为约束来自 HTML 与 HTTP,不来自框架。
<!-- Nuxt:只在客户端渲染的部分 -->
<ClientOnly>
<ThemeToggle />
<template #fallback><div class="h-9 w-9" /></template> <!-- 占位,避免布局跳动 -->
</ClientOnly>
<!-- Teleport:SSR 时可以按需关掉 -->
<Teleport to="body" :disabled="!isMounted">
<div class="modal">…</div>
</Teleport>
// 语言/主题这类「服务端已知」的信息,直接写进 HTML 属性
// 服务端:<html lang="zh-CN" data-theme="dark" dir="ltr">
// 客户端:读属性即可,不需要二次判断,也就不会不匹配<ClientOnly> 一定要给 fallback,且 fallback 的尺寸要和真实内容一致。否则首屏是空的、水合后突然出现,页面会跳一下——这就是 CLS(累积布局偏移)的来源之一,而它同时也是「用户正要点某个按钮时按钮移位」的成因。占位不是为了好看,是为了不误触。体积、按需引入与性能
这一章是全页数据最密集的一章:同一个「按钮 + 对话框」在八个 React 库上的产物体积、一个组件与八个组件的差距、shadcn 那层工具函数的真实开销,全部在本机用 Vite 8 生产构建量过。
下面所有数字都是本机跑出来的:Vite 8.1.5 生产构建、React 19.2.8、同一段「一个按钮 + 一个对话框」的代码,只换库。基线是一个只有 React 的空应用(58.0 KB gzip)。
一个 Dialog 的代价(gzip,含 JS 与 CSS)
| 库 | JS(min / gzip) | CSS(min / gzip) | 相对基线的增量 |
|---|---|---|---|
| 空白 React 应用(基线) | 186.1 / 58.0 KB | 0 | — |
| Radix UI 1.6.7 | 223.3 / 69.9 KB | 0 | +12.0 KB |
| Headless UI 2.2.10 | 241.0 / 75.9 KB | 0 | +17.9 KB |
| Base UI 1.6.0 | 245.2 / 77.1 KB | 0 | +19.1 KB |
| React Aria Components 1.19.0 | 256.0 / 79.8 KB | 0 | +21.9 KB |
| MUI 9.2.0 | 314.9 / 100.3 KB | 0 | +42.3 KB |
| Mantine 9.5.0 | 283.0 / 86.3 KB | 225.4 / 33.2 KB | +61.5 KB |
| Chakra UI 3.36.1 | 482.0 / 134.5 KB | 0 | +76.5 KB |
| Ant Design 6.5.2 | 413.1 / 134.7 KB | 0 | +76.7 KB |
固定成本 vs 边际成本(同样是)
| 场景 | gzip 总量 | 相对基线 |
|---|---|---|
| Radix:一个 Checkbox | 63.0 KB | +5.0 KB |
| Radix:八个组件(Dialog/菜单/Tabs/Tooltip/Select/Checkbox/Switch/Accordion) | 95.8 KB | +37.8 KB |
| MUI:一个 Button | 92.0 KB | +34.0 KB |
| MUI:九个组件 | 138.6 KB | +80.6 KB |
| antd:一个 Button | 95.9 KB | +37.9 KB |
| antd:十个组件(含 Table) | 269.3 KB | +211.3 KB |
| Mantine:九个组件 | 123.1 + 33.2(CSS) KB | +98.3 KB |
三条结论很清楚:① 成套库的第一个组件就要付三十几 KB 的「入场费」(样式引擎 + 主题上下文),而 Radix 的第一个组件只要 5 KB;② 之后的边际成本各家都不算大,Radix 八个组件才 37.8 KB;③ Mantine 的 CSS 是整库的——用一个组件和用九个组件,CSS 都是 33.2 KB gzip,因为那就是整个库的样式表。
怎么读这些数字
- 版本一变数字就变,别把 KB 数背下来,要记的是量级关系:无头库 5–20 KB 级、成套库 35–80 KB 级、重型表格组件单独再加几十 KB;
- gzip 之后的差距比 min 之后小得多(重复的代码模式压缩率高),所以看 min 体积会高估差距;
- 体积不等于性能:下载与解析成本之外,运行时开销(CSS-in-JS 的样式计算、大量 Context)在低端设备上可能更重要。体积是最容易量的指标,不是最重要的指标。
# 自己量一遍,比抄任何表格都可靠(三条命令)
$ npm create vite@latest size-test -- --template react
$ npm i <要测的库> && npm run build
# Vite 构建后会直接打印每个产物的 gzip 大小
dist/assets/index-XXXX.js 223.31 kB │ gzip: 69.94 kB
# 想看是谁贡献的体积:
$ npx vite-bundle-visualizer # 或 rollup-plugin-visualizer
# 只想快速查单个包的体积量级(不含你的用法差异)
# 网站 bundlephobia.com 输入包名即可,但它测的是「整包」,
# 与 tree-shaking 后的真实增量差别可能很大 —— 以自己构建的结果为准Button 就要 37.9 KB gzip,因为主题引擎、样式生成器、Context 是整套的。成套库的正确心智是「先付入场费,之后每个组件很便宜」:这也解释了为什么它们适合组件用量大的后台系统,而不适合「只想加一个下拉」的落地页。shadcn 复制进来的每个组件都会 import 一个 cn(),它背后是三四个小包。「小」是相对的——,其中一个就比其余全部加起来还大五倍。
逐个拆开(gzip 增量,基线同样是 58.0 KB 的空 React 应用)
| 包 | 作用 | 增量 |
|---|---|---|
clsx 2.1.1 | 条件拼接类名 | 约 0.05 KB |
class-variance-authority 0.7.1 | 变体(variant)组织 | +0.3 KB |
Radix Slot | asChild 的实现 | +1.2 KB |
tailwind-merge 3.6.0 | 解决 Tailwind 类名冲突 | +8.0 KB |
| 四者合计(一个 shadcn 风格的 Button) | — | +9.7 KB |
tailwind-merge 之所以这么大,是因为它内置了一张Tailwind 全部工具类的冲突分组表——要知道 p-4 与 px-2 冲突、text-sm 与 text-red-500 不冲突,就得认识所有类名的语义。
什么时候可以不要它
- 组件不接受外部
className覆盖时(内部设计系统常见):直接用clsx就够; - 用 CSS layers 解决优先级时:把组件基础样式放在靠前的层,外部覆盖放靠后的层,顺序问题在 CSS 层面解决,不需要在 JS 里删类名;
- Tailwind 4 的
@utility与层机制让这条路比以前好走。不过要提醒:这是优化,不是必须——8 KB 在多数项目里不值得为此增加复杂度。
顺带图标库
lucide-react1.27:一个图标 +0.6 KB gzip,三十个图标 +2.7 KB——tree-shaking 完全有效,平均每个图标约 70 字节(gzip 后);- 所以图标体积不是问题,前提是具名 import(
import { Home } from "lucide-react"); - 真正的坑在开发体验:某些图标库的 barrel 文件有几千个导出,开发服务器首次编译会明显变慢(生产构建不受影响)。遇到时改用
lucide-react/icons/home这类深层路径导入。
// 三种写法,三种代价
import clsx from "clsx"; // ~0.05 KB
import { twMerge } from "tailwind-merge"; // ~8 KB —— 这是大头
export const cn = (...a) => twMerge(clsx(a)); // shadcn 默认
export const cn = (...a) => clsx(a); // 不需要外部覆盖时够用
/* 用 CSS 层解决冲突,就不需要在 JS 里删类名 */
@layer components, overrides;
@layer components { .btn { padding: 1rem; } }
@layer overrides { .p-2 { padding: .5rem; } } /* 靠后的层赢,与写法顺序无关 */
// 图标:具名 import 就能被 tree-shake(30 个图标才 2.7 KB gzip)
import { Home, Search, Settings } from "lucide-react";twMerge 处理长类名串。它要解析每一个类名并查冲突表,虽然有缓存,但在大列表里每行调用一次仍然会出现在性能面板上。对于类名固定的组件,把结果提到组件外面算一次;对于列表项,考虑用固定的 class 而不是动态合并。这个开销在一百个元素以下完全无所谓,在几千个元素时会显形。cva(0.3 KB)的价值不在体积而在组织:它把「变体 → 类名」的映射集中成一张表,避免了组件里长长的三元表达式串。即使不用 Tailwind 也可以用它组织 CSS Modules 的类名。它的继任者是 tailwind-variants(自带 tailwind-merge 能力),选哪个取决于你是否需要合并。01 章给过结论,这里把完整数据摆出来:Vue 生态里「按需引入」的收益极大,但把它归功于插件是个误会。
(Vite 8 生产构建,Vue 3.5.40,同一个「按钮 + 弹窗」)
| 方式 | JS(min / gzip) | CSS(min / gzip) | gzip 合计 |
|---|---|---|---|
| 空白 Vue 应用(基线) | 58.5 / 22.8 KB | 0 | 22.8 KB |
Element Plus:app.use(ElementPlus) | 942.6 / 301.1 KB | 348.1 / 46.6 KB | 347.7 KB |
| Element Plus:具名引入 + 按组件引样式 | 125.7 / 46.8 KB | 30.6 / 4.6 KB | 51.4 KB |
| Element Plus:unplugin 自动按需 | 125.7 / 46.8 KB | 30.7 / 4.6 KB | 51.4 KB |
| Element Plus:按需引入十个组件(含 Table) | 343.6 / 117.7 KB | 103.6 / 14.1 KB | 131.8 KB |
Naive UI:app.use(naive) | 1327.6 / 358.3 KB | 0 | 358.3 KB |
| Naive UI:具名引入 | 198.9 / 65.1 KB | 0 | 65.1 KB |
| Naive UI:unplugin 自动按需 | 199.0 / 65.2 KB | 0 | 65.2 KB |
三条结论
- ① 全量注册的代价是 6–7 倍:Element Plus 347.7 vs 51.4 KB,Naive UI 358.3 vs 65.1 KB。这是本页所有对比里差距最大的一项;
- ② 插件与手写具名 import 的产物字节数完全一致(46.8 对 46.8、65.1 对 65.2 的差异来自类名哈希)。插件省的是打字,不是体积——真正起作用的是打包器对 ESM 的 tree-shaking;
- ③ CSS 也能按需:Element Plus 全量样式 46.6 KB gzip,只引两个组件的样式是 4.6 KB。这一半的收益很容易被漏掉——手写具名 import 时忘了引样式,或者图省事引了
dist/index.css。
让 tree-shaking 失效的三件事
- 任何一处
app.use(整个库):一行就把全部组件拉进依赖图,后面所有按需都白费; - 库只提供 CommonJS 产物:老库常见,打包器无法静态分析;
- 副作用标记不对:包的
sideEffects字段写得太宽松时,打包器不敢删。这三条的排查顺序应当是从上到下,第一条占了实际问题的绝大多数。
// 排查「为什么按需没生效」:先全局搜这一类调用
$ grep -rn "app.use(" src/ | grep -iE "element|naive|vuetify|antd"
// 按需引入的完整写法(样式别忘)
import { ElButton, ElDialog } from "element-plus";
import "element-plus/theme-chalk/base.css"; // 基础变量与重置,必须
import "element-plus/theme-chalk/el-button.css";
import "element-plus/theme-chalk/el-dialog.css";
// 函数式 API 不经过模板,插件看不见,要单独处理
import { ElMessage } from "element-plus";
import "element-plus/theme-chalk/el-message.css";main.js、以及某个「为了方便」的全局注册文件。每次发版前跑一次产物分析,看有没有整个库出现在依赖图里,比任何配置都可靠。react-dom 的体量——但别把它当成选框架的理由:几十 KB 在今天的网络条件下影响有限,框架选择应该由团队、生态、渲染模型决定。把它当成一个「知道就好」的事实:Vue 项目的体积预算天然宽松一点。体积优化的顺序应该是:① 别装不需要的 → ② 别在首屏加载不需要的 → ③ 才是压缩与替换。多数团队直接跳到 ③,收益最小。
哪些 UI 组件适合懒加载
- 对话框内容、抽屉内容:用户可能永远不打开。把内容组件懒加载,触发器本身留在首屏;
- 富文本编辑器、图表、日期选择器、代码高亮——这四类是典型的「体积巨大 + 用到的人不多」;
- 不适合懒加载的:按钮、输入框这类基础组件(拆分反而增加请求数与瀑布),以及首屏可见的一切。
三个实操细节
- 懒加载要配预取:用户 hover 到「打开编辑器」按钮时就开始下载(
<link rel="prefetch">或框架的 preload API),点击时就已经好了。没有预取的懒加载会把「等待」从首屏挪到点击时,体验不一定更好; - 加载态要占位:懒加载组件到达前用等尺寸骨架,避免布局跳动;
- 失败要能重试:网络抖动导致 chunk 加载失败时,
React.lazy默认会永久失败。用一个带重试的包装函数,这是生产环境的必备补丁。
怎么定首屏预算
- 业界常用的一个参照是首屏 JS 压缩后 150–200 KB——超过之后,中低端手机的解析与执行时间会明显影响可交互时间;
- 把预算写进 CI:用
size-limit或框架自带的 budget 配置,超了就让构建失败。这是唯一能长期守住体积的办法,靠人盯着一定会漂移; - 预算要按「路由」而不是「整个应用」定:首页和后台管理页的合理预算差好几倍。
// 对话框内容懒加载 + hover 预取
const Editor = lazy(() => import("./Editor"));
<button
onMouseEnter={() => import("./Editor")} // 悬停即开始下载,点击时已就绪
onClick={() => setOpen(true)}
>编辑</button>
{open && (
<Suspense fallback={<Skeleton className="h-96" />}> // 等高占位,别让布局跳
<Editor />
</Suspense>
)}
// chunk 加载失败的重试包装(生产必备)
const retry = (fn, n = 2) => fn().catch((e) => (n ? retry(fn, n - 1) : Promise.reject(e)));
const Editor2 = lazy(() => retry(() => import("./Editor")));
# CI 里守住预算
$ npx size-limit # 配置在 package.json 的 "size-limit" 字段npx vite-bundle-visualizer 或 rollup-plugin-visualizer 会画出一张按包大小排列的图。十次里有九次,最大的那块不是 UI 库——而是日期库、图表库、富文本编辑器、或者某个被整包引入的工具库(比如 import _ from "lodash")。先砍最大的那块,收益立竿见影。国际化、RTL 与本地化
多语言不只是把文案换掉:日期与数字格式、文字长度、书写方向、字体、复数规则全都要跟着变。这一章讲组件库在其中承担的部分,以及必须由你自己承担的部分。
组件内部有一批用户看得见但你没写过的文案:「关闭」「上一月」「已选 3 项」「没有数据」。这些字符串由库负责,各家的完成度差别很大。
三种做法
- ① 内置多语言表:React Aria 是这条路的代表——它的 ComboBox 弹出列表会自动带上
aria-label="建议"(中文环境),说明整套辅助文案跟随浏览器语言自动切换,不需要任何配置; - ② 提供 locale provider,语言包由你引入:antd 的
ConfigProvider locale={zhCN}、Element Plus 的ElConfigProvider :locale="zhCn"、MUI 的createTheme(zhCN)。最常见的一种; - ③ 每个组件单独传文案:无头库(Radix、Base UI)基本走这条——它们几乎不含任何可见文案,因为所有文字都由你写。这其实是无头库的一个隐性优势:没有需要翻译的内置文案。
别忘了「看不见的文案」
aria-label(关闭按钮、排序按钮、分页跳转)、屏幕阅读器专用文本、title——这些不翻译的话,读屏器用户会听到中英混杂;- 校验错误信息:来自 zod / yup 的默认消息全是英文,要么逐条写中文 message,要么用库的错误映射(zod 4 支持全局错误定制与本地化包);
- 浏览器原生的:表单约束校验的气泡文案由浏览器语言决定,你无法改——这也是很多设计系统加
noValidate自己做校验的原因之一(08 章)。
日期与数字:交给 Intl
Intl.DateTimeFormat/NumberFormat/RelativeTimeFormat/PluralRules/ListFormat已经是浏览器标配,它们处理的复杂度(历法、时区、复数规则、货币位置)远超任何手写方案;- 复数不是「加个 s」:俄语有三种复数形式、阿拉伯语有六种。用
Intl.PluralRules或 i18n 库的复数支持,别用count > 1 ? "s" : ""; - 数字格式的坑:德语用逗号做小数点、印度用不同的千分位分组(2,00,000)。金额展示一律走
NumberFormat。
// 成套库:一次配置,组件内置文案跟着换
<ConfigProvider locale={zhCN}><App /></ConfigProvider> // antd
<el-config-provider :locale="zhCn">…</el-config-provider> // Element Plus
// 数字、日期、相对时间、复数:全部交给 Intl
new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(1234.5);
// "¥1,234.50"
new Intl.RelativeTimeFormat("zh-CN", { numeric: "auto" }).format(-1, "day");
// "昨天"(numeric: "always" 时是 "1 天前")
new Intl.PluralRules("ru-RU").select(2); // "few" —— 俄语有三种复数形式<html lang> 必须设对,而且要随语言切换更新。它影响:读屏器用哪种语音朗读、浏览器的翻译提示、断词换行规则、以及 :lang() 选择器。中日韩混排时尤其明显——同一个汉字在 lang="zh" 与 lang="ja" 下会用不同的字形(如果字体支持)。这是一个属性就能修好的常见疏漏。阿拉伯语、希伯来语从右往左读。支持 RTL 的正确方式不是写一套镜像样式,而是从一开始就用「逻辑属性」写 CSS——这样一个 dir="rtl" 就能全站翻转。
物理属性 → 逻辑属性
| 物理(会写死方向) | 逻辑(自动跟随 dir) |
|---|---|
margin-left / right | margin-inline-start / end |
padding-left / right | padding-inline-start / end |
left / right | inset-inline-start / end |
text-align: left | text-align: start |
border-left | border-inline-start |
width / height | inline-size / block-size |
Tailwind 里对应的是 ms-4 / me-4(margin-inline-start/end)与 ps-/pe-,用它们代替 ml-/mr- 就等于免费支持了 RTL。
哪些东西「不该」翻转
- 数字、代码、URL、品牌名:始终从左到右。混排时靠浏览器的双向算法处理,必要时用
<bdi>标签隔离; - 媒体播放控件:播放/快进的方向按物理时间轴,不镜像;
- 图表的时间轴:一般不翻转(有争议,按目标用户的习惯定);
- 某些图标:「返回」箭头要翻,「打钩」「垃圾桶」不用翻。图标是否翻转必须逐个判断,不能整体镜像。
组件库的 RTL 支持
- 无头库通常自动支持:它们读取
dir并把方向写进data-*(Radix 的 Select 上就有dir="ltr"属性),键盘方向键的语义也会跟着翻(RTL 下←是「下一项」); - 成套库需要显式开启:antd 的
<ConfigProvider direction="rtl">、MUI 需要配direction与相应的样式插件、Element Plus 的支持较弱; - 测试方法很简单:在 DevTools 里给
<html>加dir="rtl",整站扫一眼。五分钟就能找出所有写死方向的地方。
<!-- 一个属性驱动全站方向 -->
<html lang="ar" dir="rtl">
/* 用逻辑属性写 CSS,两个方向共用一套 */
.card {
padding-inline-start: 1rem; /* LTR 下是左内边距,RTL 下自动变右 */
border-inline-start: 3px solid var(--color-primary);
text-align: start;
}
/* 需要翻转的图标:用 CSS 而不是换资源 */
[dir="rtl"] .icon-arrow-back { transform: scaleX(-1); }
<!-- 用户名等不可控内容混排时,用 bdi 隔离双向算法 -->
<p>欢迎,<bdi>{userName}</bdi>!</p>left 更准确(比如在纵向书写模式下也正确)。这是那种「现在做零成本、以后做要重构」的选择。深水区:组合模式与受控性
这一章讲的是「为什么这些库长成这样」:复合组件、asChild、受控与非受控的统一实现、ref 与命令式接口、以及把行为抽成状态机的做法。理解了这几件事,你既能用好库,也能自己写出同样水准的组件。
成套库的对话框是 <Dialog title=… footer=… onOk=…>,无头库是五六个组件拼起来。这不是风格差异,是两种 API 哲学的分野,各有明确的适用边界。
配置式 vs 组合式
- 配置式(props 驱动):上手快、一行搞定常见场景;但每多一种定制就要多一个 prop,最终长成三十个 props 的巨兽,而且总有你想改却没暴露的地方;
- 组合式(复合组件):把结构还给使用者,定制空间无限;代价是写得长、需要理解各部分的职责;
- 常见的折中:库提供组合式基元,你在薄封装层(01 章)里包一个配置式的 API 给业务用。这是两全的做法,也是 shadcn 的实际形态——它复制过来的
dialog.tsx就是把 Radix 的基元包成了更顺手的几个。
复合组件是怎么工作的
- Root 用 Context 分发状态,子组件各自消费自己需要的部分:Trigger 拿
onOpenToggle,Content 拿open,Close 拿onClose; - 因此子组件必须在 Root 内部——这就是 05 章那条报错的来源(
Tooltip must be used within TooltipProvider); - 好处是「结构自由」:Trigger 放哪都行、Content 里想放什么放什么、可以只用一半(比如不要 Overlay)。这种自由是 props 式 API 给不了的。
Vue / Svelte 侧的等价物
- Vue 里同样的模式靠
provide/inject+ 具名插槽实现,Reka UI 与 Ark UI Vue 就是这样;而 Vue 的插槽本身就比 React 的 children 更结构化,所以成套库(Element Plus)也普遍提供#header/#footer这类插槽作为定制口子; - Svelte 5 用 snippets(
{#snippet})取代了旧的 slot,Bits UI 用它实现同样的组合; - 结论:复合组件不是 React 特有的模式,是「把结构决定权交给使用者」这个思路在各框架里的落地。
// 复合组件的最小实现:Root 提供 Context,子组件消费
const Ctx = createContext(null);
function Root({ children, defaultOpen = false }) {
const [open, setOpen] = useState(defaultOpen);
return <Ctx.Provider value={{ open, setOpen }}>{children}</Ctx.Provider>;
}
function useDialog() {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("Dialog.Trigger 必须放在 Dialog.Root 内部"); // 早报错好过静默失效
return ctx;
}
function Trigger(props) {
const { setOpen } = useDialog();
return <button {...props} onClick={() => setOpen(true)} />;
}
Root.Trigger = Trigger; // 挂成命名空间:Dialog.Root / Dialog.TriggerDialog.Content 抽成自己的组件再放进去,Context 仍然能穿透(React Context 不受组件层级限制);但如果你把它渲染在 Root 外面——比如通过 props 传进去、或者在另一个 Portal 里——Context 就断了。症状是「明明写了却报 must be used within」。判据:在 React DevTools 里看那个组件的 Context 值是不是 null。throw new Error("X 必须放在 Y 内部") 能把「组件不工作但没人知道为什么」变成「一秒定位」。这也是判断第三方库质量的一个细节:好库的错误信息会直接告诉你缺了哪个包裹组件(05 章那条 Radix 的报错就是范例)。几乎所有组件库都支持「传了 value 就受控、没传就自己管」。这个模式的实现只有十几行,但每一行都在防一个具体的坑——自己写组件时值得照抄。
四条规则
- ① 判据是
prop !== undefined,不是!= null——null是一个合法的受控值(表示「没选中」); - ② 受控模式下内部 state 必须被忽略,不能「先更新内部再通知外部」,否则外部拒绝更新时 UI 会和数据不一致(这就是「受控组件应该像纯函数」的意思);
- ③ 回调必须无条件触发:无论受控与否,
onChange都要调用——非受控模式下使用者也可能只是想「知道值变了」; - ④ 受控性不应中途改变:从非受控切到受控(
value从 undefined 变成有值)会导致行为突变。库通常会警告,自己写的话至少要在开发模式下提醒一次。
为什么这个模式值得存在
- 大多数使用场景不需要受控:一个下拉菜单开不开,使用者根本不关心。强制受控会让每个用法都多两行样板;
- 但少数场景必须受控:把状态同步到 URL、多个组件联动、由外部逻辑强制关闭。这时非受控就完全做不到;
- 所以「两者都支持」不是贪心,是把选择权交给使用者——这与复合组件的哲学是一致的。
Vue 侧的对应
- Vue 的
v-model天然是受控写法,非受控要靠组件内部自己维护ref并在 prop 为undefined时使用它——逻辑和 React 版完全一样,只是写成computed的 getter/setter; defineModel()(Vue 3.4+)把这套样板收进了一个宏,但它默认是受控优先,需要非受控行为时仍然要自己处理默认值。
// 受控 / 非受控二合一(React):这十几行是各家库的公共实现
function useControllableState({ prop, defaultProp, onChange }) {
const [uncontrolled, setUncontrolled] = useState(defaultProp);
const isControlled = prop !== undefined; // ① 用 undefined 判断,不是 null
const value = isControlled ? prop : uncontrolled;
const setValue = useCallback((next) => {
const resolved = typeof next === "function" ? next(value) : next;
if (!isControlled) setUncontrolled(resolved); // ② 受控时不碰内部 state
if (resolved !== value) onChange?.(resolved); // ③ 两种模式都要通知
}, [isControlled, value, onChange]);
return [value, setValue];
}
// Vue 3:同一套逻辑,写成 computed
const inner = ref(props.defaultValue);
const value = computed({
get: () => (props.modelValue !== undefined ? props.modelValue : inner.value),
set: (v) => { if (props.modelValue === undefined) inner.value = v; emit("update:modelValue", v); },
});value 与 defaultValue 同时传是无意义的,而且多数库不会报错。defaultValue 只在首次渲染时读一次,受控模式下它完全被忽略。真正的危险是反过来的写法:value={form.name ?? ""} 看起来安全,但如果数据加载前是 undefined、加载后变成字符串,组件就在渲染过程中从非受控变成了受控——React 会警告,Vue 不会,但两边的表现都是「第一次输入时值被莫名重置」。value 和一个空的 onChange,然后点击组件——正确的实现应当什么都不变。很多自研组件在这里露馅(内部 state 已经变了,UI 动了,但数据没动),而这正是受控模式存在的意义。asChild 是 Radix 引入、如今被广泛模仿的一个模式:不渲染自己的标签,而是把行为「注入」到你给的那个子元素上。它解决的是一个很实际的问题——库的 Trigger 是 <button>,但你想用自己的 Button 组件或一个 <a>。
三种解法的对比
- ①
asChild(Slot 模式):<Trigger asChild><MyButton /></Trigger>——库把 props、ref、事件合并到MyButton上,不产生额外 DOM; - ②
as/componentprop:<Trigger as="a" href="…" />——MUI、Mantine、Chakra 走这条。类型推导更难写(要根据 as 的值推出可用 props),但用起来更直觉; - ③ render props:
<Trigger>{(props) => <MyButton {...props} />}</Trigger>——最显式,Headless UI 与 React Aria 大量使用。Base UI 用的是render属性,本质相同。
属性合并的规则(自己实现时的难点)
- 事件处理器要「都调用」:库的
onClick和你的onClick都要跑,且你的先跑、如果你preventDefault了库的就不跑; className与style要合并而不是覆盖;- ref 要转发到最终的 DOM 元素——这是 Slot 实现里最容易错的一处;
- 其余属性子元素优先(你写的
id应该赢过库生成的)。这四条弄反任何一条,都会产生「有时好使有时不好使」的诡异行为。
用 asChild 的三个注意
- 子元素必须只有一个,且必须能接收 props 与 ref:传一个
<>…</>或一个不转发 ref 的函数组件都会失败; - 自定义组件要转发 props 到真实 DOM:
function MyButton(props) { return <button {...props} /> }——少写这个展开,行为就注入不进去; - 嵌套 asChild 要小心:两层都用时,属性要穿透两层合并,某些实现会丢事件。
// asChild:不产生额外 DOM,行为注入到你的组件上
<Dialog.Trigger asChild>
<MyButton variant="ghost">打开</MyButton> // MyButton 必须把 props 透传到 <button>
</Dialog.Trigger>
// 你的组件必须长这样,asChild 才有效
function MyButton({ className, ...rest }) {
return <button className={cn("btn", className)} {...rest} />; // ...rest 不能漏
}
// Base UI 的等价写法是 render 属性
<Dialog.Trigger render={<MyButton variant="ghost" />}>打开</Dialog.Trigger>
// 事件合并的正确姿势(Slot 内部做的事)
const merged = (theirs, ours) => (e) => {
theirs?.(e); // 使用者的先跑
if (!e.defaultPrevented) ours?.(e); // 被 preventDefault 就不跑库的
};asChild 传了不转发 ref 的组件时,多数情况下不会报错,只是「点了没反应」。React 19 起函数组件可以直接接收 ref 作为 prop(不再需要 forwardRef),这让问题变轻了;但如果你的组件对 props 做了筛选(只取自己认识的几个),行为注入照样失败。判据:在 DevTools 里看那个真实 DOM 元素上有没有出现库注入的 aria-expanded / data-state——没有就是没注入进去。asChild 最实用的场景其实是把触发器换成路由链接:<DropdownMenu.Item asChild><Link to="/settings">设置</Link></DropdownMenu.Item>。这样既有菜单项的键盘语义,又有真正的 <a href>(可以中键新标签页打开、可以复制链接)。把菜单项做成 <div onClick={navigate}> 是很常见的退步——用户会失去所有链接的原生能力。Ark UI(以及基于它的 Chakra 3)走了一条和其它无头库都不同的路:把每个组件的交互逻辑写成一台与框架无关的状态机,再为 React / Vue / Svelte / Solid 各写一层薄绑定。
这么做的三个收益
- ① 跨框架一致:同一个 Select 在四个框架里的行为、
data-*属性、无障碍实现完全相同——多技术栈团队可以共享一套设计系统与样式表; - ② 逻辑可以脱离 UI 测试:状态机是纯函数,输入事件、断言状态,不需要渲染任何 DOM;
- ③ 复杂交互不会写成一团 if:菜单的「打开 → 键盘导航 → 子菜单 → 关闭并归还焦点」用状态图表达,比一堆布尔变量清晰得多。
代价
- 包很碎:
@ark-ui/react的直接依赖有 67 个(每个组件的状态机是一个独立包); - 调试时面对的是状态机:出问题要看当前状态与转移,而不是读组件代码。学习曲线更陡;
- 抽象层更厚:从你的 JSX 到最终 DOM 之间多了一层,定制某些细节时要理解它的 API 分层(
getRootProps()这类 prop getter)。
什么时候值得考虑
- 团队同时有 React 与 Vue(或要长期支持两个框架)——这是 Ark UI 的杀手锏,别的方案都做不到;
- 要自建设计系统并分发给多个产品线:行为层统一之后,各产品只需要各自的样式;
- 单框架的普通项目不必上:Radix / Base UI 的心智负担更低,生态也更大。「跨框架」这个收益如果用不上,剩下的都是成本。
// Ark UI:同一份行为,四个框架各自的绑定层
// React
import { Dialog } from "@ark-ui/react/dialog";
<Dialog.Root><Dialog.Trigger>打开</Dialog.Trigger>…</Dialog.Root>
// Vue —— 组件名、data 属性、键盘行为与 React 版完全一致
import { Dialog } from "@ark-ui/vue/dialog";
<Dialog.Root><Dialog.Trigger>打开</Dialog.Trigger>…</Dialog.Root>
/* 于是同一份样式表能喂给两个框架的产物 */
[data-scope="dialog"][data-part="content"][data-state="open"] { … }useReducer 的 action 表)把状态转移写清楚,再写渲染。好处是当交互出 bug 时,你可以只看那张转移表,而不用在 JSX 和事件处理里来回跳。TanStack Table 是同一思路在数据层的体现。生态速查:各框架的候选与专项库
把版图收成几张可以直接查的表:各框架的无头库与成套库、按需求反查该用哪个专项库、以及各库的组件覆盖情况。版本为 2026-07-29 现查 npm 的结果。
选型的第一步是把候选池缩小到三五个。下表按框架与流派切分,版本是 2026-07-29 现查 npm 的 latest。
无头 / 行为层
| 库 | 框架 | 版本 | 性格 |
|---|---|---|---|
| Radix UI | React | 1.6.7 | 生态最大、体积最小、shadcn 的地基;无官方日期选择器 |
| Base UI | React | 1.6.0 | MUI 团队新作,API 干净,滚动锁等实现最现代;生态年轻 |
| Headless UI | React / Vue | 2.2.10 / 1.7.23 | Tailwind 官方,组件少而精,与 Tailwind 配合最顺 |
| React Aria Components | React | 1.19.0 | Adobe 出品,无障碍与国际化最强,体积最大 |
| Ark UI | React / Vue / Svelte / Solid | 5.37.2 | 状态机驱动,唯一真正跨框架 |
| Reka UI(原 Radix Vue) | Vue 3 | 2.10.1 | Vue 侧的 Radix 对等物,shadcn-vue 的地基 |
| Bits UI | Svelte 5 | 2.18.1 | Svelte 侧主力,shadcn-svelte 的地基 |
| Melt | Svelte 5 | 0.44.0 | 更底层的 builder 模式(老包 @melt-ui/svelte 是上一代) |
| Kobalte | Solid | 0.13.12 | Solid 侧的事实标准 |
| Angular CDK | Angular | 22.x | 官方行为层,Material 就建立在它之上 |
成套 / 有视觉
| 库 | 框架 | 版本 | 适合 |
|---|---|---|---|
| MUI | React | 9.2.0 | 组件最全、生态最大;Material 风格明显,定制要投入 |
| Ant Design | React | 6.5.2 | 中后台标配,表单与表格最强;体积最大 |
| Mantine | React | 9.5.0 | Hooks 丰富、样式用普通 CSS;CSS 是整库的 |
| Chakra UI | React | 3.36.1 | v3 基于 Ark UI + Panda,零运行时样式 |
| HeroUI(原 NextUI) | React | 3.2.2 | 基于 React Aria + Tailwind,视觉现代 |
| Element Plus | Vue 3 | 2.14.3 | 中文生态最厚,文档与社区资源最多 |
| Naive UI | Vue 3 | 2.44.1 | TS 友好、主题灵活、CSS-in-JS 无样式文件 |
| Vuetify | Vue 3 | 4.1.6 | Material 风格、组件极全 |
| PrimeVue | Vue 3 | 5.0.0 | 组件数量惊人,也有 React/Angular 版 |
| Nuxt UI | Vue / Nuxt | 4.10.0 | 基于 Reka UI + Tailwind,Nuxt 项目开箱即用 |
| Vant | Vue 3 | 4.10.0 | 移动端专用 |
| Angular Material | Angular | 22.0.6 | 官方出品,与 CDK 同源 |
| Shoelace / Material Web | Web Components | 2.20.1 / 2.5.0 | 框架无关,适合多技术栈或嵌入式场景 |
# 这张表会过期,查最新只要一条命令
$ npm view radix-ui version reka-ui version bits-ui version
# 看某个包最近还活着吗
$ npm view element-plus time.modified
# 看它有没有被改名/废弃
$ npm view @nextui-org/react deprecatedSelect),看未解决的 issue 有多少、都是什么性质、维护者回不回。十分钟能看出这个库对你的场景成不成熟。很多需求不该由 UI 库解决,而该找对应的专项库。下面这张表按「你要做什么」组织,全部是框架无关或多框架支持的方案。
专项库反查表
| 需求 | 方案 | 说明 |
|---|---|---|
| 表格的排序/过滤/分组/分页 | TanStack Table 8.21 | 无头数据层,六个框架的绑定 |
| 长列表 / 大表格虚拟化 | TanStack Virtual 3.14 | 支持动态行高与横向虚拟化 |
| 浮层定位与碰撞 | Floating UI 0.27(React 绑定) | 几乎所有 UI 库的底层 |
| 通知 / Toast | Sonner 2.0(React) | 零依赖,API 极简 |
| 移动端抽屉 | Vaul 1.1(React) | 手势关闭、吸附点,配 Radix Dialog |
| 命令面板 | cmdk 1.1(React) | shadcn 的 Command 组件即它 |
| 表单状态与校验 | React Hook Form 7.83 + Zod 4.4 | Vue 侧对应 VeeValidate / FormKit |
| 轮播 | Embla Carousel 8.6 | 多框架,无障碍做得较好 |
| 动画 | Motion 12.43 | 原 Framer Motion,含 AnimatePresence |
| 图标 | Lucide 1.27 | tree-shaking 有效(30 个图标 2.7 KB gzip) |
| 图表 | ECharts / Recharts 3.10 / VisX | ECharts 框架无关且功能最全 |
| 拖拽排序 | dnd-kit / SortableJS | dnd-kit 的键盘可达性更好 |
| 富文本编辑 | Tiptap / Lexical / ProseMirror | 三者都很重,务必懒加载 |
| 日期处理 | Temporal API / date-fns / dayjs | Temporal 是未来,先查目标环境支持度 |
一条选型纪律
- 每加一个库,先问「不加会怎样」。轮播可以用 CSS scroll-snap(十几行)、简单的拖拽排序可以用原生 HTML5 拖放、简单的图表可以用 SVG 直接画;
- 加了之后要写进技术决策记录:为什么选它、当时的候选是谁、什么条件下应该换掉。半年后没人记得这些,而换库的人最需要它们。
# 一个组合的例子:无头 + 专项库,各司其职
radix-ui # 行为与无障碍
tailwindcss # 样式
@tanstack/react-table # 表格数据层
@tanstack/react-virtual # 虚拟化
react-hook-form + zod # 表单与校验
sonner # 通知
lucide-react # 图标
# 这套组合的产物增量(按本页 14 章的量级估算):
# Radix 若干组件 ~40 KB + 表格/虚拟化 ~15 KB + RHF/zod ~25 KB + 其余 ~10 KB
# ——总体仍小于一个成套库的入场费 + 常用组件选型时最实际的一步是拿着自己的组件清单去核对。这一卡列出各类库普遍缺失或质量参差的部分,帮你把核对的重点放对地方。
无头库普遍没有的
- 日期 / 时间选择器:只有 React Aria 与 Ark UI 有完整实现。Radix、Base UI、Headless UI、Bits UI 都需要外挂;
- 数据表格:所有无头库都不提供——这是 TanStack Table 的领域;
- 富文本、图表、文件上传队列、颜色选择器:基本都要另找;
- 虚拟化:无头库的下拉通常不内置虚拟化,几千条选项要自己接(09 章)。
成套库之间差别最大的三类
- 表格:antd 与 Element Plus 的表格能力(可编辑、树形、固定列、合并单元格)远超其它,这是很多后台项目选它们的真正原因;
- 表单:antd 的
Form与 Element Plus 的el-form提供了完整的校验与布局体系,MUI 与 Mantine 则倾向于让你用 RHF 等外部库; - 上传:带队列、断点、拖拽、预览的上传组件差异极大,这是最容易在选型时忽略、在开发时痛苦的一个。
两个常见误判
- 「有这个组件」不等于「够用」:几乎每个库都有 Upload,但支持不支持分片续传、并发控制、失败重试差得远。核对清单时要写清楚需求细节,而不是只写组件名;
- 「文档里有」不等于「稳定」:某些库把实验性组件与稳定组件放在同一份文档里。看它的 changelog 与 issue 数量比看文档更能判断成熟度。
# 选型时的核对清单该长这样(写细节,不写组件名)
□ 表格:固定左 2 列 + 虚拟化 5000 行 + 行内编辑
□ 上传:分片、并发 3、失败重试、图片预览
□ 日期:区间选择 + 禁用节假日 + 时区固定为 UTC+8
□ 下拉:远程搜索 + 已选项回显 + 多选标签折叠
□ 表单:动态增删明细行 + 跨字段校验
□ 无障碍:键盘全流程可用 + axe 零 critical
□ 体积:首屏 gzip < 200 KB
□ 主题:一套语义令牌驱动亮暗两套 + 客户品牌色
# 拿这张表去试两个候选库,各花两小时 —— 比读十篇对比文章有用自建组件库:从内部包到设计系统
一旦你复制了三十个 shadcn 组件,或者公司有三个产品线要统一视觉,你就已经在维护一个组件库了。这一章讲清楚它该怎么组织、怎么打包分发、API 怎么设计,以及破坏性变更怎么发布。
「做个内部组件库」是最容易被高估收益、低估成本的技术决策之一。它的真实成本不在写组件,在于此后每一年的维护、文档、答疑与升级。
值得建的三个信号
- ① 有两个以上产品要共享视觉,且它们不在同一个仓库里;
- ② 有专职设计师在维护设计规范,且规范会持续演进——组件库是规范的可执行形式;
- ③ 已经出现「同一个按钮在三个项目里长得不一样」的实际问题,而不只是「觉得应该统一」。
不值得建的信号
- 只有一个产品 → 就放在这个产品的
components/ui/里,不要提前抽包; - 只是想「封装一下 antd」 → 薄封装(01 章)就够,不需要独立发版;
- 没有人愿意长期负责 → 没有 owner 的组件库会在半年内变成「谁都不敢改的祖传代码」。这是最常见的死法。
三种形态,成本递增
- ① 目录形态:就是项目里的
components/ui/。零成本,适合单产品; - ② 私有包形态:发到私有 npm registry,各产品
npm i @acme/ui。需要版本管理、构建流程、变更日志; - ③ registry 形态(copy-in):像 shadcn 那样提供一个组件注册表,各产品把源码复制走。这是近两年出现的第三条路——好处是各产品可以自由改,坏处是没有统一升级。内部设计系统很适合这种形态,因为「统一的是规范,不必是代码」。
# 形态 ③:自建一个 shadcn 兼容的 registry
# registry/button.json —— 描述这个组件由什么组成
{
"name": "acme-button",
"type": "registry:ui",
"dependencies": ["radix-ui", "class-variance-authority"],
"files": [{ "path": "ui/button.tsx", "type": "registry:ui" }]
}
# 各产品这样取用(可以是内网地址)
$ npx shadcn@latest add https://ui.acme.internal/r/acme-button.json第一天就该定下来的一件事:第三方组件与你自己的组件分开放。这个约定很小,但它决定了半年后「换库」是一周还是三个月。
一个够用的目录结构
src/
components/
ui/ # 只放「对第三方的薄封装」:Button、Dialog、Input…
button.jsx # shadcn 生成的文件也落在这里
dialog.jsx
biz/ # 业务组件:OrderTable、UserPicker,只 import 上面的 ui/
lib/
utils.js # cn() 之类的工具- 业务代码只 import
@/components/ui/*,不直接 import 库。这条规矩是全章最值钱的一句:换库时你改的是ui/下的十几个文件,而不是三百个业务文件。 - 用别名(
@/)而不是相对路径,否则移动文件时改路径改到崩溃。shadcn 默认就假设你有这个别名。
薄封装长什么样
- 薄是关键词:只做三件事——固定默认样式、收敛 API(把库的十五个 props 缩成你团队真正会用的五个)、留出逃生口(
className与...rest透传)。 - 不要在薄封装里加业务逻辑(比如在 Button 里塞埋点、在 Dialog 里塞请求)。那会让「UI 层」慢慢变成一个谁都不敢动的地方。
- shadcn 的模式天然就是这个结构——它把源码复制进
components/ui/,所以你从第一天起就在改自己的代码。即使不用 shadcn,也建议照抄这个目录约定。
// src/components/ui/button.jsx —— 一个够用的薄封装
import { Slot } from "radix-ui";
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils";
const button = cva("inline-flex items-center rounded font-medium transition", {
variants: {
variant: { solid: "bg-slate-900 text-white", ghost: "hover:bg-slate-100" },
size: { sm: "h-8 px-3 text-sm", md: "h-10 px-4" },
},
defaultVariants: { variant: "solid", size: "md" },
});
export function Button({ className, variant, size, asChild, ...rest }) {
const Comp = asChild ? Slot.Root : "button"; // 逃生口:渲染成别的标签
return <Comp className={cn(button({ variant, size }), className)} {...rest} />;
}...rest 原样透传」,剩下的让使用者直接查库的官方文档。cn() 这个工具函数是 shadcn 生态的通用约定:clsx 负责条件拼接、tailwind-merge 负责让后写的 Tailwind 类覆盖先写的(否则 p-2 和 p-4 同时出现时,赢的是 CSS 里靠后的那条规则,而不是你传进来的那个)。但它不是免费的:tailwind-merge 单独就占约 8 KB gzip,比 clsx(约 0.05 KB)、cva(约 0.3 KB)、Radix Slot(约 1.2 KB)加起来还大五倍(14 章有全表)。发一个组件库包,技术上的坑集中在三处:产物格式、exports 字段、以及 CSS 怎么交付。这三处配错的表现都是「用户装了但用不了」。
产物格式
- 只发 ESM 就够了吗:今天的构建工具与 Node 都支持 ESM,但如果使用者可能在 Jest(CJS)或老构建里用,双格式更保险。用 tsup / unbuild / Vite 库模式都能一次产出两种;
- 不要打包依赖:React、Vue 必须是
peerDependencies,否则使用者的应用里会出现两份 React(症状是「Invalid hook call」); - 保留
"use client":如果组件要在 RSC 环境用,打包时不能把这行指令删掉(tsup 需要显式配置保留 banner)。
exports 字段
- 它决定了
import "@acme/ui/button"能不能解析、类型能不能被找到; - 子路径导出很重要:它让使用者可以只引一个组件,而不是整个 barrel(对 tree-shaking 与开发服务器速度都有好处);
types必须放在每个条件的最前面,否则 TypeScript 在某些 moduleResolution 下找不到类型。
CSS 怎么交付:三条路
- ① 发一个 CSS 文件,使用者
import "@acme/ui/styles.css"。最简单,但无法按组件裁剪(Mantine 就是这样,整库 CSS 是 33 KB gzip); - ② 每个组件一个 CSS 文件,配合
sideEffects标记,使用者按需引(Element Plus 的做法); - ③ 不发 CSS,只发 Tailwind 类名:要求使用者的 Tailwind 配置能扫描到你的包(
@source指令),否则类名全部不生成——这是这条路最常见的事故。发布 Tailwind 组件库时务必在文档第一屏写清楚这一步。
// package.json 的关键字段
{
"name": "@acme/ui",
"type": "module",
"sideEffects": ["*.css"], // 让打包器敢删没用到的 JS,但保留 CSS
"peerDependencies": { "react": ">=18" }, // 绝不能放进 dependencies
"exports": {
".": {
"types": "./dist/index.d.ts", // types 必须在最前
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./button": {
"types": "./dist/button.d.ts",
"import": "./dist/button.js"
},
"./styles.css": "./dist/styles.css"
}
}
/* Tailwind 4:使用者要让扫描器看见你的包 */
@import "tailwindcss";
@source "../node_modules/@acme/ui/dist"; /* 否则你的类名一个都不生成 */Invalid hook call 或 Context 莫名其妙拿不到值,而代码看起来完全正确。成因是你把 react 放进了 dependencies,或者在 monorepo 里 link 时解析到了不同的实例。排查一招见效:console.log(require("react").version) 在库和应用里各打一次,看是不是同一个模块实例(更准的办法是比较 require.resolve("react") 的路径)。npm pack 生成 tarball,然后在一个全新的空项目里 npm i ./acme-ui-1.0.0.tgz 装一次跑一遍。这个五分钟的检查能抓到 exports 配错、文件没进包(files 字段漏了)、peerDependency 缺失等几乎所有发布事故——而这些问题在 monorepo 内部开发时全都看不出来,因为那里走的是符号链接。组件库的 API 一旦发布就很难改。下面五条来自各主流库的共同实践,能挡掉大部分「早知道就不该那样设计」。
五条
- ① 永远透传未知 props 与 ref:
{...rest}与 ref 转发是使用者的最后一道逃生口。不透传的组件迟早会被 fork; - ② 提供
className与插槽级定制:多层结构的组件要暴露classNames={{ trigger, content, item }},否则使用者只能写后代选择器钻进你的内部结构(那会让你的重构永远破坏别人); - ③ 命名跟随平台而不是自创:
onChange而不是onValueUpdate、disabled而不是isDisabled(除非整个库统一用is前缀)。与 HTML 一致的名字不需要学习; - ④ 默认值要偏保守:默认不自动聚焦、默认不自动关闭、默认不带动画。「加功能」比「关掉不想要的功能」容易得多;
- ⑤ 受控与非受控都支持(16 章的实现),并且成对提供
value/defaultValue。
两个容易做错的细节
- 布尔 props 不要用否定式:
disableAnimation会导致disableAnimation={false}这种双重否定。用animated(默认 true); - 不要用一个 props 表达两件事:
size="large"同时改变字号、内边距、高度是对的;但variant="danger-outline"把语义与外观揉在一起,之后想要「危险的实心按钮」就没法表达了——拆成两个正交的 props。
文档即 API 的一部分
- 每个组件至少要有:能跑的最小示例、props 表、无障碍说明、以及「什么时候不要用它」;
- 最后一条最少见也最有用——「这个组件不适合 X 场景,请改用 Y」能省掉大量误用;
- 示例要能复制就跑:不完整的片段(省略了 import 与 Provider)是文档最大的坑。
// 一个符合五条原则的组件签名
export function Select({
value, // 受控
defaultValue, // 非受控
onValueChange, // 两种模式都会触发
disabled = false, // 跟随 HTML 命名
size = "md",
variant = "outline", // 与 size 正交,不揉在一起
classNames, // 多层结构的插槽级定制
className,
ref, // React 19 起可直接作为 prop
...rest // 逃生口:未知属性一律透传
}) { … }
// 使用者可以钻进任意一层,而不必依赖你的内部类名
<Select classNames={{ content: "max-h-72", item: "text-sm" }} data-testid="plan" />data-slot="trigger" 之类的属性标记内部结构(shadcn 新版就这么做),比暴露 classNames 更轻:使用者可以用 [data-slot=trigger] 选择器定制,而你重构内部 DOM 时只要保持这些标记不变,就不会破坏别人。这是「稳定的定制接口」的一种低成本实现。组件库的破坏性变更成本极高——一个 major 版本可能让五个团队各花一周。所以真正的技巧不是「怎么发 major」,而是「怎么少发 major」。
什么算破坏性变更(比想象的多)
- 删除或重命名 prop;改变默认值;改变 DOM 结构(别人的 CSS 会挂);改变类名;改变事件触发时机与顺序;提高 peer 依赖的最低版本;
- 「改 DOM 结构」最容易被忽略:你觉得只是内部重构,但只要有人写了后代选择器就会坏。这也是上一卡建议用
data-slot标记的原因——它让「内部结构」与「公开接口」有了明确边界。
废弃流程:三步走
- ① 在 minor 版本里引入新 API,旧的标记为 deprecated(JSDoc 的
@deprecated会让 IDE 显示删除线,这个提示比文档有效得多); - ② 运行时在开发模式打一次警告,说明「用什么替代」,并且只打一次(用 Set 去重,否则控制台会被刷屏);
- ③ 下一个 major 才真正删除,并在 changelog 里给出迁移表。
让升级变便宜
- 提供 codemod:用 jscodeshift 或 ts-morph 写一个自动改写脚本,把
<Button type="primary">改成<Button variant="solid">。主流库(MUI、antd)都提供 codemod,这是它们敢做大版本的底气; - 一次只改一件事:把「换样式引擎」和「改 API」放在同一个 major 里,会让使用者无法二分定位问题;
- 给出并存期:新旧 API 同时可用至少一个 minor 周期,让各团队按自己的节奏迁移。
// 废弃警告:开发模式、只打一次、说明替代品
const warned = new Set();
function deprecate(key, msg) {
if (process.env.NODE_ENV === "production" || warned.has(key)) return;
warned.add(key);
console.warn("[@acme/ui] " + msg);
}
export function Button({ type, variant, ...rest }) {
if (type !== undefined) {
deprecate("button.type", "Button 的 type 属性将在 v3 移除,请改用 variant。迁移:npx @acme/codemod button-variant");
}
return <button {...rest} data-variant={variant ?? type} />;
}
/** @deprecated 请改用 variant,v3 将移除 */
type ButtonType = "primary" | "default"; // IDE 里会显示删除线测试与质量保障
UI 组件的测试有一条主线:测行为、不测实现。这一章讲清楚该用什么查询方式、无障碍怎么自动化、视觉回归值不值得上,以及一套投入产出比合理的 CI 组合。
Testing Library 的整个设计围绕一句话:「测试越接近用户的使用方式,它给你的信心越大」。落到具体操作上,就是「用什么方式找到元素」这一个选择。
查询方式的优先级
- ①
getByRole(首选):getByRole("button", { name: "保存" })——它同时验证了角色正确与可访问名正确。换句话说,用 role 查询的测试顺便就是一次无障碍检查; - ②
getByLabelText(表单控件):验证了标签与控件的关联; - ③
getByText:适合非交互内容; - ④
getByTestId(最后手段):不验证任何语义,但在「实在找不到稳定标识」时可用; - 绝不要用:CSS 类名选择器、DOM 结构路径(
container.querySelector("div > span"))。它们会让每次重构都伴随一批测试失败,而那些失败没有任何信息量。
用 user-event 而不是 fireEvent
fireEvent.click()只派发一个 click 事件;userEvent.click()会派发 pointerdown → mousedown → focus → pointerup → mouseup → click 一整串,还会检查元素是否可见、是否被禁用;- 差别很实际:06 章说过「点外部关闭」应该监听
pointerdown——用fireEvent.click测这个功能会得到「测试通过但实际不工作」或反过来; - 键盘同理:
userEvent.keyboard("{Tab}")会正确移动焦点,手工派发 keydown 不会。
组件库组件的测试要点
- 测你自己的封装,不测库:Radix 的 Dialog 有没有焦点陷阱是它的测试职责,你要测的是「点了删除按钮会调用 onDelete」;
- 浮层组件在测试环境常需要额外准备:jsdom 没有布局,
ResizeObserver、DOMRect、scrollIntoView都要 mock。许多「测试里下拉打不开」是这个原因——用真浏览器跑(Vitest 的浏览器模式 / Playwright 组件测试)能一次性绕开; - 异步要等:
await screen.findByRole(…)而不是立刻getBy——动画与 portal 挂载都要一帧。
// 好的组件测试:全程用用户视角
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
test("确认删除后会调用 onDelete", async () => {
const user = userEvent.setup();
const onDelete = vi.fn();
render(<DeleteButton onDelete={onDelete} />);
await user.click(screen.getByRole("button", { name: "删除" }));
// 对话框是 portal 出去的,用 findBy 等它挂载
const dialog = await screen.findByRole("dialog", { name: "确认删除" });
await user.click(within(dialog).getByRole("button", { name: "确认" }));
expect(onDelete).toHaveBeenCalledOnce();
});
// ❌ 反面:测的是实现细节,重构必挂且没有信息量
expect(wrapper.find(".btn-danger").length).toBe(1);
expect(component.state.isOpen).toBe(true);data-testid,是在掩盖真正的问题。如果 getByRole("button", { name: "保存" }) 找不到,多半是因为那个「按钮」根本不是 button、或者没有可访问名——而这两件事对真实用户同样有害。正确反应是去修组件,而不是换一种查询方式。data-testid 应当留给「确实没有语义标识」的容器元素。getByRole 找不到元素时,Testing Library 会把当前页面上所有可用的 role 与可访问名打印出来。这份输出本身就是一份无障碍报告——看到一堆「button(无名字)」就说明你的图标按钮全都缺 aria-label。很多人把这个报错当成噪音,其实它是这套工具最有价值的副产品之一。axe 是无障碍自动化检查的事实标准(axe-core 4.12),可以接进单元测试、Storybook、E2E 与 CI。但必须清楚它的能力边界:它能查的部分只占 WCAG 问题的一部分。
它能查到的
- 缺 alt、缺 label、按钮没有可访问名、表单控件没有关联标签;
- 对比度不足(这一条价值很高,人工几乎不可能全查);
- ARIA 属性用错(role 与必需属性不匹配、指向不存在的 id);
- 重复 id、标题层级跳跃、页面缺 lang。
它查不到的(也是最重要的那部分)
- 焦点顺序是否合理、焦点陷阱是否工作、关闭后焦点有没有归还;
- 可访问名是否「有意义」:
aria-label="按钮"能通过检查,但对用户毫无用处; - 键盘能否完成完整流程;
- 动态内容有没有被播报;
- 业界常引用的经验是:自动化工具大约能覆盖三分之一左右的无障碍问题——所以 05 章那个「拿开鼠标 Tab 一遍」的手工检查不能省。
接进哪一层
- 单元测试(jest-axe / vitest-axe):对组件库最合适,每个组件一条断言,几秒钟跑完;
- Storybook 的 a11y 插件:开发时实时反馈,写组件的同时就能看到问题;
- E2E(@axe-core/playwright):查整页,能发现组件组合后才出现的问题(比如两个组件都用了同一个 id);
- 三层各查一次并不冗余,因为它们看到的 DOM 不同。
// 单元测试层:每个组件一条断言
import { axe } from "jest-axe";
test("Button 没有无障碍问题", async () => {
const { container } = render(<Button>保存</Button>);
expect(await axe(container)).toHaveNoViolations();
});
// E2E 层:查整页,只让 serious 以上的失败
import AxeBuilder from "@axe-core/playwright";
test("结算页无严重无障碍问题", async ({ page }) => {
await page.goto("/checkout");
const { violations } = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa"])
.analyze();
const serious = violations.filter((v) => ["serious", "critical"].includes(v.impact));
expect(serious).toEqual([]);
});视觉回归测试(截图对比)是唯一能自动发现「样式坏了」的手段,但它也是最容易变成噪音源的一类测试。这一卡讲清楚什么时候值得上、以及怎么让它别乱报。
什么时候值得
- 维护组件库时最值得:一次改动影响所有使用者,人工回归不现实;
- 升级依赖时价值最高:升 Tailwind、升组件库大版本,截图对比能立刻告诉你哪些地方变了;
- 业务页面上性价比较低:内容经常变,误报率高;如果要用,只截关键页面的关键区域。
不稳定(flaky)的六个来源,逐个消除
- 字体加载:等
document.fonts.ready; - 动画与过渡:截图前全局禁用(注入一段 CSS 把 duration 设成 0);
- 时间与随机数:固定时钟、固定随机种子;
- 网络数据:mock 掉,用固定数据;
- 滚动位置与视口尺寸:显式设定;
- 渲染环境差异:本机截图与 CI 截图几乎必然有细微差别(字体渲染、缩放)。解决办法是只在容器里生成基准图,或者用托管服务(Chromatic / Percy)统一环境。
交互测试放在哪一层
- 组件交互:单元测试 + user-event 就够(上一卡);
- 跨页面流程(登录 → 下单 → 支付):E2E(Playwright),数量要少而精——E2E 慢且脆,只覆盖关键路径;
- Storybook 的 play 函数是个折中:在 story 里写交互脚本,既是文档又是测试,还能在真浏览器里跑。组件库项目里性价比很高。
// Playwright 的截图对比:先把不稳定因素全掐掉
test("按钮各状态的视觉", async ({ page }) => {
await page.goto("/iframe.html?id=button--all-states");
await page.addStyleTag({ content: "*,*::before,*::after{animation:none!important;transition:none!important}" });
await page.evaluate(() => document.fonts.ready);
await expect(page.locator("#root")).toHaveScreenshot("button-states.png", {
maxDiffPixelRatio: 0.01, // 容忍极小差异,减少误报
});
});
// Storybook play 函数:文档与测试同一份
export const Opens = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(c.getByRole("button", { name: "打开" }));
await expect(await c.findByRole("dialog")).toBeVisible();
},
};npm run test:visual -- --update-snapshots)在容器里跑,或者直接用托管服务把环境固定下来。测试的价值不在数量而在「失败时能不能告诉你哪里坏了」。这一卡给一套具体的分层建议——它对组件库与业务项目都适用,只是比例不同。
四层与各自的比例
- ① 类型检查(
tsc --noEmit):最便宜的一层,抓的是最多的低级错误。必须在 CI 里跑,因为编辑器的类型检查可能被配置差异掩盖; - ② 单元 / 组件测试:数量最多,跑得最快。组件库里覆盖每个组件的核心交互 + axe;业务项目里覆盖工具函数与关键组件;
- ③ 视觉回归:组件库必备,业务项目挑关键页面;
- ④ E2E:只覆盖三到五条关键业务路径。它最贵最慢最容易 flaky,数量必须克制。
门禁与耗时
- PR 阶段跑 ①②(目标:五分钟内出结果),合并到主干后跑 ③④;
- 加一条体积预算检查(14 章的
size-limit)——它抓的是「有人不小心 import 了整个 lodash」这类问题,成本极低; - flaky 测试要么修要么删。留着一个「偶尔失败」的测试,会训练整个团队养成「重试一下就好」的习惯,从此所有失败都被忽略。这是 CI 体系最常见的死法。
组件库特有的两条
- 发布前的「装一遍」检查(18 章那条
npm pack)应当自动化:在 CI 里 pack → 装进一个 fixture 项目 → 构建 → 跑一个冒烟测试; - 把文档站的构建也放进 CI:文档里的示例代码如果编译不过,说明 API 已经变了而文档没更新。这是让文档不腐烂的最有效手段。
# CI 的四层(示意)
jobs:
fast: // PR 必过,目标 < 5 分钟
- npx tsc --noEmit
- npx vitest run // 含 axe 断言
- npx size-limit // 体积预算
- npx eslint . && npx stylelint "**/*.css"
slow: // 合并后跑
- npx playwright test // E2E + 视觉回归
- npm pack && cd fixtures/consumer && npm i ../../*.tgz && npm run build
# 本地也要能一键跑最快的那层,否则没人会在提交前跑
"scripts": { "verify": "tsc --noEmit && vitest run && size-limit" }test("提交空表单会提示必填") 比 test("handleSubmit validation") 有用得多。因为 CI 失败时你先看到的是名字——一个好名字能让你在打开代码之前就知道坏了什么。从这里到精通:路线图
本页把 UI 库这件事拆成了二十章。这一章收束:按不同处境给出接下来该做什么、哪些知识会长期有效、以及这个领域正在往哪走。
读完不动手等于没读。下面按三种常见处境各给一条可执行的路线,每条都控制在几天的工作量内。
路线 A:手上有个项目要交付
- 第一天:按 17 章的核对清单列出你真正需要的组件与细节要求,砍到三个候选(02 章的判据);
- 第二天:用两个候选各实现同一个最复杂的页面,比较代码量与卡壳次数;
- 第三天:定下来,建好
components/ui/薄封装(01 章)与令牌层(04 章),然后正常开发; - 上线前:跑一遍 05 章的键盘检查与 14 章的体积检查。
路线 B:要建公司的设计系统
- 先做令牌(04 章):这一层的收益最大且最独立于技术选型,即使以后换库也不作废;
- 再定行为层(02 章):单框架选 Radix / Base UI,多框架选 Ark UI;
- 然后决定分发形态(18 章):私有包还是 registry;
- 最后补质量基建(19 章):Storybook + axe + 视觉回归,这三样是设计系统能否长期活下去的关键。
路线 C:被交互 bug 折磨
- 按症状直接跳章:浮层被裁剪/挡住 → 06 章;焦点乱跑、弹窗关不掉 → 07 章;受控失效、值不同步 → 08 与 16 章;动画不播 → 12 章;SSR 报水合错误 → 13 章;
- 每一章的 pitfall 都是按「症状 → 判据 → 修法」写的,可以当排查手册用。
// 三个可以现在就做、十分钟见效的检查
// ① 键盘走查:把鼠标拿开,Tab 走一遍主流程
// 看:焦点可见吗?陷进去出不来吗?弹窗关了焦点回来了吗?
// ② 体积基线:构建一次,记下首屏 gzip 数字,写进 README
$ npm run build
// ③ 无障碍快扫:控制台跑一行,看有多少个没名字的按钮
[...document.querySelectorAll("button, a")]
.filter((el) => !el.textContent.trim() && !el.getAttribute("aria-label"))
.length;这个领域的知识半衰期差异极大。把时间投在长半衰期的那部分,是应对「每年都有新库」的唯一办法。
长期有效(值得深挖)
- 无障碍契约(05 章):ARIA 模式与键盘约定十几年只增不改,换任何库、任何框架都成立;
- 浏览器平台能力:
<dialog>、popover、锚点定位、inert、逻辑属性、@layer、容器查询——平台在持续把库的工作收回去,学平台的收益越来越高; - 层叠上下文、焦点模型、事件流:所有浮层 bug 的根源都在这三处;
- 设计令牌与主题的组织方式(04 章);
- API 设计与组合模式(16、18 章):这是通用的软件设计能力,不限于 UI。
会过期(现查就好)
- 具体库的 props 名与版本号——本页的版本表半年后就该重查;
- 体积数字(14 章的表随版本变化);
- 「今年最流行的样式方案」;
- 判断方法:一个知识如果换个库就不成立,它就属于这一类,不值得背,值得的是知道去哪查。
三个正在发生的变化
- ① 平台在收回浮层与对话框:popover、anchor positioning、可样式化 select、
@starting-style——五年后「为了定位与层级而装库」的理由可能会基本消失,但「为了 ARIA 与键盘」的理由还在; - ② 样式方案向构建期收敛:RSC 与 SSR 的压力让运行时 CSS-in-JS 持续退潮;
- ③ copy-in 与 registry 模式扩散:把源码交给使用者、只维护规范的分发方式,正在从 shadcn 扩散到 Vue / Svelte / Solid 生态。
// 一个判断「这个知识值不值得学」的小测试
// 问自己:换成另一个库,这句话还成立吗?
"模态框打开时必须把外部内容 inert 或 aria-hidden" // ✓ 永远成立 —— 学
"Radix 的 Dialog.Content 接受 onInteractOutside" // ✗ 换库就没了 —— 查
"transform 会创建层叠上下文,困住子元素的 z-index" // ✓ 浏览器规则 —— 学
"antd 6 的一个 Button 是 37.9 KB gzip" // ✗ 下个版本就变 —— 会量就行@starting-style 不支持时就没有动画),核心功能不依赖新特性。这样既能吃到红利,又不会被支持度绑架。把全页的判据压缩成三张清单,可以直接贴进团队的 PR 模板或技术评审文档。
选型时
- □ 产品的视觉要「像我们自己」还是「像个正常后台」(00 章);
- □ 列出真实需要的组件与细节要求,不是组件名(17 章);
- □ 候选库现查 npm 版本、最近发版时间、有没有被改名或废弃(00 章);
- □ 用两个候选各实现一遍最复杂的那个页面(02 章);
- □ 确认样式方案与你的渲染模式兼容(RSC / SSR)(03、13 章);
- □ 确认组件缺口怎么补(日期、表格、上传)(17 章)。
交付前
- □ 拿开鼠标,Tab 走一遍主流程(05 章);
- □ 每个对话框:Esc 能关、焦点归还、有可访问名(07 章);
- □ 每个图标按钮有
aria-label(05 章); - □ 表单:label 关联、错误有
aria-describedby、提交后焦点到第一个错误(08 章); - □ 暗色模式无闪烁、
color-scheme已设(04 章); - □ 首屏 gzip 体积在预算内,看过产物构成(14 章);
- □ axe 无 serious/critical 违规(19 章);
- □
prefers-reduced-motion已处理(12 章)。
长期维护
- □ 业务代码只 import
components/ui/*,有 lint 规则守着(01、02 章); - □ 令牌是单一事实来源,组件不直接用原始色(04 章);
- □ 体积预算写进 CI(14 章);
- □ 依赖升级有节奏,changelog 有人读(18 章);
- □ 每季度重查一次核心依赖的版本与维护状态(00 章)。
// 把最容易忘的三条做成 lint 规则,比清单更可靠
// ① 业务代码不许直接 import 组件库
"no-restricted-imports": ["error", {
patterns: [{ group: ["radix-ui", "@mui/*", "antd"], message: "请从 @/components/ui 引入" }]
}]
// ② 组件样式里不许出现原始层令牌
// stylelint: declaration-property-value-disallowed-list
{ "/color/": ["/var\\(--(blue|gray|red)-/"] }
// ③ 图标按钮必须有可访问名(eslint-plugin-jsx-a11y)
"jsx-a11y/control-has-associated-label": "error"