React 撤销/重做功能分析

570 阅读10分钟

React 撤销/重做功能

image.png

基本概念

撤销/重做功能的核心是保存状态的历史记录,以便在用户请求撤销或重做时能够恢复到之前的状态。具体来说,包含以下三个部分:

  1. present: 当前状态。
  2. past: 历史记录,保存所有之前的状态,用于撤销。
  3. future: 未来记录,保存撤销操作之前的状态,用于重做。

状态操作过程

  • 输入:每次输入操作,当前状态 present 会被存入 past,并且 future 会被清空。

  • 撤销past 的最后一个状态出栈(arr.pop),加入 future,并将 present 设置为这个状态。

  • 重做future 的最后一个状态出栈,加入 past,并将 present 设置为这个状态。

  • 状态变化示例

    初始状态

    present = 'abc1' // 当前状态
    past = ['a', 'ab', 'abc', 'abc1'] // 历史记录
    future = [] // 未来记录
    

    输入操作

    当用户在输入框中继续输入时,例如输入 '2':

    输入 '2' 后:
    present = 'abc12'
    past = ['a', 'ab', 'abc', 'abc1', 'abc12']
    future = []
    

    撤销操作

    用户点击撤销按钮:

    present = 'abc12'
    past = ['a', 'ab', 'abc', 'abc1', 'abc12']
    future = []
    ​
    撤销一次后:
    更新present: 将present更新为past数组的倒数第二个元素(arr[arr.length-2]),即'abc1'。
    更新past: 弹出past数组的最后一个元素,即past.pop(),将其移除。
    更新future: 将刚刚被移除的元素追加到future数组的末尾。
    present = 'abc1'
    past = ['a', 'ab', 'abc', 'abc1']
    future = ['abc12']   
    ​
    再撤销一次后:
    present = 'abc'
    past = ['a', 'ab', 'abc']
    future = ['abc12', 'abc1']
    

    重做操作

    用户点击重做按钮:

    present = 'abc'
    past = ['a', 'ab', 'abc']
    future = ['abc12', 'abc1']
    ​
    重做一次后:
    更新present: 将present更新为future数组的最后一个元素,即'abc1'。
    更新future: 弹出future数组的最后一个元素,即future.pop(),将其移除。
    更新past: 将刚刚被移除的元素追加到past数组的末尾。
    present = 'abc1'
    past = ['a', 'ab', 'abc', 'abc1']
    future = ['abc12']
    ​
    再重做一次后:
    present = 'abc12'
    past = ['a', 'ab', 'abc', 'abc1', 'abc12']
    future = []
    

撤销与重做的栈原理(可跳过)

撤销(Undo)重做(Redo) 功能的核心是使用两个栈来保存状态的历史记录。这些栈分别用来存储过去的状态和未来的状态。通过这种方式,可以方便地实现状态的回退和恢复。

基本概念

  • 栈(Stack) 是一种数据结构,遵循“后进先出”(LIFO,Last In, First Out)的原则。
  • 过去栈(Past Stack) 保存的是历史状态,用于撤销操作。
  • 未来栈(Future Stack) 保存的是被撤销的状态,用于重做操作。

状态操作过程

  1. 输入新状态

    • 当前状态存入过去栈。
    • 清空未来栈。
  2. 撤销操作

    • 将过去栈的顶部状态弹出,并将当前状态存入未来栈。
    • 将弹出的状态设为当前状态。
  3. 重做操作

    • 将未来栈的顶部状态弹出,并将当前状态存入过去栈。
    • 将弹出的状态设为当前状态。

状态变化示例

假设有一个文本编辑器,初始状态为空字符串。

初始状态

Current State: ''
Past Stack: []
Future Stack: []

输入操作

用户输入 'a':

Current State: 'a'
Past Stack: ['']
Future Stack: []

继续输入 'b':

Current State: 'ab'
Past Stack: ['', 'a']
Future Stack: []

再输入 'c':

Current State: 'abc'
Past Stack: ['', 'a', 'ab']
Future Stack: []

撤销操作

用户点击撤销按钮一次:

Past Stack: ['', 'a', 'ab']
Current State: 'ab'
Future Stack: ['abc']

再次点击撤销按钮:

Past Stack: ['', 'a']
Current State: 'a'
Future Stack: ['abc', 'ab']

重做操作

用户点击重做按钮一次:

Past Stack: ['', 'a', 'ab']
Current State: 'ab'
Future Stack: ['abc']

再点击重做按钮:

Past Stack: ['', 'a', 'ab', 'abc']
Current State: 'abc'
Future Stack: []

实现撤销与重做的伪代码

以下是实现撤销与重做功能的简单伪代码示例:

class UndoRedoStack {
  constructor() {
    this.past = [];
    this.present = null;
    this.future = [];
  }
​
  setPresent(state) {
    if (this.present !== null) {
      this.past.push(this.present);
    }
    this.present = state;
    this.future = [];
  }
​
  undo() {
    if (this.past.length > 0) {
      this.future.push(this.present);
      this.present = this.past.pop();
    } else {
      console.log('No more states to undo');
    }
  }
​
  redo() {
    if (this.future.length > 0) {
      this.past.push(this.present);
      this.present = this.future.pop();
    } else {
      console.log('No more states to redo');
    }
  }
}
​
// 使用示例
let editor = new UndoRedoStack();
editor.setPresent('');
editor.setPresent('a');
editor.setPresent('ab');
editor.setPresent('abc');
​
console.log(editor.present); // 'abc'
editor.undo();
console.log(editor.present); // 'ab'
editor.undo();
console.log(editor.present); // 'a'
editor.redo();
console.log(editor.present); // 'ab'

撤销与重做的功能通过管理两个栈来实现,确保能够有效地回退到之前的状态或恢复到被撤销的状态。这个机制在许多应用中广泛使用,特别是需要提供历史记录功能的应用,如文本编辑器、绘图软件等。

使用 immer实现重做/撤销

使用 Immer 库可以让你更方便地管理不可变状态,并且它的实现比 redux-undo 更高效,内存开销更小。通过 Immer,你可以实现状态回溯而不需要额外的库。

下面是使用 Immer 来实现状态回溯的示例:

  1. 安装 Immer:

首先,确保你安装了 Immer:

npm install immer
  1. 定义 Redux store 和 Reducer:

在你的 Redux store 配置中使用 Immer 进行状态管理。

import { configureStore, createSlice } from '@reduxjs/toolkit';
import { produce } from 'immer';

// 定义初始状态,包含三个部分:过去状态、当前状态和未来状态
const initialState = {
  past: [],
  present: {
    list: []
  },
  future: []
};

// 创建一个 slice,用于处理 todo 列表的相关逻辑
const todoSlice = createSlice({
  name: 'todoList',
  initialState,
  reducers: {
    // 添加新项目到列表
    addListRedux: (state, action) => {
      // 将当前状态保存到 past 数组中
      state.past.push(state.present);
      // 使用 Immer 的 produce 函数更新当前状态
      state.present = produce(state.present, draft => {
        draft.list.push(action.payload);
      });
      // 清空 future 数组,因为新增操作会使之前的未来状态无效
      state.future = [];
    },
    // 从列表中删除指定项目
    delListRedux: (state, action) => {
      // 将当前状态保存到 past 数组中
      state.past.push(state.present);
      // 使用 Immer 的 produce 函数更新当前状态
      state.present = produce(state.present, draft => {
        draft.list.splice(action.payload, 1);
      });
      // 清空 future 数组,因为删除操作会使之前的未来状态无效
      state.future = [];
    },
    // 撤销上一次操作
    undo: (state) => {
      // 如果 past 数组中有记录,进行撤销操作
      if (state.past.length > 0) {
        // 将当前状态保存到 future 数组的开头
        state.future.unshift(state.present);
        // 从 past 数组中取出最后一个状态作为当前状态
        state.present = state.past.pop();
      }
    },
    // 重做上一次被撤销的操作
    redo: (state) => {
      // 如果 future 数组中有记录,进行重做操作
      if (state.future.length > 0) {
        // 将当前状态保存到 past 数组中
        state.past.push(state.present);
        // 从 future 数组中取出第一个状态作为当前状态
        state.present = state.future.shift();
      }
    }
  }
});

// 从 slice 中导出 action creators,用于组件中调用
export const { addListRedux, delListRedux, undo, redo } = todoSlice.actions;
export default todoSlice.reducer

配置 Store

然后,使用 redux-undo 中间件配置 Redux store:

import { configureStore } from '@reduxjs/toolkit';
import todoListReducer from './todoList'; // 确保路径正确

export default configureStore({
  reducer: {
    todoList: todoListReducer
  },
});
  1. 更新组件以使用新的 Redux 逻辑:

调整组件代码以使用新的状态回溯逻辑。

