前言
之前在社区里发表过一篇文章——《Web 应用的撤销重做实现》,里面详细介绍了几种关于撤销重做实现的思路。通过评论区得知了 Immer 这个库,于是就去了解实践了一下,再结合当前自己的日常开发需求,撸了一个实现撤销重做的 Dva 插件。
插件使用介绍
插件地址:
1. 实例化插件
import dva from 'dva';
import createUndoRedo from 'dva-immer-undo-redo';
const app = dva();
// ... 其他插件先使用,确保该插件在最后
app.use(
createUndoRedo({
include: ['namespace'],
namespace: 'timeline' // 默认是 timeline
})
);
app.router(...);
app.start(...);
插件的 options
有三个可配置项:
options.namespace
:选填,默认值:timeline
,存储撤销重做状态的命名空间,默认状态:
// state.timeline
{
canRedo: false,
canUndo: false,
undoCount: 0,
redoCount: 0,
}
options.include
:必填,希望实现撤销重做的命名空间。options.limit
:选填,默认值:1024
,设置撤销重做栈的数量限制。
2. 撤销 Undo
dispatch({ type: 'timeline/undo' })
3. 重做 Redo
dispatch({ type: 'timeline/redo' })
4. Reducer 默认内置了 Immer
在插件中,我们已经内置了 Immer,所以在 Reducer 中,你可以直接对 state
进行操作,例如:
// models/counter.js
{
namespace: 'counter',
state: {
count: 0,
},
reducers: {
add(state) {
state.count += 1;
}
}
}
这样你就不需要自己去构造不可变数据并 return newState
,让 reducer
代码变得更加简洁。
原理介绍
插件结构
export default (options) => {
return {
_handleActions,
onReducer,
extraReducers,
};
}
这里用到了 Dva 插件的三个 hook:_handleActions
、onReducer
、extraReducers
extraReducers 初始化默认 state
extraReducers
是 createStore
的时候额外的 reducer
,后通过 combineReducers
聚合 reducer
,这样在初始化的时候就会自动聚合 state
。
export default (options = {}) => {
const { namespace } = options;
const initialState = {
canUndo: false,
canRedo: false,
undoCount: 0,
redoCount: 0,
};
return {
// ...
extraReducers: {
[namespace](state = initialState) {
return state;
}
},
};
}
_handleActions 内置 Immer 并收集 patches
_handleActions
让你有能力为所有的 reducer
进行扩展,这里我们利用这一点,与 immer
配合使用,这样就可以将原本 reducer
中的 state
变成 draft
。同时,可以在第三个参数中,我们就可以收集一次更改的 patches
。
import immer, { applyPatches } from 'immer';
export default (options = {}) => {
const { namespace } = options;
const initialState = {...};
let stack = [];
let inverseStack = [];
return {
// ...
_handleActions(handlers, defaultState) {
return (state = defaultState, action) => {
const { type } = action;
const result = immer(state, (draft) => {
const handler = handlers[type];
if (typeof handler === 'function') {
const compatiableRet = handler(draft, action);
if (compatiableRet !== undefined) {
return compatiableRet;
}
}
}, (patches, inversePatches) => {
if (patches.length) {
const namespace = type.split('/')[0];
if (newOptions.include.includes(namespace) && !namespace.includes('@@')) {
inverseStack = [];
if (action.clear === true) {
stack = [];
} else if (action.replace === true) {
const stackItem = stack.pop();
if (stackItem) {
const { patches: itemPatches, inversePatches: itemInversePatches } = stackItem;
patches = [...itemPatches, ...patches];
inversePatches = [...inversePatches, ...itemInversePatches]
}
}
if (action.clear !== true) {
stack.push({ namespace, patches, inversePatches });
}
stack = stack.slice(-newOptions.limit);
}
}
});
return result === undefined ? {} : result;
};
},
};
}
onReducer 实现撤销重做
onReducer
这个 hook
可以实现高阶 Reducer
函数,参照直接使用 Redux
,等价于:
const originReducer = (state, action) => state;
const reduxReducer = onReducer(originReducer);
利用高阶函数,我们就可以劫持一些 action
,进行特殊处理:
import immer, { applyPatches } from 'immer';
export default (options = {}) => {
const { namespace } = options;
const initialState = {...};
let stack = [];
let inverseStack = [];
return {
// ...
onReducer(reducer) {
return (state, action) => {
let newState = state;
if (action.type === `${namespace}/undo`) {
const stackItem = stack.pop();
if (stackItem) {
inverseStack.push(stackItem);
newState = immer(state, (draft) => {
const { namespace: nsp, inversePatches } = stackItem;
draft[nsp] = applyPatches(draft[nsp], inversePatches);
});
}
} else if (action.type === `${namespace}/redo`) {
const stackItem = inverseStack.pop();
if (stackItem) {
stack.push(stackItem);
newState = immer(state, (draft) => {
const { namespace: nsp, patches } = stackItem;
draft[nsp] = applyPatches(draft[nsp], patches);
});
}
} else if (action.type === `${namespace}/clear`) {
stack = [];
inverseStack = [];
} else {
newState = reducer(state, action);
}
return immer(newState, (draft: any) => {
const canUndo = stack.length > 0;
const canRedo = inverseStack.length > 0;
if (draft[namespace].canUndo !== canUndo) {
draft[namespace].canUndo = canUndo;
}
if (draft[namespace].canRedo !== canRedo) {
draft[namespace].canRedo = canRedo;
}
if (draft[namespace].undoCount !== stack.length) {
draft[namespace].undoCount = stack.length;
}
if (draft[namespace].redoCount !== inverseStack.length) {
draft[namespace].redoCount = inverseStack.length;
}
});
};
},
};
}
在这个函数里,我们拦截了三个 action
:namespace/undo
、namespace/redo
、namespace/clear
,然后根据之前收集的 patches
,来对状态进行操作以实现撤销重做。
这里,我们还可以在执行完正常的 reducer
后对整体的 state
做一些修改,这里用来改 canRedo
、canUndo
等状态。