VueUse until 完全指南:用 Promise 优雅等待响应式状态变化的编程模式
2026/9/10 3:55:49 网站建设 项目流程

VueUse until 完全指南:用 Promise 优雅等待响应式状态变化的编程模式

【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi

until是 VueUse 中一个"一次性 Promise 化 watch"工具:它把 Vue 的响应式状态监听包装成可await的 Promise,让你在refcomputed、getter 函数乃至数组发生变化时精准续接异步流程,无需手写watch+ 回调 + 手动 resolve 的样板代码。本指南以 airi 仓库中的 until 参考文档 为核心,结合仓库内 Web 端、桌面端、Live2D/VRM 渲染、语音转写等真实源码调用场景,系统讲解其 API、选项、类型系统与实战模式。

为什么需要 until:从回调式 watch 到 Promise 式等待

在 Vue 3 中,watch的核心模型是"事件回调":你注册一个回调,状态变化时由框架调用它。但当你的业务逻辑是"先做 A,等某个状态达到条件,再做 B"这种线性流程时,回调模型会把代码拆成碎片,还要手动管理标志位和resolve

// 回调式写法:需要额外的 promise 与标志位,代码分散 let resolveReady: (v: any) => void const ready = new Promise(res => { resolveReady = res }) watch(source, (v) => { if (v === true) resolveReady(v) }, { immediate: true }) await ready

until把上述模式收敛为一个 awaitable 的链式 API,内部对目标源建立一个一次性 watch,条件满足即 resolve,并自动清理监听:

await until(source).toBe(true)

从源码结构看(见下方"类型声明"一节),until接收一个WatchSource<T> | MaybeRefOrGetter<T>参数,兼容refreactive对象、getter 函数等所有可被watch追踪的源,并返回带有toBetoMatchchangedchangedTimes等方法的实例,支持not取反链式调用。它在 airi 仓库中被归类为 Watch 类别下的核心工具,约定调用级别为AUTO——即编写 Vue 业务代码时,凡是"等待某个状态满足后再继续"的需求,都应优先考虑它。

基础用法:等待异步数据就绪

until最常见的场景是等待异步数据加载完成。参考文档给出的经典示例是与useAsyncState配合:先发起一个异步请求,再用until等待其isReady标志翻转。

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! })()

关键点在于:isReady是一个响应式ref<boolean>until(isReady).toBe(true)会阻塞后续代码直到请求完成;当数据真正到达后,state才被读取,避免拿到初始空值。

airi 仓库里与此完全同构的例子是 display-models.ts:store 用displayModelsFromIndexedDBLoading标志表示 IndexedDB 中自定义模型(Live2D / VRM / Spine / MMD / Tachie)的异步加载状态,而loadDisplayModelsFromIndexedDBgetDisplayModeladdDisplayModelrenameDisplayModelremoveDisplayModel五个异步方法都统一以同一行代码开头:

await until(displayModelsFromIndexedDBLoading).toBe(false)

这里有两层含义:一是等待加载流程彻底结束(false),二是串行化并发访问——无论调用方是模型选择器还是设置页,任何操作都要先排队等加载完成,避免在 IndexedDB 读写过程中插入竞态。这是until在真实 store 中承担"异步互斥门闩(gate)"职责的典型体现。

自定义条件匹配:toMatch 与 getter 源

参考文档第二个示例展示了toMatch——当你需要的不只是"等于某个值",而是"满足某个谓词"时使用。配合invoke可以在任何地方启动异步流程:

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!') })

toMatch的谓词签名与Array.prototype.filter类似,返回true时 Promise 立即 resolve,并携带当时的快照值。它甚至可以充当类型守卫:类型声明中toMatch有一个重载,当谓词返回类型谓词v is U时,resolve 出的类型会被收窄为U(见 until.md)。

除直接传入ref外,until的入参还接受getter 函数。airi 仓库大量使用这种形式等待多个条件同时成立:

  • OrbitControls.vue:await until(() => cameraTres.value && renderer.domElement).toBeTruthy(),等待 three.js 相机与渲染器 DOM 均就绪后再初始化轨道控制;
  • SkyBox.vue:await until(() => !!renderer && !!renderer.domElement).toBeTruthy(),保证天空盒挂载时 WebGL 渲染器已存在;
  • VRMModel.vue:await until(() => scene.value).toBeTruthy(),等待 3D 场景创建完成后再加载 VRM 模型。

