preface

A previous post in the community, Undo Redo Implementation for Web Applications, detailed several ideas for undo redo implementation. I learned about the Immer library through the comments section, so I went to understand and practice it, and combined with my current daily development needs, I developed a Dva plug-in to achieve undo redo.

Introduction to plug-in

Plug-in address:

Github.com/frontdog/dv…

1. Instantiate the plug-in

import dva from 'dva';
import createUndoRedo from 'dva-immer-undo-redo';

const app = dva();

/ /... Other plug-ins are used first, making sure this plug-in is last
app.use(
    createUndoRedo({
        include: ['namespace'].namespace: 'timeline' // The default is timeline})); app.router(...) ; app.start(...) ;Copy the code

Options for the plug-in have three configurable options:

  • options.namespace: Optional. Default value:timeline, the namespace that stores undo redo state. Default state:
// state.timeline
{
    canRedo: false.canUndo: false.undoCount: 0.redoCount: 0,}Copy the code
  • options.include: Mandatory. Namespace that you want to implement undo redo.
  • options.limit: Optional. Default value:1024To set the limit on the number of undo redo stacks.

2. Cancel the Undo

dispatch({ type: 'timeline/undo' })
Copy the code

3. Redo Redo

dispatch({ type: 'timeline/redo' })
Copy the code

4. Reducer has built-in Immer by default

In the Reducer, we already have Immer built in, so you can work directly on state, for example:

// models/counter.js
{
    namespace: 'counter'.state: {
        count: 0,},reducers: {
        add(state) {
            state.count += 1; }}}Copy the code

This saves you from having to construct immutable data yourself and return newState, making the Reducer code simpler.

The principle is introduced

The plug-in structure

export default (options) => {
    return {
        _handleActions,
        onReducer,
        extraReducers,
    };
}
Copy the code

Three hooks of Dva plug-in are used here: _handleActions, onReducer and extraReducers

ExtraReducers initializes the default state

ExtraReducers is an additional reducer when createStore is created, and then the reducer is aggregated by combineReducers, so that state is automatically aggregated during initialization.

export default (options = {}) => {
    const { namespace } = options;
    const initialState = {
        canUndo: false.canRedo: false.undoCount: 0.redoCount: 0};return {
        // ...
        extraReducers: {
            [namespace](state = initialState) {
                returnstate; }}}; }Copy the code

_handleActions Built-in Immer and collection of patches

_handleActions gives you the ability to extend all reducer’s, which we use here, in conjunction with immer, to turn the original Reducer state into a draft. Meanwhile, in the third parameter, we can collect patches of a change.

import immer, { applyPatches } from 'immer';

export default (options = {}) => {
    const { namespace } = options;
    constinitialState = {... };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; }; }}; }Copy the code

OnReducer Enables undo redo

OnReducer this hook can achieve high order Reducer function, refer to direct use of Redux, equivalent to:

const originReducer = (state, action) => state;
const reduxReducer = onReducer(originReducer);
Copy the code

With higher-order functions, we can hijack some actions for special processing:

import immer, { applyPatches } from 'immer';

export default (options = {}) => {
    const { namespace } = options;
    constinitialState = {... };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; }}); }; }}; }Copy the code

In this function, we intercept three actions: Namespace /undo, namespace/redo, and namespace/clear, and then operate on the state according to patches collected previously to implement undo redo.

Here, we can also make some changes to the overall state after the normal reducer, which is used to change canRedo, canUndo and other states.