UI 组件库完整知识体系交互讲解

全景: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>
最贵的错误不是选错库,而是「先用成套库快速搭起来,以后再慢慢改成我们的设计」。这条路几乎总是走不通:成套库的定制上限由它的主题系统决定,改颜色圆角可以,改结构与交互就要跟框架打架——到那一步往往只能整体换掉。
选型先问一个问题就能砍掉一半选项:「这个产品的视觉,是要像我们自己,还是像一个正常的后台就行?」要像自己(有设计师、有品牌规范)→ 无头或 copy-in;只要正常 → 成套库,别折腾。

「哪个库好」没有答案,「在我的框架里、我的流派下有哪些候选」才有。下面这张表是今天的全部候选池,17 章会逐格展开每个库的性格。

主流候选池

框架无头 / 行为层成套 / 有视觉copy-in 脚手架
ReactRadix UI、Base UI、Headless UI、React Aria Components、Ark UIMUI、Ant Design、Mantine、Chakra UI、HeroUIshadcn/ui
VueReka UI、Headless UIElement Plus、Naive UI、Vuetify、Ant Design Vueshadcn-vue
SvelteBits UI、Melt UISkeleton、Flowbite Svelteshadcn-svelte
SolidKobalte、Ark UIsolid-ui
跨框架Ark UI(Zag 状态机)Web Components 系(Shoelace / Wired)

近两年改过名字的,别搜错

  • Radix Vue → Reka UIradix-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/react
「star 多、教程多」是滞后指标。前端 UI 库淘汰得快,而搜索引擎与模型的记忆更新更慢——radix-vue 已改名 reka-ui@nextui-org/react 已 deprecated 提示改用 @heroui/react,旧包名却仍占着搜索结果前排。
除了 star 数,看两个更有信息量的指标:① 最近一次发版距今多久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 路线图
别把本页当「选型答案表」照抄。这个领域没有一个选择能同时最优:Radix 生态最大但更新慢过一阵、Base UI 最新最干净但生态还小、React Aria 无障碍最强但 API 最重——先确定自己受哪一条约束,再回表里挑。
本页的体积与行为数字都是在这台机器上量出来的:体积用 Vite 8 生产构建(gzip 后),行为用真 Chrome 驱动键盘鼠标观察 DOM。体积会随版本变,量法不会——照着量法自己复核比信数字重要。

上手:把两种流派各跑一遍

这一章不讲理论,只做一件事:在半小时内把「无头库」和「成套库」各跑通一次,看清它们的手感差异。跑完你会明白为什么无头库第一眼像坏了,也会明白成套库为什么当天就能交付。

两条路线共用同一个起点:一个空的 Vite 项目。分岔发生在第二步——无头路线要先把样式方案装好,成套路线只需注册一次。

共同的第一步

  • npm create vite@latest my-ui -- --template react(Vue 换 --template vue)→ npm installnpm run dev

然后分岔

  • 路线 A:无头 + Tailwind——装 tailwindcss @tailwindcss/vite radix-ui,vite 配置里加插件,CSS 入口只需一行 @import "tailwindcss";Tailwind 4 起不再需要 config 文件);
  • 路线 B:成套库——装 element-plusapp.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 4 的项目里照抄 v3 教程的 @tailwind base; 三行。它的表现不是「完全不生效」而是「一部分好使、一部分静默消失」——最难查的那种。v4 只要一行 @import "tailwindcss";
Vite 8 起不再内置 esbuild:用 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: hiddentransform,弹窗就会被裁掉,z-index 无解
无头库的正确期待是「行为对、样式全靠你」。先照文档把定位与遮罩的 class 抄一遍,别怀疑装错了。

上手阶段的坑就那么三类,认得出来能省掉几小时——而最危险的那一类什么都不说

① 会大声报错的:缺 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: nonevisibility: 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;
}
别用「!important 大法」去覆盖成套库的样式。它短期有效,长期会变成一个没人敢删的 overrides.css:库升级后类名一变,你的覆盖有一半静默失效(不报错,只是某些地方变丑),另一半还在生效但已经没有对应元素。正确顺序是先找主题令牌 → 再找组件级 styleOverrides / classNames → 最后才考虑写 CSS,而且写 CSS 时用 @layer 明确优先级(03 章)。
判断一个成套库的定制上限,有个十分钟就能做完的实验:打开它文档站里最复杂的那个组件(通常是表格或日期选择器),用 DevTools 看它的 DOM 结构,数一数有几层 div、类名是不是稳定的、有没有暴露 classNames / slots 这类插槽 API。有插槽 API 说明作者预留了定制口子;只有一堆哈希类名说明「改样式靠猜」。

五家无头库都能给你一个「能用键盘操作的对话框」,但它们在 DOM 上留下的痕迹、默认行为的取舍、以及体积差得相当明显。下面这张表是本机在真 Chrome 里逐条测出来的(React 19.2.8 + Vite 8 生产构建)。

同一个「Dialog + 触发按钮」的对照

