一份行为,三种宿主:聊聊 XiHan.UI 这个框架无关的 Headless 组件库

3 小时 12 分钟前
 zhaifanhua

一份行为,三种宿主:聊聊 XiHan.UI 这个框架无关的 Headless 组件库

做前端久了,多半碰到过这种局面:后台用 Vue ,官网用 React ,角落里还剩几个没有框架的老页面。三边各有一个弹窗,按 Esc 有的关有的不关,关掉以后焦点有的回到按钮上,有的掉回页面顶部。用鼠标的人察觉不到,用键盘和读屏的人每天都被绊一下。再往后换框架,组件库整套重写,最先丢的正是这些看不见的东西:方向键往哪走,读屏念什么,焦点落在哪。

XiHan.UI 就是冲着这件事做的:

快速、轻量、高效、用心的框架无关 Headless UI 组件库。是面向企业级前端的设计系统运行时,无头内核,多框架适配器。提供基础组件与 AI 组件,覆盖从中后台到 AI 对话的界面构建场景。曦寒懿( XiHanFun )开源生态的前端基座。

下面把它的设计、细节和取舍摊开讲,也包括它现在做不到的地方。文档站每个组件都有能点、能切换框架的真实示例:

一、先看用起来是什么样

pnpm add @xihan-ui/vue @xihan-ui/tokens @xihan-ui/styles

入口引一次皮肤,组件按名字 import ,不用 app.use()。所有包都声明了 sideEffects: false,没用到的组件会被摇掉:

<XhDialogRoot v-slot="{ setOpen }">
  <XhDialogTrigger>打开对话框</XhDialogTrigger>
  <XhDialogContent>
    <XhDialogTitle>确认操作</XhDialogTitle>
    <XhButton @click="setOpen(false)">确定</XhButton>
  </XhDialogContent>
</XhDialogRoot>

打开以后,焦点圈在内容区、Esc 或点遮罩关闭、关闭后焦点回到触发按钮、背后锁住滚动、其余部分对读屏隐藏,这些一行都不用写。React 19 的组件名和写法完全一样。没有框架的页面用自定义元素,结构自己写,用 data-xh-part 标出每个节点的角色:

<xh-dialog>
  <button data-xh-part="trigger">打开</button>
  <div data-xh-part="positioner">
    <div data-xh-part="content">…</div>
  </div>
</xh-dialog>

三种写法跑的是同一台状态机、同一份 connect,差别只在于谁把属性写到 DOM 上。

二、一个组件,五份产物

