生态产品多个设计器现已发布正式版

查看生态总览
跳转到内容

Vue 适配器 ​

@xihan-ui/vue 是无头内核的 Vue 3 外壳。它负责三件事:把状态机接入 Vue 的响应式、把部件封装为组件、把 connect 产出的 props 展开到 vnode 上。它不实现任何组件逻辑。

依赖:vue 是 peer 依赖(由项目提供);@xihan-ui/backgrounds 与 @xihan-ui/sound 是可选 peer,不使用视觉效果或音效时不需要安装。

组件命名 ​

每个部件一个组件,一律 Xh 前缀 + 组件名 + 部件名:

ts
import {
  XhAccordionContent,
  XhAccordionHeader,
  XhAccordionIndicator,
  XhAccordionItem,
  XhAccordionRoot,
  XhAccordionTrigger,
} from "@xihan-ui/vue";

只有一个部件的组件不带部件后缀(XhButton、XhSwitch、XhBadge)。全部导出组件按组件分组列在组件参考。

默认按名称 import,sideEffects: false 让打包器移除未使用的部分;也可以交给 unplugin-vue-components 按需自动引入,或用插件全量注册,见注册方式。

注册方式 ​

四种写法,产物里的组件从少到多:

写法产物里的组件模板里的类型
按名称 import只有用到的来自 import
@xihan-ui/vue/resolver 配 unplugin-vue-components只有用到的unplugin-vue-components 生成的 components.d.ts
Nuxt 项目装 @xihan-ui/vue/nuxt只有用到的Nuxt 生成的组件声明
@xihan-ui/vue/plugin 全量注册全部组件引入插件模块即生效

自动引入 ​

ts
// vite.config.ts
import vue from "@vitejs/plugin-vue";
import { XiHanUIResolver } from "@xihan-ui/vue/resolver";
import Components from "unplugin-vue-components/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [vue(), Components({ resolvers: [XiHanUIResolver()], dts: true })],
});

解析器只认 Xh 前缀,<XhButton> 与 <xh-button> 都解析到 @xihan-ui/vue 的同名导出;名字拼错时打包器会报主入口没有这个导出,不会静默忽略。皮肤不随组件引入:入口处引一次 @xihan-ui/styles,或者交给 @xihan-ui/styles/vite 按用到的组件注入,见按用到的组件自动注入。

全量注册 ​

ts
// main.ts
import { XiHanUI } from "@xihan-ui/vue/plugin";
import { createApp } from "vue";
import App from "./App.vue";
import "@xihan-ui/styles";

createApp(App).use(XiHanUI).mount("#app");

插件引用全部组件,产物不再摇树,适合原型、内部后台这类不在意首包体积的场景。引入插件模块的同时就给 GlobalComponents 补上了全部组件的类型,Volar 与 vue-tsc 在模板里直接校验 props,不需要另配 types。

配置与视觉环境 ​

provideXhConfig 接收响应式配置。八轴视觉环境必须显式给出对应 DOM 根;嵌套 provide 自动接父控制器,局部 motion 不改全局 JS override:

ts
provideXhConfig({
  locale: "zh-CN",
  visualEnvironment: {
    root: workspaceElement,
    initial: { mode: "dark", density: "compact", motion: "reduce" },
  },
});

物理 Portal 会由 Core 从该根桥接已解析八轴到实例壳,Vue 适配器不复制视觉状态。

事件与 v-model ​

值类组件同时发两个事件:

ts
emits: {
  'value-change': (details: PayloadOf<AccordionProps, 'onValueChange'>) => true,
  'update:value': (value: PayloadOf<AccordionProps, 'onValueChange'>['value']) => true,
}
事件载荷用途
value-change完整明细对象,如 { value }需要全部上下文时
update:value裸值供 v-model 使用
vue
<template>
  <!-- 双向绑定 -->
  <XhAccordionRoot v-model:value="panels" multiple />

  <!-- 或自行处理明细 -->
  <XhAccordionRoot :value="panels" @value-change="onChange" />
</template>

具体的绑定名按组件而定:开关是 v-model:checked,浮层是 v-model:open,输入框是 v-model:value。

原生属性透传与严格模板检查 ​

组件上没声明成 prop 的属性按 Vue 的属性透传落到根元素上:给 XhFormRoot 写 id,外部的提交按钮就能用 form="…" 关联到这张表单;class、style、aria-*、data-* 同理。组件的类型只列出自己的 prop,没有把根元素的原生属性并进来——把几百个原生属性并进每个组件,会让 prop 表与类型提示都淹没在里面。

开启 vue-tsc 的 strictTemplates(或单开 checkUnknownProps)后,这类透传属性会被报成未知属性。在项目的 env.d.ts 里扩一次 ComponentCustomProps 即可,组件自己的 prop 照常受检,拼错的属性名仍然会报:

ts
// env.d.ts
import type { HTMLAttributes } from 'vue'

declare module 'vue' {
  interface ComponentCustomProps extends Omit<HTMLAttributes, 'class' | 'style'> {}
}

这条扩充对项目里所有组件生效(第三方组件同样放行原生属性),由项目自己决定是否开启。表单、按钮等元素专有的属性(novalidate、form)不在通用原生属性里,需要时把对应的 FormHTMLAttributes、ButtonHTMLAttributes 一并并进去。

升级时同样建议开启 checkUnknownProps 与 checkUnknownEvents:移除或改名的属性不会报错,只会被当成原生属性透传到根元素上而静默失效,这两项检查能把它们一次扫出来。

asChild 与事件取消 ​

支持 asChild 的部件可以把行为接到作者提供的单个子节点上。Fragment 会展开后检查,仅忽略空白、注释和条件占位;零个或多个可挂载子节点、元素旁并列的非空文本或数字都会明确报错,不会生成默认按钮或丢弃可见内容。需要默认按钮时移除 asChild,组合多个内容时提供一个实际宿主节点。

作者写在部件或子节点上的事件处理器先执行;调用 preventDefault() 后,部件内部动作不再执行。作者的处理器数组仍按原顺序运行,stopImmediatePropagation() 仍可停止同节点后续处理器。普通回调保留全部参数,ref 的登记和清理不受事件取消影响。

受控与非受控 ​

传受控属性即受控,只传 default* 即非受控:

vue
<template>
  <XhSwitch v-model:checked="wifi" />      <!-- 受控 -->
  <XhSwitch :default-checked="false" />     <!-- 非受控 -->
</template>

受控时组件不会自行改变:它只发出变更意图,由使用者写回新值后才真正改变。这条语义收在状态机的 cell 与 watch 中,不由各组件自行判断。详见状态机运行时。

default* 只在 setup 时读取一次,之后修改不影响当前值,只改变表单重置的落点;要换初值就换 key。三端的取法见初值的读取时机。

作用域插槽 ​

根组件通过作用域插槽提供命令式方法:

vue
<template>
  <XhDialogRoot v-slot="{ setOpen }">
    <XhDialogTrigger>打开</XhDialogTrigger>
    <XhDialogContent>
      <XhButton @click="setOpen(false)">取消</XhButton>
    </XhDialogContent>
  </XhDialogRoot>
</template>

载荷有类型 ​

插槽载荷都写进了组件的 SlotsType,vue-tsc 可以捕获两类拼写错误:

vue
<template>
  <XhTabsRoot :collection="tabs">
    <!-- ✓ node 是 TabsNodeMeta,字段可补全 -->
    <template #panel="node">{{ node.label }}</template>
  </XhTabsRoot>
</template>

两类拼写错误分别如下:

vue
<template>
  <!-- ✗ TS2551: Property 'lable' does not exist on type 'TabsNodeMeta'. Did you mean 'label'? -->
  <XhTabsRoot :collection="tabs">
    <template #panel="node">{{ node.lable }}</template>
  </XhTabsRoot>

  <!-- ✗ TS2339: 组件没有这个插槽 -->
  <XhTabsRoot :collection="tabs">
    <template #panle>…</template>
  </XhTabsRoot>
</template>

载荷类型本身也从主入口导出(命名如 TabsPanelSlotProps、StepsRootSlotProps),需要把插槽内容拆成子组件时可以直接用于标注 props。

插槽键在类型上一律可选:组件内部按作者是否编写该插槽决定是否按 collection 铺开默认结构,键若非可选,该判断在类型上恒为真。

不使用现成 DOM 结构时,直接使用 api 自行渲染:

vue
<script setup lang="ts">
import { useAccordion } from "@xihan-ui/vue";

const { api } = useAccordion(
  { multiple: true, defaultValue: ["a"] },
  details => console.log(details.value), // onValueChange
);
</script>

<template>
  <section v-bind="api.getRootProps()">
    <article v-bind="api.getItemProps({ value: 'a' })">
      <h3 v-bind="api.getHeaderProps({ value: 'a' })">
        <button v-bind="api.getTriggerProps({ value: 'a' })">第一节</button>
      </h3>
      <div v-bind="api.getContentProps({ value: 'a' })">内容</div>
    </article>
  </section>
</template>

api 是一个 ComputedRef,随状态机状态变化重新求值。纯展示型组件(如 XhBadge 等没有状态机的组件)不提供组合式函数。

上下文类型(AccordionContext 等)也一并导出,便于向下透传 api 时标注类型。父子组件之间的 provide / inject 是内部实现,不对外开放;自定义结构请直接用组合式函数获取 api,不接入现成组件的上下文。

状态机接入 Vue 响应式 ​

内部只有一层薄适配。createVueRuntime() 实现 ReactiveRuntime 的五个接口:

接口Vue 实现
cellshallowRef + 受控语义(受控时值从 prop() 读,内部 ref 不写)
trackwatch(deps, fn, { flush: 'pre' })
flushnextTick
onMount / onCleanuponMounted / onBeforeUnmount(不在组件内则立即执行 / 忽略)

