Dva + Immer,轻松实现撤销重做功能

4,858 阅读3分钟

前言

之前在社区里发表过一篇文章——《Web 应用的撤销重做实现》,里面详细介绍了几种关于撤销重做实现的思路。通过评论区得知了 Immer 这个库,于是就去了解实践了一下,再结合当前自己的日常开发需求,撸了一个实现撤销重做的 Dva 插件。

插件使用介绍

插件地址:

github.com/frontdog/dv…

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:_handleActionsonReducerextraReducers

extraReducers 初始化默认 state

extraReducerscreateStore 的时候额外的 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;
                    }
                });
            };
        },

    };
}

在这个函数里,我们拦截了三个 actionnamespace/undonamespace/redonamespace/clear,然后根据之前收集的 patches,来对状态进行操作以实现撤销重做。

这里,我们还可以在执行完正常的 reducer 后对整体的 state 做一些修改,这里用来改 canRedocanUndo 等状态。