背景#
这个项目的 UI 体系不追求“开箱即用”,而是更像一套面向自己团队的构建规范:
- 借鉴 Radix UI 的 原语 (Primitive) 思路,拆分出最小职责的组件。
- 组件保持 Headless,只提供行为和结构,样式由调用方掌控。
- 更偏好最新的浏览器能力,利用原生 API 替代额外的 polyfill 与状态同步。
设计原则#
- 状态只保留一份:所有复合组件都由
Root管理状态,其余节点通过 Context 订阅。例如DialogRoot提供open / setOpen / labelId等上下文,DialogContent、DialogClose仅消费。 - 行为原语 > DOM 包装:优先使用
<dialog>、CSS Anchor、requestAnimationFrame等原生特性,把 React 角色缩小到绑定事件和衔接状态。 - 定制优先:所有可视元素都接受
asChild或className,允许调用者无缝换成自己的标签、样式系统。 - 不做兼容性负担:仅支持现代浏览器,省去 polyfill 与复杂判断,把性能预算投入在交互体验上。
原语层 (Primitives)#
Dialog#
DialogRoot负责受控 / 非受控状态,提供closeOnEscape、closeOnOutsideClick等策略开关。DialogContent直接操作原生<dialog>:- 使用
showModal()/show()与close()同步open状态。 - 只监听一次
cancel事件,在closeOnEscape = false时preventDefault()并在下一帧调用showModal()恢复模态,避免额外的keydown兜底。 - 通过
useClickOutside(包装成useClickOutside(dialogRef, handler))处理点击遮罩关闭,和外部onClick合并为一个回调。
- 使用
DialogTrigger、DialogClose只关心“触发开关状态”这件事,视觉表现完全交给调用者。Portal原语提供挂载容器,必要时把内容送到布局外层。
Popover#
PopoverRoot用CSS anchor名称 (--popover-${id}) 记录锚点,hover 模式下用setTimeout控制延迟关闭。- 触发器与内容层同样支持
asChild,可以绑定到任意元素、支持无障碍属性透传。
Headless 的实现方式#
withAsChild#
DialogTrigger / DialogClose / PopoverTrigger 等都通过 withAsChild() 包装:
export const DialogClose = withAsChild(DialogCloseBase, {
handlerProps: ['onClick'],
})
- 如果
asChild为false,就渲染DialogCloseBase自己的<button>。 - 若传入
asChild,则将子元素克隆一次,把onClick、className以及ref合并进去,并遵循 “先执行子元素事件,再判断event.defaultPrevented决定是否继续” 的约定。 - 这样可以把“换标签 + 接管样式”变成零额外成本的操作。
通用工具#
useClickOutside:基于event.clientX/Y与getBoundingClientRect判断是否点在内容外,避免在每个组件里重复写逻辑。Portal:集中管理根节点,SSR 下直接返回null,客户端才执行createPortal。
createComponentContext#
Radix UI 的 createContextScope 给了我很大启发,但我并不需要那么复杂的 scope 机制,于是抽了一个最小的 createComponentContext:
const [DropdownContextProvider, useDropdownContext] = createComponentContext<PopoverContextType>({
name: 'Dropdown',
errorMessage: 'Dropdown 组件必须在 <Dropdown.Root> 内部使用',
})
function DropdownProvider({ children }: { children: ReactNode }) {
const popover = usePopoverContext()
return <DropdownContextProvider value={popover}>{children}</DropdownContextProvider>
}
组件层不再直接复用原语的 Provider,而是“桥接”一层自己的 Provider:
- 保持解耦:UI 层永远依赖
useDropdownContext,哪怕将底层从Popover换成别的实现,也只要改这个桥接层。 - 可以扩展:如果 UI 组件需要新增状态(例如选中项、过滤条件),只需要在 Provider 的 value 上合并即可。
- 错误提示更友好:
createComponentContext会统一抛出带组件名的错误,帮助在开发期定位“漏掉 Root/Provider” 的问题。
使用策略#
- 先挑原语:按照交互行为拆分(如 Dialog、Popover、Dropdown)。
- 再组合 UI 组件:例如
src/components/ui/dropdown会把 Dialog/Popover 等原语封装成项目常用形态,同时继续暴露className/asChild。 - 拥抱最新 API:
<dialog>负责模态体验,避免人为管理焦点锁定。- CSS Anchor 让浮层定位更稳定,减少计算布局。
- 通过
requestAnimationFrame控制节奏,避免布局抖动。
- 样式保持轻量:原语层不写视觉,只在 UI 层提供默认 Tailwind 类,用户可以完整覆盖。
未来拓展#
- 继续关注浏览器对
Popover API、View Transitions、:has()等特性支持,把复杂交互交还给原生。 - 为常用原语补充 Story / 测试场景,确保 headless 特性在各种组合下行为一致。
- 提炼更多通用 hook(例如焦点管理、键盘导航),进一步减少组件内部样板代码。
随着这些原则落地,组件库更像是一套“约定 + 工具箱”,让真正的界面实现保持灵活,同时享受原生 API 带来的性能与一致性。