- 人工智能
- AI Agent
- MCP 服务
- 工具调用
- GUI 自动化
- 语音
- 数字人
- 交互助手
【免费下载链接】airi
💖🧸 自托管、归你拥有的 Grok 风格 AI 伴侣与 waifu / 赛博生命灵魂容器,目标是接近 Neuro-sama 的高度;支持实时语音聊天、Minecraft 和 Factorio 游玩,支持 Web / macOS / Windows。
本指南以 airi 开源仓库中 VueUse 函数参考文档 until.md 为骨架,系统讲解 VueUseuntil的完整用法:它是把"监听变化"转换成"一次性的 Promise"的工具,适用于等待异步数据就绪、等待 DOM 挂载、等待某个条件达成等场景。读完本文,你将掌握until全部断言方法(toBe、toMatch、changed、changedTimes、取反not)与超时配置,并能借鉴 airi 仓库中的真实实现模式。
一、until是什么:把 watch 变成可 await 的一次性 Promise
Vue 的watch是"持续监听、反复回调"的机制,而在很多业务场景中,我们真正想要的是"等某个 ref 满足条件,然后继续往下执行"。until正是为此设计的 VueUse 组合式函数,官方将其定位为 "Promised one-time watch for changes"——即"Promise 化的单次监听"。
它接受WatchSource<T> | MaybeRefOrGetter<T>(ref、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 等待自定义条件(toMatch+invoke)
当"是否就绪"不是简单的布尔值,而是需要比较运算时,使用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,可传 ref |
toMatch(condition) | 等到条件函数返回true | 同时支持类型收窄泛型 |
toBeTruthy() | 等到值为真值 | 排除false/null/undefined/0/0n/"" |
toBeNull() | 等到值为null | 有取反用法 |
toBeUndefined() | 等到值为undefined | — |
toBeNaN() | 等到值为NaN | 返回Promise<T>(含 NaN 情况) |
changed() | 等到值发生一次变化 | 等待首次变更 |
changedTimes(n) | 等到值变化满n次 | 默认n = 1 |
not.* | 取反断言 | 例如not.toBeNull()= 等到值不为null |
changed与changedTimes是"等待变化次数"的专用断言,适合需要跳过前几次变更、只关心第 N 次变化的场景(例如等待抖动中的状态稳定)。
四、配置项详解:UntilToMatchOptions
所有断言方法都接受可选的第二个参数options?: UntilToMatchOptions,其完整类型声明如下(见 until.md):
export 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 until<T extends unknown[]>( r: WatchSource<T> | MaybeRefOrGetter<T>, ): UntilArrayInstance<T> export declare function until<T>( r: WatchSource<T> | MaybeRefOrGetter<T>, ): UntilValueInstance<T>- 当传入的 ref 元素类型是数组时,返回
UntilArrayInstance<T>,额外提供toContains(value)断言(等待数组包含某元素); - 其余情况返回
UntilValueInstance<T>,提供toBe/toBeTruthy/toBeNull/toBeUndefined/toBeNaN与not取反链。
两者共享UntilBaseInstance<T, Not>基类中的toMatch、changed、changedTimes。Not这个泛型参数很有意思:not属性会把它翻转(Not extends true ? false : true),从而让not.toBeNull()的返回值类型被精确推导为Promise<Exclude<T, null>>,而不是笼统的Promise<T>。同理,toBe在Not为true时返回Promise<T>(取反语义下不能收窄),否则返回Promise<P>(类型收窄为断言值类型)。Falsy类型别名false | void | null | undefined | 0 | 0n | ""则支撑了toBeTruthy的收窄逻辑:
toBeTruthy: ( options?: UntilToMatchOptions, ) => Not extends true ? Promise<T & Falsy> : Promise<Exclude<T, 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 分类表)。 - 条件函数优先用 getter:
until(() => 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 Agent
- MCP 服务
- 工具调用
- GUI 自动化
- 语音
- 数字人
- 交互助手
【免费下载链接】airi
💖🧸 自托管、归你拥有的 Grok 风格 AI 伴侣与 waifu / 赛博生命灵魂容器,目标是接近 Neuro-sama 的高度;支持实时语音聊天、Minecraft 和 Factorio 游玩,支持 Web / macOS / Windows。
相关推荐
ReadMeABook登录方式大揭秘:Plex OAuth、OIDC与本地管理员三种认证对比
ReadMeABook登录方式大揭秘:Plex OAuth、OIDC与本地管理员三种认证对比 ReadMeABook 是一款面向 Plex 和 Audioboo
VueUse `until` 完全指南:用 Promise 一次性等待响应式状态变化的 Watch
VueUse until 完全指南:用 Promise 一次性等待响应式状态变化的 Watch until 是 VueUse 提供的一个「承诺化一次性 watc
前端airi 项目实战:用 VueUse useRouteHash 将 URL 锚点变成响应式状态
airi 项目实战:用 VueUse useRouteHash 将 URL 锚点变成响应式状态 useRouteHash 是 VueUse @vueuse/ro
人工智能AI 应用大模型AI Agent交互助手语音数字人后端前端桌面应用移动开发GUI 自动化MCP 服务
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考