以对话框为例,它在仓库里落成五处:无头内核(解剖、状态机、键盘规格表、connect)、Vue 组件、React 组件、自定义元素 <xh-dialog>、纯 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<string[]>(() => ({
  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(),这类处理器改挂原生监听,有门禁逐组件核对;全局配置的代理实现了 ownKeys,否则 { ...props } 一展开默认值就没了。

Web Components 选的是 Light DOM 行为宿主:元素不渲染结构,只找到作者写的角色节点写入属性和事件。不用 Shadow DOM ,是因为它会封住结构、挡住外部 CSS ,表单关联和跨边界的 aria-* 引用都要额外机制。代价是结构得自己写,所以有校验:作者写 data-xh-part(声明),接线后写 data-part(事实),分得清“写了但没接上”。缺必备部件报 wc.missing-part,标签用错报 wc.wrong-part-tag,比如表单的 label 写成 <div>,for 关联会静默失效。布尔属性是三态的,缺席、="false"、其余各不相同;角色节点真的进出才重新接线;脚本到达前用 :not(:defined) 先收起浮层子树,免得裸文本被搜索引擎当正文;包里还附带 custom-elements.json 给编辑器补全。

五、无障碍:每个组件一张键盘规格表

每个组件有一份机读的键盘规格表:

export const accordionKeyboard: KeyboardTable = {
  component: "accordion",
  source: "https://www.w3.org/WAI/ARIA/apg/patterns/accordion/#keyboardinteraction",
  rows: [
    { id: "accordion.kbd.toggle", keys: ["Space", "Enter"],
      when: "focus in trigger, not disabled", does: "展开/收起该条目的 content" },
    // …
  ],
};

测试用例通过 covers 反查行 id ,少覆盖一行套件就失败,文档站的键盘表也由它渲染。想让测试通过只能补用例,不能改分母。

几处 ARIA 处理:禁用项仍需可聚焦时用 aria-disabled,不用会让元素失焦的原生 disabled;手风琴按 APG 规定不做 roving tabindex ;方向键的轴向和 RTL 换向收在一处;不属于导航的按键一律不 preventDefault。输入法组合期的按键单独识别,这条对中文用户格外重要:拼音输入时的回车是在确认候选词,所以提示输入框里组合中的 Enter 一律放行。

焦点环向内收一个环宽,聚焦前后占位不变。环内收以后,紧挨着它的是元素自己的面,WCAG 2.2 要求 3:1 ,而实心面上品牌色的环能低到 1:1 ,这些档位的环改取该面配对的前景色。环也不随语气变色,warning 色对白底只有 2.70:1 。这条由静态计算和真实 Chromium 逐档实测两道门禁一起守,两边的分母还要互相对账。axe 扫描同样跑在真实 Chromium 里,明暗各一遍;存量违规登记成表,某条不再命中就判定登记过期,必须删掉。

同一份皮肤还要在紧凑密度、RTL 、粗指针(命中区至少 44×44px )、减弱动效、减少透明度、强制色和打印下都成立。

六、浮层:最容易出 bug ,拆得最细

点外面关闭、焦点陷入、背景禁止滚动,这些在 core 里实现一次,所有浮层调用。

浮层叠成一个栈。每层要登记 branches(逻辑属于本层、DOM 在别处的节点,比如 portal 出去的子菜单,漏了的话点子菜单会关掉父层)和 surfaces(遮罩这类点了要关本层的面)。层级由逻辑栈派生,不靠各组件拿静态 z-index 去猜。

只有栈顶响应 Esc 。Esc 、点外部、焦点移出都会派发一张可取消的表决票,preventDefault() 即否决,表单没填完先弹个确认,不用绕开组件实现。触屏上 pointerdown 只建立候选,同一目标上的 click 到来才提交,中途一滚动就作废,在手机上划页面不会误关浮层。

滚动锁是引用计数的,让出来的滚动条宽度写在 --xh-scroll-lock-gutter 上,fixed 顶栏读它就不会在弹窗打开时跳一下。背景失活给其余子树加 inert。退场动画用租约:等浏览器真正创建的动画对象播完再卸载,不写死超时;焦点则在逻辑关闭那一刻就交回触发器。

定位引擎也是自研的,十二种方位、主轴放不下自动翻面、交叉轴沿边推回并留 4px 余量,支持虚拟锚点。状态机传给引擎的 strategy、内联 position、皮肤里的 position 三处必须一致,否则整族浮层偏移一个滚动距离还不报错,有门禁专门核对。首帧坐标未定时浮层默认 visibility: hidden,引擎回报坐标后才显示,不会在左上角闪一下;用 visibility 不用 display ,是因为后者会让元素退出排版,量不到尺寸。

浮层 portal 到 body 后,会把来源处的主题、品牌、密度、动效、书写方向,连同祖先上声明的 CSS 变量一起投到实例壳上,局部深色区里弹出的菜单还是深色。组件私有槽不跨 Portal ,表格斑马行改写的底色不会漏进行里的下拉菜单。子菜单用安全三角,指针斜着移过去途中扫过别的条目,子菜单不会被切走。

七、指针与手势

跟手交互要同时做对四件事:监听挂在 document 上,处理 pointercancel,第二根手指不能劫持会话,卸载时清干净监听。漏一条就是只在真机上偶发的 bug 。@xihan-ui/pointer 把这四条一次做对,压缩后 2.04 kB ,滑块、分栏、取色器、签名板、轮播、排序、表格、树都走它。

双指缩放相对起始那一刻计算,避免逐帧累乘积累浮点误差;拖放落点按中心是否越过判定,不按矩形相交,项高不一时后者会来回跳;几何一律取按下时的快照,避免让位之后自激振荡;激活阈值用直线距离,斜着拖 4px + 4px 实际是 5.7px ,分轴比较会误判成没动。

八、主题:一个控制器,八条轴

令牌源是 DTCG 格式的 JSON ,产物提交进仓库,CI 重新生成后比对。颜色一律写成 oklch,同明度不同色相看起来一样亮,所以整套色板只定一条明度曲线:任何色相的 700 档铺白字都过 4.5:1 ,深色主题沿同一条曲线反向取档。令牌分原语、语义、组件覆盖槽三层,皮肤只能消费语义层。

createVisualEnvironmentController 统一管理明暗、品牌、密度、书写方向、对比度、动效、透明度、材质八条轴:

const visual = createVisualEnvironmentController({
  root: document.documentElement,
  storageKey: "app-visual-environment",
  onStorageError: detail => console.error("视觉偏好持久化失败", detail),
  initial: { mode: "system", motion: "system", transparency: "system" },
});
visual.setPreference({ mode: "dark", density: "compact" });

它最终投影成 <html data-theme="dark" data-density="compact" data-motion="reduce" dir="rtl" …> 这样一组属性,服务端渲染时直接输出就不会闪。开了持久化就必须传 onStorageError,失败不会被装成成功;局部区域用带 parent 的子控制器;密度只收紧高度和间距,不缩字号。

品牌色给一枚种子就够:registerBrand('acme', '#16a34a') 派生出 11 档梯度。派生只取种子的色相和彩度,明度曲线不变,所以实心底白字 4.5:1 对任何种子都成立;半透明的种子直接拒绝。

材质分 solid 、soft 、frosted (只给瞬态浮层用的磨砂)、elevated 几档,不提供“玻璃”。打开 data-material="liquid" 后,浮动按钮、轮播控制、看图工具条、吸顶顶栏会换成液态面:读下层亮暗选色调,切换点带 0.179 ± 0.04 的滞回区间,内容滚过分界附近不会来回闪;下层读不出颜色就停在可读下限,保证标签至少 4.5:1 。彩色区块上还可以声明墨色域,中性描边改用墨色按比例透明,同一支灰描边“黄底上 1.06:1 、黑底上 16.68:1”的落差就消失了。

九、皮肤:纯 CSS ,可以整套扔掉

@xihan-ui/styles 不依赖任何 JS 。层序是 xihan.reset, xihan.tokens, xihan.motion, xihan.components, xihan.overrides,reset 只作用于带 data-scope 的库节点、特指度为 0 ,和现有页面共存不用隔离。改样式有三种粒度,优先用前两种,直接写死的规则会绕开令牌,深色和密度切换随之失效:

:root { --xh-shape-control: var(--xh-radius-md); }   /* 改语义令牌:影响全库 */
:root { --xh-button-h: 40px; }                       /* 改组件覆盖槽:只影响一类组件 */
@layer xihan.overrides {                              /* 直接写规则 */
  [data-scope='button'][data-part='root'] { text-transform: uppercase; }
}

CSS 级联有条容易忽略的规则:无层声明胜过任何有层声明,与特指度无关。宿主一条无层的 button { padding: 0 } 就能把皮肤压成裸元素,Tailwind v3 的 preflight 、normalize.css 、VitePress 都是这种无层重置。XiHan.UI 自己的文档站就撞上过,所以包里另提供一份同源生成的 index.unlayered.css,规则改按特指度竞争。全量皮肤压缩后约 130 kB ;按需引入时漏引一份是静默的,开发期可以开 startSkinCheck(),它会报出缺哪份、该怎么 import 。

每个部件归入六个家族之一:Action Control 、Field Chrome 、Collection Item 、Surface 、Overlay 、Feedback 。Select 的 trigger 、option 、content 分属三个家族,同族成员的同类部件由门禁逐条比对,必须同值。几条全库规则:圆角只有 4 / 8 / 12px 三档,外加 pill 和 circle ;只有 Button 默认品牌实心;离散控件按下 120ms 缩到 0.97 、释放 200ms 回来,整行条目只换底色,但不允许零反馈;边界只由描边承担;字段默认 16rem 宽、压缩底线 12rem ;禁用不能只降 opacity 。

皮肤最怕写错不报错:引用一个不存在的令牌名,整条声明会在计算值阶段静默失效。对应的门禁有查孤儿令牌、查未命名的共享字面量、查声明了却没接线的部件、查永远选不中节点的遗留规则。data-tone 也能用在自己的节点上,取到整族 --xh-tone-* 语气色,随明暗和品牌一起切。完全不要默认皮肤也行,组件只往 DOM 写属性:开合一律是 data-state='open' | 'closed',布尔状态为真时属性存在。

十、动效、声音和背景

@xihan-ui/motion 的缓动和时长与令牌同源,有门禁双向比对。JS 动画用 readMotion(el) 从元素上读实际生效的令牌,不写死毫秒;resolveEasing 认不出的写法直接抛错,不悄悄按匀速播放。弹簧用解析解,任意时刻 O(1) 可算,能烘焙成 CSS 的 linear() 交给浏览器,沉降时长与 0.1ms 步长的龙格-库塔积分逐点比对过;核心组件只用超调不超过 3% 的预设。减弱动效只有一条通道:最近祖先的 data-motion,其次是应用级 override ,最后是系统设置。降级是去掉位移、保留 120ms 淡变;有进场就有退场,初始内容不播进场。

@xihan-ui/animations 里的动画是一份可以 JSON 序列化的配方,播放前校验,不合法直接拒播。其中一条来自 WCAG 2.3.1:任意一秒内闪烁不超过三次。splitText 按码点拆字,原文写进 aria-label,免得读屏逐字念。

@xihan-ui/sound 没有任何音频文件,提示音在播放的那一瞬间用振荡器和噪声合成,整包不到 5 kB ,内置清亮、极简、柔和三套主题。withNotificationSound(toast) 包一层就能给通知配声,调用点不用改;浏览器自动播放策略挂起时只保留最近一声;单个元素配声挂在 click 上,键盘激活也会发声,按下后拖开取消的不发声。

@xihan-ui/backgrounds 是 WebGL2 背景效果(极光、星云、流体、星空等),WebGL2 不可用时降级成 CSS 背景。滚出视口自动暂停、减弱动效下冻结,页面开十个效果也只有一条共享的 rAF 循环。文字、图片、SVG 都能采样成点云,让粒子排成“曦寒”两个字,再形变到别的形状。

十一、组件:挑些细节说

组件按通用、布局、导航、数据录入、数据展示、图表、反馈、浮层、AI 对话分类,每个都有内核、三端组件和皮肤。挑几处:

提示延迟、轮播间隔、轻提示停留这类“等待”不归动效管,由组件属性给缺省值:Tooltip 700 / 300ms ,子菜单 100 / 300ms ,卡片通知 5000ms ,轻提示 4000ms 。减弱动效不会让提示更早消失,唯一的例外是自动播放,减弱动效下轮播不自己起播。

十二、AI 对话

读流、收敛状态、增量渲染,拆成三个不碰 DOM 的包。

@xihan-ui/chat-stream 的链路是 SSE 读取 → 协议归一 → parts 归约 → 会话 store 。传输层是一个接口,stream() 不抛异常,错误在流的边界转成事件。一条消息由正文、思维链、工具调用、文件、引用来源等 part 组成,增量按块键回到原位,工具调用先到参数后到结果也不会错位。会话按树来存,每条消息记着自己的父消息:

const store = createThreadStore({ transport: createHttpSseTransport({ url: "/api/chat" }) });
store.submit("你好");
store.regenerate(messageId);   // 同一提问再要一条回复,原回复留作分支
store.edit(messageId, "改过的问题");
store.retry();                 // 撤掉失败的回复重新发起,不产生分支
store.stop();                  // 保留已产出的内容,这条记为 aborted
store.continue();              // 在截断处接着写

selectBranch 用来做“‹ 2 / 3 ›”切换,getTree() 导出整棵树可恢复会话,非法操作立即报错。帧批处理把一帧内的增量合并成一次通知,打字机把网络抖动摊平成匀速输出。

@xihan-ui/markdown 每次接收截至当前的全文,已定型的前缀块冻结,只解析尾巴;生长中的块 key 恒为 'live',框架复用同一份 DOM ,用户的选区和滚动位置不会丢。消毒在解析器内部完成,不透传原生 HTML ,所以直接 v-html 是安全的。没写完的 **粗 先按闭合显示,半截链接只显示文字,流结束后再严格解析。它是 CommonMark 的子集,官方用例通过 489/652 ( 75.0%),刨掉按设计不支持的两节原生 HTML 是 82.0%,仓库里有棘轮盯着,只许升不许降。

@xihan-ui/code-highlight 只区分注释、字符串、数字、关键字、标点五类。不内置 Shiki ,是因为几百 kB 的语法数据对偶尔出现一段代码的场景不成比例;着色器是端口,需要更高精度时把 Shiki 接进来,组件一行不用改。

对话组件按职责拆开:PromptInput (处理输入法,发送与停止共用一颗按钮)、MessageFeed (粘底跟随、播报区、未读数)、ToolCall (运行时展开、结束收起,手动开合过一次就不再自动;等待批准的闸门常驻,不会被折叠藏起来)、Reasoning (显示已经想了几秒)、Approval (超时按拒绝,授权范围与判定原子提交)、QuestionFlow (一次一题,末题不替用户提交)、Citation 、CodeView 、DiffView (支持行评论)和 Log (支持 ANSI 着色)。

十三、图表:零依赖的引擎

最近一个大版本带来了 @xihan-ui/viz,比例尺、刻度、布局、拾取、降采样全是纯函数。CartesianChart 一个组件覆盖柱、条、堆叠、瀑布、折线、面积、散点、K 线、箱线和小提琴,支持参考线、趋势线、Ctrl / ⌘ 滚轮与双指缩放、刷选和多图联动;另有饼图、漏斗、雷达、桑基、关系图(五种布局)、层级图(矩形树图、旭日图等,可逐层下钻)、迷你图和热力图。

标记超过 3000 个时数据层自动切到画布,键盘、读屏、主题和打印不受影响;列式数据仓配合降采样,百万点仍然流畅,实时追加会合并到同一帧刷新。无障碍没有省:绘图区只占一个 Tab 位,方向键在数据点之间移动,还会自动生成视觉隐藏的摘要和数据表。分类色亮暗各八支,过了对比度和色弱区分校验,超过八个系列会报诊断;强制色和打印下改用纹理区分系列;双 y 轴是刻意不提供的。

十四、表单、日期与国际化

复合控件给了 name 就会生成表单影子,随 <form> 一起提交:没勾选的 checkbox 不进 FormData,与原生一致。form.reset() 时组件按当下的 props 回到默认值;受控组件要响应重置必须传 defaultValue,否则组件的空值会被当默认值发出去,“重置”就成了“清空数据”。参与表单的组件名单由门禁从源码里扫,新组件没接重置会被拦下来。

日期运算是自研的 @xihan-ui/core/date,命名沿用 Temporal 。之所以自己写,是因为日期的边界情况错一处,就会有某一天错位:1 月 31 日加一个月是 2 月 28 日;Date 表示时刻不是日期,跨夏令时会差一小时;周首日按地区不同,美国周日、中国周一、埃及周六; 2027-01-01 属于 2026 年的第 53 周。周规则按 CLDR 查表,不问各浏览器实现不一的 Intl.Locale#getWeekInfo;值对象写 a < b 会直接抛错。

PlainDate.from("2026-01-31").add({ months: 1 }).toString(); // '2026-02-28'
today("Asia/Shanghai").toString();                          // 上海此刻的日期

内建文案默认英文,日期组件跟随 locale,解析顺序是实例 props → 全局配置 → 浏览器语言 → en-US。中文项目在根组件调用一次 provideXhConfig({ locale: 'zh-CN', translations }) 即可,按键合并,传 ref 还能运行时切换。服务端渲染要在两端注入同一个 locale,否则月历会水合不一致。

十五、命令式服务

删除前确认、保存后提示这类反馈,一次调用弹出最省事。库里有对话框、通知、顶部进度条三个服务工厂:

const ok = await dialog.confirm({
  title: "删除这条记录?", tone: "danger", okText: "删除",
  onOk: () => api.remove(id),   // 返回 Promise 时按钮自动加载,失败不关闭
});

const id = toast.loading("正在上传…");
await upload(file);
toast.update(id, { loading: false, tone: "success", title: "上传完成" });
toast.info("已删除 3 条记录", { actionLabel: "撤销", onAction: () => restore() });

对话框多次调用会排队,前一个退场完才弹下一个。通知分轻提示和卡片两种预设,从加载中到完成是同一条通知就地改写,dedupe: 'content' 让连发的报错显示成「同步失败 ×2 」,超出上限时先挤掉低优先级的。进度条用在途计数:写成布尔开关的话,三个并发请求里第一个返回它就收起了。文档里还有一条建议:可撤销的操作别弹确认框,直接执行,再给一条带“撤销”按钮的轻提示。

十六、零第三方运行时依赖

除宿主框架本身,所有库包的运行时都不引入第三方依赖。日期、浮层定位、代码着色、Web Components 响应式基类、Markdown 、状态机和图表引擎全是自研,常见的同类选择分别是 date-fns 、Floating UI 、Shiki 、Lit 、markdown-it 、XState 和各类图表库。代价是每一块都要自己证明是对的,所以测试里拿成熟的响应式基类对拍自研版、用 CommonMark 官方用例集测渲染器,这些依赖只在测试里出现。新增运行时依赖要登记白名单,写明理由和移除条件;分层依赖由 dependency-cruiser 强制;全部 ESM ,体积有棘轮把关。

图标是结构化记录,渲染时逐节点创建元素,运行期不解析 SVG 字符串,也就不存在写 innerHTML 的路径。npx xihan-icons <svg 目录> 能把任意图标集转成同样的模块,转换时丢掉 class 和 onclick,碰到 <script>、<use> 直接报错跳过。

十七、工程上的几道门

pnpm gate 按包与依赖、令牌、皮肤、视觉、浮层、动效、无障碍、适配器、文档、仓库十个模块执行,前面提到的检查基本都在里面。除此之外还有几道:

还有个小彩蛋:在 vite.config.ts 里挂上 xihanUiBanner(),开发服务器启动时,终端会打出彩虹色的 Logo 和两句诗:

碧落降恩承淑颜,共挚崎缘挽曦寒。
迁般故事终成忆,谨此葳蕤换思短。

十八、它在哪儿被用着

XiHan.BasicApp 的前端整体用 XiHan.UI 重建,这是它在真实业务里的第一个大体量消费方。文档站另有一册“场景示例”,把后台壳、表单页、数据页、对话页整屏摆出来,都是真组件,没有一处裸色值,也没有一个写死的毫秒数。

十九、最后

XiHan.UI 是曦寒懿( XiHanFun )开源生态的前端基座,同一生态里还有:

本地跑起来看看(需要 Node.js 24+、pnpm 11+):

git clone https://github.com/XiHanFun/XiHan.UI.git
cd XiHan.UI/ui && pnpm install --frozen-lockfile && pnpm build
cd ../docs && pnpm install && pnpm dev

项目采用 MIT 授权。组件状态图与 ARIA 接线参考了 Zag.js 的规格,无障碍交互依据 W3C APG ,Markdown 以 CommonMark 规范为判据,无障碍扫描用的是 axe-core ,在此一并致谢。欢迎提 Issue 和 PR ,觉得有用的话点个 Star 就是很大的鼓励。

微信搜一搜「摘繁华」,关注公众号获取后续更新:

219 次点击
所在节点    程序员
2 条回复
murongxdb
2 小时 32 分钟前
还在卷 UI 库吗
flyqie
2 小时 30 分钟前
不知道为什么,这文章总有种 AI 生成的错觉。。。?

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/1246151

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX