Create a Vuex Undo / Redo plugin for VueJS

Original author: Anthony Gore
  • Transfer
  • Tutorial

image


There are many advantages to centralizing the status of your application in the Vuex store. One advantage is that all transactions are recorded. This allows you to use handy features, such as run-time debugging , where you can switch between previous states to separate execution tasks.


In this article, I will show you how to create the Undo / Redo function further on the Rollback / Return using Vuex, which works similarly to debugging during debug. This feature can be used in various scenarios, from complex forms to browser-based games.


You can check out the finished code here on Github and try the demo in this Codepen . I also created a plugin as an NPM module called vuex-undo-redo if you want to use it in a project.


Note: this article was originally posted here on the developer blog Vue.js 2017/11/13

Plugin Setup


To make this function reusable, we will create it as a Vue plugin. This function requires us to add some methods and data to the Vue instance, so we structure the plugin as a mixin.


module.exports = {
  install(Vue) {
    Vue.mixin({
      // Code goes here
    });
  }
};

To use it in the project, we can simply import the plugin and connect it:


import VuexUndoRedo from'./plugin.js';
Vue.use(VuexUndoRedo);

Idea


The work of the feature will be to roll back the last mutation if the user wants to cancel, and then reapply it if he wants to repeat. How do we do this?


Approach number 1


The first possible approach is to take "snapshots" of the state of the repository after each mutation and place the snapshot into an array. To undo / redo, we can get the correct snapshot and replace it with the storage status.


The problem with this approach is that the state of the repository is a JavaScript object. When you put a JavaScript object in an array, you simply place an object reference. A naive implementation, like the following, will not work:


var state = { ... };
var snapshot = [];
// Push the first state
snapshot.push(state);
// Push the second state
state.val = "new val";
snapshot.push(state);
// Both snapshots are simply a reference to stateconsole.log(snapshot[0] === snapshot[1]); // true

The snapshot approach will require that you first make a state clone before push. Given that the state of Vue becomes reactive due to the automatic addition of get and set functions, it does not work well with cloning.


Approach number 2


Another possible approach is to register each recorded mutation. To cancel, we reset the repository to its initial state and then rerun the mutations; all but the last. Return a similar concept.


Given the principles of Flux, restarting mutations from the same initial state should ideally recreate the state. Since this is a cleaner approach than the first, let's continue.


Mutation registration


Vuex offers an API method for subscribing to mutations that we can use to register them. We will install it on the hook created. In the callback, we simply put the mutation into an array, which can later be re-run.


Vue.mixin({
  data() {
    return {
      done: []
    }
  },
  created() {
    this.$store.subscribe(mutation => {
      this.done.push(mutation);
    }
  }
});

Rollback method


To cancel the mutation, we will clear the repository, and then rerun all mutations except the last one. This is how the code works:


  1. Use popthe array method to remove the last mutation.
  2. Clear the store state with a special mutation EMPTY_STATE(explained below)
  3. Repeat each remaining mutation, fixing it again in the new store. Please note that the subscription method is still active during this process, that is, each mutation will be added again. Remove it right away with help pop.

const EMPTY_STATE = 'emptyState';
Vue.mixin({
  data() { ... },
  created() { ... },
  methods() {
    undo() {
      this.done.pop();
      this.$store.commit(EMPTY_STATE);
      this.done.forEach(mutation => {
        this.$store.commit(`${mutation.type}`, mutation.payload);
        this.done.pop();
      });
    }
  }
});

Cleaning store


Whenever this plugin is used, the developer must implement a mutation in his repository called emptyState. The challenge is to bring the store back to its original state so that it is ready for recovery from scratch.


The developer must do this on his own, because the plugin we create does not have access to the store, only to the Vue instance. Here is an example implementation:


new Vuex.Store({
  state: {
    myVal: null
  },
  mutations: {
    emptyState() {
      this.replaceState({ myval: null });       
    }
  }
});

Returning to our plugin, the emptyStatemutation should not be added to our list done, since we do not want to re-fix it in the rollback process. Prevent this with the following logic:


Vue.mixin({
  data() { ... },
  created() {
    this.$store.subscribe(mutation => {
      if (mutation.type !== EMPTY_STATE) {
        this.done.push(mutation);
      }
    });
  },
  methods() { ... }
});

Return method


Let's create a new data property undonethat will be an array. When we remove the last mutation from donethe rollback process, we put it in this array:


Vue.mixin({
  data() {
    return {
      done: [],
      undone: []
    }
  },
  methods: {
    undo() {
      this.undone.push(this.done.pop());
      ...
    }
  }
});

Now we can create a redomethod that will simply take the last added mutation undoneand re-fix it.


methods: {
  undo() { ... },
  redo() {
    let commit = this.undone.pop();
    this.$store.commit(`${commit.type}`, commit.payload);
  }
}

No refund is possible.


If the user initiates a cancellation one or more times, and then makes a new new commit, the content undonewill be considered invalid. If this happens, we have to empty it undone.


We can detect new commits from our callback subscription when adding a commit. However, the logic is tricky, since the callback has no obvious way to know what a new commit is and what undo / redo is.


The easiest approach is to set the newMutation flag. It will be true by default, but the rollback and return methods will temporarily set it to false. If mutation is set to true, the callback subscribewill clear the array undone.


module.exports = {
  install(Vue) {
    Vue.mixin({
      data() {
        return {
          done: [],
          undone: [],
          newMutation: true
        };
      },
      created() {
        this.$store.subscribe(mutation => {
          if (mutation.type !== EMPTY_STATE) {
            this.done.push(mutation);
          }
          if (this.newMutation) {
            this.undone = [];
          }
        });
      },
      methods: {
        redo() {
          let commit = this.undone.pop();
          this.newMutation = false;
          this.$store.commit(`${commit.type}`, commit.payload);
          this.newMutation = true;
        },
        undo() {
          this.undone.push(this.done.pop());
          this.newMutation = false;
          this.$store.commit(EMPTY_STATE);
          this.done.forEach(mutation => {
            this.$store.commit(`${mutation.type}`, mutation.payload);
            this.done.pop();
          });
          this.newMutation = true;
        }
      }
    });
  },
}

The main functionality is now complete! Add the plugin to your own project or to my demo to test it.


Public API


In my demonstration, you will notice that the cancel and return buttons are disabled if their functionality is not currently possible. For example, if there were no commits yet, you obviously cannot undo or redo. A developer using this plugin may want to implement this functionality.


To allow this, a plugin can provide two calculated properties canUndoand canRedoas part of a public API. This is trivial to implement:


module.exports = {
  install(Vue) {
    Vue.mixin({
      data() { ... },
      created() { ... },
      methods: { ... },
      computed: {},
      computed: {
        canRedo() {
          returnthis.undone.length;
        },
        canUndo() {
          returnthis.done.length;
        }
      },
    });
  },
}

Also popular now: