☰
airi 实战:用 VueUse `useTimeoutFn` 封装可控制的 `setTimeout` 定时器
2026/10/14 3:10:20 网站建设 项目流程
  • 人工智能
  • AI Agent
  • MCP 服务
  • 工具调用
  • GUI 自动化
  • 语音
  • 数字人
  • 交互助手

【免费下载链接】airi

💖🧸 自托管、归你拥有的 Grok 风格 AI 伴侣与 waifu / 赛博生命灵魂容器,目标是接近 Neuro-sama 的高度;支持实时语音聊天、Minecraft 和 Factorio 游玩,支持 Web / macOS / Windows。

项目地址:https://gitcode.com/moeru-ai/airi
点击查看免费下载

useTimeoutFn是 VueUse 对浏览器原生setTimeout的响应式封装,它在保留定时触发能力的同时,额外暴露isPending、start、stop三个控制接口,让定时器可以在组件生命周期内被随时启动、停止与复用。在 airi(自托管 AI 伴侣项目)的 Electron / Web 多端前端中,useTimeoutFn被广泛用于悬停菜单延迟、OIDC 令牌定时刷新、语音 inlay 自动隐藏、Live2D 按住寻帧等高频交互场景。读完本文,你将掌握useTimeoutFn的完整 API、响应式间隔用法,以及 airi 仓库中的五类实战模式与选型对比。

关联文档位于仓库 .agents/skills/vueuse-functions/references/useTimeoutFn.md,属于.agents/skills/vueuse-functions技能包中的 Animation(动画)分类,其引用文档 SKILL.md 将其调用规则标记为AUTO,即满足条件即可自动使用。

为什么需要useTimeoutFn:原生setTimeout的三个痛点

原生setTimeout返回的是一个数值型 timer id,它本身不具备任何响应式能力,在实际组件开发中会带来三个常见问题:

  1. 无法感知状态:定时器是否还在等待中没有任何响应式信号,UI 无法据此更新;
  2. 难以协作:clearTimeout需要手动保存 timer id,在多个事件源(进入/离开、按下/释放、窗口失焦)竞争同一个定时器时极易漏清理或重复清理;
  3. 生命周期管理繁琐:组件卸载时若忘记clearTimeout,回调会在组件销毁后继续执行,造成内存泄漏或对已卸载 ref 的写入。

useTimeoutFn正是为解决这些问题而设计的——它是 "Wrapper forsetTimeoutwith controls"(带控制的setTimeout封装):

import { useTimeoutFn } from '@vueuse/core' const { isPending, start, stop } = useTimeoutFn(() => { /* ... */ }, 3000)

完整 API 与类型声明解读

关联文档给出了完整的类型声明,这里逐项拆解:

选项:UseTimeoutFnOptions

export interface UseTimeoutFnOptions { /** * Start the timer immediately * * @default true */ immediate?: boolean /** * Execute the callback immediately after calling `start` * * @default false */ immediateCallback?: boolean }
选项类型默认值含义
immediatebooleantrue调用useTimeoutFn后是否立即启动定时器
immediateCallbackbooleanfalse调用start()时是否立即先执行一次回调(之后再等间隔到期触发)

返回值:UseTimeoutFnReturn

export type UseTimeoutFnReturn<CallbackFn extends AnyFn> = Stoppable< Parameters<CallbackFn> | [] >

返回值是 VueUse 通用Stoppable类型,展开后包含三个成员:

返回值类型说明
isPendingReadonly<Ref<boolean>>定时器是否处于等待触发状态,响应式
start() => void启动(或重新启动)定时器
stop() => void停止定时器,回调不再触发

Parameters<CallbackFn> | []表示start类型上携带回调函数的参数类型信息,方便 TS 推导。

函数签名

export declare function useTimeoutFn<CallbackFn extends AnyFn>( cb: CallbackFn, interval: MaybeRefOrGetter<number>, options?: UseTimeoutFnOptions, ): UseTimeoutFnReturn<CallbackFn>

关键点在于第二个参数interval的类型是MaybeRefOrGetter<number>,意味着定时时长本身可以是ref、getter 函数或普通数值,这让定时器时长能够动态响应式变化。

基本用法:immediate默认开启

最直接的用法是不传options,定时器创建后立即开始计时,3 秒后执行回调:

import { useTimeoutFn } from '@vueue/core' const { isPending, start, stop } = useTimeoutFn(() => { console.log('3 秒到了') }, 3000) console.log(isPending.value) // true

airi 仓库中大量用法都显式传入{ immediate: false },把启动时机交给用户事件控制。例如apps/stage-tamagotchi/src/renderer/components/inlay/voice-inlay.vue中,语音 inlay(桌面悬浮输入窗)在内容清空后延迟 1.2 秒自动隐藏,避免连续语音草稿之间窗口闪烁:

const EMPTY_HIDE_DELAY_MS = 1200 const emptyHide = useTimeoutFn(() => void hideInlay(), EMPTY_HIDE_DELAY_MS, { immediate: false }) function handlePresence(visible: boolean) { if (visible) emptyHide.stop() // 有内容时取消隐藏定时器 else if (shown) emptyHide.start() // 内容清空后启动延迟隐藏 shown = visible }

这里体现了start/stop的核心用法:用事件驱动的开关反复启停同一个定时器,而不是每次都重建。

响应式间隔:动态调整定时时长

由于interval支持MaybeRefOrGetter,定时时长可以是ref。airi 的认证 Store(packages/stage-ui/src/stores/auth.ts)利用这一特性实现了 OIDC 令牌的定时刷新调度:

const refreshDelayMs = ref(0) const { start: startRefreshTimer, stop: stopRefreshTimer } = useTimeoutFn( () => { void useAuthStore().refreshTokenNow() }, refreshDelayMs, { immediate: false }, ) function scheduleTokenRefresh(expiresInSeconds: number): void { stopRefreshTimer() // Guard against missing/invalid lifetimes (e.g. token response omitted // expires_in). useTimeoutFn with NaN/<=0 delay would fire immediately // and spin a refresh loop — skip scheduling instead. if (!Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) return // Refresh at 80% of lifetime refreshDelayMs.value = expiresInSeconds * 0.8 * 1000 startRefreshTimer() }

这段代码展示了两个重要细节:

  1. 先改 ref 再start():useTimeoutFn内部会解包响应式间隔,在start()调用时读取当前值,因此「修改refreshDelayMs.value→ 调用startRefreshTimer()」的顺序保证了定时器使用最新时长;
  2. 非法时长的防御:代码注释明确指出,useTimeoutFn遇到NaN或小于等于 0 的延迟会立即触发回调——这在令牌刷新场景中会形成刷新死循环,因此scheduleTokenRefresh在调度前先做了Number.isFinite与> 0的守卫。

刷新时机取令牌生命周期的 80%(expiresInSeconds * 0.8 * 1000ms),既避免令牌过期,又留有网络往返余量。该 Store 还通过signOut()、clearAuthState()中的stopRefreshTimer()在登出/清空凭据时停止调度。

实战模式一:悬停菜单的延迟开合(use-hover-menu.ts)

airi 的聊天输入区悬停菜单(packages/stage-ui/src/components/scenarios/chat/composables/use-hover-menu.ts)用两个useTimeoutFn分别实现打开延迟与关闭延迟:

const opening = useTimeoutFn(() => { if (enabled()) open.value = true }, options.openDelayMs ?? 600, { immediate: false }) const closing = useTimeoutFn(() => open.value = false, options.closeDelayMs ?? 300, { immediate: false }) function enter() { closing.stop() // 指针进入:取消关闭计划 if (!open.value) opening.start() // 且菜单未打开时,启动打开延迟 } function leave() { opening.stop() // 指针离开:取消打开计划 if (open.value) closing.start() // 且菜单已打开时,启动关闭延迟 }

设计意图:指针在触发按钮上短暂掠过(少于 600ms)不会误开菜单,而移出后 300ms 内回来也不会闪关。openNow()(右键或快捷键)则直接取消opening并立即打开。这是典型的「两个对偶定时器 + 事件交叉取消」模式,start/stop的可重复调用特性让它实现得非常干净。

实战模式二:动画阶段状态机(controls-island-root.vue)

airi 桌面端控制岛在窗口拖动后需要迁移到新角落,迁移动画由「离开 → 进入前准备 → 到达」多个阶段组成。两处实现(apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-root.vue 与 kirie 版的 controls-island-root.vue)用三个useTimeoutFn串联阶段切换:

const { start: finishArrival, stop: stopArrival } = useTimeoutFn(() => { motionPhase.value = 'idle' relocationTarget.value = undefined }, placementArrivalDurationMs, { immediate: false }) const { start: startArrival, stop: stopEnterPreparation } = useTimeoutFn(() => { motionPhase.value = 'arriving' finishArrival() }, placementEnterPreparationMs, { immediate: false }) const { start: finishLeave, stop: stopLeave } = useTimeoutFn(() => { if (!relocationTarget.value) { motionPhase.value = 'idle' return } dock.value = relocationTarget.value motionPhase.value = 'entering' startArrival() }, placementLeaveDurationMs, { immediate: false })

阶段流转为:leaving(旧角淡出,150ms)→entering(切换 dock)→arriving(新角入场,16ms 准备后 150ms 完成)→idle。每个定时器到期时手动调用下一个阶段的start,形成链式调度;而stopRelocation()一次性stop三个定时器,用于处理「迁移中途又发生新迁移」的取消场景。同时代码尊重prefers-reduced-motion,在用户偏好减少动效时直接切换 dock 而跳过动画。

实战模式三:组件/Store 卸载自动清理

useTimeoutFn的回调与定时器会在宿主作用域销毁时被自动清理(组件卸载、Store dispose 均适用),这是它相对原生setTimeout的重要优势。auth.ts的注释印证了这一点:

// --- OIDC token refresh scheduling --- // Uses useTimeoutFn for automatic cleanup on store teardown.

swipe-actions 组件(packages/ui/src/components/swipe-actions/swipe-actions-root.vue)同样依赖这一行为——其注释明确写道 "VueUse disposes all listeners, the frame loop, and the idle timer when this row unmounts",其中轮子序列空闲定时器与提交复位定时器均由useTimeoutFn承载:

const wheelEnd = useTimeoutFn(endWheel, 180, { immediate: false }) const commitReset = useTimeoutFn(resetCommittedRow, 500, { immediate: false })

wheelEnd用于解决"浏览器没有触控板松手事件"的问题:滚轮序列超过 180ms 无新事件即视为结束并释放;commitReset则在完整滑动提交动作后留出 500ms 恢复窗口,供调用方移除行或复位行。两个定时器都靠组件卸载自动清理,无需手动onUnmounted清理。

实战模式四:一次性 UI 反馈自动消退

登录状态胶囊(apps/stage-tamagotchi/src/renderer/components/stage-islands/auth-status-island.vue)用useTimeoutFn实现「成功提示 3 秒后自动消失」:

const { start, stop } = useTimeoutFn(() => { if (status.value?.state === 'success') status.value = undefined }, 3000, { immediate: false }) watch(status, (value) => { stop() if (value?.state === 'success') start() }, { immediate: true })

每次状态变化先stop()重置,仅当状态变为success时启动 3 秒倒计时;期间若状态再次变化(例如用户点了重新登录),定时器会被下一次 watch 的stop()取消,不会误清除新状态。

实战模式五:与useIntervalFn组合实现「按住加速」

airi 的 Live2D 动作编辑器支持按住快捷键或手柄按键实现加速寻帧:短按移动 1 帧,按住超过 300ms 后以 80ms 间隔重复寻帧,且随按住时长加速(use-playback-keys.ts 与 use-gamepad-actions.ts):

const holdInterval = useIntervalFn(() => { if (activeDirection !== 0) step(activeDirection, heldStepCount()) }, holdSeekPolicy.intervalMs, { immediate: false }) const holdDelay = useTimeoutFn(() => { if (activeDirection === 0) return holdStarted = true step(activeDirection, heldStepCount()) holdInterval.resume() }, holdSeekPolicy.delayMs, { immediate: false })

这里的协作方式是:useTimeoutFn负责「按住多久后进入重复模式」的门槛(300ms),useIntervalFn负责门槛之后的周期性执行(80ms)。按住时holdDelay.start(),未到 300ms 就松手则holdDelay.stop(),并按!holdStarted判断是否补发一次单步寻帧;进入重复模式后由clearDirection()统一stop/pause。窗口失焦(useEventListener(window, 'blur', clearDirection))与组件卸载(tryOnBeforeUnmount(clearDirection))都会中止寻帧循环。

选型对比:Animation 分类下的定时相关函数

关联文档把useTimeoutFn归入 Animation 分类,同分类与邻近的定时函数各司其职,airi 仓库中均有用例:

函数职责返回典型场景(airi)
useTimeoutFn可控制的setTimeoutisPending/start/stop悬停延迟、令牌刷新调度、动画阶段切换
useTimeout响应式布尔值,超时后变为trueready或带controls需要"到时即 true"的状态,而非执行副作用
useIntervalFn可控制的setIntervalpause/resume/isActive按住寻帧的周期执行
useTimeoutPoll基于超时的轮询同useTimeoutFn+ 轮询周期性拉取远端状态
refAutoReset一段时间后自动重置的 refref临时高亮、一次性提示状态

其中useTimeout的UseTimeoutOptions直接继承了UseTimeoutFnOptions(immediate等选项),并额外提供controls与callback选项;从 useTimeout.md 的类型声明可以确认这一继承关系。useIntervalFn返回的是Pausable而非Stoppable,因此它提供pause/resume而不是stop/start,语义上更贴合"周期性可暂停"的场景。

选择建议:需要执行副作用且要手动启停 →useTimeoutFn;只需要一个到时翻转的响应式标记 →useTimeout;需要周期重复 →useIntervalFn。

注意事项小结

结合 airi 仓库的实际用法,使用useTimeoutFn时有四个值得注意的实践要点:

  1. 默认immediate: true:如果希望定时器由事件触发,务必显式传入{ immediate: false },airi 仓库中几乎所有用例都是这样做的;
  2. 动态时长记得先改 ref 再start():interval支持ref,但生效时机在start()调用那一刻(如 auth.ts 的scheduleTokenRefresh);
  3. 对NaN/非正时长做守卫:useTimeoutFn对非法时长会立即触发回调,在令牌刷新、轮询等可能形成循环的场景中必须先校验(见scheduleTokenRefresh的Number.isFinite守卫);
  4. 卸载自动清理,但别忘了显式stop的场景:作用域销毁时定时器会自动清理;但"登出""窗口失焦""动画中断"等运行中取消仍需手动调用stop(如clearDirection()、stopRelocation()、signOut()中的stopRefreshTimer())。

总体来看,useTimeoutFn是 airi 前端中处理"一次性延迟副作用"的统一基础设施——从毫秒级的动画阶段、到秒级的 UI 提示消退、再到分钟级的令牌刷新,它凭借可控、可复用、可自动清理的特性,让定时器逻辑始终保持在声明式、可测试的状态。

  • 人工智能
  • AI Agent
  • MCP 服务
  • 工具调用
  • GUI 自动化
  • 语音
  • 数字人
  • 交互助手

【免费下载链接】airi

💖🧸 自托管、归你拥有的 Grok 风格 AI 伴侣与 waifu / 赛博生命灵魂容器,目标是接近 Neuro-sama 的高度;支持实时语音聊天、Minecraft 和 Factorio 游玩,支持 Web / macOS / Windows。

项目地址:https://gitcode.com/moeru-ai/airi
点击查看免费下载
上一篇:DataHub Ask DataHub:基于元数据图谱与组织知识库的对话式 AI 助手实战指南
下一篇:Foundry 字节码验证加固:`forge verify-bytecode` 拒绝与部署不一致的构造参数

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

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

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

立即咨询