ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

airi 项目 VueUse `until` 实战指南:以 Promise 等待响应式状态变化

airi 项目 VueUse `until` 实战指南:以 Promise 等待响应式状态变化
  • 人工智能
  • AI Agent
  • MCP 服务
  • 工具调用
  • 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 的一次性 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)可以组合任意复杂条件。

七、最佳实践与注意事项

  1. 单次语义:until的 Promise 只 resolve 一次。若要持续响应变化,请改用watch系列(如watchOnce、whenever,见 SKILL.md 的 Watch 分类表)。
  2. 条件函数优先用 getter:until(() => someRef.value > 10)比until(someRef).toMatch(...)更灵活,可组合多条件(airi 的pixiApp && stage判断即为此模式)。
  3. 务必设置超时:对于"条件可能永不满足"的等待(用户操作、外部进程),始终传timeout,并根据需求决定throwOnTimeout是"抛错"还是"放行"。
  4. 配合锁/计数模式:用toBe(false)/toBe(0)做互斥与计数归零等待,可显著减少异步竞态(airi 的 IndexedDB store 与图片读取即是范例)。
  5. 类型收窄是免费的:toBe/toBeTruthy/not等断言在 TypeScript 层面会收窄后续值类型,尽量让被监听 ref 带完整泛型信息。
  6. 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。

项目地址:https://gitcode.com/moeru-ai/airi
点击查看免费下载
上一篇:Warp 云模式(Cloud Mode)待查询 UI 统一改造:让 Oz 与第三方云代理共用 Queued-Query 交互模式
下一篇:Go Web 应用部署实战:用 Supervisord 守护你的 Go 程序(build-web-application-with-golang)

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进