getter 写法把"多源就绪条件"折叠成一个布尔表达式,比逐个await until(refA).toBeTruthy()更紧凑,也天然支持任意复杂的复合条件。

超时控制:防止无限等待

真实业务中,等待的状态可能永远不出现(设备未授权、请求失败、资源加载异常)。until通过options提供两个超时相关选项,参考文档完整给出了两种语义:

import { until } from '@vueuse/core' // 直到 ref.value === true 或 1000ms 超时(静默 resolve,不抛错) await until(ref).toBe(true, { timeout: 1000 }) // 超时则抛错,需要 try/catch 捕获 try { await until(ref).toBe(true, { timeout: 1000, throwOnTimeout: true }) // ref.value === true } catch (e) { // timeout }

选项的默认值在类型声明中有明确标注(until.md):

选项类型默认值作用
timeoutnumber0(永不超时)Promise 等待的毫秒上限,0表示不限时
throwOnTimeoutbooleanfalse超时时是否 reject;为false时静默 resolve
deepWatchOptions["deep"]false是否深度监听内部变化,直接透传给内部watch

airi 仓库对超时选项的使用非常考究,尤其是throwOnTimeouttry/catch的组合。语音转写链路是最典型的例子:use-transcriptions.ts 在启动麦克风流时这样写:

await askPermission() // If still no stream, try starting it manually if (!stream.value && hearingEnabled.value) { startStream() // Wait for the stream to become available with a timeout. try { await until(stream).toBeTruthy({ timeout: 3000, throwOnTimeout: true }) } catch { console.error('Timed out waiting for audio stream. Stopping transcription.', { source: 'useTranscriptions' }) isListening.value = false return } }

同样的模式也出现在 browser-web-speech-api.vue:用户拒绝麦克风授权或系统迟迟不返回流时,3 秒后抛出、停止监听并给出日志,而不是让异步流程永久挂起。这是"可失败的等待"的标准姿势:先给足时间,再优雅降级

另一个短超时场景在 Live2D 模型加载中:Model.vue 在画布重建期间用await until(() => !!pixiApp.value && !!pixiApp.value.stage).toBeTruthy({ timeout: 1500 })短暂等待新 stage 出现,超时则回退到mounted状态避免白屏。

链式断言全家桶:toBe 系列与 not 取反

参考文档"More Examples"一节汇总了until的全部断言方法,直接覆盖了日常 90% 的需求:

import { until } from '@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()

各方法语义如下(对应类型声明 UntilValueInstance):

方法等待条件备注
toBe(value)与目标值严格相等,value支持MaybeRefOrGetter可与任意响应式源比较
toMatch(fn)谓词返回true支持类型守卫收窄
changed()值发生任意变化等待首次变化
changedTimes(n)值变化次数达到n从 1 开始计数
toBeTruthy()/toBeFalsy()真值 / 假值判定等价于!!v
toBeNull()/toBeUndefined()/toBeNaN()对应空值判定not取反可表达"非空"
not前缀上述所有断言取反类型层面也会翻转

not是真正的类型级取反:UntilValueInstance<T, Not>not会把Not泛型翻转,因此until(ref).not.toBeNull()的返回值类型会被收窄为Exclude<T, null>——取反不只是语义反转,TypeScript 推导也跟着反转。

airi 中的两个实战用法:

  • audio-context.ts 与 audio-recorder.ts 都用await until(mediaRef).toBeTruthy()等待麦克风MediaStream注入后再创建AudioContext/ 开始录音——保证音频管线初始化时流一定可用;
  • Model.vue 用await until(modelLoading).not.toBeTruthy()等待上一个模型加载结束后再获取互斥锁,配合modelLoadMutex防止并发重载 Live2D 模型。

数组实例:toContains 与类型收窄

until接收的源是数组类型时,返回的是UntilArrayInstance<T>(until.md),除了继承toMatchchangedchangedTimes外,额外提供toContains

export interface UntilArrayInstance<T> extends UntilBaseInstance<T> { readonly not: UntilArrayInstance<T> toContains: ( value: MaybeRefOrGetter<ElementOf<ShallowUnwrapRef<T>>>, options?: UntilToMatchOptions, ) => Promise<T> }

toContains等待数组中包含指定元素,value同样可以是ref或 getter。需要注意not在数组实例上的语义:它返回的仍是UntilArrayInstance<T>(而非UntilValueInstance),因此until(arr).not.toContains(x)表达"等待数组不再包含 x"。

完整类型声明与内部实现要点

