airi 项目 VueUse `until` 实战指南:以 Promise 等待响应式状态变化
人工智能AI AgentMCP 服务工具调用GUI 自动化语音数字人交互助手【免费下载链接】airi 自托管、归你拥有的 Grok 风格 AI 伴侣与 waifu / 赛博生命灵魂容器目标是接近 Neuro-sama 的高度支持实时语音聊天、Minecraft 和 Factorio 游玩支持 Web / macOS / Windows。项目地址https://gitcode.com/moeru-ai/airi点击查看免费下载本指南以 airi 开源仓库中 VueUse 函数参考文档 until.md 为骨架系统讲解 VueUseuntil的完整用法它是把监听变化转换成一次性的 Promise的工具适用于等待异步数据就绪、等待 DOM 挂载、等待某个条件达成等场景。读完本文你将掌握until全部断言方法toBe、toMatch、changed、changedTimes、取反not与超时配置并能借鉴 airi 仓库中的真实实现模式。一、until是什么把 watch 变成可 await 的一次性 PromiseVue 的watch是持续监听、反复回调的机制而在很多业务场景中我们真正想要的是等某个 ref 满足条件然后继续往下执行。until正是为此设计的 VueUse 组合式函数官方将其定位为 Promised one-time watch for changes——即Promise 化的单次监听。它接受WatchSourceT | MaybeRefOrGetterTref、getter 函数或普通值返回一个包含断言方法的实例await该断言返回的 Promise 后代码会在条件满足的那一刻继续执行且只满足一次、不会重复触发。在 airi 仓库中vueuse/core作为目录级 catalog 依赖被统一管理版本为^14.4.0见 pnpm-workspace.yaml并在apps/stage-pocket、apps/stage-tamagotchi、packages/stage-ui、packages/stage-ui-live2d、packages/stage-ui-three等多个子包的package.json中以vueuse/core: catalog:形式引用是贯穿 Web / Electron 渲染层的常用工具。二、安装与引入until属于 VueUse 核心包无需额外依赖pnpm add vueuse/core # airi 仓库中统一通过 catalog 管理版本引入方式import { until } from vueuse/core在 airi 仓库中实际用法与ref、toRef、useAsyncState、useCounter、invoke等 Vue / VueUse API 混用例如 audio-record.ts 的引入方式import { until } from vueuse/core import { ref, toRef } from vue三、基本用法从官方示例到逐步拆解3.1 等待异步数据就绪toBe(true)这是最典型的使用场景。useAsyncState异步拉取数据时isReady一开始是false数据就绪后才变为true。用until(isReady).toBe(true)即可让异步流程排队等待import { until, useAsyncState } from vueuse/core const { state, isReady } useAsyncState( fetch(https://jsonplaceholder.typicode.com/todos/1).then(t t.json()), {}, ) ;(async () { await until(isReady).toBe(true) console.log(state) // state is now ready! })()要点await until(isReady).toBe(true)会一直挂起直到isReady的值为true才放行随后再读取state一定是就绪后的完整数据。3.2 等待自定义条件toMatchinvoke当是否就绪不是简单的布尔值而是需要比较运算时使用toMatch传入条件函数。官方文档特别提示此时可以借助invoke来调用 async 函数invoke是 VueUse 提供的安全执行异步函数的工具import { invoke, until, useCounter } from vueuse/core const { count } useCounter() invoke(async () { await until(count).toMatch(v v 7) alert(Counter is now larger than 7!) })当计数器count超过 7 的那一刻toMatch的 Promise 才会 resolve弹窗触发。相比手动watch 标志位 清理监听这种方式把流程线性化代码意图一目了然。3.3 超时控制timeout/throwOnTimeout有些等待条件可能永远不满足例如用户始终不执行某个操作。此时应设置超时避免流程永久挂起import { until } from vueuse/core // 在 ref.value true 或 1000ms 过后 resolve await until(ref).toBe(true, { timeout: 1000 }) // 超时会抛出异常reject try { await until(ref).toBe(true, { timeout: 1000, throwOnTimeout: true }) // ref.value true } catch (e) { // 超时 }注意两种模式的区别{ timeout: 1000 }超时后 Promise 正常 resolve条件未满足时直接放行{ timeout: 1000, throwOnTimeout: true }超时后 Promise reject需要try/catch捕获。适合条件必须满足否则视为失败的严格场景。3.4 更多断言方法一览官方文档给出的完整断言集合import { until } from vueue/core // 实际引入应为 vueuse/core await until(ref).toBe(true) await until(ref).toMatch(v v 10 v 100) await until(ref).changed() await until(ref).changedTimes(10) await until(ref).toBeTruthy() await until(ref).toBeNull() await until(ref).not.toBeNull() await until(ref).not.toBeTruthy()各方法语义方法语义说明toBe(value)等到值严格等于value接受MaybeRefOrGetter可传 reftoMatch(condition)等到条件函数返回true同时支持类型收窄泛型toBeTruthy()等到值为真值排除false/null/undefined/0/0n/toBeNull()等到值为null有取反用法toBeUndefined()等到值为undefined—toBeNaN()等到值为NaN返回PromiseT含 NaN 情况changed()等到值发生一次变化等待首次变更changedTimes(n)等到值变化满n次默认n 1not.*取反断言例如not.toBeNull() 等到值不为nullchanged与changedTimes是等待变化次数的专用断言适合需要跳过前几次变更、只关心第 N 次变化的场景例如等待抖动中的状态稳定。四、配置项详解UntilToMatchOptions所有断言方法都接受可选的第二个参数options?: UntilToMatchOptions其完整类型声明如下见 until.mdexport interface UntilToMatchOptions extends ConfigurableFlushSync { /** * Milliseconds timeout for promise to resolve/reject if the when condition does not meet. * 0 for never timed out * * default 0 */ timeout?: number /** * Reject the promise when timeout * * default false */ throwOnTimeout?: boolean /** * deep option for internal watch * * default false */ deep?: WatchOptions[deep] }三个核心选项的实践要点timeout?: number默认0等待的最长毫秒数0表示永不超时。airi 仓库中有大量带超时的真实用例例如 InteractiveArea.vue 中await until(activeSessionId).toMatch(Boolean, { timeout: 5000 })——限制最多等待 5 秒让新窗口选中会话。throwOnTimeout?: boolean默认false为true时超时会使 Promise reject。在 Model.vue 中配合try/catch处理超时降级等不到新 stage 就放弃这次加载恢复挂载态而不是留下空白画面。deep?: WatchOptions[deep]默认false透传给内部 watch 的深度监听选项。当被监听的是对象/数组的深层属性变化时需要开启deep: true才能被捕获airi 中监听displays显示器列表时就配合了toMatch判断长度。该接口继承自ConfigurableFlushSync因此内部 watch 的 flush 时机同样可控默认sync保证异步流程中能及时感知同步变更。五、类型体系解析从重载到实例方法until的导出函数签名见 until.md对值与数组做了重载区分export declare function untilT extends unknown[]( r: WatchSourceT | MaybeRefOrGetterT, ): UntilArrayInstanceT export declare function untilT( r: WatchSourceT | MaybeRefOrGetterT, ): UntilValueInstanceT当传入的 ref 元素类型是数组时返回UntilArrayInstanceT额外提供toContains(value)断言等待数组包含某元素其余情况返回UntilValueInstanceT提供toBe/toBeTruthy/toBeNull/toBeUndefined/toBeNaN与not取反链。两者共享UntilBaseInstanceT, Not基类中的toMatch、changed、changedTimes。Not这个泛型参数很有意思not属性会把它翻转Not extends true ? false : true从而让not.toBeNull()的返回值类型被精确推导为PromiseExcludeT, null而不是笼统的PromiseT。同理toBe在Not为true时返回PromiseT取反语义下不能收窄否则返回PromiseP类型收窄为断言值类型。Falsy类型别名false | void | null | undefined | 0 | 0n | 则支撑了toBeTruthy的收窄逻辑toBeTruthy: ( options?: UntilToMatchOptions, ) Not extends true ? PromiseT Falsy : PromiseExcludeT, Falsy这套类型设计意味着只要源 ref 有明确的 TS 类型await until(ref).toBe(true)之后拿到的值就是收窄后的类型编译器会在条件达成后的代码里给出准确的类型提示从而减少不必要的!断言。六、airi 仓库真实实践六类典型场景until在 airi 仓库中并非教科书式演示而是被广泛用于真实业务逻辑。以下均为仓库内可直接查阅的实现相对路径按场景归类6.1 等待媒体流就绪后再启动录音audio-record.ts 中录音开始前先等待MediaStream可用async function startRecord() { await start() await until(mediaRef).toBeTruthy() if (!mediaRef.value) { console.error(No media media available) return } audioRecorder.value new MediaRecorder(mediaRef.value) audioRecorder.value.start() }先用await until(mediaRef).toBeTruthy()保证mediaRef.value非空再创建MediaRecorder彻底避免了在流尚未就绪时就去构造录音器的竞态问题。这是toBeTruthy作为非空守卫的典型用法。6.2 等待异步加载完成防止并发竞争display-models.ts 是一个 Pinia store多处操作前都先等待 IndexedDB 加载结束async function loadDisplayModelsFromIndexedDB() { await until(displayModelsFromIndexedDBLoading).toBe(false) displayModelsFromIndexedDBLoading.value true // ... 遍历 IndexedDB 加载模型 ... displayModelsFromIndexedDBLoading.value false } async function getDisplayModel(id: string) { await until(displayModelsFromIndexedDBLoading).toBe(false) // 先查内存再查 IndexedDB避免与写入操作竞争 }模式本质是用布尔 ref 充当互斥锁toBe(false)等待上一次加载完成然后置true开始本次加载。这种等待异步资源就绪再读取的手法在 store 层非常实用。6.3 等待 DOM 元素挂载后再播放动画chat-bubble-minimalism.vue 在onMounted中等待模板 ref 就绪onMounted(async () { await until(chatBubbleContainerRef).toBeTruthy() await until(chatBubbleRef).toBeTruthy() const containerWidth chatBubbleContainerRef.value!.getBoundingClientRect().width createTimeline({ loop: false }) // 用 animejs 创建气泡展开动画 .set(chatBubbleRef.value!, { ... }) })虽然通常onMounted后模板 ref 已存在但在条件渲染、异步渲染子组件等场景下 ref 仍可能为undefined。用until(...).toBeTruthy()做防御性等待再安全地调用getBoundingClientRect()计算容器宽度比setTimeout更可靠、无硬编码延迟。6.4 等待窗口/会话状态同步完成带超时InteractiveArea.vue 是多窗口对话切换的核心组件两处等待各有侧重// 快照草稿等图片读取全部完成计数归零 await until(pendingImages).toBe(0) // 恢复草稿新窗口最多等 5 秒选中会话 await until(activeSessionId).toMatch(Boolean, { timeout: 5000 })第一处用toBe(0)等待待处理图片计数归零防止草稿捕获时漏掉仍在读取的图片snapshotDraft第二处用toMatch(Boolean, { timeout: 5000 })等待会话 ID 变为真值并设置 5 秒超时兜底超时后流程继续、选择不恢复——这正是throwOnTimeout之外超时静默放行策略的实际运用。6.5 等待屏幕捕获相关状态就绪use-screen-ambient-light.ts 在启动屏幕环境光采样前先确保显示器列表与窗口边界信息可用if (displays.value.length 0) await until(displays).toMatch(currentDisplays currentDisplays.length 0) if (!hasWindowBounds.value) await until(hasWindowBounds).toBe(true)显示器枚举与窗口边界由主进程异步推送渲染层用until等待其就绪后再执行getDisplayMedia屏幕捕获与采光计算避免了拿空列表找主导显示器导致的异常。6.6 等待 3D / Live2D 场景对象创建完成在 3D 与 Live2D 渲染模块中until被用作场景初始化屏障VRMModel.vue 中await until(() scene.value).toBeTruthy()等待 Three.js 场景对象创建后再加载 VRM 模型Model.vue 中await until(modelLoading).not.toBeTruthy()——取反用法等待模型加载标志位变为非真值防止并发触发多次重载第 296 行还用await until(() !!pixiApp.value !!pixiApp.value.stage).toBeTruthy({ timeout: 1500 })在画布销毁重建期间等待新 stage并以try/catch兜底超时降级。这些案例证明until是渲染层对象生命周期竞态的通用解决方案配合 getter 形式() scene.value可以组合任意复杂条件。七、最佳实践与注意事项单次语义until的 Promise 只 resolve 一次。若要持续响应变化请改用watch系列如watchOnce、whenever见 SKILL.md 的 Watch 分类表。条件函数优先用 getteruntil(() someRef.value 10)比until(someRef).toMatch(...)更灵活可组合多条件airi 的pixiApp stage判断即为此模式。务必设置超时对于条件可能永不满足的等待用户操作、外部进程始终传timeout并根据需求决定throwOnTimeout是抛错还是放行。配合锁/计数模式用toBe(false)/toBe(0)做互斥与计数归零等待可显著减少异步竞态airi 的 IndexedDB store 与图片读取即是范例。类型收窄是免费的toBe/toBeTruthy/not等断言在 TypeScript 层面会收窄后续值类型尽量让被监听 ref 带完整泛型信息。deep选项监听对象/数组的深层变化时设置deep: true否则内部 watch 可能无法感知嵌套变更。八、结语until把 Vue 的响应式监听与异步流程无缝衔接让等待某个状态达成变成一行可读、可超时、类型安全的await。本文以 airi 仓库中的参考文档为骨架结合 audio-record.ts、display-models.ts、InteractiveArea.vue、use-screen-ambient-light.ts、Model.vue、VRMModel.vue 等真实实现覆盖了从媒体流就绪、IndexedDB 加载、DOM 挂载、窗口状态同步到 3D 场景初始化的六大实战场景。在你的 Vue 3 / Nuxt 3 项目中遇到必须先等某个响应式条件满足再继续的需求时优先考虑until——它将为你省去大量手写 watch 与状态标志位代码。赞分享人工智能AI AgentMCP 服务工具调用GUI 自动化语音数字人交互助手【免费下载链接】airi 自托管、归你拥有的 Grok 风格 AI 伴侣与 waifu / 赛博生命灵魂容器目标是接近 Neuro-sama 的高度支持实时语音聊天、Minecraft 和 Factorio 游玩支持 Web / macOS / Windows。项目地址https://gitcode.com/moeru-ai/airi点击查看免费下载相关推荐ReadMeABook登录方式大揭秘Plex OAuth、OIDC与本地管理员三种认证对比ReadMeABook登录方式大揭秘Plex OAuth、OIDC与本地管理员三种认证对比 ReadMeABook 是一款面向 Plex 和 AudiobooVueUse until 完全指南用 Promise 一次性等待响应式状态变化的 WatchVueUse until 完全指南用 Promise 一次性等待响应式状态变化的 Watch until 是 VueUse 提供的一个「承诺化一次性 watc前端airi 项目实战用 VueUse useRouteHash 将 URL 锚点变成响应式状态airi 项目实战用 VueUse useRouteHash 将 URL 锚点变成响应式状态 useRouteHash 是 VueUse vueuse/ro人工智能AI 应用大模型AI Agent交互助手语音数字人后端前端桌面应用移动开发GUI 自动化MCP 服务上一篇Warp 云模式Cloud Mode待查询 UI 统一改造让 Oz 与第三方云代理共用 Queued-Query 交互模式下一篇Go Web 应用部署实战用 Supervisord 守护你的 Go 程序build-web-application-with-golang创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考