维度Radix 1.6.7Base UI 1.6.0Headless UI 2.2.10React Aria 1.19.0
gzip 增量(相对空白 React 应用)+12.0 KB+19.1 KB+17.9 KB+21.9 KB
状态属性data-state="open"data-opendata-headlessui-state + data-opendata-rac + data-focused
Portal 落点直接挂到 body自建一个 div 挂 body#headlessui-portal-root自建 div 挂 body
打开后焦点落在弹窗内第一个可聚焦元素弹窗内第一个可聚焦元素对话框容器本身对话框元素本身
aria-modal不用不用true不用
屏蔽外部内容的方式aria-hiddenaria-hiddeninert + aria-hiddeninert
滚动锁的落点bodyoverflow:hidden + data-scroll-locked + 补 paddingbody overflow + htmlscrollbar-gutterhtml:overflow + 补 paddinghtml:overflow + scrollbar-gutter
点外部默认关闭(要 isDismissable
Esc 关闭 / 焦点归还触发器是 / 是是 / 是是 / 是是 / 是

怎么读这张表

  • 四家在「该做对的事」上全部做对了——Esc、焦点陷阱、焦点归还、屏蔽外部内容,无一例外。差异在实现手段而不是正确性;
  • aria-modal 只有 Headless UI 用。这不是谁错了:把外部内容 inertaria-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>
别按「star 数」在这五家里选,按「你需要的那个组件它有没有」选。最典型的缺口是日期选择器:Radix 没有官方 DatePicker(社区方案质量参差),React Aria 有而且是全场最强(内置多历法与国际化),Base UI 与 Headless UI 也都没有。先把项目要用的组件列成清单,再去逐个对照文档的组件目录——这一步能在半小时内淘汰掉一半候选。
跨框架团队请重点看 Ark UI:它把交互逻辑写成框架无关的状态机(Zag.js),再为四个框架各生成一层薄绑定。这意味着 React 组和 Vue 组能共享同一份「行为规格」与同一套 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   # 人工判断哪些改动要留
shadcn 不是「无成本的 Radix」。它复制进来的每个文件都带着一套具体的样式取舍与依赖(cvaclsxtailwind-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 上设,关闭顺序不同就会残留一个锁不掉的页面)。症状是「关掉弹窗后页面滚不动了」——排查时直接看 htmlbody 的行内样式;
  • ③ 样式作用域:成套库的全局重置样式(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 也能赢 */
}
两个库的「点外部关闭」会互相误伤。典型现场:Radix 的下拉里放了一个 Element Plus 的日期选择器,点日期面板时下拉直接关掉了——因为日期面板被 portal 到了 body,落在 Radix 认定的「外部」。解法是告诉外层「这块也算里面」:Radix 系用 onPointerDownOutside / onInteractOutside 里判断 event.targetpreventDefault(),或者把内层弹层的 appendTo / getPopupContainer 指回外层容器。浮层嵌浮层时这个问题几乎必然出现,06 章有完整讨论。
排查「弹窗被挡住」时,别先调 z-index。先在 DevTools 里从弹窗往上找,看有没有祖先元素带 transformfilterbackdrop-filterwill-changecontain 或非 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";
薄封装挡不住「概念泄漏」。就算所有 import 都收敛了,库的心智仍会渗进业务代码:Radix 的 asChild、antd Form 的 name 路径、MUI 的 sx 属性,一旦在业务里用开,换库时照样要逐处改。薄封装能收敛 import,收敛不了 API 风格——所以真正想留退路时,封装层要连这些概念一起挡在外面(代价是封装变厚,又违反了 01 章那条「别写厚封装」)。这个矛盾没有完美解,只能按项目寿命权衡。
给薄封装加一条 lint 规则,比口头约定管用得多:用 ESLint 的 no-restricted-imports 禁止业务目录直接 import 组件库,只允许 @/components/ui/*规则写十分钟,能守住三年——否则总有人在赶工期时直接 import 一下,一年后就是几百个触点。

样式方案:把视觉接到组件上

无头库把样式的决定权还给了你,随之而来的是一个必须回答的问题:这些样式写在哪、怎么被覆盖、怎么随状态变化。这一章比较五种落地方式,并讲清楚 data 属性驱动这套现代无头库的通用写法。

这几年样式方案的格局有过一次明显的换代:运行时 CSS-in-JS 退潮,Tailwind 与「零运行时」方案上位,直接原因是服务端渲染与 React Server Components 对「运行时生成样式」很不友好。

五种方案的定位

方案代表样式在哪生成适合
原子类Tailwind 4构建期扫描类名生成 CSS与无头库配合、追求「样式随组件走」
CSS ModulesVite / Next 内置构建期,类名加哈希喜欢写常规 CSS、要作用域隔离
运行时 CSS-in-JSEmotion、styled-components浏览器里运行时插入存量项目;新项目要慎重
零运行时 CSS-in-JSvanilla-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">      // 工具类
别在新项目里默认选 styled-components / Emotion。它们没有「坏掉」,但代价已经和当年不同:运行时插入样式在 SSR 下要做样式提取(配置繁琐、每个框架一套)、在 RSC 下直接不可用、首屏还多一次样式计算。存量项目继续用没问题,新项目请把它当成「需要理由才选」的方案,而不是默认选项。
不确定选哪个时,有个稳妥的默认组合:「无头库 + Tailwind + CSS 变量做令牌」。它同时满足三件事——样式与组件在同一个文件里(改起来快)、令牌是原生 CSS 变量(设计系统能跨技术栈复用)、构建期出 CSS(SSR / RSC 全兼容)。这也是 shadcn 走的路线,它之所以流行不只是因为好看。

无头库不给你样式,但会给你一套可以挂样式的钩子:把组件的每个状态写成 DOM 属性。理解这一套之后,「怎么给无头组件写样式」就变成了一个纯 CSS 问题。

各家的属性命名(抓的真实 DOM)

状态RadixBase UIHeadless UIReact Aria
展开 / 收起data-state="open" / "closed"data-open(关闭时属性消失)data-open + data-headlessui-statedata-open="true"
选中data-state="checked"aria-selected="true"data-selecteddata-selected="true"
键盘高亮项data-highlighteddata-highlighted(并把 tabindex 设为 0)aria-activedescendant 指向data-focused / data-focus-visible
禁用data-disableddata-disableddata-disableddata-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" />
动画退场时 DOM 已经被移除了。data-[state=closed]:animate-out 看起来很美好,但如果库在状态变成 closed 的同一帧就卸载了节点,动画根本没机会播。各家给了不同的解法:Radix 的 forceMount + 自己控制、Base UI 的 keepMounted、Headless UI 的 transition 属性、以及通用的 animate-presence 方案。12 章整章在讲这道坎,它是无头库里最常见的「样式写了没效果」。
写样式前,先把组件在各个状态下的 DOM 抓出来看一眼:DevTools 里点开组件 → 展开/选中/禁用各操作一次 → 观察属性怎么变。这比读文档快,也更可靠(文档常常漏写某些内部属性)。本卡那张表就是这么抓出来的,你完全可以对自己用的库做一遍同样的事。

「我传了 className,但样式没生效」是无头库用户最常问的问题。它其实是三个不同的问题穿着同一件外套。

题一:组件到底透不透传

  • 无头库基本都透传 className 与其余 DOM 属性到它渲染的那个元素上,但多层结构的组件只有部分节点能被你摸到(比如 Select 的 Trigger / Content / Item 各是一个可传参的组件,中间的 Viewport / Positioner 有时不暴露);
  • 成套库则各有各的插槽 API:MUI 的 sx / slotProps、antd 的 classNamesstyles、Mantine 的 classNames先查它有没有插槽 API,再考虑写 CSS 选择器往里钻——靠后代选择器钻进第三方内部结构,是最脆的一种写法。

题二:两个 Tailwind 类打架时谁赢

  • CSS 里胜负由规则在样式表中的先后决定,与你在 class 属性里写的顺序无关。所以 <Button className="p-8"> 覆盖组件自带的 p-4 时,赢的可能是 p-4
  • 这正是 tailwind-merge 存在的理由:它识别出 p-8p-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; } }   /* 靠后的层赢,哪怕选择器更弱 */
Tailwind 4 只会生成它在源码里「看得见」的类名。动态拼接的类名("text-" + color)在构建期扫不出来,产物里根本没有那条规则——表现是「本地开发好好的,某些颜色上线后没样式」(因为开发时可能被别的文件里的同名类救了)。正确做法是把完整类名写成常量映射:const map = { red: "text-red-500", blue: "text-blue-500" }
调试样式冲突时,DevTools 的 Styles 面板会把被覆盖的规则加删除线并标出所属的 @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; }   /* 这条会赢过上面所有层里的规则 */
不要用「把我的 CSS import 放到最后」来解决优先级问题。它在开发服务器上有效(因为顺序是稳定的),在生产构建里可能失效(因为 chunk 顺序变了),于是形成最棘手的一类 bug:本地怎么都复现不了。看到「本地正常线上不对」且是样式问题,先去看 @layer 与 chunk 拆分,别去看代码逻辑。
想验证生产构建里的 CSS 顺序是否符合预期,别靠肉眼看 devtools:构建一次,直接打开产物里的 CSS 文件搜关键类名,看它们的先后。加 @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-),只允许语义层。
OKLCH 而不是 HSL 来定义色板。它的亮度分量 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);
CSS 变量在 @media 查询条件里不能用。@media (min-width: var(--bp-md)) 是无效的——变量在层叠计算阶段才被解析,而媒体查询在更早的阶段就要求确定值。断点必须写成常量(或者靠构建工具生成)。同理,变量也不能用在 @import 的 URL、选择器名里。「变量到处都能用」这个直觉在这几个位置会失效,且报错方式是「整条规则被静默忽略」。
局部主题是 CSS 变量最被低估的能力:在任意容器上重新定义语义层变量,那一块就换了主题。「深色的顶部横幅」「预览区域用客户的品牌色」这类需求,用 <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)";
}
别用「JS 计算颜色然后 setProperty 几十个变量」来实现换肤。它在首屏会有一段没颜色的空窗,在 SSR 下会水合不匹配,而且每加一个派生色就要改 JS。派生交给 CSS 的 color-mix() / 相对颜色语法,JS 只负责写入那一个源头变量——这条边界划清楚,换肤功能的复杂度会掉一个数量级。
多品牌项目建议把「令牌」从代码里独立出来,做成一个单独的包(或一份 JSON),由构建流程生成各品牌的 CSS。这样设计师改令牌不需要动组件代码,组件代码也不需要知道有几个品牌——Style Dictionary 就是干这件事的标准工具,下一卡展开。