import { useSelector, useDispatch } from 'react-redux';
import { addListRedux, delListRedux, undo, redo } from '../../redux/store/todoList';
import React, { useState } from 'react';
import { Divider, List, Typography, Input, Button } from 'antd';
​
const TodoListComponent = () => {
  const [inputValue, setInputValue] = useState('');
  const data = useSelector(state => state.todoList.present.list);
  const dispatch = useDispatch();
​
  console.log(data);
​
  function addList() {
    dispatch(addListRedux(inputValue));
  }
​
  function delList(id) {
    dispatch(delListRedux(id));
  }
​
  function handleUndo() {
    dispatch(undo());
  }
​
  function handleRedo() {
    dispatch(redo());
  }
​
  return (
    <div style={{ width: '30vw' }}>
      <Divider orientation="left">
        <Input
          value={inputValue}
          onChange={e => setInputValue(e.target.value)}
        />
        <Button style={{ margin: '0 10px 0 10px' }} onClick={addList}>
          新增
        </Button>
      </Divider>
      <List
        header={<div>redux-undo</div>}
        footer={
          <div>
            <Button onClick={handleUndo} type="primary" style={{ margin: '0 10px 0 10px' }}>
              撤销
            </Button>
            <Button onClick={handleRedo}>重做</Button>
          </div>
        }
        bordered
        dataSource={data}
        renderItem={(item, index) => (
          <List.Item>
            <div style={{ width: '100%', display: 'flex', justifyContent: 'space-between' }}>
              <Typography.Text mark>{item}</Typography.Text>
              <Button onClick={() => delList(index)} size="small" danger>
                删除
              </Button>
            </div>
          </List.Item>
        )}
      />
    </div>
  );
};
​
export default TodoListComponent;

代码说明:

  1. 状态管理

    • initialState 定义了 pastpresentfuture 状态。
    • todoSlice 定义了添加、删除、撤销和重做的 reducers,并使用 Immer 的 produce 来处理状态变化。
  2. 函数定义

    • addListReduxdelListRedux:每次状态变化时,将当前状态推到 past 数组,并清空 future
    • undoredo:根据 pastfuture 数组进行状态回溯和重做。

使用 redux-undo

1. 提供撤销/重做功能

在复杂的应用中,用户可能需要撤销和重做操作来恢复或重现之前的状态。例如:

  • 文字编辑器:用户可能希望撤销误操作。
  • 数据输入:用户输入错误时,需要返回之前的正确状态。
  • 图形编辑软件:用户希望撤销上一步的绘图操作。

redux-undo 能够轻松实现这些功能,增强用户体验。

2. 简化状态管理

手动实现撤销/重做功能可能需要复杂的状态管理逻辑。redux-undo 提供了一种简单的方式来记录和管理状态变化,使得开发者能够专注于核心功能的实现,而无需担心状态记录的复杂性。

3. 提高代码可维护性

通过使用 redux-undo,可以将撤销/重做逻辑与业务逻辑分离,提升代码的可读性和可维护性。使用专门的中间件管理历史状态,能够减少代码重复和错误。

4. 灵活的配置和扩展性

redux-undo 提供了丰富的配置选项,可以根据需求进行灵活定制。例如,可以限制历史记录的数量、过滤特定的动作、分组动作等。这样可以确保撤销/重做功能的高效性和适用性。

安装 Redux-Undo

npm install --save redux-undo@beta

Redux-Undo 使用示例

创建 Slice

首先,定义你的 Redux Slice,并确保它能处理添加和删除操作:

import { createSlice } from '@reduxjs/toolkit';
​
const initialState = {
  list: ['123'],
};
​
export const todoListSlice = createSlice({
  name: 'todoList',
  initialState,
  reducers: {
    addListRedux(state, action) {
      state.list.push(action.payload);
    },
    delListRedux(state, action) {
      const id = action.payload;
      state.list.splice(id, 1);
    },
  },
});
​
export const { addListRedux, delListRedux } = todoListSlice.actions;
export default todoListSlice.reducer;

配置 Store

然后,使用 redux-undo 中间件配置 Redux store:

import { configureStore } from '@reduxjs/toolkit';
import undoable from 'redux-undo';
import todoListReducer from './todoList'; // 确保路径正确export default configureStore({
  reducer: {
    todoList: undoable(todoListReducer, {
      limit: 20, // 限制历史记录数量
    }),
  },
});

创建组件

使用 useSelectoruseDispatch 钩子来访问和更新状态:

