React 撤销/重做功能
基本概念
撤销/重做功能的核心是保存状态的历史记录,以便在用户请求撤销或重做时能够恢复到之前的状态。具体来说,包含以下三个部分:
- present: 当前状态。
- past: 历史记录,保存所有之前的状态,用于撤销。
- 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) 保存的是被撤销的状态,用于重做操作。
状态操作过程
-
输入新状态:
- 当前状态存入过去栈。
- 清空未来栈。
-
撤销操作:
- 将过去栈的顶部状态弹出,并将当前状态存入未来栈。
- 将弹出的状态设为当前状态。
-
重做操作:
- 将未来栈的顶部状态弹出,并将当前状态存入过去栈。
- 将弹出的状态设为当前状态。
状态变化示例
假设有一个文本编辑器,初始状态为空字符串。
初始状态:
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 来实现状态回溯的示例:
- 安装 Immer:
首先,确保你安装了 Immer:
npm install immer
- 定义 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
},
});
- 更新组件以使用新的 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;
代码说明:
-
状态管理:
initialState定义了past、present和future状态。todoSlice定义了添加、删除、撤销和重做的 reducers,并使用 Immer 的produce来处理状态变化。
-
函数定义:
addListRedux和delListRedux:每次状态变化时,将当前状态推到past数组,并清空future。undo和redo:根据past和future数组进行状态回溯和重做。
使用 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, // 限制历史记录数量
}),
},
});
创建组件
使用 useSelector 和 useDispatch 钩子来访问和更新状态:
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:用于向列表中添加新项目,调用addListReduxaction。delList:用于删除指定 id 的列表项目,调用delListReduxaction。undo:用于撤销上一次的操作,调用redux-undo提供的undoaction。redo:用于重做上一次被撤销的操作,调用redux-undo提供的redoaction。
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
undoType 和 redoType
- 类型:
string - 作用: 自定义撤销和重做动作的类型。这在使用自定义的撤销/重做动作时特别有用。
undoType: 'CUSTOM_UNDO',
redoType: 'CUSTOM_REDO'
jumpToPastType 和 jumpToFutureType
- 类型:
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
}),
},
});