C/前端撤销与重做的探索

Created Mon, 22 Nov 2021 23:15:28 +0800 Modified Thu, 11 Jan 2024 22:37:11 +0800

提要与背景

撤销与重做是编排类应用涉及到一个常用的需求, 用户操作的入口杂, 动态性较高, 所以撤销重做的界定是一个比较关键的点. 在普通撤销重做的基础上, 加入可视化的历史记录面板以增强界面提供的信息, 方便用户的操作.

由于应用是使用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', 根据不可变库的实现, 该属性向父级递归的更新其引用, 使得parent1state1引用地址获得了更新, 而child1, child3 以及parent2及其子节点保持原有引用地址不变, 实现了未修改处的复用.

immutable 图中展示了一种严格immutable的数据更新过程.

react推荐propsstateimmutable的, 不过在实际开发中, 经常能见到, 只要将传给react state的那份数据顶层引用地址更新就能成功设置状态的场景, 而嵌套复杂数据对象内部地址引用没有更新, 会有潜在的危险. 翻车demo

immutable利用数据地址来作为判断数据变更与否的依据, 弥补了得传统===操作符不能比较复杂数据对象的问题, 通过引入immutable的概念, 快照的模式也可以很大程度上视作为只关注修改的属性的差分模式, 而单条记录也只需要保存顶层的根节点地址state, 状态的回溯也只需要在状态仓库的顶层进行相应筛选(过滤无需记录的状态)与提交. 在纯前端的应用环境下, 遵循了immutable的快照撤销既能最大程度复用数据内存, 也能在任意历史数据快照之间快速切换

但是也有弊端, 由于数据引用地址是编程语言的执行环境提供的, 所以在跨环境, 或者执行环境不同的场景下, 没有办法引入数据地址作为额外标识来实现immutable, 快照将会退化到深拷贝模式.

差分补丁

差分补丁主要关注于每两次之间的状态差分, 需要在状态转移时, 生成相应的正向与反向补丁, 这两种补丁保证了两个状态之间的来回切换而不需要保存完整的状态信息.

  • 差分补丁的结构设计可以参照JsonPatch, 通过类似op的字段来标识增删改的操作

  • 一种更暴力的方式就是对每个操作入口进行收敛, 人为的去提取信息并作记录编写对应的正逆向补丁,对于动态程度比较高(例如传key value的反射取值)写出来的代码就会比较难看了

patch

差分更新的单条记录的量不会过大, 且补丁独立于状态库单独存储, 但仍然受到修改程度影响, 所以当一次性修改很多属性的时候, 补丁的大小也会等量的增长, 所以在性能方面仍然不及使用不可变库模式下的快照. 且如果在timetravel时(从state2 重做到 state4),差分补丁需要走完(state2 -> state3 -> state4,反之亦然)整个应用补丁的链路。如果跨度较大计算复杂度也会随之上升。

其优势主要体现在与存储的完整数据长度上, 快照模式下的硬伤就是完整数据过大问题, 例如 对涉及到跨环境通信的撤销(例如历史记录云同步, 跨标签页同步记录) 时, 快照的模式决定了它将回到深拷贝方式, 当进行通信的时候, 没有了js环境, 所有的数据回归到字符串, 实现方式只能回归到深拷贝的JSON.stringify, 原来的内存占用过大问题转化为请求体过大问题. 而差分补丁的方式则是最大程度上减少请求体里所携带的信息.


浅读一个基于快照的撤销与重做

因为快照的存储方式比较常见,社区里也有不少基于快照的解决方案,不需要重复造轮子了,看一下zustand官方推荐的zundo中间件是怎么实现撤销回溯的

zundo

简单描述下zustand,可以理解成一个不需要写actionType的版本的hooks风格的redux

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import { create } from 'zustand'

const useBearStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}))

const FC = () => {
  return (
    <div onClick={useBearStore((state) => state.increasePopulation)}>
      {useBearStore((state) => state.bears)}
    </div>
  )
}

从入口文件开始

zustand通过传递store实例来模拟传统注册中间件的效果. 中间件通过拦截被传递的的store实例(下文统称外部实例)的setsetState方法, 使其额外执行一系列记录外部实例状态变更的操作. 这里就可以看到,zundo通过调用传入的storeget方法,拿到了外部实例的全部/部分(通过传入回调实现)的快照

 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
    /**
     * @note 修改了注释便于阅读
     * @see https://github.com/charkour/zundo/blob/84e27b84e6e1fb343754480b28af450a1de67a9f/src/index.ts#L58
     * */
    const setState = store.setState; // 缓存原来的`setState`,因为也会有其他中间件修改store的setState方法
    store.setState = (...args) => {
      // 这里拿到更新前的仓库状态
      // zundo的配置支持传入预处理方法只对部分状态进行追踪,否则默认追踪当前仓库的所有状态
      const pastState = options?.partialize?.(get()) || get();
      // 执行仓库更新
      setState(...args);
      // 记录状态的主入口,注意这里的调用时机必须在状态更新后
      curriedHandleSet(pastState);
    };

     return config(
      //和上面的setState方法是一样的
      (...args) => {
        const pastState = options?.partialize?.(get()) || get();
        set(...args);
        curriedHandleSet(pastState);
      },
      get,
      store,
    );

中间件内部状态

中间件zundo内部也是一个单独的zustand状态仓库,调用了zustandcreateStore创建了一份实例(下文统称内部实例),用来保存状态快照,以及对应的操作状态的方法, 并且以temporal的字段将内部实例暴露给外部实例。

1
2
3
4
store.temporal = createStore(
  options?.wrapTemporal?.(temporalStateCreator(set, get, options)) ||
    temporalStateCreator(set, get, options),
);

中间件初始化时会传递外部实例的set/get 给到内部实例的方法,形成闭包调用,这样内部实例在执行撤销重做时就能反过来调用外部实例的setState来操作外部状态

所以这里表面说是状态回溯,实则是把一个以前的快照以一个新的setState的形式提交更新,对于状态仓库来说并没有真正的回到过去,而是来到了一个新的状态,只不过这个新的状态恰巧和前面某一次的状态一模一样

内部实例对外暴露的状态定义, 以_为开头定义的属性/方法标识该成员仅中间件内部逻辑使用,不应该暴露给上层开发者:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
 * @note 调整了顺序
 * @see https://github.com/charkour/zundo/blob/84e27b84e6e1fb343754480b28af450a1de67a9f/src/temporal.ts#L14
 * */
return {
  // 可以通过中间件设置配置初始撤销/回溯的快照数据,或许可以用来做多应用同步,或者持久化存储的回填?
  pastStates: options?.pastStates || [],
  futureStates: options?.futureStates || [],
  // 相当于enable, 一个开关状态用于控制是否记录快照, 通过`pause`和`resume`来切换
  isTracking: true,
  pause: () => set({ isTracking: false }),
  resume: () => set({ isTracking: true }),
  // 对外暴露的撤销与重做方法,主要配合`pastStates`和`futureStates`实现了时间回溯
  undo: (step = 1) => {/**/},
  redo: (step = 1) => {/**/},
  // 重置内部仓库状态
  clear: () => set({ pastStates: [], futureStates: [] }),
  // 设置可选的持久化相关的接口,默认不会启用
  setOnSave: (_onSave) => set({ _onSave }),
  // 默认初始化从配置中读取save方法, 也可以通过 `setOnSave` 异步地更新
  _onSave: options?.onSave, 
  //...
}

在入口文件注册中间件时的 curriedHandleSet 就指向的zundo内部实例的 _handleSet方法,在拦截过后的set方法里最后一个调用, 如此可通过使用userGet就能获取到最新的外部实例状态

 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
30
31
32
export const temporalStateCreator = <TState>(
  userSet: StoreApi<TState>['setState'],
  userGet: StoreApi<TState>['getState'],
  options?: ZundoOptions<TState>,
) => {
  const stateCreator: StateCreator<_TemporalState<TState>, [], []> = (set, get) => {
    return {
      //...
      _handleSet: (pastState) => {
        if (get().isTracking) {
          const currentState = options?.partialize?.(userGet()) || userGet();
          // 这里支持开发者自己处理前后状态差异,开发者可以用来自己实现差分方法,但是
          // 最终还是需要输出位外部实例的状态数据结构(像快照一样)虽然名字叫`diff`,
          // 但是并不是开启差分补丁模式, 可以理解成一个增强版的预处理方法
          const deltaState = options?.diff?.(pastState, currentState);
          // 支持自定义相等运算方法
          if (!(options?.equality?.(pastState, currentState) || deltaState === null)) {
            // 支持设置历史列表`pastStates`最大容量, 溢出后将丢弃最旧的数据,默认容量无上限
            if (options?.limit && get().pastStates.length >= options?.limit) {
              get().pastStates.shift();
            }
            // 调用持久化方法(如存在)
            get()._onSave?.(pastState, currentState);
            set({
              pastStates: get().pastStates.concat(deltaState || pastState),
              futureStates: [],
            });
          }
        }
    }
  }
}

如果剔除所有配置化的参数逻辑, _handleSet的默认行为可以简化为, 当外部应用状态发生了变更, 且在中间件开启了isTracking的状态,将外部实例 更新前 的状态pastState作为最新的快照数据追加到内部实例的pastStates中. 此处使用了concat而非push, 因为concat返回了一个新的数组, 简洁的实现了immutable.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
return {
  _handleSet :(pastState)=>{
    if (get().isTracking) {
       set({
        pastStates: get().pastStates.concat(pastState), 
        futureStates: [] //外部状态变更将清空futureStates
      });
    }
  }
}

历史记录列表

zundo 使用了2个数组来实现历史记录, 每一个数组内相邻的两份快照都代表了一次外部实例状态转移的前后状态, 所以快照的顺序即代表了操作的顺序。 pastStates数组代表了可以撤销的快照数量, futureStates数组代表了可以重做的快照数量.

  • 当外部实例发生操作, 状态变化时, 由上文提到的_handleSet方法拦截了状态并推入pastStates

step1

  • 此外,应用本身处于撤销若干步的状态(即futureStates中有值), 外部实例发生的状态变更会让历史记录列表丢弃futureStates中存放的重做的数据, 形成一条新的快照状态转移链路.

step1_

  • 当外部实例执行undo时, zundo会先从外部实例拿一份最新的状态, 将pastStates倒序遍历并切割, 遍历长度为撤销的步数, (比如一次性撤销2步) 默认为1步, 这样倒序遍历并切割后的数组的第一个元素就是将要应用到外部实例的快照数据, 然后将切割数组中剩下的元素倒序的推入futureStates数组
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
  const currentState = options?.partialize?.(userGet()) || userGet();
  // 从尾部开始倒序切割, 获得一个新的倒序子数组
  const statesToApply = get().pastStates.splice(-steps, steps);
  // 倒序子数组的第一份数据就是将要应用的快照
  const nextState = statesToApply.shift()!;
  userSet(nextState);
  set({
    // 由于使用了`splice`, `pastStates`引用未变但是存的数据变了
    pastStates: get().pastStates,
    futureStates: get().futureStates.concat(
      options?.diff?.(currentState, nextState) || currentState,
      statesToApply.reverse(),
    ),
  });

这块的源码涉及到两次数组翻转有点绕, 简单来说就是pastStates是一个数组, 一次性撤销几步, 就从pastStates从后往前数拿第几个数据, 把他设置回外部实例,然后先把当前最新状态保存到futureStates, 再把比拿走的那份数据index大的所有快照全都 倒序地 塞到futureStates的数组尾部, 用来保证pastStates中快照的顺序由最久远的快照排列到最临近的快照, 而futureStates中的数据顺序由最临近的快照排列到最久远的快照

图例展示了一次撤销两步时, 内部实例的数组变化情况:

step2

  • 当外部实例执行redo时, 方法的行为与undo相似, 只是把操作的对象数组相互交换了一下, 源码也十分相似
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
  const currentState = options?.partialize?.(userGet()) || userGet();
  const statesToApply = get().futureStates.splice(-steps, steps); 
  const nextState = statesToApply.shift()!;
  userSet(nextState);
  set({
    pastStates: get().pastStates.concat(
      options?.diff?.(currentState, nextState) || currentState,
      statesToApply.reverse(),
    ),
    futureStates: get().futureStates,
  });

小结

zundo实现了一个基于immutable规范的快照撤销与重做能力, 通过拦截zustandset来捕获快照, 同时维护在内部的状态仓库里, 当执行撤销回退时, zundo分别从不同的状态数组内拿到对应的快照并set回使用该中间件的store已达成时间回溯的效果. 快照的思想更加关注于状态的结果, 对状态转移如何产生的并不关心.


实现一个基于差分补丁的撤销与重做

正如前置知识章节的示意图, 使用补丁数据进行撤销与重做, 要关注补丁对生成的过程而非追踪应用的状态, 设计思想类似命令模式, 将触发状态转移的源头方法进行收敛, 统一进行差分计算, 同时能够同步记录一些其他运行时的元数据, 在应用交互(执行状态转移命令)时, 将这些元数据与补丁保存到历史记录集合中用作展示以及撤销重做的核心功能实现

套用到编排类应用的业务场景中去,可以归纳为

  • 这些注册好的确定的指令就是提供给用户的, 用户交互发生时直接/间接需要调用到的方法.
  • 推入列表的数据记录了可以重现用户操作的数据.
  • 在实现以上两点的前提下, 降低代码侵入性提高动态更新的支持

这个版本的撤销与重做的实现是基于 @reduxjs/toolkit(下文会简写为RTK) 这个redux官方封装的功能增强版轮子来实现的. 至于为什么, 首先看一下官方基于slice概念的示例

1
2
3
4
5
6
7
8
9
import { createSlice } from '@reduxjs/toolkit'

const counterSlice = createSlice({
  name: 'counter',
  initialState: 0,
  reducers: {
    increment: (state) => state + 1,
  },
})

经过RTK封装后的redux十分简洁, 看上去非常像上文提到的zustandstore定义方式, 但是RTK显式的区分了statereducers的定义, 并且reducers中单个reducer支持对象式的定义, 这为我们的扩展提供了切入点.

补丁数据

撤销重做的核心就是正向与逆向的补丁数据,在每次修改state后,可以通过类似microdiff这类的库,通过交换顺序对前后两个状态之间进行计算正向与反向的patch, 这样的设计需要侵入并拦截状态管理库的数据的setter过程,例如传统的redux则就需要在每个reducer中显式的实现, 就没有那么优雅.

好消息是,常用的不可变库immer, 其实就提供了这个记录补丁数据的能力, 包括正向与逆向, 只是该功能默认不开启。而RTKredux的封装正是基于immer来实现的

显式地开启补丁功能官方文档.

1
2
3
4
// version 6
import { enablePatches } from 'immer'

enablePatches();

开启后即可在produce方法中, 传入一个回调作为第三个参数, 该回调将会获得produce执行过程中对draft对象所修改的所有行为的补丁, 以patches标注为正向补丁, inversePatches标注为反向回溯补丁, 同时immer还提供了使用补丁的方法applyPatches来应用刚刚输出的正反向补丁

注: 新版的immer可以通过使用produceWithPatches这个方法来生成补丁, 更加的简洁, 同样也需要显示的开启Patch插件. 但是由于开发这个需求的时候还没有这个功能所以将会以回调形式进行改造.

 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
30
31
32
33
34
import produce, { applyPatches, produceWithPatches } from 'immer';

const state = {
  name: "Micheal",
  age: 32
}
const changes = []
const inverseChanges = []

const nextState = produce(
  state,
  draft => {
    draft.age = 33
  },
  // 这里可以接受一个回调用来接收补丁, 用闭包函数来记录补丁
  (patches, inversePatches) => {
    changes.push(...patches)
    inverseChanges.push(...inversePatches)
  }
)
// 新版的元组写法, 更函数式一点
const [nextStateFromNewApi, patches, inversePatches] = produceWithPatches(
  state, 
  draft => {
    draft.age = 33
  },
)

// 应用补丁
const lastState = applyPatches(state, inverseChanges)
expect(lastState).toEqual({
  name: "Micheal", 
  age: 32 
})

于是我们撤销重做的改造就会围绕着这两份patch数据进行

设计命令注册方法

从现有的项目出发, 用户的操作会带来数据上的改变, 但不是所有的操作都需要历史记录, 就像PhotoShop那样, 有时候打开了画板, 是不需要记录到历史的, 而那些编辑了/操作了图层的动作才是需要记录的, 所以这里就需要将记录patch的控制权开放给上层应用, 这样才能够有选择的记录补丁.

redux的核心是一个个reducer, 我们在定义这些reducer的过程中其实也是在向redux注册一个又一个的命令, 我们可以在复用这一块的注册行为的基础上去扩展以最终达到注册历史记录命令的目的. 对于单个reducer, RTK支持这样的写法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
reducers: {
  increment: {
    reducer(state, action) {
      const { payload } = action
      return Math.min(state + payload.step, payload.upperLimit)
    },
    prepare(step = 1, upperLimit = Infinity) {
      return { payload: {step, upperLimit } }
    }
  }
},
/**
 * 这样通过接受一个函数`Object`来达到对`reducer`入参更细致的控制
 * 就可以写成`dispatch(store.actions.increment())`的形式
 * 而非     `dispatch(store.actions.increment({step:1, upperLimit:Infinity }))`
 * /

reducer的模式天然契合,于是设想我们是否可以给每个reducer额外添加一份回调, 用于转发produce的第三个回调, 这样能用作记录补丁的入口, 同时它也应该是一个可选的回调, 当不传入方法时, 它输出的数据便不会被记录, 这样也满足了上文提到的有选择性的记录补丁的需求.

于是设想它应该是这样的

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  reducer(state, action) {
    const { payload } = action
    return Math.min(state + payload.step, payload.upperLimit)
  },
  prepare(step = 1, upperLimit = Infinity) {
    return { payload: {step, upperLimit } }
  }
+ patch:(patches, inversePatches, action) => {
+   // 记录patches 和 action
+ }
},

修改源码

在默认的RTK中, 并没有实现上述功能, 于是需要修改源码, 让immerreducer执行时记录差分补丁. immer作用的区域是createReducer.ts这个文件, 首先需要在其引入模块部分开启补丁模式. 而createReducer.ts会被createSlice.ts文件引用,同时为了保证改造后的类型推断完整, creatReducer.ts中的类型涉及到文件也要修改,经排查只有mapBuilders.ts, 也会被createSlice.ts引用

RTK的源码拆分比较灵活,我们可以通过实现自己的createSlice, createReducer等接口来替换原本的功能,做到最小程度的改动

本文基于@reduxjs/[email protected]版本进行改造(因为本文鸽了2年), 后续官方包的破坏性更新可能会导致不可用, 但源码的改造思路应该不会受到影响.

  1. 首先从类型定义入手, 修改对应的配置类型标注让ts能正确推导, 原有的reducers配置定义存放在createSlice.ts#L227ValidateSliceCaseReducers类型,

     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'>
          }
        : {}
    }
    

    可见只有reducerprepare两个定义, 在这里加上我们想要的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;
    +          }
    +    : {};
    };
    
  2. createSlice解析配置时, 也要像caseReducer一样, 将所有的patch收集起来, 为了命名一致, 也叫它casePatcher. CasePatcher类型就是刚刚定义的patch方法类型, 并且在接下来解析slice中的reducerprepare中额外添加记录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 {
    
  3. 在改造createSlice.ts支持我们设计的额外回调入口后, 需要改造createRedcuer.ts让我们传入的patch在运行时真正生效.

    通过调用同名函数createReducercreateSlice中收集到的caseReducers的集合(sliceCaseReducersByType)封入一个闭包函数, 并在运行时动态地过滤出action对应的caseReducer并执行(来模拟传统reduxreducerswitch-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);
    +   },
      )
    }
    
  4. 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封装成一个闭包供undoredo调用, 比起直接存patch数据, 底层实现逻辑代码会更清晰可读, 不过代价就是封装闭包的方法需要上层应用或者适配器来实现, 建议根据接入视图框架的方式来进行选择.
  • zundo没有对当前的状态记录, 也就是进入页面时, 列表为空, 可以默认携带一个类似“初始化页面”的操作记录用于更好的提示
  • 步进大于1的timetravel的情况, 差分模式需要依次走完完整的链路, 并不能像zundo那样使用双数组切分取值+拼接的形式, 所以这里使用单个数组+currentIndex的模式来实现

具体的实现逻辑参照下图

stack

  1. 首先我们定义一个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闭包并不会同时作为同一条Recordundoredo. 只有新的record被生成时, 前一个recordredo才会被赋值, 所以redo才会被定义为optional. 一个很符合直觉的解释就是: 你不能重做(区别于再做)一个最新的状态, 你只能做撤销.

    此外, name作为元数据输入生成, 而timestamp可以在创建Record时立即自动记录.

  2. 顺着上面的思路, 我们可以推导出历史记录列表的一些能力, 进行历史记录列表的构建

    • 支持生成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, 衍生出一条新的状态转移链路. 且赋值目前最后一条recordredo

     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方法的 redoundo闭包是由一次状态转移产生的patchesinversePatches构建而来的. 一个补全列表尾部recordredo, 一个用于直接构建新的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);
        }
      }
    }
    
  3. 最后可以完善一下构造函数, 将一个初始状态推入数组, 这一步也可由持有历史记录列表实例的上层应用来完成.

    1
    2
    3
    4
    5
    
    class HistoryPool {
      constructor() {
        this.addRecord('初始化', () => {}, () => {})
      }
    }
    

接入react, 配合RTK实现撤销重做

至此我们已经完成了拓展RTKpatch回调入口以及原生js实现的历史记录列表逻辑, 而RTK又是由react驱动的store, 所以历史记录表作为纯js实例, 也需要有一个在react中的上层实现才能和RTK配合使用.

  1. RTK实现应用补丁的reducer.

    zundo中, 修改store的状态是通过闭包获取到set方法后, 可以在中间件内部拿到快照并直接操作外部store, 而RTK中即使是middleware能拦截的只有getState,dispatchaction, 因此需要一个专有的reducer来配合特定的action进行状态转移(应用补丁)

    在构造slice时, 定义一个applyRecord(名字随意)的reducer, payload接收patch[], 且通过immer提供的applyPatches来应用补丁. 每次redo/unodo, 最终都激活这个applyRecordreducer, 把patchesinversePatches传给他完成状态补丁更新.

     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: {/***/}
      }
    })
    
  2. 将历史记录列表实例接入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, 而非触发状态转移的reduceractionType. 传入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时, dispatchaction, 继而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() // 下文会实现
        }
      }
      

      同时通过setStaterecords等数据接入react的生命周期, 收敛到修改状态的回调方法为timeTravel,undo,redocreateRecord, 在这些方法的最后额外增加一次带有memosetState.

       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对象封装成一个可以返回statehook, 对于其他支持hook版本的react, 可以通过安装use-sync-external-storenpm依赖来使用该能力.

      关于useSyncExternalStore, 可以看成是useStateuseEffect的组合, 具体的原理解析可以参考这篇文章

      使用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用于封装useSyncExternalStoregetSnapshot, 其实就是描述当前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重写的timeTravelcreateRecord方法也需要在这个方式下实现, 顺便吧undoredo也一起实现了. 需要注意的是, 这里可以通过构造函数传入getDispatch方法来注入, 当然也可以像方法1一样使用abstract class.

      方法1中当然也可以直接把getDispatch作为构造参数传入, 不这么做的原因是, 作者习惯于将reactprops开放给运行时需要响应式的变量, 而一些静态的参数则通过工厂, 类重写的方式, 让应用省去更新追踪响应式的过程(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, 对接入状态的组件层级也没有要求

  3. 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`
    

    于是才引入了historyRefUndoRedo.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)
    }
    
  4. 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>
      )
    }
    

总结

文章分析了作者在前端历史记录的实现方式上的学习成果, 介绍了诸如差分, 快照, 不可变性等概念. 并且分别对两种实现方式进行了解读并给出了一些应用场景选型的建议. 在解读源码的同时也基于immerRTK进行了改造, 实现了一个基于差分的撤销与重做, 对已经再用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