- 前端
【免费下载链接】vueuse
Collection of essential Vue Composition Utilities for Vue 3
useDebouncedRefHistory是 VueUse 中用于跟踪 ref 变更历史的核心工具函数之一,它是useRefHistory的"防抖速记版":当状态开始变化后,仅在设定的延迟窗口内没有新变化时,才把最终快照写入历史记录。本文将以该函数为核心,讲解它的用法、全部配置参数、返回值语义,并结合 packages/core/useDebouncedRefHistory/index.ts、packages/shared/utils/filters.ts 等源码与测试,深入剖析其底层实现原理。读完本文,你将能熟练使用它实现带"停顿检测"的历史记录、撤销/重做(undo/redo)、暂停恢复等能力,并理解它与节流版本useThrottledRefHistory的取舍差异。
一、函数定位:useRefHistory 的防抖(debounce)速记版
在 VueUse 的函数体系里,useRefHistory负责完整地跟踪一个 ref 的变更历史并暴露 undo / redo 能力,但它默认会对每一次变更都立即记录快照。在很多真实场景中(如编辑器自动保存、搜索框输入、表单状态快照),我们并不希望每次都落盘,而是希望等用户"停手"一段时间后再记录,这正好是防抖(debounce)的语义。
useDebouncedRefHistory正是为此设计的"速记版"。它的源码极短,全部逻辑如下(见 packages/core/useDebouncedRefHistory/index.ts):
export function useDebouncedRefHistory<Raw, Serialized = Raw>( source: Ref<Raw>, options: Omit<UseRefHistoryOptions<Raw, Serialized>, 'eventFilter'> & { debounce?: MaybeRefOrGetter<number> } = {}, ): UseRefHistoryReturn<Raw, Serialized> { const filter = options.debounce ? debounceFilter(options.debounce) : undefined const history = useRefHistory(source, { ...options, eventFilter: filter }) return { ...history, } }从中可以提炼出三条关键事实:
- 它接收
debounce参数(毫秒数,且可以是 ref 或 getter,即MaybeRefOrGetter<number>),内部用debounceFilter生成一个事件过滤器; - 它把该过滤器通过
eventFilter选项注入useRefHistory,从而"改造"底层的历史记录提交时机; - 返回值与
useRefHistory完全一致(UseRefHistoryReturn),因此所有撤销/重做、暂停/恢复能力全部继承自底层实现。
官方对它的定位就一句话:"Shorthand foruseRefHistorywith debounced filter."(带防抖过滤器的useRefHistory速记写法)。它省去了你手动组合debounceFilter与eventFilter的样板代码。
二、快速上手:在状态停止变化 1000ms 后记录快照
原文档给出的核心示例是:当计数器开始变化后,等待 1000ms 才截取一次快照。
import { useDebouncedRefHistory } from '@vueuse/core' import { shallowRef } from 'vue' const counter = shallowRef(0) const { history, undo, redo } = useDebouncedRefHistory(counter, { deep: true, debounce: 1000 })行为语义如下:
- 当
counter.value连续快速变化(例如 0 → 1 → 2 → 3 在 1 秒内完成)时,不会为每一步都生成历史记录; - 只有当最后一次变化之后经过 1000ms 没有新的变化,才会把当前值作为一条快照写入
history; - 这意味着防抖期内的中间值会被"合并"掉,最终历史记录里只保留停顿后的稳定状态——非常适合需要"静默后保存"的交互。
注意debounce可以传 ref 或 getter,这意味着延迟时间可以是响应式的。官方演示 packages/core/useDebouncedRefHistory/demo.vue 就展示了这一点:
const delay = shallowRef(1000) const { count, inc, dec } = useCounter() const { history, undo, redo, canUndo, canRedo } = useDebouncedRefHistory( count, { capacity: 10, debounce: delay }, )模板里通过<input v-model="delay" type="number">动态调整延迟,同时用capacity: 10限制历史最多保留 10 条,并用canUndo/canRedo控制按钮的禁用状态——这是一个可以直接照搬使用的完整交互模板。
三、参数详解:完整配置项一览
useDebouncedRefHistory的参数是Omit<UseRefHistoryOptions<Raw, Serialized>, 'eventFilter'> & { debounce?: MaybeRefOrGetter<number> }。其中UseRefHistoryOptions定义于 packages/core/useRefHistory/index.ts,各字段含义如下:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
debounce | MaybeRefOrGetter<number> | 不传则不过滤 | 防抖延迟毫秒数,支持 ref / getter(响应式)。传入后内部生成debounceFilter;不传则等价于普通useRefHistory |
deep | boolean | false | 是否深度监听源 ref 的内部变化;为true时会为历史中存储的值创建克隆 |
capacity | number | 无限制 | 历史最多保留的记录条数,超过后丢弃最旧记录 |
clone | boolean \| CloneFn<Raw> | false | 拍快照时是否克隆值,是dump: JSON.parse(JSON.stringify(value))的快捷方式;可传自定义克隆函数 |
dump | (v: Raw) => Serialized | 恒等 / 克隆 | 将原始值序列化为历史中存储的形式(如 Date → 时间戳) |
parse | (v: Serialized) => Raw | 恒等 / 克隆 | 将历史中的值反序列化回原始值,用于 undo / redo 恢复 |
shouldCommit | (oldValue, newValue) => boolean | 恒返回true | 判断某次变更是否应提交为历史记录,可用来过滤无关变更 |
flush | 'pre' \| 'post' \| 'sync' | 'pre' | 底层 watch 的刷新时机,继承自ConfigurableFlush |
eventFilter | EventFilter | 由内部注入 | 事件过滤器,useDebouncedRefHistory已替你从选项中移除并注入防抖过滤器,用户无需再传 |
关于deep的一个细节:当deep: true时,内部会以clone: options.clone || deep传给手动历史层(见 useRefHistory/index.ts),即深监听的同时也会克隆快照,保证历史记录不被后续原地修改污染。如果只修改对象内部属性而希望被捕获,务必开启deep: true,原文档示例中就是这样做的。
dump/parse是另一组实用组合:例如想把对象以 JSON 字符串形式存入历史,可以dump: v => JSON.stringify(v)、parse: v => JSON.parse(v),从而让history中存储轻量化的序列化数据。
四、返回值详解:一个完整的历史管理工具箱
useDebouncedRefHistory返回UseRefHistoryReturn<Raw, Serialized>,由useRefHistory与底层的useManualRefHistory(定义见 packages/core/useManualRefHistory/index.ts)共同提供:
撤销 / 重做
undo():撤销一次变更,将当前快照压入 redo 栈;redo():重做一次撤销,将快照压回 undo 栈;canUndo: ComputedRef<boolean>/canRedo: ComputedRef<boolean>:对应栈是否非空,常用于禁用按钮;reset():用最近一条历史快照恢复源值。
历史数据
history: ComputedRef<UseRefHistoryRecord<Serialized>[]>:[last, ...undoStack],新纪录在最前,每条记录形如{ snapshot, timestamp };last: Ref<UseRefHistoryRecord<Serialized>>:最近一次快照点(暂停期间源值变化时与history头部可能不一致);undoStack/redoStack:两栈的原始数组引用。
跟踪控制
isTracking: Ref<boolean>:当前是否在跟踪;pause():暂停跟踪,期间的变更不会写入历史;resume(commit?: boolean):恢复跟踪,传入true会在恢复后立即补记一条快照;batch(fn):在函数作用域内自动暂停并在结束时合并提交,适合批量修改源值只记录一条历史;dispose():停止底层 watcher 并清空历史;commit():手动创建一条历史记录(来自 manualHistory 层)。
五、底层原理:源码级剖析
5.1 debounceFilter:防抖事件过滤器
防抖的核心实现在 packages/shared/utils/filters.ts 的debounceFilter中。它返回一个带cancel/flush/isPending的CancelableEventFilter。核心逻辑:
export function debounceFilter(ms: MaybeRefOrGetter<number>, options: DebounceFilterOptions = {}): CancelableEventFilter { let timer: TimerHandle let maxTimer: TimerHandle const _pending = shallowRef(false) const handler = (invoke: AnyFn) => { const duration = toValue(ms) const maxDuration = toValue(options.maxWait) if (timer) _clearTimeout(timer) if (duration <= 0 || (maxDuration !== undefined && maxDuration <= 0)) { // 延迟 <= 0 时直接调用,等价于不防抖 _pending.value = false return Promise.resolve(invoke()) } _pending.value = true return new Promise((resolve, reject) => { // 每来一次新事件,重置常规 timer timer = setTimeout(() => { maxTimer = undefined _pending.value = false resolve(invoke()) }, duration) }) } // ... }关键点:
- 每次新变更都会重置常规计时器,因此只有"停止变更达到
duration毫秒"时才会真正触发invoke(即提交快照); - 可选的
maxWait限制了最长等待时间,防止持续不断的变化让回调永远不执行(debounceFilter第二参DebounceFilterOptions中的maxWait字段); _pending以shallowReadonly暴露为isPending,可用于监听"是否正处于防抖等待中";duration <= 0时直接调用,等价于无防抖——这与useDebouncedRefHistory中debounce缺省时的行为一致。
5.2 过滤器如何注入 watch
useRefHistory把传入的eventFilter包进pausableFilter得到组合过滤器composedFilter,再交给watchIgnorable监听源 ref(见 useRefHistory/index.ts):
const { eventFilter: composedFilter, pause, resume: resumeTracking, isActive: isTracking, } = pausableFilter(eventFilter) const { ignoreUpdates, ignorePrevAsyncUpdates, stop, } = watchIgnorable( source, commit, { deep, flush, eventFilter: composedFilter }, )于是变更事件经过"防抖过滤器 → 可暂停包装"两层过滤后,才触发commit()真正写入历史。commit中还会调用shouldCommit(lastRawValue, source.value)做二次裁决,通过后才manualCommit()。
5.3 历史栈的数据结构
历史栈由useManualRefHistory维护(见 packages/core/useManualRefHistory/index.ts):
- 每条记录是
markRaw({ snapshot: dump(source.value), timestamp: timestamp() }); commit():把当前last压入undoStack头部,生成新快照作为last;超过capacity时从尾部裁剪;同时清空redoStack;undo():undoStack.shift()取栈首记录,把当前last压回redoStack,再通过setSource(内部用ignoreUpdates包裹以避免回写触发新记录)恢复源值;history是[last, ...undoStack]的计算属性——这就是为什么文档说"最新记录在最前"。
值得注意的一个细节:useRefHistory会覆盖默认setSource,在恢复历史时调用ignorePrevAsyncUpdates()丢弃之前的异步待提交更新,从而正确处理"undo 后又修改"这类交错操作(见 useRefHistory/index.ts 中的注释说明)。
六、与 useRefHistory、useThrottledRefHistory 的取舍
三者共享同一套历史/撤销/重做机制,区别仅在eventFilter:
| 函数 | 过滤器 | 特性 |
|---|---|---|
useRefHistory | 无(直接提交) | 每次变更都记录,历史最完整 |
useDebouncedRefHistory | debounceFilter | 停止变化debounce毫秒后才记录,忽略中间态 |
useThrottledRefHistory | throttleFilter | 固定节奏记录(默认每 200ms 至多一次,trailing 收尾),见 useThrottledRefHistory/index.ts |
从源码对比看(useThrottledRefHistory/index.ts):节流版默认throttle = 200、trailing = true,且在配置存在时也会始终生成过滤器;而防抖版只有显式传入debounce时才生成过滤器,缺省时行为退化为普通useRefHistory。
选型建议:
- 高频输入 + 希望记录"停顿后的最终状态" →
useDebouncedRefHistory; - 需要持续、有节奏地采样(如滚动位置上报)→
useThrottledRefHistory; - 需要每步都可撤销 → 直接用
useRefHistory。
七、测试验证:行为由测试用例背书
仓库为其编写了专门的浏览器测试 packages/core/useDebouncedRefHistory/index.browser.test.ts,直接印证了防抖语义:
it("once the ref's value has changed and some time has passed, ensure the snapshot is updated", async () => { const v = shallowRef(0) const { history } = useDebouncedRefHistory(v, { debounce: 10 }) v.value = 100 expect(history.value.length).toBe(1) // 防抖期内:只有初始快照 expect(history.value[0].snapshot).toBe(0) await vi.waitFor(() => { expect(history.value.length).toBe(2) // 10ms 后:新快照落库 expect(history.value[0].snapshot).toBe(100) }, { interval: 5 }) }) it('when debounce is undefined', async () => { const v = shallowRef(0) const { history } = useDebouncedRefHistory(v, { deep: false }) v.value = 100 await nextTick() expect(history.value.length).toBe(2) // 无 debounce:立即记录 expect(history.value[0].snapshot).toBe(100) })这两个用例精确验证了两条行为:一是防抖期间不产生新记录、延迟过后才更新快照;二是debounce缺省时不设过滤器,变更在nextTick后即被记录(等价于普通历史记录)。这也再次确认了源码中options.debounce ? debounceFilter(...) : undefined的分支逻辑。
八、完整实战:可动态调整延迟的撤销/重做组件
综合以上所有知识点,一个可直接运行的完整示例如下(参照官方演示精简而成):
<script setup lang="ts"> import { useDebouncedRefHistory, useCounter } from '@vueuse/core' import { shallowRef } from 'vue' const delay = shallowRef(1000) const { count, inc, dec } = useCounter() const { history, undo, redo, canUndo, canRedo } = useDebouncedRefHistory( count, { capacity: 10, debounce: delay }, ) </script> <template> <div>Count: {{ count }}</div> <button @click="inc()">Increment</button> <button @click="dec()">Decrement</button> <button :disabled="!canUndo" @click="undo()">Undo</button> <button :disabled="!canRedo" @click="redo()">Redo</button> <label>Delay (ms): <input v-model="delay" type="number"></label> <ul> <li v-for="i in history" :key="i.timestamp"> {{ i.timestamp }}: {{ JSON.stringify(i.snapshot) }} </li> </ul> </template>要点回顾:debounce绑定响应式 ref,可实时调整防抖窗口;capacity限制历史条数;canUndo/canRedo驱动按钮状态;history中的每条记录包含snapshot与timestamp两个字段。如果需要更精细的控制,还可以解构出pause、resume、batch、dispose、clear、reset等方法来嵌入到复杂的业务交互中。
小结
useDebouncedRefHistory以极薄的封装,把 VueUse 的"事件过滤器(event filter)"机制与"ref 历史记录"机制优雅地组合在一起:一个debounce参数即可获得"停顿后记录快照"的语义,而完整的 undo/redo/暂停/恢复/批量提交能力则原样继承自useRefHistory与useManualRefHistory。理解它的源码(index.ts → useRefHistory → useManualRefHistory → debounceFilter)也就理解了 VueUse 整个历史记录家族的设计脉络,可以举一反三地使用useThrottledRefHistory或自定义eventFilter构建任意采样策略。
- 前端
【免费下载链接】vueuse
Collection of essential Vue Composition Utilities for Vue 3
相关推荐
drawDB撤销重做机制:状态管理与历史记录
drawDB撤销重做机制:状态管理与历史记录 引言:数据库设计中的操作安全保障 在数据库设计工具中,用户经常会进行各种复杂操作:添加表、修改字段、创建关系、调整
前端数据库数据库客户端egui撤销重做:操作历史记录和状态恢复
egui撤销重做:操作历史记录和状态恢复 痛点场景 你是否曾经在开发GUI应用时遇到过这样的困扰: 用户误操作后无法回退,导致数据丢失 需要手动实现复杂的撤销/
UI组件前端桌面应用Airi 项目中的 VueUse useThrottledRefHistory:为 ref 状态机接入节流历史记录与撤销重做
Airi 项目中的 VueUse useThrottledRefHistory:为 ref 状态机接入节流历史记录与撤销重做 useThrottledRefHi
AI 应用人工智能大模型数字人AI Agent语音前端后端桌面应用移动开发即时通讯3D渲染
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考