until的完整类型声明见 until.md,核心结构如下:

export interface UntilToMatchOptions extends ConfigurableFlushSync { timeout?: number // 默认 0,0 表示永不超时 throwOnTimeout?: boolean // 默认 false deep?: WatchOptions["deep"] // 默认 false,透传给内部 watch } export interface UntilBaseInstance<T, Not extends boolean = false> { toMatch: (...) changed: (options?: UntilToMatchOptions) => Promise<T> changedTimes: (n?: number, options?: UntilToMatchOptions) => Promise<T> } type Falsy = false | void | null | undefined | 0 | 0n | "" export interface UntilValueInstance<T, Not extends boolean = false> extends UntilBaseInstance<T, Not> { readonly not: UntilValueInstance<T, Not extends true ? false : true> toBe: ... toBeTruthy: ... // Not 为 true 时返回 Promise<T & Falsy> toBeNull: ... // Not 为 true 时返回 Promise<Exclude<T, null>> toBeUndefined: ... toBeNaN: ... }

几个值得注意的实现细节:

  1. 源参数兼容性until(r: WatchSource<T> | MaybeRefOrGetter<T>)WatchSource覆盖ref/reactive/ getter 函数,这与 Vuewatch的源类型完全一致,因此until本质上是"一个建立在watch之上的 Promise 封装"。
  2. toBeTruthy的类型收窄:正常情况下返回Promise<Exclude<T, Falsy>>(去掉所有假值类型),取反后返回Promise<T & Falsy>——Falsy联合类型在声明中直接列出:false | void | null | undefined | 0 | 0n | ""
  3. 一次性的本质:每次调用只等待一次条件满足即 resolve,不会持续监听,监听器随即被清理,天然避免内存泄漏。
  4. ConfigurableFlushSyncUntilToMatchOptions继承了 VueUse 的同步 flush 配置族,保证在 flush 语义上与依赖方一致。

在 airi 中的完整应用地图与最佳实践

把仓库里的真实调用点串起来,可以绘制出until在 airi 各端的使用全景:

应用层文件用法解决的问题
Web/桌面端display-models.tstoBe(false)× 5IndexedDB 模型加载并发互斥
Web/桌面端audio-context.ts、audio-recorder.tstoBeTruthy()等待麦克风流就绪再初始化音频
Web/桌面端use-transcriptions.ts、browser-web-speech-api.vuetoBeTruthy({ timeout, throwOnTimeout })麦克风授权超时降级
Web/桌面端chat-bubble-minimalism.vuetoBeTruthy()× 2等待气泡 DOM ref 挂载后再运行动画
Live2DModel.vuenot.toBeTruthy()toBeTruthy({ timeout })模型加载互斥锁 + 画布重建等待
3D 场景OrbitControls.vue、SkyBox.vue、VRMModel.vuegetter +toBeTruthy()等待相机/渲染器/场景多源就绪

综合这些调用,可以沉淀出几条适用于任何 Vue 3 / Nuxt 3 项目的最佳实践:

  1. 等待 DOM ref 用toBeTruthy()ref<HTMLDivElement>()在模板渲染后才有值,await until(elRef).toBeTruthy()nextTick更稳健(chat-bubble-minimalism.vue 正是这么做的)。
  2. 可失败的等待务必配timeout+throwOnTimeout+try/catch:凡是依赖用户授权、外部设备、网络请求的状态,都必须设置超时兜底,并给出降级路径。
  3. 多个异步写操作共享一个 loading 标志时,用until(x).toBe(false)做入口门闩:display-models.ts 的五个方法统一前置等待,是消除竞态的简洁方案。
  4. 复合就绪条件优先用 getter 源until(() => a.value && b.value).toBeTruthy()比串行 await 更清晰、更快。
  5. 对象/数组深层变化记得传deep: true:默认浅监听,若等待的是嵌套字段变化,需显式开启(对应UntilToMatchOptions.deep)。

结语

until用最小的 API 面(一个入口、一套断言、两个超时选项)把 Vue 的响应式世界与异步流程编织在一起,是"Promise 化响应式状态"的标准答案。在 airi 中,它既承担着 IndexedDB 模型存储的并发门闩,也守护着实时语音转写与 Live2D / VRM 渲染的初始化时序——从 Web 到桌面再到 3D 舞台,同一套等待语义贯穿始终。下一次当你发现自己在手写watch+ 标志位 +resolve时,不妨先问一句:until是不是已经替你写好了?

【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询