- SignalDesk2小时前
一份行为,三种宿主:聊聊 XiHan.UI 这个框架无关的 Headless 组件库 做前端久了,多半碰到过这种局面:后台用 Vue ,官网用 React ,角落里还剩几个没有框架的老页面。三边各有一个弹窗,按 Esc 有的关有的不关,关掉以后焦点有的回到按钮上,有的掉回页面顶部。用鼠标的人察觉不到,用键盘和读屏的人每天都被绊一下。再往后换框架,组件库整套重写,最先丢的正是这些看不见的东西:方向键往哪走,读屏念什么,焦点落在哪。 XiHan.UI 就是冲着这件事做的: 快速、轻量、高效、用心的框架无关 Headless UI 组件库。是面向企业级前端的设计系统运行时,无头内核,多框架适配器。提供基础组件与 AI 组件,覆盖从中后台到 AI 对话的界面构建场景。曦寒懿( XiHanFun )开源生态的前端基座。 下面把它的设计、细节和取舍摊开讲,也包括它现在做不到的地方。文档站每个组件都有能点、能切换框架的真实示例: 文档站: https://ui.docs.xihanfun.com GitHub: https://github.com/XiHanFun/XiHan.UI Gitee: https://gitee.com/XiHanFun/XiHan.UI GitCode: https://gitcode.com/XiHanFun/XiHan.UI 一、先看用起来是什么样 pnpm add @xihan-ui/vue @xihan-ui/tokens @xihan-ui/styles 入口引一次皮肤,组件按名字 import ,不用 app.use() 。所有包都声明了 sideEffects: false ,没用到的组件会被摇掉: 打开对话框 确认操作 确定 打开以后,焦点圈在内容区、Esc 或点遮罩关闭、关闭后焦点回到触发按钮、背后锁住滚动、其余部分对读屏隐藏,这些一行都不用写。React 19 的组件名和写法完全一样。没有框架的页面用自定义元素,结构自己写,用 data-xh-part 标出每个节点的角色: 打开 … 三种写法跑的是同一台状态机、同一份 connect ,差别只在于谁把属性写到 DOM 上。 二、一个组件,五份产物 以对话框为例,它在仓库里落成五处:无头内核(解剖、状态机、键盘规格表、 connect )、Vue 组件、React 组件、自定义元素 、纯 CSS 皮肤。行为只在内核里定义一次,适配器只做三件事:把状态机接进各自的响应式,把部件包成组件,把属性铺到节点上。 全库的地基是解剖:每个部件在 DOM 上由 data-scope 加 data-part 唯一标识,皮肤、测试、诊断都建在这对属性上。皮肤只认属性不认类名,所以同一份 CSS 同时作用于三端: [data-scope='button'][data-part='root'][data-variant='solid'] { … } connect 是内核的出口,一个纯函数:输入状态机服务,输出一组 getXxxProps() 。每个 getter 产出结构标识、ARIA 语义、状态钩子、id 关联和事件处理器五类东西,里面没有一行 DOM 操作: getTriggerProps: item => normalize.button({ ...parts.trigger.attrs, // data-scope + data-part "id": triggerId(item.value), // 由 scope 派生,同页多实例不冲突 "aria-controls": contentId(item.value), "aria-expanded": isOpen(item.value) ? "true" : "false", "data-state": stateAttr(item), // 皮肤钩子 "onClick": () => { / send({ type: 'ITEM.TOGGLE', … }) / }, }); 不想要现成结构时,直接拿 api 用 v-bind 铺到自己的标签上。一次点击经过的路径是这样的: 用户点击 → 适配器把 DOM 事件交给 connect 产出的 onClick → service.send({ type: 'TRIGGER.CLICK' }) → 状态机 closed → open ;行为原语锁滚动、建焦点域、压入层栈,浮层再请定位引擎算坐标 → 适配器重读 connect ,把新的 aria- / data- 铺到部件上 → 皮肤按 [data-state='open'] 命中新规则,动画播放 中间几步与框架无关,适配器只负责首尾。 想接 Svelte 或 Solid ,要写的只有一份 ReactiveRuntime (五个接口)、一份 NormalizeProps 和一层组件包装, @xihan-ui/core/vanilla 就是参考实现。 三、状态机:很薄,规矩不少 状态机运行时自研、零依赖。 states 里只能用字符串引用具名的动作、守卫和副作用,写内联函数或引用不存在的名字, createMachine 当场抛错,开发和生产一致。这样状态图本身是可静态分析的数据,测试能直接算转移覆盖率。 受控与非受控收在 cell 一处: value: cell (() => ({ value: prop("value"), // 传了就是受控,组件只调 onChange 通知你 defaultValue: prop("defaultValue") ?? [], // 只传它就是非受控,组件自己持有 onChange: value => prop("onValueChange")?.({ value }), })), 浮层开关走“意图加回写”,受控时点触发器只发 open-change ,你写回 open 状态机才转移。服务没有直接改状态的入口,状态只能由事件驱动,所以永远能用一串事件复现,跨端一致性测试靠的就是这一点。 副作用装配是事务化的:一批 effect 中后一项初始化失败,前面拿到的资源按逆序全部释放,回滚异常和原始异常一起放进 AggregateError 。浮层打开时“登记层、消隐层、焦点域、滚动锁、背景失活”就在同一个事务里,失败不会留下一个永远占着栈顶的半成品。 四、三端一致是测出来的 框架无关的组件库最常见的病,是各适配器慢慢走样。这里每个组件有一份零框架的一致性规格,三个适配器各实现一个 harness ,运行器逐帧采集归一化后的 DOM 快照比对:id 的具体值和 data-v-* 被抹掉, aria-controls 这类引用翻译成 @part(...) ,抹完必须逐字相同。还有抹不掉的差异,说明抽象漏了,要改的是库,不是测试。 每个适配器也有自己的坑。React 的 props 变化不经过可订阅的源,推式依赖追踪会让所有 track 静默失效,所以 React 运行时改成每次提交后逐项比对, flush 用 flushSync ; pointerenter 不冒泡、合成事件上没有 stopImmediatePropagation()
- 情报分类:技术学习与提效
- 分类依据:内容涉及技术、AI、软件工具或工程实践
- 信息来源:服务器 / V2EX
- 发布时间:2026/10/2 18:41:06
- 暂无回复