令牌真正的价值在跨越设计与工程的边界。如果设计稿里的变量和代码里的变量是两套人手工同步的东西,它们一定会漂移——问题只是多久。

今天可行的链路

  • 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);
}
别把「间距 / 圆角 / 字号」也一股脑塞进 Figma 变量再导出,除非设计师真的在维护它们。常见的失败是:颜色确实由设计师维护,而间距是前端拍脑袋定的——把后者也塞进这条链路后,前端改一个间距要去 Figma 里改,然后跑生成、提 PR。令牌链路的适用范围应当只覆盖「设计侧真正在做决定」的那部分,其余留在代码里更快。
在生成的 CSS 文件顶部写一行醒目的「此文件由 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 藏起来。
Chrome DevTools 里有个几乎没人用但极好用的面板:Elements → 右侧 Accessibility 标签页。它直接显示这个元素计算出来的 Name、Role、以及无障碍树上的父子关系。怀疑任何无障碍问题时,先看这三行——比读规范快一百倍,本页多处结论就是这么得到的(只不过用 CDP 批量跑了一遍)。

模态框的核心不是「盖了一层灰」,而是「外面的东西对所有输入方式都不再存在」。视觉上有遮罩,键盘上要有焦点陷阱,读屏器上要屏蔽——三者缺一,模态就是假的。

三种技术手段

  • 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-styletransition-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 写死成 falsearia-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 条记录";
「加了 ARIA 属性」经常比「什么都不加」更糟。WebAIM 每年扫一百万个首页,结论一直是同一个方向:使用 ARIA 的页面平均检出的错误比不用的更多。原因不是 ARIA 有害,而是它只承诺改变语义、不承诺任何行为,于是半吊子实现制造出「读屏器以为能用、实际不能用」的元素。纪律:要么完整实现某个 role 的全部键盘契约,要么就别声明那个 role。
想知道自己的页面在读屏器里是什么体验,不必先学 NVDA/VoiceOver 的全部快捷键。先用 Chrome 的「Accessibility Tree」全树视图(DevTools 右上角菜单 → More tools → Accessibility)扫一眼:结构是否合理、有没有一堆无名的 button、有没有整块内容莫名其妙消失。大部分问题在这一步就现形了,真上读屏器是最后一步而不是第一步。

浮层与定位:下拉、气泡、提示

下拉菜单、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 是为了避免亚像素模糊与重排)。
flipshift 的顺序很重要,而且是「先 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} />  // antd
overflow: 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(输入型):组合框、日期输入。要注意区分 focusfocus-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 秒,无法配置)、样式完全不可控、触屏设备上根本不显示、部分读屏器会把它和可访问名混在一起读两遍。它唯一合适的场景是给已有可访问名的元素补充「额外说明」,而不是当作提示的主要载体。
tooltip 里永远不要放交互元素(链接、按钮、可复制文本)。这不是风格问题:hover 触发的浮层在触屏上打不开、键盘上够不着,而且移动鼠标进去的路径本身就不可靠。需要交互就改用 popover(click 触发)——这也是 Radix 把 Tooltip 与 Popover 做成两个组件、且文档反复强调这一点的原因。

下拉里放选择器、对话框里开菜单、菜单里再展开子菜单——嵌套浮层是 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") 是错的。三个原因:① 点击事件在 mousedownmouseup 之间如果 DOM 变了就可能丢失;② 在浮层内按下鼠标、拖到外面松开会被误判为外部点击(选中文本时高频发生);③ 触屏与手写笔走的是 pointer 事件。正确做法是监听 pointerdown 并检查 event.composedPath()(这样才能穿透 Shadow DOM)——各家库都是这么做的,自研时务必抄对。
调试嵌套浮层时,先把「点外部关闭」整个关掉(Radix 里给 Content 传 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 关闭)的完整来回;
  • ④ 屏蔽外部inertaria-hidden(05 章的对比表)。

初始焦点该给谁:一条实用规则

  • 默认给「最安全的那个元素」——不是「确认删除」按钮,而是「取消」或对话框本身。危险操作绝不能成为默认焦点,否则用户习惯性按空格就删了数据;
  • 表单类对话框给第一个输入框;纯确认类给取消;内容很长的给对话框容器(让读屏器从标题开始读);
  • 各库都提供 autoFocusinitialFocus 一类的口子,需要时显式指定,别依赖默认

三个容易漏的边角

  • 对话框里的内容是异步加载的:打开时里面还没有可聚焦元素,焦点无处可去。解法是先把焦点给容器,加载完再移;
  • 对话框内容比屏幕高:要让对话框内部滚动而不是页面滚动,否则滚动锁与内容滚动会打架;
  • 嵌套对话框:第二层打开时第一层要变成「外部」。同一个库内部会处理,跨库不会(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 里聚焦表格容器或下一行)。凡是「操作会让触发器消失」的对话框,都要单独处理这一条。
验证这四件事只要三十秒,而且不需要任何工具:用键盘打开对话框 → 按 Tab 转一圈看会不会跑出去 → 按 Esc → 看焦点回没回到触发按钮。这个手工检查比任何自动化断言都灵敏,因为它同时覆盖了四条契约。把它写进你们的 PR 检查清单,成本一行字。

对话框打开时页面不该还能滚。实现这件事有三种做法,它们在「布局会不会跳一下」这个细节上表现不同——这是用户能感知到但说不清的那类瑕疵。

