Lzh on GitHub

追踪一个 ref 的更改历史,并提供撤销(undo)和重做(redo)功能。

通过 Vue School 的这个免费视频课程学习 useRefHistory

Demo

<script setup lang="ts">
import { formatDate, useCounter, useRefHistory } from '@vueuse/core'

function format(ts: number) {
  return formatDate(new Date(ts), 'YYYY-MM-DD HH:mm:ss')
}

const { count, inc, dec } = useCounter()
const { history, undo, redo, canUndo, canRedo } = useRefHistory(count, { capacity: 10 })
</script>

<template>
  <div>Count: {{ count }}</div>
  <button @click="inc()">
    Increment
  </button>
  <button @click="dec()">
    Decrement
  </button>
  <span class="ml-2">/</span>
  <button :disabled="!canUndo" @click="undo()">
    Undo
  </button>
  <button :disabled="!canRedo" @click="redo()">
    Redo
  </button>
  <br>
  <br>
  <note>History (limited to 10 records for demo)</note>
  <div class="code-block mt-4">
    <div v-for="i in history" :key="i.timestamp">
      <span class="opacity-50 mr-2 font-mono">{{ format(i.timestamp) }}</span>
      <span class="font-mono">{ value: {{ i.snapshot }} }</span>
    </div>
  </div>
</template>

使用

import { useRefHistory } from '@vueuse/core'
import { shallowRef } from 'vue'

const counter = shallowRef(0)
const { history, undo, redo } = useRefHistory(counter)

在内部,当 ref 值被修改时,watch 会被用来触发一个历史记录点。这意味着历史记录点是 异步触发 的,它会 批量处理 同一个 “tick” 中的修改。

counter.value += 1

await nextTick()
console.log(history.value)
/* [
  { snapshot: 1, timestamp: 1601912898062 },
  { snapshot: 0, timestamp: 1601912898061 }
] */

你可以使用 undoref 的值重置到上一个历史记录点。

console.log(counter.value) // 1
undo()
console.log(counter.value) // 0

Objects / arrays

在使用对象或数组时,由于更改它们的属性并不会改变引用本身,所以 不会触发提交 历史记录。要跟踪属性的变化,你需要传入 deep: true。这样做会为每个历史记录创建 克隆

const state = ref({
  foo: 1,
  bar: 'bar',
})

const { history, undo, redo } = useRefHistory(state, {
  deep: true,
})

state.value.foo = 2

await nextTick()
console.log(history.value)
/* [
  { snapshot: { foo: 2, bar: 'bar' } },
  { snapshot: { foo: 1, bar: 'bar' } }
] */

自定义克隆函数

useRefHistory 默认只内嵌了最简的克隆函数 x => JSON.parse(JSON.stringify(x))。要使用功能更全面或自定义的克隆函数,你可以通过 clone 选项进行设置。

例如,使用 structuredClone

import { useRefHistory } from '@vueuse/core'

const refHistory = useRefHistory(target, { clone: structuredClone })

或者使用 Lodash 的 cloneDeep

import { useRefHistory } from '@vueuse/core'
import { cloneDeep } from 'lodash-es'

const refHistory = useRefHistory(target, { clone: cloneDeep })

或者一个更轻量的 Klona

import { useRefHistory } from '@vueuse/core'
import { klona } from 'klona'

const refHistory = useRefHistory(target, { clone: klona })

自定义 Dump 和 Parse 函数

除了使用 clone 选项,你还可以传入自定义函数来控制序列化和解析。如果你不需要历史值是对象,这可以在撤销时节省一次额外的克隆操作。当你想将快照直接字符串化以便保存到本地存储时,这也很有用。

import { useRefHistory } from '@vueuse/core'

const refHistory = useRefHistory(target, {
  dump: JSON.stringify,
  parse: JSON.parse,
})

历史容量

我们默认会 保留所有历史记录(无限),直到你明确清除它们。你可以通过 capacity 选项来设置要保留的最大历史记录数量。

const refHistory = useRefHistory(target, {
  capacity: 15, // limit to 15 history records
})

refHistory.clear() // explicitly clear all the history

历史记录刷新时机

根据 Vue 的官方文档:Vue 的响应式系统会 缓冲无效的副作用并异步刷新 它们,以避免在同一 “tick” 中发生多次状态变更时出现不必要的重复调用。

