☰
VueUse useDebouncedRefHistory 实战指南:基于防抖的响应式状态历史记录与撤销重做
2026/10/1 8:29:57 网站建设 项目流程
  • 前端

【免费下载链接】vueuse

Collection of essential Vue Composition Utilities for Vue 3

项目地址:https://gitcode.com/gh_mirrors/vu/vueuse
点击查看免费下载

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, } }

从中可以提炼出三条关键事实:

  1. 它接收debounce参数(毫秒数,且可以是 ref 或 getter,即MaybeRefOrGetter<number>),内部用debounceFilter生成一个事件过滤器;
  2. 它把该过滤器通过eventFilter选项注入useRefHistory,从而"改造"底层的历史记录提交时机;
  3. 返回值与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,各字段含义如下:

参数类型默认值说明
debounceMaybeRefOrGetter<number>不传则不过滤防抖延迟毫秒数,支持 ref / getter(响应式)。传入后内部生成debounceFilter;不传则等价于普通useRefHistory
deepbooleanfalse是否深度监听源 ref 的内部变化;为true时会为历史中存储的值创建克隆
capacitynumber无限制历史最多保留的记录条数,超过后丢弃最旧记录
cloneboolean \| 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
eventFilterEventFilter由内部注入事件过滤器,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无(直接提交)每次变更都记录,历史最完整
useDebouncedRefHistorydebounceFilter停止变化debounce毫秒后才记录,忽略中间态
useThrottledRefHistorythrottleFilter固定节奏记录(默认每 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

项目地址:https://gitcode.com/gh_mirrors/vu/vueuse
点击查看免费下载

相关推荐

上一篇:Bangumi番组计划多端上架:一次跑通的发布流程
下一篇:从0到1掌握XPush:Android开发者必备的推送框架使用教程

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

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

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

立即咨询