为什么会跳

  • 桌面浏览器的滚动条占据布局宽度(约 15px)。给 overflow: hidden 之后滚动条消失,页面可用宽度突然变宽,所有内容向右挪了十几像素
  • 所以锁滚动必须同时补偿这个宽度。两种补法:加 padding-right(传统)或 scrollbar-gutter: stable(现代,提前预留出槽位)。

四家库的实际做法

锁在哪补偿方式留下的痕迹
Radixbodybody 加 padding-rightdata-scroll-locked="1"pointer-events: none
Base UIbody overflowhtmlscrollbar-gutter: stable行内 overflow: hidden
Headless UIhtmlhtml 加 padding-right行内 overflow + padding
React Ariahtmlscrollbar-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); }
Radix 在锁滚动期间给 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
Radix 把 DialogAlertDialog 分成两个组件,不是为了凑数:role="alertdialog" 会让读屏器在打开时立刻朗读内容(普通 dialog 只读标题),而且它强制要求有明确的确认与取消动作。破坏性操作用 AlertDialog,其余用 Dialog——这个区分在各家库里名字不同但普遍存在(Element Plus 是 ElMessageBox,antd 是 Modal.confirm)。

表单:受控、校验与提交

业务代码里一半的时间花在表单上。这一章讲清楚受控与非受控的真正分界、组件库的表单控件怎么和原生 form 打通、校验该放在哪一层,以及错误提示怎么写才对读屏器有效。

这对概念被讲滥了,但真正的判据只有一句:状态的唯一事实来源在组件外面(你手里)还是在组件内部(DOM 或它自己的 state)里。

三种形态

  • 非受控:只给 defaultValue,值存在 DOM 里,提交时用 FormData 或 ref 读。性能最好(每次输入不触发 React 重渲染),代码最少;
  • 受控value + onChange 成对出现,每次输入都过一遍你的状态。需要联动时才必要(一个字段影响另一个、实时格式化、跨组件同步);
  • 混合(组件库的常见做法):库内部维护状态,同时接受可选的 value —— 传了就受控,不传就自己管。Radix 系的 open / defaultOpenvalue / 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 用旧值重渲染,浏览器把光标放到了字符串末尾——用户在中间插字时每输入一个字符光标就跳一次。判据:只在「输入中间位置」时出现。解法是输入框的值必须同步更新(异步的事情另存一份状态),或改用非受控 + 防抖读取。
Vue 侧的对应关系值得单独记一下:v-model 展开就是 :model-value + @update:model-value本质是受控。想要非受控效果(不想每次输入都进响应式系统)在 Vue 里反而要刻意为之——直接用 ref 拿 DOM 或者用 v-model.lazy(change 时才同步)。Vue 生态里表单性能问题比 React 少,代价是默认就走了受控这条路。

无头库的 Select、Checkbox、Switch 渲染出来的都是 divbutton它们不是原生表单控件,默认不会出现在 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 提交。白拿三样东西:回车提交、浏览器自动填充(尤其是密码管理器,它认的是 formautocomplete 属性)、以及 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(决定键盘)、inputmodeautocompletemaxlengthrequired 要不要保留取决于你是否接受浏览器的默认气泡——多数设计系统会加 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" + 自己校验
即使全用 JS 校验,也请保留 typeinputmode它们决定移动端弹出哪种键盘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 的规矩,这里同样适用)。
错误文案要说明怎么改而不只是哪里错:「密码至少 12 位」比「密码格式不正确」有用得多;「这个邮箱已注册,去登录?」比「邮箱重复」有用得多。这条不需要任何技术投入,但它对表单完成率的影响大于所有交互优化的总和。

表单一旦超过二十个字段、带上「可增删的明细行」,两类问题会同时出现:输入卡顿状态管理失控。这一卡讲这两件事的成因与对策。

为什么会卡

  • 受控表单里,每敲一个字符都会让持有状态的那个组件重渲染。如果状态放在表单根组件上,整棵子树(可能是几百个节点)跟着重算;
  • 三种解法,效果依次递增:① 把状态下沉到最小的子组件;② 用非受控 + 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>
表单状态别塞进全局状态管理器。把 Redux / Pinia 当表单状态用会带来三个后果:每次输入都触发全局订阅者、离开页面要记得清理否则脏数据串到下一次、以及无法利用任何表单库的优化。表单状态是天然局部的——除非确实要跨路由保留草稿,那也应该是「离开时序列化一份草稿」而不是「实时同步进全局 store」。
长表单值得考虑「分步 + 每步独立校验」而不是一页到底:每步的 schema 是完整 schema 的一个切片(zod 的 .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>
把焦点移进 Combobox 的选项里,会让输入框「失去」焦点,用户就打不了字了。这是自研组合框最常见的错误,症状是「打两个字之后键盘就没反应了」。规范里对此的解法就是 aria-activedescendantDOM 焦点不动,只用属性告诉辅助技术「当前高亮的是哪一项」。反过来,不可输入的 Select 用哪种模式都行——这就是为什么各家库在两类组件上会做出不同选择,而这并不是不一致。
发现的一个细节值得学:React Aria 的 ComboBox 会给弹出的列表加一个 aria-label,内容是当前浏览器语言下的「建议」二字(中文环境就是「建议」)。这说明它内置了一整套本地化字符串表——对国际化项目是实打实的省事,15 章会展开这一点。

在一片自定义下拉里,<select> 常常被当成「土」的代名词。但它有三个别人给不了的东西,而且移动端的差距特别大。

原生 select 的三个优势

  • ① 移动端调起系统选择器:iOS 的滚轮、Android 的原生列表——大屏手指操作友好、支持系统级的辅助功能、不会被输入法遮住。自定义下拉在移动端的体验普遍不如它
  • ② 零成本的正确性:键盘、读屏器、表单集成、首字母跳转全部免费且永远正确;
  • ③ 零体积:没有任何 JS。

它的真实限制

  • 选项内容只能是纯文本:放不了图标、两行描述、头像。这是最常见的换库理由;
  • 弹出层样式不可控<option> 的样式支持极其有限(各浏览器不同);
  • 不能搜索、不能多选出标签multiple 的原生外观基本不能用于产品)。

一个正在变化的事实

  • CSS 正在推进可定制的 selectappearance: 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 节点是几百毫秒。结论几乎总是「先虚拟化,别急着优化过滤算法」——这个判断顺序反了会白干很多活。
搜索结果的排序比搜索本身更影响体验:前缀匹配应当排在包含匹配前面(搜 "an" 时 "Anna" 应该在 "Brian" 之前),完全匹配置顶。这条规则用三行代码就能实现,效果远好于直接把后端返回的顺序摆上去。如果用模糊匹配(fuzzy),务必把匹配到的字符高亮出来,否则用户不理解为什么这一条会出现。

日期选择器是所有组件里唯一一个「实现难度主要不在 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 inputFormData.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>
多选下拉「选中后立刻关闭」是最招人烦的交互。用户要选五项就得开合五次。多选面板应当保持打开,直到用户明确关闭(点外部、Esc、或一个「完成」按钮)。反过来,单选选中后应该立刻关闭。这两个默认行为在各库里都能配(Radix 的 onOpenChange、antd 的 open 受控),但默认值不一定符合你的场景,务必自己试一遍
标签输入里的「创建新项」要有明确视觉提示:列表最上方显示「创建 "xxx"」这一条,而不是让用户猜「打完回车会怎样」。并且要处理重复——输入一个已存在的值时应该高亮已有项而不是创建重复项。这两条是标签输入组件最常见的产品缺口。