watch 函数类似,你可以使用 flush 选项来修改刷新时机。

const refHistory = useRefHistory(target, {
  flush: 'sync', // options 'pre' (default), 'post' and 'sync'
})

默认是 'pre',这与 Vue 的侦听器(watchers)的默认行为保持一致。这也有助于避免常见问题,例如在对 ref 值进行多步更新时生成多个历史记录点,这可能会破坏应用程序状态的不变量。如果需要在同一 “tick” 中创建多个历史记录点,你可以使用 commit()

const r = shallowRef(0)
const { history, commit } = useRefHistory(r)

r.value = 1
commit()

r.value = 2
commit()

console.log(history.value)
/* [
  { snapshot: 2 },
  { snapshot: 1 },
  { snapshot: 0 },
] */

另一方面,当使用 flush: 'sync' 时,你可以使用 batch(fn) 来为多个同步操作生成 单个历史记录点

const r = ref({ names: [], version: 1 })
const { history, batch } = useRefHistory(r, { flush: 'sync' })

batch(() => {
  r.value.names.push('Lena')
  r.value.version++
})

console.log(history.value)
/* [
  { snapshot: { names: [ 'Lena' ], version: 2 },
  { snapshot: { names: [], version: 1 },
] */

如果使用了 { flush: 'sync', deep: true },在对数组进行可变 splice 操作时,batch 也非常有用。splice 可以生成多达三个原子操作,这些操作都将被推送到 ref 历史记录中。

const arr = ref([1, 2, 3])
const { history, batch } = useRefHistory(arr, { deep: true, flush: 'sync' })

batch(() => {
  arr.value.splice(1, 1) // batch ensures only one history point is generated
})

另一种选择是避免直接修改原始 ref 值,可以使用 arr.value = [...arr.value].splice(1,1)

推荐阅读

历史与持久化 - 作者:@patak-dev

类型声明

export interface UseRefHistoryOptions<Raw, Serialized = Raw>
  extends ConfigurableEventFilter {
  /**
   * Watch for deep changes, default to false
   *
   * When set to true, it will also create clones for values store in the history
   *
   * @default false
   */
  deep?: boolean
  /**
   * The flush option allows for greater control over the timing of a history point, default to 'pre'
   *
   * Possible values: 'pre', 'post', 'sync'
   * It works in the same way as the flush option in watch and watch effect in vue reactivity
   *
   * @default 'pre'
   */
  flush?: "pre" | "post" | "sync"
  /**
   * Maximum number of history to be kept. Default to unlimited.
   */
  capacity?: number
  /**
   * Clone when taking a snapshot, shortcut for dump: JSON.parse(JSON.stringify(value)).
   * Default to false
   *
   * @default false
   */
  clone?: boolean | CloneFn<Raw>
  /**
   * Serialize data into the history
   */
  dump?: (v: Raw) => Serialized
  /**
   * Deserialize data from the history
   */
  parse?: (v: Serialized) => Raw
  /**
   * Function to determine if the commit should proceed
   * @param oldValue Previous value
   * @param newValue New value
   * @returns boolean indicating if commit should proceed
   */
  shouldCommit?: (oldValue: Raw | undefined, newValue: Raw) => boolean
}

export interface UseRefHistoryReturn<Raw, Serialized>
  extends UseManualRefHistoryReturn<Raw, Serialized> {
  /**
   * A ref representing if the tracking is enabled
   */
  isTracking: Ref<boolean>
  /**
   * Pause change tracking
   */
  pause: () => void
  /**
   * Resume change tracking
   *
   * @param [commit] if true, a history record will be create after resuming
   */
  resume: (commit?: boolean) => void
  /**
   * A sugar for auto pause and auto resuming within a function scope
   *
   * @param fn
   */
  batch: (fn: (cancel: Fn) => void) => void
  /**
   * Clear the data and stop the watch
   */
  dispose: () => void
}

/**
 * 追踪一个 ref 的更改历史,并提供撤销(undo)和重做(redo)功能。
 *
 * @see https://vueuse.org/useRefHistory
 * @param source
 * @param options
 */
export declare function useRefHistory<Raw, Serialized = Raw>(
  source: Ref<Raw>,
  options?: UseRefHistoryOptions<Raw, Serialized>,
): UseRefHistoryReturn<Raw, Serialized>