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,让你在ref、computed、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 readyuntil把上述模式收敛为一个 awaitable 的链式 API,内部对目标源建立一个一次性 watch,条件满足即 resolve,并自动清理监听:
await until(source).toBe(true)从源码结构看(见下方"类型声明"一节),until接收一个WatchSource<T> | MaybeRefOrGetter<T>参数,兼容ref、reactive对象、getter 函数等所有可被watch追踪的源,并返回带有toBe、toMatch、changed、changedTimes等方法的实例,支持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)的异步加载状态,而loadDisplayModelsFromIndexedDB、getDisplayModel、addDisplayModel、renameDisplayModel、removeDisplayModel五个异步方法都统一以同一行代码开头:
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):
| 选项 | 类型 | 默认值 | 作用 |
|---|---|---|---|
timeout | number | 0(永不超时) | Promise 等待的毫秒上限,0表示不限时 |
throwOnTimeout | boolean | false | 超时时是否 reject;为false时静默 resolve |
deep | WatchOptions["deep"] | false | 是否深度监听内部变化,直接透传给内部watch |
airi 仓库对超时选项的使用非常考究,尤其是throwOnTimeout与try/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),除了继承toMatch、changed、changedTimes外,额外提供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: ... }几个值得注意的实现细节:
- 源参数兼容性:
until(r: WatchSource<T> | MaybeRefOrGetter<T>),WatchSource覆盖ref/reactive/ getter 函数,这与 Vuewatch的源类型完全一致,因此until本质上是"一个建立在watch之上的 Promise 封装"。 toBeTruthy的类型收窄:正常情况下返回Promise<Exclude<T, Falsy>>(去掉所有假值类型),取反后返回Promise<T & Falsy>——Falsy联合类型在声明中直接列出:false | void | null | undefined | 0 | 0n | ""。- 一次性的本质:每次调用只等待一次条件满足即 resolve,不会持续监听,监听器随即被清理,天然避免内存泄漏。
ConfigurableFlushSync:UntilToMatchOptions继承了 VueUse 的同步 flush 配置族,保证在 flush 语义上与依赖方一致。
在 airi 中的完整应用地图与最佳实践
把仓库里的真实调用点串起来,可以绘制出until在 airi 各端的使用全景:
| 应用层 | 文件 | 用法 | 解决的问题 |
|---|---|---|---|
| Web/桌面端 | display-models.ts | toBe(false)× 5 | IndexedDB 模型加载并发互斥 |
| Web/桌面端 | audio-context.ts、audio-recorder.ts | toBeTruthy() | 等待麦克风流就绪再初始化音频 |
| Web/桌面端 | use-transcriptions.ts、browser-web-speech-api.vue | toBeTruthy({ timeout, throwOnTimeout }) | 麦克风授权超时降级 |
| Web/桌面端 | chat-bubble-minimalism.vue | toBeTruthy()× 2 | 等待气泡 DOM ref 挂载后再运行动画 |
| Live2D | Model.vue | not.toBeTruthy()、toBeTruthy({ timeout }) | 模型加载互斥锁 + 画布重建等待 |
| 3D 场景 | OrbitControls.vue、SkyBox.vue、VRMModel.vue | getter +toBeTruthy() | 等待相机/渲染器/场景多源就绪 |
综合这些调用,可以沉淀出几条适用于任何 Vue 3 / Nuxt 3 项目的最佳实践:
- 等待 DOM ref 用
toBeTruthy():ref<HTMLDivElement>()在模板渲染后才有值,await until(elRef).toBeTruthy()比nextTick更稳健(chat-bubble-minimalism.vue 正是这么做的)。 - 可失败的等待务必配
timeout+throwOnTimeout+try/catch:凡是依赖用户授权、外部设备、网络请求的状态,都必须设置超时兜底,并给出降级路径。 - 多个异步写操作共享一个 loading 标志时,用
until(x).toBe(false)做入口门闩:display-models.ts 的五个方法统一前置等待,是消除竞态的简洁方案。 - 复合就绪条件优先用 getter 源:
until(() => a.value && b.value).toBeTruthy()比串行 await 更清晰、更快。 - 对象/数组深层变化记得传
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),仅供参考