表格与虚拟化

表格是后台系统的主角,也是最容易被组件库绑架的地方。这一章把表格拆成数据层、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>
别把「每行一个组件 + 每个单元格一个组件」当成默认结构。一千行 × 八列 = 八千个组件实例,React 侧的调和成本会直接体现在滚动卡顿上。先用普通元素渲染,确有复用需求时再抽组件;单元格里的重逻辑(格式化、条件样式)应当在数据层算好,不要每次渲染都算。这类性能问题在开发时(几十行假数据)完全看不出来。
列定义应当是数据的属性而不是 UI 的属性:把 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-onlyth 可以去掉粗体和居中),而不是换标签。
空状态、加载态、错误态是表格的三个「非快乐路径」,而它们出现的频率远高于设计稿的估计。空状态要区分「本来就没有数据」和「筛选后没有结果」——后者应当提示「清除筛选」。加载态优先用骨架屏保持表格高度,避免加载完成时页面跳动(这会导致误点击)。

反馈与导航: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 Plus ElMessage)用起来最省事,但要留意它们是否满足上面第 ①③④ 条——命令式 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; }
}
把 Toast 渲染在 <body> 下但没管 pointer-events,会在对话框打开时变得不可点。07 章那条 Radix 滚动锁的 pitfall 在这里现形:Radix 打开对话框时给 body 加了 pointer-events: none,你的 Toast(属于另一个库)就整个点不动了,但看得见。症状是「弹窗开着的时候 Toast 上的撤销按钮点不了」,解法是给 Toast 容器显式加 pointer-events: auto
Toast 的显示时长有个经验公式:基础 3 秒 + 每个单词 0.3 秒左右,带操作按钮的至少 6 秒。比这更重要的是——「操作成功」类的 Toast 其实常常可以不要:如果界面上已经能看出结果(列表里多了一行、按钮变成了「已关注」),再弹一个 Toast 是噪音。

Tabs 的 ARIA 模式很成熟,规矩也很少,但「用方向键还是 Tab 键切换」这一条几乎所有自研实现都做反了。

三条键盘规矩

  • ① Tab 键只进出整个 tablist,不在标签间移动:焦点进入 tablist 时落在当前选中的那个标签上,再按 Tab 就跳到面板内容里;
  • ② 方向键在标签间移动(水平 tablist 用 ←→,垂直用 ↑↓),Home / End 跳首尾;
  • ③ 自动激活 vs 手动激活:方向键移动时是立刻切换内容(自动),还是移动焦点、按 Enter/Space 才切(手动)?内容加载慢或很重时必须用手动,否则用户按住方向键会连续触发几次加载。各库都提供了这个开关(Radix 的 activationMode="manual")。

属性关联

  • role="tablist"role="tab"(带 aria-selectedaria-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 })}>
Tabs 与「路由」的边界要想清楚。如果每个面板的内容是独立的数据请求、能被直接链接、有各自的加载与错误态,它其实应该是路由而不是 Tabs——用嵌套路由实现,能白拿代码分割、预加载、后退前进。反过来,纯粹是「同一份数据的不同视图」才适合 Tabs。用 Tabs 硬扛路由的场景,最后都会长出一堆手写的缓存与加载逻辑。
把当前 tab 写进 URL(query 或 hash)几乎总是值得的:刷新不丢、能分享、浏览器后退符合预期。这一条对「设置页」「详情页」尤其明显——用户从别处跳回来时希望还在原来那一栏。成本是三行代码,收益是消除一整类「怎么又跳回第一个 tab」的投诉。

这三个组件最常见的错误是「角色用错」:把导航做成菜单、把折叠面板做成手风琴,语义一错,读屏器给用户的心智模型就全错了。

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-contentallow-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 动画
  • Sveltetransition: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>
动画时长设得太长是最常见的体验错误。界面反馈类动画的合理区间是 120–250ms;超过 300ms 用户就会感觉「这个应用有点慢」。而且这个感觉是累积的——每次点击都多等 200ms,一天下来就是「这系统真卡」。大幅度位移可以稍长(进入 250ms、退出 150ms),退场永远应该比进场快:用户已经决定关掉它了。
分不清该用 transition 还是 animation 时,有个简单判据:元素会不会被卸载。不卸载(只是改 class / 属性)→ transition 更简单;会被卸载 → 用 animation(配合库的 presence)或交给动画库。Radix 的文档里那句「用 animation」不是风格建议,是它的实现要求。

动画性能只有一条硬规则:只动 transformopacity。这两个属性可以完全在合成线程完成,不触发布局与绘制;其它属性都会把主线程拖下水。

代价从低到高

  • 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 动画已经自动优化);
  • 动画期间读取布局属性offsetHeightgetBoundingClientRect)会强制同步布局,把合成线程的优势全部抵消;
  • 大面积 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 秒的动效」有明确要求:必须提供暂停手段。轮播图不给暂停按钮是最常见的违规项之一。
想知道某个动画是否真的走了合成线程:DevTools → Rendering 面板 → 勾选 "Paint flashing",动画期间如果元素一直闪绿色,说明每帧都在重绘。另一个更直接的工具是 Performance 面板录一段,看有没有出现「Layout」与「Paint」的长条。这个检查十秒钟,比任何猜测都可靠。

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 压掉警告是掩耳盗铃。它只让警告消失,两端不一致的事实还在——客户端会用自己的版本覆盖,于是首屏内容闪一下变成另一个。它唯一的正当用途是「明知不可能一致」的场景(比如显示当前时间)。看到自己在加这个属性时,先问一句「这个值为什么两端不同」。
主题切换是水合不匹配的经典重灾区:服务端不知道用户选了暗色,渲染出亮色 HTML,客户端读 localStorage 后变暗色 → 报错 + 闪白。标准解法是 04 章那段内联脚本:它在框架接管之前就把 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.js 的构建输出会列出每个路由的 First Load JS;再细一点可以用 @next/bundle-analyzer 看具体是哪个包贡献的体积。「我以为它在服务端」是 RSC 项目里最常见的误判,而这个检查一分钟就能做完。

Vue 的 SSR 面对的是同一批问题,但工具与叫法不同。把 React 侧的经验平移过来时,这几处对应关系值得记住。

对应表

