1.3 State – useRefHistory
作用
追踪一个ref
的变化历史,同时提供了undo
和redo
方法。
这个方法就是useManualRefHistory
的封装,不需要手动的commit
,而是方法自动判断什么时候应该commit
。因此我们需要搞清楚什么情况下,这个方法会自动提交commit
。
官方示例
- 基础类型(数字、字符串等)
import { ref } from 'vue'
import { useRefHistory } from '@vueuse/core'
const counter = ref(0)
// 使用 useRefHistory 追踪 counter 的变化
// undo 撤销,redo 重做
const { history, undo, redo } = useRefHistory(counter)
在内部,watch
用于在ref
值被修改时触发历史点。这意味着历史点是在同一个“tick”中异步触发批量修改的。所以需要nextTick
之后才能记录到变更。
counter.value += 1
await nextTick()
console.log(history.value)
/* [
{ snapshot: 1, timestamp: 1601912898062 },
{ snapshot: 0, timestamp: 1601912898061 }
] */
- 对象/数组类型
在使用对象或数组时,由于更改它们的属性不会更改引用,因此不会触发提交。要跟踪属性的变化,需要传递deep: true
。它将为每个历史记录创建克隆。
const state = ref({
foo: 1,
bar: 'bar',
})
// 使用 deep: true 来追踪对象属性变化
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' } }
] */
- 函数
在内部,使用了简单的方式克隆函数,也就是x => JSON.parse(JSON.stringify(x))
。通常来说,我们需要自己定义这个克隆的方式,比如最常用的,使用lodash
的 cloneDeep
函数
import { cloneDeep } from 'lodash-es'
import { useRefHistory } from '@vueuse/core'
const refHistory = useRefHistory(target, { clone: cloneDeep })
- 历史记录的容量
// 使用 capacity 来指定
const refHistory = useRefHistory(target, {
capacity: 15, // limit to 15 history records
})
refHistory.clear() // explicitly clear all the history
- 刷新的时机
vue的响应式系统会把一系列更新缓存起来,在合适的时机统一调用,以避免在同一个追踪中发生多个状态突变时不必要的重复调用。比如在同一个computed
中,多个变量发生了变化,computed
函数也只会调用一次。
和watch
一样,你可以指定flush
选项来改变刷新时机。默认是pre
。
- 默认情况下,侦听器回调会在父组件更新 之后、所属组件的 DOM 更新之前被调用。这意味着如果你尝试在侦听器回调中访问所属组件的 DOM,那么 DOM 将处于更新前的状态。
- 同步触发的侦听器,它会在 Vue 进行任何更新之前触发。同步侦听器不会进行批处理,每当检测到响应式数据发生变化时就会触发。
- 如果想在侦听器回调中能访问被 Vue 更新之后的所属组件的 DOM,你需要指明
flush: 'post'
选项:
const refHistory = useRefHistory(target, {
flush: 'sync', // options 'pre' (default), 'post' and 'sync'
})
如果需要在同一次变更过程中,需要创建多个变更点,可以使用commit
方法。
const r = ref(0)
const { history, commit } = useRefHistory(r)
r.value = 1
// 每一次commit,增加一次记录
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' })
// 因为是sync模式,所以两次操作应该生成两条记录,用batch可以合并记录
batch(() => {
r.value.names.push('Lena')
r.value.version++
})
console.log(history.value)
/* [
{ snapshot: { names: [ 'Lena' ], version: 2 },
{ snapshot: { names: [], version: 1 },
] */
对于splice
操作,默认是要生成三条操作记录的。如果使用了{ flush: 'sync', deep: true }
,也可使用batch(fn)
来合并这些操作为一条记录。
const arr = ref([1, 2, 3])
const { history, batch } = useRefHistory(arr, { deep: true, flush: 'sync' })
// 必须把操作包裹在 batch 中
batch(() => {
arr.value.splice(1, 1)
})
源码分析
// 先举一个例子,和useManualRefHistory一样
假设 source 的变化为: 1 --> 2 --> 3
那么: undoStack [] --> [1] --> [2, 1] // 两个数组都是从前面放,从前面取
redoStack [] --> [] --> []
执行 undo 操作后,
undoStack [] --> [1] --> [2, 1] --> [1]
redoStack [] --> [] --> [] --> [2]
再次执行 undo 操作后,
undoStack [] --> [1] --> [2, 1] --> [1] --> []
redoStack [] --> [] --> [] --> [2] --> [1, 2]
执行 redo 操作后,
undoStack [] --> [1] --> [2, 1] --> [1] --> [] --> [1]
redoStack [] --> [] --> [] --> [2] --> [1, 2] --> [2]
先看一下useRefhistory
的实现,在packages/core/useRefhistory/index.ts
文件中。源码太多,这里不贴了,地址如下。
https://github.com/vueuse/vueuse/blob/main/packages/core/useRefHistory/index.ts
1、首先看一下pause
和resume
是怎么实现的(这两个方法是手动版本没有的,因为手动的可以完全控制追踪时机)。
这段代码返回了一个对象,实现了一个可以暂停和恢复的事件过滤器。可以通过pause
来暂停跟踪过程,resume
来恢复跟踪。
const {
eventFilter: composedFilter,
pause,
resume: resumeTracking,
isActive: isTracking,
} = pausableFilter(eventFilter)
内部实现非常简单,其中bypassFilter
就是一个直接执行函数。通过修改isActive
来控制回调是否执行。
export function pausableFilter(extendFilter: EventFilter = bypassFilter): Pausable & { eventFilter: EventFilter } {
const isActive = ref(true)
function pause() {
isActive.value = false
}
function resume() {
isActive.value = true
}
const eventFilter: EventFilter = (...args) => {
if (isActive.value)
extendFilter(...args)
}
return { isActive, pause, resume, eventFilter }
}
2、自动监控是如何实现的。
watchIgnorable
这个函数扩展了watch
函数的功能,以便能够更精细地控制何时忽略数据源(source
)的更新。这个功能在处理撤销/重做、数据回滚或任何需要基于历史操作动态忽略某些状态更新的场景中非常有用。
const {
ignoreUpdates,
ignorePrevAsyncUpdates,
stop,
} = watchIgnorable(
source,
commit,
{ deep, flush, eventFilter: composedFilter },
)
watchIgnorable
的实现在 https://github.com/vueuse/vueuse/blob/main/packages/shared/watchIgnorable/index.ts
这个函数接受三个参数,第一个是监控的源数据source
,用来被watch
监控;第二个是cb
,也就是手动版本的commit
方法,用来作为回调执行(cb应该是ignorePrevAsyncUpdates()
+manualCommit()
,为了理解方便,就当作manualCommit
吧);第三个是配置options
,直接作为watch
的第三个参数,包括flush
、deep
等参数。
export function watchIgnorable<Immediate extends Readonly<boolean> = false>(
source: any,
cb: any,
options: WatchWithFilterOptions<Immediate> = {},
): WatchIgnorableReturn {
const {
eventFilter = bypassFilter,
...watchOptions
} = options
const filteredCb = createFilterWrapper(
eventFilter,
cb,
)
let ignoreUpdates: IgnoredUpdater
let ignorePrevAsyncUpdates: () => void
let stop: () => void
// ......
return { stop, ignoreUpdates, ignorePrevAsyncUpdates }
}
具体的执行过程分为两种,
- 第一种是同步模式。顾名思义,同步模式下,每一次修改
source
,都会commit
一次,生成一次记录。
if (watchOptions.flush === 'sync') {
const ignore = ref(false)
// 由于flush模式被设置为'sync',这意味着所有的更新都是同步的,因此没有必要(也无法)忽略之前的异步更新。
ignorePrevAsyncUpdates = () => {}
ignoreUpdates = (updater: () => void) => {
// 由于flush模式为'sync',这个忽略机制实际上并不直接“计数”或“忽略”任何更新,
// 而是通过条件检查(在watch的回调函数中)来避免在ignore.value为true时调用filteredCb。
ignore.value = true
updater()
ignore.value = false
}
// 用户会调用ignoreUpdates,传递一个方法updater去执行,假如这个方法执行很快(假设是同步方法),
// 那么watch触发的时候,ignore就一直是false。
// 也就是说同步模式下,每次source更新,都会commit一个数据。
stop = watch(
source,
(...args) => {
if (!ignore.value)
filteredCb(...args)
},
watchOptions,
)
}
- 第二种是异步模式,包括
pre
和post
模式
else {
// flush 'pre' and 'post'
const disposables: Fn[] = []
/**
* 这两个变量的用途:如果同步触发次数多于忽略计数,那么我们现在需要提交ref值中的修改。
*
* 在发生影响ref的历史操作(撤销、重做、恢复)之前,将递增ignoreCounter(普通的修改不递增ignoreCounter)。
* syncCounter随着ref值的每次更改同步递增。这让我们知道ref修改了多少次,并支持链式同步操作。
*/
const ignoreCounter = ref(0)
const syncCounter = ref(0)
// 忽略之前的异步更新,方法就是让ignoreCounter和syncCounter相等
// 因为在下面第二个watch中,如果ignore,就不会触发commit了
ignorePrevAsyncUpdates = () => {
ignoreCounter.value = syncCounter.value
}
// 使用同步的方式,统计ref修改了多少次
disposables.push(
watch(
source,
() => {
syncCounter.value++
},
{ ...watchOptions, flush: 'sync' },
),
)
ignoreUpdates = (updater: () => void) => {
/**
* 这个方法是干什么的?暂时先不管,我们先看它的作用
*
* 执行updater函数,并且记录一下在执行过程中执行了多少同步修改,
* 把这个值加到忽略的次数上
*
* 比如updater()之前,syncCounter和ignoreCounter都是0
* updater()修改了两次source,那么syncCounter就是2,ignoreCounter也是2
*/
const syncCounterPrev = syncCounter.value
updater()
ignoreCounter.value += syncCounter.value - syncCounterPrev
}
/**
* ignoreCounter 记录了历史操作的数量。如果操作的数量ignoreCounter === syncCounter,也就是说source没有其他更新。
* 因为每一次历史操作,也会让syncCounter加1,所以二者相等就代表没有其他修改。
*
* 如果没有其他修改,则忽略此次提交;
* 如果不是历史操作,而是其他修改,比如soucr++,ignoreCounter 一定比syncCounter少1,那么这时候否则触发一次commit。
*
* 重置ignoreCounter和syncCounter,为下一次更新做准备
*/
disposables.push(
watch(
source,
(...args) => {
// 如果执行了历史操作(ignoremter > 0)并且之后没有对源ref值进行其他更改,则忽略此提交
const ignore = ignoreCounter.value > 0 && ignoreCounter.value === syncCounter.value
ignoreCounter.value = 0
syncCounter.value = 0
if (ignore)
return
filteredCb(...args)
},
watchOptions,
),
)
// disposables保存了watch的销毁函数,依次执行,销毁所有watch
stop = () => {
disposables.forEach(fn => fn())
}
}
3、使用了useManualRefHistory
生成一个手动的版本,把commit
方法抛出来,给自动的hook
使用。
const manualHistory = useManualRefHistory(source, { ...options, clone: options.clone || deep, setSource })
const { clear, commit: manualCommit } = manualHistory
4、接下来看一下这个hook特有的方法
/**
* 调用ignorePrevAsyncUpdates,让ignoreCounter和syncCounter保持一致,目的是不使用自动更新;
* 然后调用一次手动更新方法
*/
function commit() {
// 只有pre 和 post 下管用,因为sync 模式下,这个函数是空函数
ignorePrevAsyncUpdates()
manualCommit()
}
/**
* 配合batch的使用,以及ignoreUpdates这个函数来看
* batch(() => {
* r.value.names.push('Lena')
* r.value.version++
* })
* ignoreUpdates = (updater: () => void) => {
* const syncCounterPrev = syncCounter.value
* updater()
* ignoreCounter.value += syncCounter.value - syncCounterPrev
* }
*
*/
function batch(fn: (cancel: Fn) => void) {
let canceled = false
const cancel = () => canceled = true
// 结合上面的函数可以看出,cancel并没有被执行,也就是canceled始终为false
// 从这个实现来看,ignoreUpdates并没有起到作用。除非在执行fn过程中,手动执行cancel
ignoreUpdates(() => {
fn(cancel)
})
// canceled始终为false,也就是在这个hook中,这段是一定会执行的。调用commit,依然是不自动更新,触发手动更新
if (!canceled)
commit()
}
以上过程解释了代码是如何运作的,但是对于一些细节还是描述不够,比如为什么ignoreCounter
代表了历史操作的次数?如果没有历史操作,自动更新的流程是什么?我们通过两个例子来说明。
1、修改ref
的值,只会触发两个watch
,且同步的watch
先触发
2、执行undo
之后,数据的变化如下