「Redux」基于Redux、Immer实现<撤销重做>功能

240 阅读4分钟

「Redux」基于Redux、Immer实现<撤销重做>功能

相关链接:

场景复现

在一个项目中,有一个需求:基于之前存储的操作流水日志,来实现回放功能,需要支持播放,暂停、进度条拖动功能。

什么是操作流水日志? 比如,来看一下一个JSON数据包:「initState」代表初始数据,「logs」代表对该数据的一系列操作。

{
  "initState":{
    "value": 0
  },
  "logs": [
    { "type":"add", "payload": 3 },
    { "type":"subtract", "payload": 1 },
    { "type":"multiply", "payload": 2 },
    { "type":"add", "value": 3 },
  ]
}

然后我们对该数据实现「播放」功能,那么数据的变化就是:「0 -> 3 -> 2 -> 4 -> 7」

那么暂停功能可能就是一小段:「0 -> 3 -> 2」 或者 「2 -> 4 -> 7」

那么对于进度条拖动的话,那么就涉及到对数据的逆向操作。比如:「7 -> 4 -> 2 -> 3 -> 0」。上一步「add:3」,那么撤销上一步就是「subtract:3」。

有两种实现方式:(假设是从7到4)

  • 对于最后落到的结点,都基于初始状态,重新对数据进行操作到该结点的位置。那么就需要执行4次对数据的计算。
  • 存在撤销的数据,如果我们知道怎么从7撤回到4,那么只需要执行1次即可以实现目标。

对比两种方式,其实会发现,大部分情况下:

  • 第一种方式消耗比较大,每次都需要进行大量数据的修改,但这种方式我们不需要编写额外的逻辑,实现起来比较简单;
  • 第二种方式对于数据的修改比较轻量,但我们需要知道并存储「对数据修改的每一个反向操作」,实现难度比较大。

命令模式

对于第一种方式,比较简单,我们不给予示例。

对于第二种方式,我们可以用「命令模式」进行实现。

type ActionType = 'add' | 'subtract' | 'multiply'
type CommandReturn = {
  exec: () => void
  undo: () => void
}
type useCount = [number, Dispatch<SetStateAction<number>>]


// 应用action生成新state
const applyAction = (state: number, type: ActionType, payload: number) => {
  if (type === 'add') {
    return state + payload
  } else if (type === 'subtract') {
    return state - payload
  } else if (type === 'multiply') {
    return state * payload
  } else {
    return state
  }
}


// 命令生成器,生成命令对象
const Command = (reciver: useCount, type: ActionType, payload: number): CommandReturn => {
  const [value, setValue] = reciver;
  let oldValue: number = value;
  return {
    exec() {
      setValue(applyAction(oldValue, type, payload))
    },
    undo() {
      setValue(oldValue)
    }
  }
}


const demoData = {
  "initState": {
    "value": 0
  },
  "logs": [
    { "type": "add", "payload": 3 },
    { "type": "subtract", "payload": 1 },
    { "type": "multiply", "payload": 2 },
    { "type": "add", "payload": 3 },
  ]
}


const CommandDemo = function () {
  const [data] = useState(demoData);
  // 命令接收者
  const reciver = useState<number>(data.initState.value);
  // 当前命令
  const [current, setCurrent] = useState<number>(-1);
  // 命令列表
  const [commandList, setCommandList] = useState<CommandReturn[]>([]);
  // 上一步
  const prev = () => {
    commandList[current].undo();
    setCurrent(current - 1);
  }
  // 下一步
  const next = () => {
    const { type, payload } = data.logs[current + 1];
    // 不重复创建
    const commond = commandList[current + 1] || Command(reciver, type, payload);
    if (current + 1 === commandList.length) setCommandList([...commandList, commond]);
    commond.exec();
    setCurrent(current + 1);
  }


  return <>
    <div>{reciver[0]}</div>
    <div>
      <button disabled={current < 0} onClick={prev}>上一步</button>
      <button disabled={current === data.logs.length - 1} onClick={next}>下一步</button>
    </div>
  </>;
};

以上是一个基础的撤销重做的demo,那么当我们使用redux来进行状态的管理,效果也是一样的。

Immer中的Patch功能

在React中,我们使用Redux来进行数据状态的管理,Redux中遵循着「不可变数据」的规则,底层使用「Immer」来帮助处理原始状态,生成新的状态,避免开发者繁琐地使用「...」等。

使用 Immer,您会将所有更改应用到临时 draft,它是 currentState 的代理。一旦你完成了所有的 mutations,Immer 将根据对 draft state 的 mutations 生成 nextState。这意味着您可以通过简单地修改数据来与数据交互,同时保留不可变数据的所有好处。

import produce from "immer"

const nextState = produce(baseState, draft => {
    draft[1].done = true
    draft.push({title: "Tweet about it"})
})

而在 producer 运行期间,Immer 可以记录所有的补丁来回溯 reducer造成的更改 。

⚠ 在版本6之后,必须在启动应用程序调用一次「 enablePatches() 」来启用对 Patches 的支持。

在我们上述的功能,Patches可以轻松的完成我们的需求。那么我们如何使用「Immer」中的Patches功能,来让我们的store具备「撤销、重做」等功能呢?

借助Redux中间件

Redux middleware 解决的问题与 Express 或 Koa middleware 不同,但在概念上是相似的。它在 dispatch action 的时候和 action 到达 reducer 那一刻之间提供了三方的逻辑拓展点。可以使用 Redux middleware 进行日志记录、故障监控上报、与异步 API 通信、路由等。

当你使用了中间件,它就像是一个中间人,让你能够在真正dispatch之前,dispatch之后,执行你想要完成的事情。

我们可以整理一下,如果我们想通过中间件实现「撤销、重做」功能,那么我们需要在dispatch时,可以借助「Immer」的能力,保存下该操作的「正补丁 和 逆补丁」,以便供后续的撤销重做功能提供材料。

另外,要想执行「撤销、重做」操作,本质上还是修改store的状态来实现,但触发原始的action,并不能达到我们的要求。那么我们就需要新增两个action,分别是「undo」和「redo」,当需要撤回时,dispatch(undo),从队列中获取当前所在位置的逆补丁并且应用,即可获得新的state,实现撤销功能,重做功能也是同理,取出正补丁并应用。

示例代码