问题React / NextVue / Nuxt
稳定 iduseId()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 包起来」是个坏习惯。包一层确实不报错了,代价是那块内容完全不参与 SSR:首屏没有它、SEO 抓不到、慢网络下要等 JS 才出现。它应该是最后一招。绝大多数水合问题的正解在前两层——用 CSS 表达差异、或把服务端已知的信息写进 HTML 属性。
<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 KB0
Radix UI 1.6.7223.3 / 69.9 KB0+12.0 KB
Headless UI 2.2.10241.0 / 75.9 KB0+17.9 KB
Base UI 1.6.0245.2 / 77.1 KB0+19.1 KB
React Aria Components 1.19.0256.0 / 79.8 KB0+21.9 KB
MUI 9.2.0314.9 / 100.3 KB0+42.3 KB
Mantine 9.5.0283.0 / 86.3 KB225.4 / 33.2 KB+61.5 KB
Chakra UI 3.36.1482.0 / 134.5 KB0+76.5 KB
Ant Design 6.5.2413.1 / 134.7 KB0+76.7 KB

固定成本 vs 边际成本(同样是)

场景gzip 总量相对基线
Radix:一个 Checkbox63.0 KB+5.0 KB
Radix:八个组件(Dialog/菜单/Tabs/Tooltip/Select/Checkbox/Switch/Accordion)95.8 KB+37.8 KB
MUI:一个 Button92.0 KB+34.0 KB
MUI:九个组件138.6 KB+80.6 KB
antd:一个 Button95.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 后的真实增量差别可能很大 —— 以自己构建的结果为准
「按需引入」不会降低固定成本。很多人以为只用一个 antd 组件就只付那个组件的钱——一个 Button 就要 37.9 KB gzip,因为主题引擎、样式生成器、Context 是整套的。成套库的正确心智是「先付入场费,之后每个组件很便宜」:这也解释了为什么它们适合组件用量大的后台系统,而不适合「只想加一个下拉」的落地页。
量体积时一定要看「增量」而不是「总量」:一个空 React 应用本身就有 58 KB gzip,如果拿总量比,会得出「Radix 的对话框有 70 KB」这种严重误导的结论。正确做法是先构建一个不含库的基线,再逐个加库对比。本卡所有数字都是这么得到的。

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 SlotasChild 的实现+1.2 KB
tailwind-merge 3.6.0解决 Tailwind 类名冲突+8.0 KB
四者合计(一个 shadcn 风格的 Button)+9.7 KB

tailwind-merge 之所以这么大,是因为它内置了一张Tailwind 全部工具类的冲突分组表——要知道 p-4px-2 冲突、text-smtext-red-500 不冲突,就得认识所有类名的语义。

什么时候可以不要它

  • 组件不接受外部 className 覆盖时(内部设计系统常见):直接用 clsx 就够;
  • 用 CSS layers 解决优先级时:把组件基础样式放在靠前的层,外部覆盖放靠后的层,顺序问题在 CSS 层面解决,不需要在 JS 里删类名
  • Tailwind 4 的 @utility 与层机制让这条路比以前好走。不过要提醒:这是优化,不是必须——8 KB 在多数项目里不值得为此增加复杂度。

顺带图标库

  • lucide-react 1.27:一个图标 +0.6 KB gzip,三十个图标 +2.7 KB——tree-shaking 完全有效,平均每个图标约 70 字节(gzip 后);
  • 所以图标体积不是问题,前提是具名 importimport { 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 KB022.8 KB
Element Plus:app.use(ElementPlus)942.6 / 301.1 KB348.1 / 46.6 KB347.7 KB
Element Plus:具名引入 + 按组件引样式125.7 / 46.8 KB30.6 / 4.6 KB51.4 KB
Element Plus:unplugin 自动按需125.7 / 46.8 KB30.7 / 4.6 KB51.4 KB
Element Plus:按需引入十个组件(含 Table)343.6 / 117.7 KB103.6 / 14.1 KB131.8 KB
Naive UI:app.use(naive)1327.6 / 358.3 KB0358.3 KB
Naive UI:具名引入198.9 / 65.1 KB065.1 KB
Naive UI:unplugin 自动按需199.0 / 65.2 KB065.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";
「我配了 unplugin 所以体积没问题」是个危险的自信。证明插件不比手写 import 更小——真正决定体积的是你有没有在某处全量注册。而全量注册最容易藏在两个地方:老代码里的 main.js、以及某个「为了方便」的全局注册文件。每次发版前跑一次产物分析,看有没有整个库出现在依赖图里,比任何配置都可靠。
Vue 应用的基线本身就比 React 小(22.8 vs 58.0 KB gzip)。这个差距主要来自 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" 字段
过度分割比不分割更糟。把每个组件都拆成独立 chunk 会导致:请求数暴涨(HTTP/2 下也有开销)、模块图变深形成瀑布式加载、公共依赖被重复打进多个 chunk。合理的粒度是「按路由分 + 少数几个重型组件单独分」,而不是「按组件分」。判据:打开 Network 面板看首屏发了多少个 JS 请求,超过十几个就该合并了。
优化体积之前先看一眼产物构成npx vite-bundle-visualizerrollup-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 / rightmargin-inline-start / end
padding-left / rightpadding-inline-start / end
left / rightinset-inline-start / end
text-align: lefttext-align: start
border-leftborder-inline-start
width / heightinline-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>
文案长度是国际化最容易出错的地方,而它和 RTL 无关。德语单词常常比中文长 2–3 倍(「设置」→「Einstellungen」),俄语也很长。固定宽度的按钮、单行不换行的标签、精确对齐的表格列,全都会在翻译后崩掉。防守方法:设计阶段就用最长的语言测一遍,或者在开发环境提供一个「把所有文案加长 40%」的伪本地化开关——这个开关能在上线前发现 90% 的布局问题。
即使你的产品短期内不做阿拉伯语,也建议直接用逻辑属性:写法一样长、浏览器支持早已完备,而且它天然表达了「行内起始」这个意图,比 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.Trigger
复合组件对「中间隔了一层」很脆弱。如果你把 Dialog.Content 抽成自己的组件再放进去,Context 仍然能穿透(React Context 不受组件层级限制);但如果你把它渲染在 Root 外面——比如通过 props 传进去、或者在另一个 Portal 里——Context 就断了。症状是「明明写了却报 must be used within」。判据:在 React DevTools 里看那个组件的 Context 值是不是 null。
写复合组件时,Context 缺失一定要抛错而不是静默降级。一句 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); },
});
valuedefaultValue 同时传是无意义的,而且多数库不会报错。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 / component prop<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 了库的就不跑;
  • classNamestyle 要合并而不是覆盖;
  • ref 要转发到最终的 DOM 元素——这是 Slot 实现里最容易错的一处;
  • 其余属性子元素优先(你写的 id 应该赢过库生成的)。这四条弄反任何一条,都会产生「有时好使有时不好使」的诡异行为。

用 asChild 的三个注意

  • 子元素必须只有一个,且必须能接收 props 与 ref:传一个 <>…</> 或一个不转发 ref 的函数组件都会失败;
  • 自定义组件要转发 props 到真实 DOMfunction 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"] { … }
别为了「以后可能换框架」而选跨框架方案。换框架时真正的成本从来不是 UI 库,而是路由、状态管理、构建体系与团队习惯。Ark UI 的价值在「现在就同时有两个框架」,不在「未来可能换」。为一个不确定的未来支付确定的抽象成本,是选型里最常见的亏本买卖。
即使不用 Ark UI,「把交互逻辑与渲染分开」这个思路也值得借鉴:自己写复杂组件时,先用一个 reducer(或 useReducer 的 action 表)把状态转移写清楚,再写渲染。好处是当交互出 bug 时,你可以只看那张转移表,而不用在 JSX 和事件处理里来回跳。TanStack Table 是同一思路在数据层的体现。

