提要与背景
撤销与重做是编排类应用涉及到一个常用的需求, 用户操作的入口杂, 动态性较高, 所以撤销重做的界定是一个比较关键的点. 在普通撤销重做的基础上, 加入可视化的历史记录面板以增强界面提供的信息, 方便用户的操作.
由于应用是使用react
作为视图框架, 所以本文都会围绕着react
的生态来实现相关的功能
文章大纲
高频名词与缩写
- immutable: 一种数据更新规范, 通过更新引用数据类型地址来标注引用类型变量的修改
- zustand: 一个状态管理库, 常被视作redux的替代品. 以回调而非
redux action
字符串为驱动单位. - zundo:
zustand
的一个中间件, 用于实现单个状态仓库的撤销重做. - RTK:
@reduxjs/toolkit
的缩写, 一个用于简化与封装redux
的全局状态管理仓库定义的工具. - patch/patches: 由于在本文中出现次数极多, 故定义
patch
特指同名回调函数,patches
特指差分补丁数据 - useSyncExternalStore: 用于把外部状态仓库接入到
react
更新周期的hook
前置知识
这个需求按功能模块可以划分为, 数据可逆模块 + 历史列表展现模块。 通常情况下实现数据可逆有两种方式: 快照与差分.
快照
快照的思想比较容易理解, 直接将执行后的完整状态作为数据推入列表, 由于每一条记录都包含了应用上所有组件完整的状态, 列表中相邻两条记录可以是相互独立的没有耦合, 所以状态之间的切换可以是跳跃的 (可以直接从状态2回到状态4而不考虑中间有多少个状态), 回溯状态的计算量不会随着记录跳跃距离而增长.
-
一种比较容易理解的快照机制为深拷贝,常见实现方法为
JSON.parse(JSON.stringify())
,lodash.cloneDeep()
等. 从内存角度来看, 每个状态都是独立一块内存区域, 哪怕是两个相邻记录之间只有很小的改动, 在此方法下的快照都会完整的将状态深拷贝一份作为备份, 这个方法的缺点也是深拷贝带来的内存空间的浪费, 如果条历史记录记录的状态特别多, 亦或者是历史记录列表特别长的时候, 内存问题在本文中提到的所有记录数据方法中将是最差的. -
在深拷贝之上的改进方法则是借助不可变库(
immutablejs
,immerjs
)来解决上者带来的内存问题, 当不可变性介入时, 传统的深拷贝操作将被不可变计算代替, 使得运行过程中只需要记录状态的顶层引用即可.如图所示, 状态state1
修改child 2
后来到child 2'
, 根据不可变库的实现, 该属性向父级递归的更新其引用, 使得parent1
与state1
引用地址获得了更新, 而child1
,child3
以及parent2
及其子节点保持原有引用地址不变, 实现了未修改处的复用.
图中展示了一种严格
immutable
的数据更新过程.
react
推荐props
和state
是immutable
的, 不过在实际开发中, 经常能见到, 只要将传给react state
的那份数据顶层引用地址更新就能成功设置状态的场景, 而嵌套复杂数据对象内部地址引用没有更新, 会有潜在的危险. 翻车demo
immutable
利用数据地址来作为判断数据变更与否的依据, 弥补了得传统===
操作符不能比较复杂数据对象的问题, 通过引入immutable
的概念, 快照的模式也可以很大程度上视作为只关注修改的属性的差分模式, 而单条记录也只需要保存顶层的根节点地址state
, 状态的回溯也只需要在状态仓库的顶层进行相应筛选(过滤无需记录的状态)与提交. 在纯前端的应用环境下, 遵循了immutable
的快照撤销既能最大程度复用数据内存, 也能在任意历史数据快照之间快速切换
但是也有弊端, 由于数据引用地址是编程语言的执行环境提供的, 所以在跨环境, 或者执行环境不同的场景下, 没有办法引入数据地址作为额外标识来实现immutable
, 快照将会退化到深拷贝模式.
差分补丁
差分补丁主要关注于每两次之间的状态差分, 需要在状态转移时, 生成相应的正向与反向补丁, 这两种补丁保证了两个状态之间的来回切换而不需要保存完整的状态信息.
-
差分补丁的结构设计可以参照
JsonPatch
, 通过类似op
的字段来标识增删改的操作 -
一种更暴力的方式就是对每个操作入口进行收敛, 人为的去提取信息并作记录编写对应的正逆向补丁,对于动态程度比较高(例如传key value的反射取值)写出来的代码就会比较难看了
差分更新的单条记录的量不会过大, 且补丁独立于状态库单独存储, 但仍然受到修改程度影响, 所以当一次性修改很多属性的时候, 补丁的大小也会等量的增长, 所以在性能方面仍然不及使用不可变库模式下的快照. 且如果在timetravel
时(从state2
重做到 state4
),差分补丁需要走完(state2
-> state3
-> state4
,反之亦然)整个应用补丁的链路。如果跨度较大计算复杂度也会随之上升。
其优势主要体现在与存储的完整数据长度上, 快照模式下的硬伤就是完整数据过大问题, 例如 对涉及到跨环境通信的撤销(例如历史记录云同步, 跨标签页同步记录) 时, 快照的模式决定了它将回到深拷贝方式, 当进行通信的时候, 没有了js环境, 所有的数据回归到字符串, 实现方式只能回归到深拷贝的JSON.stringify
, 原来的内存占用过大问题转化为请求体过大问题. 而差分补丁的方式则是最大程度上减少请求体里所携带的信息.
浅读一个基于快照的撤销与重做
因为快照的存储方式比较常见,社区里也有不少基于快照的解决方案,不需要重复造轮子了,看一下zustand
官方推荐的zundo
中间件是怎么实现撤销回溯的
简单描述下
zustand
,可以理解成一个不需要写actionType
的版本的hooks
风格的redux
|
|
从入口文件开始
zustand
通过传递store
实例来模拟传统注册中间件的效果. 中间件通过拦截被传递的的store
实例(下文统称外部实例)的set
和setState
方法, 使其额外执行一系列记录外部实例状态变更的操作. 这里就可以看到,zundo
通过调用传入的store
的get
方法,拿到了外部实例的全部/部分(通过传入回调实现)的快照
|
|
中间件内部状态
中间件zundo
内部也是一个单独的zustand
状态仓库,调用了zustand
的createStore
创建了一份实例(下文统称内部实例),用来保存状态快照,以及对应的操作状态的方法, 并且以temporal
的字段将内部实例暴露给外部实例。
|
|
中间件初始化时会传递外部实例的set
/get
给到内部实例的方法,形成闭包调用,这样内部实例在执行撤销重做时就能反过来调用外部实例的setState
来操作外部状态
所以这里表面说是状态回溯,实则是把一个以前的快照以一个新的setState
的形式提交更新,对于状态仓库来说并没有真正的回到过去,而是来到了一个新的状态,只不过这个新的状态恰巧和前面某一次的状态一模一样
内部实例对外暴露的状态定义, 以_
为开头定义的属性/方法标识该成员仅中间件内部逻辑使用,不应该暴露给上层开发者:
|
|
在入口文件注册中间件时的 curriedHandleSet
就指向的zundo
内部实例的 _handleSet
方法,在拦截过后的set
方法里最后一个调用, 如此可通过使用userGet
就能获取到最新的外部实例状态
|
|
如果剔除所有配置化的参数逻辑, _handleSet
的默认行为可以简化为, 当外部应用状态发生了变更, 且在中间件开启了isTracking
的状态,将外部实例 更新前 的状态pastState
作为最新的快照数据追加到内部实例的pastStates
中. 此处使用了concat
而非push
, 因为concat
返回了一个新的数组, 简洁的实现了immutable
.
|
|
历史记录列表
zundo
使用了2个数组来实现历史记录, 每一个数组内相邻的两份快照都代表了一次外部实例状态转移的前后状态, 所以快照的顺序即代表了操作的顺序。
pastStates
数组代表了可以撤销的快照数量, futureStates
数组代表了可以重做的快照数量.
- 当外部实例发生操作, 状态变化时, 由上文提到的
_handleSet
方法拦截了状态并推入pastStates
- 此外,应用本身处于撤销若干步的状态(即
futureStates
中有值), 外部实例发生的状态变更会让历史记录列表丢弃futureStates
中存放的重做的数据, 形成一条新的快照状态转移链路.
- 当外部实例执行
undo
时,zundo
会先从外部实例拿一份最新的状态, 将pastStates
倒序遍历并切割, 遍历长度为撤销的步数, (比如一次性撤销2步) 默认为1步, 这样倒序遍历并切割后的数组的第一个元素就是将要应用到外部实例的快照数据, 然后将切割数组中剩下的元素倒序的推入futureStates
数组
|
|
这块的源码涉及到两次数组翻转有点绕, 简单来说就是
pastStates
是一个数组, 一次性撤销几步, 就从pastStates
从后往前数拿第几个数据, 把他设置回外部实例,然后先把当前最新状态保存到futureStates
, 再把比拿走的那份数据index
大的所有快照全都 倒序地 塞到futureStates
的数组尾部, 用来保证pastStates
中快照的顺序由最久远的快照排列到最临近的快照, 而futureStates
中的数据顺序由最临近的快照排列到最久远的快照
图例展示了一次撤销两步时, 内部实例的数组变化情况:
- 当外部实例执行
redo
时, 方法的行为与undo
相似, 只是把操作的对象数组相互交换了一下, 源码也十分相似
|
|
小结
zundo
实现了一个基于immutable
规范的快照撤销与重做能力, 通过拦截zustand
的set
来捕获快照, 同时维护在内部的状态仓库里, 当执行撤销回退时, zundo
分别从不同的状态数组内拿到对应的快照并set
回使用该中间件的store
已达成时间回溯的效果. 快照的思想更加关注于状态的结果, 对状态转移如何产生的并不关心.
实现一个基于差分补丁的撤销与重做
正如前置知识章节的示意图, 使用补丁数据进行撤销与重做, 要关注补丁对生成的过程而非追踪应用的状态, 设计思想类似命令模式, 将触发状态转移的源头方法进行收敛, 统一进行差分计算, 同时能够同步记录一些其他运行时的元数据, 在应用交互(执行状态转移命令)时, 将这些元数据与补丁保存到历史记录集合中用作展示以及撤销重做的核心功能实现
套用到编排类应用的业务场景中去,可以归纳为
- 这些注册好的确定的指令就是提供给用户的, 用户交互发生时直接/间接需要调用到的方法.
- 推入列表的数据记录了可以重现用户操作的数据.
- 在实现以上两点的前提下, 降低代码侵入性提高动态更新的支持
这个版本的撤销与重做的实现是基于 @reduxjs/toolkit
(下文会简写为RTK
) 这个redux
官方封装的功能增强版轮子来实现的. 至于为什么, 首先看一下官方基于slice
概念的示例
|
|
经过RTK
封装后的redux
十分简洁, 看上去非常像上文提到的zustand
的store
定义方式, 但是RTK
显式的区分了state
和reducers
的定义, 并且reducers
中单个reducer
支持对象式的定义, 这为我们的扩展提供了切入点.
补丁数据
撤销重做的核心就是正向与逆向的补丁数据,在每次修改state
后,可以通过类似microdiff
这类的库,通过交换顺序对前后两个状态之间进行计算正向与反向的patch
, 这样的设计需要侵入并拦截状态管理库的数据的setter
过程,例如传统的redux
则就需要在每个reducer
中显式的实现, 就没有那么优雅.
好消息是,常用的不可变库immer
, 其实就提供了这个记录补丁数据的能力, 包括正向与逆向, 只是该功能默认不开启。而RTK
对redux
的封装正是基于immer
来实现的
显式地开启补丁功能官方文档.
|
|
开启后即可在produce
方法中, 传入一个回调作为第三个参数, 该回调将会获得produce
执行过程中对draft
对象所修改的所有行为的补丁, 以patches
标注为正向补丁, inversePatches
标注为反向回溯补丁, 同时immer
还提供了使用补丁的方法applyPatches
来应用刚刚输出的正反向补丁
注: 新版的
immer
可以通过使用produceWithPatches
这个方法来生成补丁, 更加的简洁, 同样也需要显示的开启Patch
插件. 但是由于开发这个需求的时候还没有这个功能所以将会以回调形式进行改造.
|
|
于是我们撤销重做的改造就会围绕着这两份patch
数据进行
设计命令注册方法
从现有的项目出发, 用户的操作会带来数据上的改变, 但不是所有的操作都需要历史记录, 就像PhotoShop
那样, 有时候打开了画板, 是不需要记录到历史的, 而那些编辑了/操作了图层的动作才是需要记录的, 所以这里就需要将记录patch
的控制权开放给上层应用, 这样才能够有选择的记录补丁.
redux
的核心是一个个reducer
, 我们在定义这些reducer
的过程中其实也是在向redux
注册一个又一个的命令, 我们可以在复用这一块的注册行为的基础上去扩展以最终达到注册历史记录命令的目的. 对于单个reducer
, RTK
支持这样的写法:
|
|
reducer
的模式天然契合,于是设想我们是否可以给每个reducer
额外添加一份回调, 用于转发produce
的第三个回调, 这样能用作记录补丁的入口, 同时它也应该是一个可选的回调, 当不传入方法时, 它输出的数据便不会被记录, 这样也满足了上文提到的有选择性的记录补丁的需求.
于是设想它应该是这样的
|
|
修改源码
在默认的RTK
中, 并没有实现上述功能, 于是需要修改源码, 让immer
在reducer
执行时记录差分补丁. immer
作用的区域是createReducer.ts
这个文件, 首先需要在其引入模块部分开启补丁模式. 而createReducer.ts
会被createSlice.ts
文件引用,同时为了保证改造后的类型推断完整, creatReducer.ts
中的类型涉及到文件也要修改,经排查只有mapBuilders.ts
, 也会被createSlice.ts
引用
RTK
的源码拆分比较灵活,我们可以通过实现自己的createSlice
, createReducer
等接口来替换原本的功能,做到最小程度的改动
本文基于
@reduxjs/[email protected]
版本进行改造(因为本文鸽了2年), 后续官方包的破坏性更新可能会导致不可用, 但源码的改造思路应该不会受到影响.
-
首先从类型定义入手, 修改对应的配置类型标注让
ts
能正确推导, 原有的reducers
配置定义存放在createSlice.ts#L227
的ValidateSliceCaseReducers
类型,1 2 3 4 5 6 7 8 9 10 11 12
/** * @see https://github.com/reduxjs/redux-toolkit/blob/f7689133ee56e7787447e6089907fa6d1639b771/packages/toolkit/src/createSlice.ts#L227 * */ export type ValidateSliceCaseReducers<S, ACR extends SliceCaseReducers<S>> = ACR & { [T in keyof ACR]: ACR[T] extends { reducer(s: S, action?: infer A): any } ? { prepare(...a: never[]): Omit<A, 'type'> } : {} }
可见只有
reducer
和prepare
两个定义, 在这里加上我们想要的patch
方法定义, 用|
连接, 代表可选. 并且额外接收一个action
参数, 可以用来接收元数据1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
export declare type ValidateSliceCaseReducers<S, ACR extends SliceCaseReducers<S>> = ACR & { [T in keyof ACR]: ACR[T] extends { reducer(s: S, action?: infer A): any; } - ? { - prepare(...a: never[]): Omit<A, 'type'> - } + ? + | { + prepare(...a: never[]): Omit<A, 'type'>; + } + | { + patch(data: Patch[], inverse: Patch[], action?: A): void; + } + : {}; };
-
在
createSlice
解析配置时, 也要像caseReducer
一样, 将所有的patch
收集起来, 为了命名一致, 也叫它casePatcher
.CasePatcher
类型就是刚刚定义的patch
方法类型, 并且在接下来解析slice
中的reducer
和prepare
中额外添加记录patch
的能力. 当然Record<string,CasePatcher>
中的key
也保持一致, 同为${slice}/${actionKey}
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
/** * @see https://github.com/reduxjs/redux-toolkit/blob/f7689133ee56e7787447e6089907fa6d1639b771/packages/toolkit/src/createSlice.ts#L295 * */ const sliceCaseReducersByName: Record<string, CaseReducer> = {} const sliceCaseReducersByType: Record<string, CaseReducer> = {} + const sliceCasePatchersByType: Record<string, CasePatcher> = {}; if ('reducer' in maybeReducerWithPrepare) { caseReducer = maybeReducerWithPrepare.reducer - prepareCallback = maybeReducerWithPrepare.prepare + if ('prepare' in maybeReducerWithPrepare) { + prepareCallback = maybeReducerWithPrepare.prepare; + } + if ('patch' in maybeReducerWithPrepare) { + sliceCasePatchersByType[type] = maybeReducerWithPrepare.patch as CasePatcher<any>; + } } else {
-
在改造
createSlice.ts
支持我们设计的额外回调入口后, 需要改造createRedcuer.ts
让我们传入的patch
在运行时真正生效.通过调用同名函数
createReducer
将createSlice
中收集到的caseReducers
的集合(sliceCaseReducersByType
)封入一个闭包函数, 并在运行时动态地过滤出action
对应的caseReducer
并执行(来模拟传统redux
中reducer
的switch-case
). 这里是运行时调用的入口, 也是RTK
借用immer
能力的地方, 需要在这里显示开启enablePatches
, 并且还要在createReducer
的方法中需要接收刚刚解析的sliceCasePatchersByType
.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
/** * @see https://github.com/reduxjs/redux-toolkit/blob/f7689133ee56e7787447e6089907fa6d1639b771/packages/toolkit/src/createReducer.ts#L1 * */ - import type { Draft } from 'immer' - import createNextState, { isDraft, isDraftable, enableES5 } from 'immer' + import type { Draft, Patch } from 'immer' + import createNextState, { isDraft, isDraftable, enableES5, enablePatches } from 'immer' import type { AnyAction, Action, Reducer } from 'redux' ... + enablePatches(); ... /** * @see https://github.com/reduxjs/redux-toolkit/blob/f7689133ee56e7787447e6089907fa6d1639b771/packages/toolkit/src/createReducer.ts#L276 **/ } else { + const casePatcher = + typeof slicePatchCallbackByType?.[action.type] === 'function' + ? slicePatchCallbackByType[action.type] + : noop; return createNextState(previousState, (draft: Draft<S>) => { return caseReducer(draft, action) - } + }, + (data: Patch[], inverse: Patch[]) => { + casePatcher(data, inverse, action); + }, ) }
-
DONE! 至此已完成了所有的源码改动, 得益于
RTK
优秀的代码功能拆分和immer
的补丁能力, 让我们用不到40行的修改就完成了对原有功能的扩展, 具体的使用方式:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
import { configureStore } from "@reduxjs/toolkit" import { createSlice } from './my-path-to/createSlice' const mySliceWithPatch = createSlice({ name: 'mySliceWithPatch', initialState: { counter: 0 }, reducers: { increment: { reducer(state, action) { state.counter = state.counter + 1 }, patch(patch, inversePatch, action) { // 补丁记录行为 } } } }) // `RTK`的`configureStore`兼容自定义的`createSlice` const store = configureStore({reducer: mySliceWithPatch.reducer}) // dispatch 一个 action const increment = () => store.dispatch(mySliceWithPatch.actions.increment())
实现历史记录列表
由于之前我们已经解读过zundo
的历史记录实现的方式, 故原理不在多做赘述, 列出对比zundo
快照实现的差异
immer
产生的pathes
需要按照顺序进行存储, 并且需要用一个值来记录当前的回退位置。- 可以进一步将
patches
封装成一个闭包供undo
和redo
调用, 比起直接存patch
数据, 底层实现逻辑代码会更清晰可读, 不过代价就是封装闭包的方法需要上层应用或者适配器来实现, 建议根据接入视图框架的方式来进行选择.
- 可以进一步将
zundo
没有对当前的状态记录, 也就是进入页面时, 列表为空, 可以默认携带一个类似“初始化页面”的操作记录用于更好的提示- 步进大于1的
timetravel
的情况, 差分模式需要依次走完完整的链路, 并不能像zundo
那样使用双数组切分取值+拼接的形式, 所以这里使用单个数组+currentIndex
的模式来实现
具体的实现逻辑参照下图
-
首先我们定义一个
record
的类型用来指代state
的状态, 存放一些撤销与回退的方法1 2 3 4 5
class HistoryRecord { redo?: () => void //注意这里是optional timestamp = new Date() constructor(public name: string, public undo: () => void ) {} }
从逻辑图中也能看到, 每一个
record
代表一个state
, 但是每一次操作产生的apply (inverse) patches
闭包并不会同时作为同一条Record
的undo
和redo
. 只有新的record
被生成时, 前一个record
的redo
才会被赋值, 所以redo
才会被定义为optional
. 一个很符合直觉的解释就是: 你不能重做(区别于再做)一个最新的状态, 你只能做撤销.此外,
name
作为元数据输入生成, 而timestamp
可以在创建Record
时立即自动记录. -
顺着上面的思路, 我们可以推导出历史记录列表的一些能力, 进行历史记录列表的构建
- 支持生成
record
, 且生成时需要关联上一条记录的redo
(这也是为什么需要有一个init state
, 可以避免写一些边界判断) - 有一个指针变量用于标识当前的撤销重做位置
1 2 3 4
class HistoryPool { current = -1 // 因为用于标注真实的`index`, 所以初始为 -1 records: HistoryRecord[] = [] }
当状态发生转移, 创建一条
record
, 此时如果在撤销若干步的某个状态(当前record
不是最后一个)时this.current + 1 !== this.records.length
, 需要舍弃后面的所有record
, 衍生出一条新的状态转移链路. 且赋值目前最后一条record
的redo
。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
class HistoryPool { // ... /** * 如本节开篇时阐述的, 这里的`addRecord`也可以设计成直接接收`patches`和`inversePatches` * 当然, 这样做需要同步修改 `RecordType`的实现 **/ addRecord(name: string, redo: () => void, undo: () => void) { const nextIndex = this.current + 1 // 直接使用`immer`保证`records`的`immutable` this.records = produce(this.records, (draft) => { if (draft.length !== nextIndex) { draft.splice(nextIndex); } draft.push(new HistoryRecord(name, undo)); const currentNode = draft[this.current]; if (currentNode) { currentNode.redo = redo; } }) this.current = nextIndex; } }
这里
addRecord
方法的redo
和undo
闭包是由一次状态转移产生的patches
和inversePatches
构建而来的. 一个补全列表尾部record
的redo
, 一个用于直接构建新的record
.然后就可以完成历史记录列表的撤销与重做入口, 通过移动
current
来实现.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
class HistoryPool { //... redo() { if (this.current + 1 === this.records.length) return // 最后一条记录不能`redo` this.records[this.current].redo?.() ++this.current } undo() { if (this.current === 0 ) return // 第一条记录不能`undo` this.records[this.current].undo() --this.current } timeTravel(to:number){ if (to === this.current) return; const cb = to > this.current ? this.redo : this.undo; const count = Math.abs(to - this.current); for (let i = 0; i < count; i++) { cb.call(this); } } }
- 支持生成
-
最后可以完善一下构造函数, 将一个初始状态推入数组, 这一步也可由持有历史记录列表实例的上层应用来完成.
1 2 3 4 5
class HistoryPool { constructor() { this.addRecord('初始化', () => {}, () => {}) } }
接入react
, 配合RTK
实现撤销重做
至此我们已经完成了拓展RTK
的patch
回调入口以及原生js
实现的历史记录列表逻辑, 而RTK
又是由react
驱动的store
, 所以历史记录表作为纯js
实例, 也需要有一个在react
中的上层实现才能和RTK
配合使用.
-
在
RTK
实现应用补丁的reducer
.在
zundo
中, 修改store
的状态是通过闭包获取到set
方法后, 可以在中间件内部拿到快照并直接操作外部store
, 而RTK
中即使是middleware
能拦截的只有getState
,dispatch
和action
, 因此需要一个专有的reducer
来配合特定的action
进行状态转移(应用补丁)在构造
slice
时, 定义一个applyRecord
(名字随意)的reducer
,payload
接收patch[]
, 且通过immer
提供的applyPatches
来应用补丁. 每次redo
/unodo
, 最终都激活这个applyRecord
的reducer
, 把patches
或inversePatches
传给他完成状态补丁更新.1 2 3 4 5 6 7 8 9 10 11 12 13
import { Patch, applyPatches } from 'immer'; //... const mySliceWithPatch = createSlice({ name: 'mySliceWithPatch', initialState: { counter: 0 }, reducers: { applyRecord(state, action: { type:string, payload: Patch[]}){ return applyPatches(state, action.payload) }, increment: {/***/} } })
-
将历史记录列表实例接入
react
这一部分的功能主要聚焦于
react state
的更新方法对接,undo
/redo
等历史记录列表的方法暴露, 以及一些react
环境下的功能优化-
方式1 一个常见的方法就是通过
react
组件 + 内部实例的方式进行生命周期与方法的暴露, 这里使用一个abstract class
来抽象一些存在store
副作用的方法, 保证组件与store
解耦, 这样的设计是提升组件的通用性, 甚至可以开源出去, 让其他开发者的应用也能通过实现方法来接入自己的redux store
.1 2 3 4
abstract class UndoRedo extends React.Component { private _history = new HistoryPool() abstract getDispatch(actionType: string): Dispatch<AnyAction> }
createRecord
方法实现了将patches
封入闭包的过程, 传入的actionType
是应用补丁reducer
(上文中为mySliceWithPatch/applyRecord
)的actionType
, 而非触发状态转移的reducer
的actionType
. 传入actionType
会更灵活一点,可以在不同的slice
/store
之前共享一个历史记录列表实例, 通过getDispatch
来触发不同slice
/store
中的applyRecord reducer
.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
abstract class UndoRedo extends React.Component { //... createRecord = (name:string, patches: Patch[], inversePatches: Patch[], actionType: string) => { const dispatch = this.getDispatch(actionType) const redo = () => { // 这里的 `type` 就是上文 `applyRecord` `reducer`所需要的的`action`数据格式. dispatch({ type: actionType, payload: patches }); }; const undo = () => { // 这里的 `type` 就是上文 `applyRecord` `reducer`所需要的的`action`数据格式. dispatch({ type: actionType, payload: inversePatches }); }; this._history.addRecord(name, redo, undo) this.setMemoState() // 下文会实现 } }
最后还需要封装一下
timeTravel
, 因为我们选择了封入闭包函数的方式给到底层历史记录列表进行存储, 而底层的timeTravel
在循环过程中每执行一次undo
/redo
时,dispatch
了action
, 继而react-redux
就会对视图进行一次刷新. 如果timeTravel
的跨度较大, 就会产生许多次不必要的rerender
.想要解决这个问题需要在调用方法的作用域使用
batch
方法将多次更新合并为一次commit
, 而batch
是由react-redux
提供的, 所以应当是在实现接入react
生命周期的组件被引入, 于是就额外封装一下底层的timeTravel
.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
abstract class UndoRedo extends React.Component { //... timeTravel = (to: number) => { batch(() => { this._history.timeTravel(to) }) this.setMemoState() // 下文会实现 } undo = () => { this._history.undo() this.setMemoState() // 下文会实现 } redo = () => { this._history.redo() this.setMemoState() // 下文会实现 } }
同时通过
setState
将records
等数据接入react
的生命周期, 收敛到修改状态的回调方法为timeTravel
,undo
,redo
和createRecord
, 在这些方法的最后额外增加一次带有memo
的setState
.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
abstract class UndoRedo extends React.Component { // 这里需要再 `_history` 下方初始化, 可以直接拿 `_history` 的初始值来初始化 `state` state = { records: this._history.records, current: this._history.current } // 类似 `lodash.memoize`, 通过创建一个闭包缓存入参, 每次调用时顺序地比较每个参数, 全部相等时返回缓存的计算结果 // `_history` 的 `records` 已经实现了 `immutable` memoState = createMemo((records: HistoryRecord[], current: number) => { return { records, current } }) setMemoState = () => { const { records, current } = this._history const nextState = this.memoState(records, current) if (nextState !== this.state) { this.setState(nextState) } } }
最终将状态通过
Provider
的形式暴露给其他组件, 所以该组件通常作为靠近顶层的父节点.1 2 3 4 5 6 7 8 9
abstract class UndoRedo extends React.Component { render() { return ( <UndoRedoContext.Provider value={this.state}> {this.props.children} </UndoRedoContext.Provider> ) } }
这里存在一个问题,
RTK
作为全局变量管理, 定义reducers
的一些列方法(主要是patch
)需要在整个react
应用(包含UndoRedo
组件)实例化前进行的声明, 而记录patches
的方法又需要指向在UndoRedo
的组件实例内的方法, 于是一个先有鸡还是先有蛋的问题就产生了, 只能通过组件的静态方法来转发, 代价是这个组件只能作为单例运行1 2 3 4 5 6 7 8 9 10 11
abstract class UndoRedo extends React.Component { static interface = {} constructor(props: any) { super(props); UndoRedo.interface = { redo: this.redo, undo: this.undo, createRecord: this.createRecord, timeTravel: this.timeTravel, }; }
-
方式2 还有一种方法就是通过
react 18
新增的useSyncExternalStore
方法将纯js
对象封装成一个可以返回state
的hook
, 对于其他支持hook
版本的react
, 可以通过安装use-sync-external-store
的npm
依赖来使用该能力.关于
useSyncExternalStore
, 可以看成是useState
和useEffect
的组合, 具体的原理解析可以参考这篇文章使用
useSyncExternalStore
的外部状态仓库对象需要实现subscribe
方法, 然后接收一个闭包getSnapshot
用于的返回调用useSyncExternalStore
这个hook
的组件需要的状态. 于是需要改造一下历史记录列表, 首先让store
支持订阅, 我们简单的维护一个Set
用来存放订阅回调即可.1 2 3 4 5 6 7 8 9
class UndoRedoStore extends HistoryPool { subscriptions = new Set<() => void>(); subscribe = (onStoreChange: () => void) => { this.subscriptions.add(onStoreChange); return () => { this.subscriptions.delete(onStoreChange); }; }; }
然后需要实现
getState
用于封装useSyncExternalStore
中getSnapshot
, 其实就是描述当前store
暴露的状态, 和方法1中定义的state
类似, 也是返回一个遵循immutable
更新过程的数据变量. 相应的原先setMemoState
的功能为计算内部state
和通知react
更新, 这里由于计算state
的过程由getState
实现,setMemoState
的逻辑也简化为通知react
更新, 只不过不同于使用setState
, 这里使用subscription
中存储的onStoreChange
来实现.这里取名为
setMemoState
其实有点不恰当, 但是是为了标识这个方法的调用时机与方法1中一致, 故保持命名相同1 2 3 4 5 6 7 8 9 10 11 12 13 14
class UndoRedoStore extends HistoryPool { // ... memoState = createMemo((records: HistoryRecord[], current: number) => { return { records, current }; }); getState = () => { return this.memoState(this.records, this.current); }; setMemoState() { this.subscriptions.forEach((cb) => { cb(); }); } }
方法1中用
batch
重写的timeTravel
和createRecord
方法也需要在这个方式下实现, 顺便吧undo
和redo
也一起实现了. 需要注意的是, 这里可以通过构造函数传入getDispatch
方法来注入, 当然也可以像方法1一样使用abstract class
.方法1中当然也可以直接把
getDispatch
作为构造参数传入, 不这么做的原因是, 作者习惯于将react
的props
开放给运行时需要响应式的变量, 而一些静态的参数则通过工厂, 类重写的方式, 让应用省去更新追踪响应式的过程(memo
等).1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
class UndoRedoStore extends HistoryPool { constructor(public getDispatch:()=> Dispatch) {} timeTravel = (to: number) => { batch(() => { // 变成super.timeTravel super.timeTravel(to); }); }; undo = () => { super.undo() // 变成super.undo this.setMemoState() } redo = () => { super.redo() // 变成super.redo this.setMemoState() } createRecord = (name:string, patches: Patch[], inversePatches: Patch[], actionType: string) { //...和方法1一样 } }
然后通过使用
useSyncExternalStore
来封装成一个hook
, 这里就拟实现一个支持传入回调来获取历史记录列表状态的hook
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
const historyRef = { current: null } // 使用`historyRef` 来创建 `slice`, 用途和方法1的静态类一样, 下文会讲 const RTKStore = configureStore({ reducer: slice.reducer }) const historyStore = historyRef.current = new UndoRedoStore(() => RTKStore.dispatch) type HistoryStoreState = ReturnType<typeof historyStore.getState> const useHistorySelector = <RES,>(selector: (s: HistoryStoreState) => RES) => { return useSyncExternalStore<RES>(historyStore.subscribe,() => selector(historyStore.getState())) } // 简单封装一下 const createHistoryStore = (getDispatch: (actionType:string) => Dispatch) => { const store = new UndoRedoStore(getDispatch) const useSelector = <RES,>(selector: (s: HistoryStoreState) => RES) => { return useSyncExternalStore<RES>(store.subscribe,() => selector(store.getState())) } return { store, useSelector } }
两种方法相比较, 虽然思路相同, 但更推荐第二种方法, 实现的方法比较优雅. 也没有引入额外的
Context
, 对接入状态的组件层级也没有要求 -
-
将
RTK
回调入参传递给历史记录列表的react
实现, 其实只需要直接将参数传入createRecord
方法即可, 但是也是会存在上文提到循环引用的问题.方式1中由于组件的静态方法是个对象, 并且在组件实例化后会把相应的实例方法挂载上去, 所以在我们用
createSlice
定义patch
函数的时候, 就可以通过创建闭包来形成懒取值来避免循环引用, 这个问题在方式2的实现中更加直观1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
const mySliceWithPatch = createSlice({ name: 'mySliceWithPatch', initialState: { counter: 0 }, reducers: { applyRecord(state, action: PayloadAction<Patch[]>){ //... }, increment: { reducer(state, action) { state.counter = state.counter + 1 }, patch(patch, inversePatch, action) { // 这里就需要有`historyStore`的实例了 historyStore.createRecord("计数增加", patch, inversePatch, 'mySliceWithPatch/applyRecord') } } } }) const RTKStore = configureStore({ reducer: mySliceWithPatch.reducer }) // 但是这里才刚刚初始化, 因为需要接收`store`的dispatch const { store: historyStore, useSelector } = createHistoryStore(() => RTKStore.dispatch)
如果用方式1的模式来初始化:
1 2 3 4 5 6 7
const RTKStore = configureStore({ reducer: mySliceWithPatch.reducer }) class UndoRedoImpl extends UndoRedo { getDispatch(actionType: string){ return RTKStore.dispatch } } // ...然后在组件中使用`UndoRedoImpl`
于是才引入了
historyRef
和UndoRedo.interface
作转发, 修改patch
中的方法调用为1 2 3 4 5 6
patch(patch, inversePatch, action) { - historyStore.createRecord("计数增加", patch, inversePatch) + historyRef.current?.createRecord("计数增加", patch, inversePatch) // 或者 + UndoRedo.interface?.createRecord("计数增加", patch, inversePatch) }
-
DONE, 至此改造已经全部完成, 具体组件在应用中的使用可以通过
useSelector
或者useContext
来获取历史记录的状态, 这里给出一个具体的应用组件实现方案, 完整的示例demo在这里1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
import { useHistorySelector, historyStore } from '@/store' const HistoryViewBox = () => { const { current, records } = useHistorySelector((s) => s); const canRedo = React.useMemo(() => { return records.length - 1 !== current; }, [records, current]); const canUndo = React.useMemo(() => { return current !== 0; }, [current]); return ( <div> <div style={{ display: 'flex', justifyContent: 'space-between' }}> <div>历史记录</div> <div style={{opacity: canUndo? 1 : 0.3}} onClick={historyStore.undo}>↻</div> <div style={{opacity: canRedo? 1 : 0.3}} onClick={historyStore.redo}>↺</div> </div> {records.map((record,i) => { return <div style={{ display: 'flex', justifyContent: 'space-between' }} onClick={()=>{historyStore.timeTravel(i)}}> <span>{record.name}</span><span>{record.timestamp.toLocaleTimeString()}</span> </div> })} </div> ) }
总结
文章分析了作者在前端历史记录的实现方式上的学习成果, 介绍了诸如差分, 快照, 不可变性等概念. 并且分别对两种实现方式进行了解读并给出了一些应用场景选型的建议. 在解读源码的同时也基于immer
对RTK
进行了改造, 实现了一个基于差分的撤销与重做, 对已经再用RTK
且有撤销重做需求的项目也算得上是一个改造成本较低的方案.
拓展与思考(挖坑)
文章篇幅有限还有一些点没有涉及到, 这里把作者想到的点先列出来
- 如何使用存储patches的方式而非存储redo/undo回调的方式来实现历史命令记录实例, 有什么好处
- 有没有更好的办法从根本上解决循环引用的问题. 例如让RTK支持如zustand的中间件模式, 这样历史记录列表可以在RTK内部完成实例化, 也不需要额外定义applyRecord的reducer了
- 与yjs配合实现多人协同的撤销回退
参考
immer文档: https://immerjs.github.io/immer/
zustand文档: https://zustand-demo.pmnd.rs/
zundo 源码: https://github.com/charkour/zundo
redux-toolkit文档: https://redux-toolkit.js.org/
Understanding useSyncExternalStore hook: https://blog.saeloun.com/2021/12/30/react-18-useSyncExternalStore-api/#understanding-usesyncexternalstore-hook