useMachine(machine, props, scope) 封装了它。props 传的是 getter 而不是对象,因此在模板中原地修改某个 prop 也能生效。

getter 的求值放在一个 computed 里:连接层每读一个 prop 都会经过它,依赖没动时复用同一份展开结果(状态机的身份缓存跟着命中),依赖一动就产出新对象,身份缓存照旧失效。失效面与组件自己的重渲一致,getter 因此必须只读响应式来源——组件 props、attrs、ref、注入的上下文、全局配置。读普通变量或普通数组的长度不会让它重算;那类来源本来也驱动不了 computed(() => connectX(...)) 的重算,只是以前靠每次重新展开碰巧读到过新值。

行为原语 ​

@xihan-ui/vue/behavior 单独提供滚动锁、悬停意图、滚动观察、贴底和连敲检索的 Vue 包装。useHoverIntent 到 mounted 后才读取模板 ref,并持续观察 trigger 与三个计时参数;trigger 暂时为 null 时释放旧绑定,节点重新出现后再建立。content getter 与回调现读当前响应式选项,不会因浮层内容挂载或普通闭包换代重启安全三角。选项对象本身也可传 ref 或 getter;显式类型使用该子入口的 UseHoverIntentOptions。

useScrollLock 在组件 mounted 后才读取模板 ref,并以 post watcher 跟随 active;释放时先清本地句柄,清理抛错后再次激活仍可建立。active 为真期间不因配置对象更新重锁,关闭再开启才读取新配置。

背景层 ​

Vue 侧的视觉适配位于单独的子入口,不引入就不会把 WebGL 引擎打进包:

ts
import { useBackground, vBackground, XhBackground } from "@xihan-ui/vue/backgrounds";

三种用法见背景层。

声音层 ​

同样是单独的子入口。withNotificationSound / withDialogSound 为命令式反馈服务配置声音,调用点不需要修改;v-sound 为单个元素配置声音:

ts
import { setSoundPlayer, vSound, withNotificationSound } from "@xihan-ui/vue/sound";

默认映射与开关见声音层。

服务端渲染 ​

  • createVueRuntime() 的 isServer 由 typeof window === 'undefined' 判定,服务端不挂事件、不读媒体查询;
  • id 由 createVueIdGenerator() 生成,同一棵树两端一致,不会 hydration 不匹配;
  • 视觉环境属性建议在服务端就渲染到 <html> 上,见安装与接入 · 服务端渲染;Nuxt 项目交给 Nuxt 模块。

Nuxt ​

@xihan-ui/vue/nuxt 是 Nuxt 模块。装上 @xihan-ui/vue 与 @xihan-ui/styles 后只写一行:

ts
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ["@xihan-ui/vue/nuxt"],
});

模块接好三件事,各有一个开关:

做什么选项缺省
组件自动注册:模板里直接写 <XhButton> / <xh-button>,按用到的组件从主入口导入,照常摇树componentstrue
注入令牌与皮肤,排在应用自己的样式之前styles:"layered";宿主有无层 reset 时用 "unlayered";false 自己引"layered"
SSR 首帧主题:服务端把八轴视觉环境渲染到 <html> 上,head 里一段内联脚本在首帧前读回读者存过的偏好,客户端随后接管成视觉环境控制器visualEnvironment:{ storageKey, initial };false 由应用自己管{}

选项写在 xihanUI 下,或写在模块元组的第二项(后者优先):

ts
export default defineNuxtConfig({
  modules: ["@xihan-ui/vue/nuxt"],
  xihanUI: {
    visualEnvironment: {
      storageKey: "my-app-visual",
      initial: { density: "compact" },
    },
  },
});

控制器经 useNuxtApp().$xhVisual 交给应用,切明暗、密度等调 setPreference。写了 storageKey 时偏好存进 localStorage,下次打开的首帧就是那一套,不会先闪一下浅色。initial 里写了的轴压过读者存的值,要留给读者自己选的轴不要写进 initial;写错的值在构建时就报。

vue
<script setup lang="ts">
const { $xhVisual } = useNuxtApp();

function toggleDark() {
  $xhVisual.setPreference({ mode: $xhVisual.getState().mode === "dark" ? "light" : "dark" });
}
</script>

服务端没有媒体查询与存储:那一端的 $xhVisual 按 initial 解出,system 轴取基线。按明暗分支渲染的内容放到 onMounted 之后,免得水合对不上。完整示例在仓库的 examples/nuxt,CI 每轮都对着本仓刚构建的包装一遍,类型检查、构建并检查首屏。

与 Web Components 适配器的关系 ​

两者运行同一个状态机、同一份 connect,输出的 DOM 属性完全一致:跨适配器一致性测试逐帧比对归一化快照,无法消除的差异即判失败。

Vue 项目使用本适配器;需要在多个框架或无框架页面中复用同一套组件时使用自定义元素。两者可以在同一页面共存。

相关 ​

Released under The MIT License