生态速查:各框架的候选与专项库

把版图收成几张可以直接查的表:各框架的无头库与成套库、按需求反查该用哪个专项库、以及各库的组件覆盖情况。版本为 2026-07-29 现查 npm 的结果。

选型的第一步是把候选池缩小到三五个。下表按框架与流派切分,版本是 2026-07-29 现查 npm 的 latest。

无头 / 行为层

框架版本性格
Radix UIReact1.6.7生态最大、体积最小、shadcn 的地基;无官方日期选择器
Base UIReact1.6.0MUI 团队新作,API 干净,滚动锁等实现最现代;生态年轻
Headless UIReact / Vue2.2.10 / 1.7.23Tailwind 官方,组件少而精,与 Tailwind 配合最顺
React Aria ComponentsReact1.19.0Adobe 出品,无障碍与国际化最强,体积最大
Ark UIReact / Vue / Svelte / Solid5.37.2状态机驱动,唯一真正跨框架
Reka UI(原 Radix Vue)Vue 32.10.1Vue 侧的 Radix 对等物,shadcn-vue 的地基
Bits UISvelte 52.18.1Svelte 侧主力,shadcn-svelte 的地基
MeltSvelte 50.44.0更底层的 builder 模式(老包 @melt-ui/svelte 是上一代)
KobalteSolid0.13.12Solid 侧的事实标准
Angular CDKAngular22.x官方行为层,Material 就建立在它之上

成套 / 有视觉

框架版本适合
MUIReact9.2.0组件最全、生态最大;Material 风格明显,定制要投入
Ant DesignReact6.5.2中后台标配,表单与表格最强;体积最大
MantineReact9.5.0Hooks 丰富、样式用普通 CSS;CSS 是整库的
Chakra UIReact3.36.1v3 基于 Ark UI + Panda,零运行时样式
HeroUI(原 NextUI)React3.2.2基于 React Aria + Tailwind,视觉现代
Element PlusVue 32.14.3中文生态最厚,文档与社区资源最多
Naive UIVue 32.44.1TS 友好、主题灵活、CSS-in-JS 无样式文件
VuetifyVue 34.1.6Material 风格、组件极全
PrimeVueVue 35.0.0组件数量惊人,也有 React/Angular 版
Nuxt UIVue / Nuxt4.10.0基于 Reka UI + Tailwind,Nuxt 项目开箱即用
VantVue 34.10.0移动端专用
Angular MaterialAngular22.0.6官方出品,与 CDK 同源
Shoelace / Material WebWeb Components2.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 deprecated
别按「谁的官网好看」选库。官网是文档团队的作品,不代表你的产品用它做出来会好看——更该看的是「用它做出来的真实产品长什么样」。一个更实际的判据:去它的 GitHub Issues 里搜你最关心的那个组件名(比如 Select),看未解决的 issue 有多少、都是什么性质、维护者回不回。十分钟能看出这个库对你的场景成不成熟。
国内项目还有一批值得考虑的成套库:Arco Design(字节,2.66)、Semi Design(抖音,2.101)、TDesign(腾讯,1.18),三家都同时提供 React 与 Vue 版本,中文文档与设计规范完整。它们的组件目录与 antd 高度重合,选择主要看视觉偏好与团队熟悉度。

很多需求不该由 UI 库解决,而该找对应的专项库。下面这张表按「你要做什么」组织,全部是框架无关或多框架支持的方案。

专项库反查表

需求方案说明
表格的排序/过滤/分组/分页TanStack Table 8.21无头数据层,六个框架的绑定
长列表 / 大表格虚拟化TanStack Virtual 3.14支持动态行高与横向虚拟化
浮层定位与碰撞Floating UI 0.27(React 绑定)几乎所有 UI 库的底层
通知 / ToastSonner 2.0(React)零依赖,API 极简
移动端抽屉Vaul 1.1(React)手势关闭、吸附点,配 Radix Dialog
命令面板cmdk 1.1(React)shadcn 的 Command 组件即它
表单状态与校验React Hook Form 7.83 + Zod 4.4Vue 侧对应 VeeValidate / FormKit
轮播Embla Carousel 8.6多框架,无障碍做得较好
动画Motion 12.43原 Framer Motion,含 AnimatePresence
图标Lucide 1.27tree-shaking 有效(30 个图标 2.7 KB gzip)
图表ECharts / Recharts 3.10 / VisXECharts 框架无关且功能最全
拖拽排序dnd-kit / SortableJSdnd-kit 的键盘可达性更好
富文本编辑Tiptap / Lexical / ProseMirror三者都很重,务必懒加载
日期处理Temporal API / date-fns / dayjsTemporal 是未来,先查目标环境支持度

一条选型纪律

  • 每加一个库,先问「不加会怎样」。轮播可以用 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
# ——总体仍小于一个成套库的入场费 + 常用组件
「一个库能干十件事」通常是缺点而不是优点。把表格、图表、日历、编辑器打包在一起的「全家桶」库,往往每一项都只做到七十分,而且体积与升级风险捆在一起——你只想升级表格的一个 bug 修复,却要连带接受编辑器的破坏性变更。组合多个专精的库,比依赖一个大而全的库更容易长期维护。
专项库比成套库更值得投入学习:TanStack Table、Floating UI、React Hook Form 这类库的知识跨框架、跨项目、跨年份都有效,而某个成套库的 API 只在它自己的版本里有效。如果时间有限,优先把专项库学透,成套库现查文档就够

选型时最实际的一步是拿着自己的组件清单去核对。这一卡列出各类库普遍缺失或质量参差的部分,帮你把核对的重点放对地方。

无头库普遍没有的

  • 日期 / 时间选择器:只有 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
□ 主题:一套语义令牌驱动亮暗两套 + 客户品牌色

# 拿这张表去试两个候选库,各花两小时 —— 比读十篇对比文章有用
别在选型阶段被「组件数量」打动。「200+ 组件」这类宣传里,真正会用到的通常不超过 25 个,而剩下的 175 个仍然要跟着一起升级、一起受破坏性变更影响组件数量的边际价值很快归零,而维护成本是线性增长的——一个组件少但每个都做得深的库,长期体验往往更好。
核对清单里一定要包含一条「最刁钻的需求」——通常是产品经理提过、你觉得「应该能做」的那一个。它往往是决定成败的那条:能优雅实现的库,其它需求基本都不成问题;只能 hack 实现的,后面会持续付出代价。

自建组件库:从内部包到设计系统

一旦你复制了三十个 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
组件库最常见的失败模式是「太早抽象」。只有一个使用者时,你无法知道哪些是共性、哪些是这个产品的特殊需求,于是抽出来的 API 全是猜的。经验法则是「三次法则」:同一个组件在三个地方出现过、且需求确实一致时,才抽出去。在此之前复制粘贴是更便宜的选择——复制的代价是可见的,错误抽象的代价是隐形且持续的。
建库之前先做一件成本极低的事:把现有三个项目里的「按钮」全部截图放在一起。如果它们差异很小(只是颜色略不同),说明统一的收益有限,用一份 CSS 变量就够;如果它们结构与交互都不一样,说明问题在规范而不在代码——先把规范定下来,再考虑代码。这一步能避免大量「建了没人用」的组件库。

第一天就该定下来的一件事:第三方组件与你自己的组件分开放。这个约定很小,但它决定了半年后「换库」是一周还是三个月。

一个够用的目录结构

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} />;
}
别把薄封装写成「厚封装」——最常见的失控是把库的 props 全部转发一遍并逐个改名。这样做的结果是:库升级加了新 props 你要跟着加,团队查文档时官方文档全都对不上号,而收益只有「名字更顺眼」。薄封装的正确姿势是「少数几个自己的 props + ...rest 原样透传」,剩下的让使用者直接查库的官方文档。
cn() 这个工具函数是 shadcn 生态的通用约定:clsx 负责条件拼接、tailwind-merge 负责让后写的 Tailwind 类覆盖先写的(否则 p-2p-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";   /* 否则你的类名一个都不生成 */
组件库里出现两份 React 是最难查的一类错误。症状是 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 而不是 onValueUpdatedisabled 而不是 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" />
别把「配置项」做成组件的 props。主题、默认动画时长、国际化文案这类全局一致的东西应当走 Provider 或 CSS 变量,而不是每个组件都加一个 prop。反面案例的最终形态是:每个组件有四十个 props,其中三十个在所有用法里都传同样的值。判据:如果一个 prop 在 95% 的用法里取同一个值,它就不该是 prop。
给组件加一个 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 里会显示删除线
「反正是内部库,破坏性变更直接发就行」是自欺欺人。内部使用者同样会被打断工作、同样会拒绝升级,最后的结果是三个产品线锁在三个不同的版本上,你要同时维护三条分支——这比对外发布还惨。内部库同样要遵守 semver 与废弃流程,唯一的区别是你可以主动帮使用者提迁移 PR(这也是最有效的一招)。
changesets 管理版本与变更日志:每个 PR 附带一个 markdown 片段说明「这是 patch/minor/major,改了什么」,发版时自动汇总成 changelog 并升版本号。它最大的价值不是自动化,而是强制作者在写代码时就想清楚「这算不算破坏性变更」——这个思考发生在 PR 阶段,而不是发版前一晚。

测试与质量保障

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 没有布局,ResizeObserverDOMRectscrollIntoView 都要 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([]);
});
axe 在组件级测试里会漏掉一整类问题:需要页面上下文的规则。比如「页面必须有一个 h1」「landmark 区域不能重复」「同一页面 id 不能重复」——单独渲染一个按钮时这些规则无从判断。所以组件级通过不等于页面级通过,两层都要跑。反过来,页面级检查里组件的问题会被大量重复上报(一个表格里一百个按钮缺名字 = 一百条违规),所以组件级先清零,页面级才好读。
第一次给存量项目接 axe 时,别指望零违规。正确做法是先记录当前的违规数量作为基线,只要求「不增加」(很多工具支持 baseline 文件),然后按 impact 从高到低逐步清零。一上来就要求全绿,结果通常是整个检查被关掉。

视觉回归测试(截图对比)是唯一能自动发现「样式坏了」的手段,但它也是最容易变成噪音源的一类测试。这一卡讲清楚什么时候值得上、以及怎么让它别乱报。

什么时候值得

  • 维护组件库时最值得:一次改动影响所有使用者,人工回归不现实;
  • 升级依赖时价值最高:升 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();
  },
};
视觉回归测试最大的风险不是误报,是「习惯性批准」。当一次改动产生四十张差异图时,没人会逐张细看,于是「全部接受」,真正的回归就混在里面过去了。对策是把变更做小(一个 PR 只改一件事),以及把截图按组件拆细(每张图只包含一个组件的几个状态),让每次差异都在可审阅的范围内。
视觉回归的基准图必须在 CI 环境生成并提交,不能用开发机的截图当基准——不同操作系统的字体渲染差异会让每次 CI 都全红。多数团队的做法是提供一条命令(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" }
覆盖率数字是最容易被滥用的指标。把门槛设成 80% 之后,团队会开始写「渲染一下不报错」这类无断言测试来凑数——覆盖率上去了,缺陷检出能力没有变化,维护成本反而增加。更有意义的做法是对关键模块要求高覆盖(表单校验、金额计算),对 UI 外壳不设硬指标,并且看「改坏了会不会有测试失败」而不是看百分比
给测试起名字时用「行为 + 预期」而不是「函数名 + 场景」: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;
别把「重构成新库」当成第一个动作。存量项目里最划算的顺序永远是:先补令牌与薄封装(不改任何视觉)→ 再补无障碍与体积基线(不改任何 API)→ 最后才考虑换库,而且是新页面用新方案、老页面不动一次性重写 UI 层的项目,绝大多数会卡在「两套并存」的中间态半年以上——这一条在 00 章说过,这里再说一遍。
如果只能做一件事,做令牌层(04 章)。它成本最低(一个 CSS 文件)、收益最持久(换库、换框架、换设计师都不作废),而且它是暗色模式、多品牌、设计协作三件事共同的地基。相比之下「选哪个库」这个决定的半衰期只有两三年。

这个领域的知识半衰期差异极大。把时间投在长半衰期的那部分,是应对「每年都有新库」的唯一办法。

长期有效(值得深挖)

  • 无障碍契约(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"                   // ✗ 下个版本就变 —— 会量就行
「等平台标准成熟了再说」和「立刻全面采用新特性」都是错的。正确姿势是分层采用:把新平台能力用在可降级的地方(锚点定位不支持时回退到 Floating UI、@starting-style 不支持时就没有动画),核心功能不依赖新特性。这样既能吃到红利,又不会被支持度绑架。
跟进这个领域最高效的方式不是刷「十大 UI 库对比」,而是订阅两三个规范与浏览器的更新渠道(各浏览器的 release notes、Interop 年度项目、WAI-ARIA Authoring Practices 的更新)。库的变化是平台变化的下游——知道平台要给什么,就能提前判断哪些库的设计会过时。

把全页的判据压缩成三张清单,可以直接贴进团队的 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"
清单会腐烂。半年后团队里没人记得为什么有这些条目,于是要么被无视,要么被机械执行(打勾但不真做)。对策是给每一条写上「为什么」的一句话(本页每章的 pitfall 就是这些「为什么」),并且在每次真实事故之后回来更新它——一条从真实事故里长出来的检查项,比十条抄来的更有生命力。
这份清单最好的用法不是「每次交付前逐条检查」(没人会长期这么做),而是把能自动化的都自动化掉:lint 规则、CI 门禁、PR 模板里的三个复选框。剩下确实只能人工的,只保留「Tab 走一遍」这一条——它二十秒、覆盖面最广,而且做过几次之后会变成本能。