import { useSelector, useDispatch } from 'react-redux';
import { addListRedux, delListRedux } from '../../redux/store/todoList';
import React, { useState } from 'react';
import { Divider, List, Typography, Input, Button } from 'antd';
import { ActionCreators } from 'redux-undo';
​
const TodoListComponent = () => {
  const [inputValue, setInputValue] = useState('');
  const data = useSelector(state => state.todoList.present.list);
  const dispatch = useDispatch();
​
  console.log(data);
​
  function addList() {
    dispatch(addListRedux(inputValue));
  }
​
  function delList(id) {
    dispatch(delListRedux(id));
  }
​
  function undo() {
    dispatch(ActionCreators.undo());
  }
​
  function redo() {
    dispatch(ActionCreators.redo());
  }
​
  return (
    <div style={{ width: '30vw' }}>
      <Divider orientation="left">
        <Input
          value={inputValue}
          onChange={e => setInputValue(e.target.value)}
          defaultValue="0571"
        />
        <Button style={{ margin: '0 10px 0 10px' }} onClick={addList}>
          新增
        </Button>
      </Divider>
      <List
        header={<div>redux-undo</div>}
        footer={
          <div>
            <Button onClick={undo} type="primary" style={{ margin: '0 10px 0 10px' }}>
              撤销
            </Button>
            <Button onClick={redo}>重做</Button>
          </div>
        }
        bordered
        dataSource={data}
        renderItem={(item, index) => (
          <List.Item>
            <div style={{ width: '100%', display: 'flex', justifyContent: 'space-between' }}>
              <Typography.Text mark>{item}</Typography.Text>
              <Button onClick={() => delList(index)} size="small" danger>
                删除
              </Button>
            </div>
          </List.Item>
        )}
      />
    </div>
  );
};
​
export default TodoListComponent;
  • addList:用于向列表中添加新项目,调用 addListRedux action。
  • delList:用于删除指定 id 的列表项目,调用 delListRedux action。
  • undo:用于撤销上一次的操作,调用 redux-undo 提供的 undo action。
  • redo:用于重做上一次被撤销的操作,调用 redux-undo 提供的 redo action。

redux-undo 中的 undoable 函数可以接受一个配置对象,用于自定义撤销/重做功能的行为。下面详细讲解各个配置项的作用和使用方法。

Redux-Undo API 详解

  • ActionCreators.undo(): 分发此 action 以撤销上一个操作。
  • ActionCreators.redo(): 分发此 action 以重做上一个撤销的操作。
  • undoable(reducer, config): 包装原始 reducer,使其支持撤销和重做功能。config 参数用于配置撤销和重做的行为,例如限制历史记录数量等。

undoable配置项详解

limit

  • 类型: number
  • 作用: 限制历史记录的数量,防止占用过多内存。默认不限制。
limit: 20 // 最多保留 20 条历史记录

filter

  • 类型: (action, currentState, previousHistory) => boolean
  • 作用: 一个过滤函数,决定某些动作是否需要被记录。返回 true 表示记录,false 表示忽略。
filter: (action) => action.type !== 'todoList/ignoreThisAction'

groupBy

  • 类型: (action, currentState, previousHistory) => string | undefined
  • 作用: 将多个动作分组为一个历史记录项。返回相同字符串的动作将被分组在一起,返回 undefined 表示不分组。
groupBy: (action) => action.type.startsWith('todoList/') ? 'todoListGroup' : undefined

undoTyperedoType

  • 类型: string
  • 作用: 自定义撤销和重做动作的类型。这在使用自定义的撤销/重做动作时特别有用。
undoType: 'CUSTOM_UNDO',
redoType: 'CUSTOM_REDO'

jumpToPastTypejumpToFutureType

  • 类型: string
  • 作用: 自定义跳转到某个历史状态的动作类型。
jumpToPastType: 'CUSTOM_JUMP_TO_PAST',
jumpToFutureType: 'CUSTOM_JUMP_TO_FUTURE'

initTypes

  • 类型: string[]
  • 作用: 定义初始化时不需要记录的动作类型。通常用来忽略 Redux 初始化和一些其他不需要记录的动作。
initTypes: ['@@redux/INIT', '@@INIT']

clearHistoryType

  • 类型: string
  • 作用: 自定义清除历史记录的动作类型。
clearHistoryType: 'CLEAR_HISTORY'

neverSkipReducer

  • 类型: boolean
  • 作用: 确保即使在撤销/重做操作时,仍然会调用原始 reducer。这在某些需要在每次状态变化时都进行特定操作的情况下非常有用。
neverSkipReducer: true

示例代码

以下是一个完整的示例,展示了如何使用这些配置项:

import { configureStore } from '@reduxjs/toolkit';
import undoable from 'redux-undo';
import todoListReducer from './todoList'; // 确保路径正确export default configureStore({
  reducer: {
    todoList: undoable(todoListReducer, {
      limit: 20, // 限制历史记录数量
      filter: (action) => action.type !== 'todoList/ignoreThisAction', // 忽略特定动作
      groupBy: (action) => action.type.startsWith('todoList/') ? 'todoListGroup' : undefined, // 动作分组
      undoType: 'CUSTOM_UNDO', // 自定义撤销动作类型
      redoType: 'CUSTOM_REDO', // 自定义重做动作类型
      jumpToPastType: 'CUSTOM_JUMP_TO_PAST', // 自定义跳转到过去的动作类型
      jumpToFutureType: 'CUSTOM_JUMP_TO_FUTURE', // 自定义跳转到未来的动作类型
      initTypes: ['@@redux/INIT', '@@INIT'], // 初始化时不记录的动作类型
      clearHistoryType: 'CLEAR_HISTORY', // 自定义清除历史记录的动作类型
      neverSkipReducer: true // 在撤销/重做操作时仍然调用原始 reducer
    }),
  },
});