image


Есть много преимуществ для централизации состояния вашего приложения в Vuex store. Одним из преимуществ является то, что все транзакции записываются. Это позволяет использовать удобные функции, такие как отладка по времени выполнения, где вы можете переключаться между предыдущими состояниями, чтобы отделять задачи выполнения.


В этой статье я покажу, как создать функцию Undo/Redo далее Отката/Возврата с помощью Vuex, которая работает аналогично отладке во время дебага. Эта функция может использоваться в различных сценариях, от сложных форм до игр на основе браузера.


Вы можете проверить готовый код здесь, на Github, и попробовать демо в этом Codepen. Я также создал плагин как модуль NPM под названием vuex-undo-redo, если вы хотите использовать его в проекте.


Примечание: эта статья была первоначально размещена здесь, в блоге разработчиков Vue.js 2017/11/13

Настройка плагина


Чтобы сделать эту функцию многократно используемой, мы создадим ее как плагин Vue. Эта функция требует от нас добавления некоторых методов и данных в экземпляр Vue, поэтому мы структурируем плагин как миксин.


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

Чтобы использовать его в проекте, мы можем просто импортировать плагин и подключить его:


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

Идея


Работа фичи будет заключаться в откате последней мутации, если пользователь хочет отменить, а затем повторно применить ее, если он хочет повторить. Как мы это осуществим?


Подход № 1


Первый возможный подход заключается в том, чтобы делать «снимки» состояние хранилища после каждой мутации и помещать снимок в массив. Чтобы отменить/повторить, мы можем получить правильный снимок и заменить его состоянием хранилища.


Проблема с этим подходом состоит в том, что состояние хранилища является объектом JavaScript. Когда вы помещаете объект JavaScript в массив, вы просто помещаете ссылку на объект. Наивная реализация, как и следующая, не будет работать:


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 state
console.log(snapshot[0] === snapshot[1]); // true

Подход моментального снимка потребует, чтобы вы сначала сделали клон состояния перед push. Учитывая, что состояние Vue становится реактивным благодаря автоматическому добавлению функций get и set, оно не очень хорошо работает с клонированием.


Подход № 2


Другой возможный подход заключается в регистрации каждой зафиксированной мутации. Чтобы отменить, мы сбрасываем хранилище в его начальное состояние и затем снова запускаем мутации; все кроме последней. Возврат аналогичная концепция.


Учитывая принципы Flux, повторный запуск мутаций из одного и того же начального состояния должен идеально воссоздать состояние. Поскольку это более чистый подход, чем первый, давайте продолжим.


Регистрация мутаций


Vuex предлагает метод API для подписки на мутации, который мы можем использовать для их регистрации. Мы установим это на хук created. В обратном вызове мы просто помещаем мутацию в массив, который позже можно будет повторно запустить.


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

Метод отката


Чтобы отменить мутацию, мы очистим хранилище, а затем повторно запустим все мутации, кроме последней. Вот как работает код:


  1. Используем pop метод массива, чтобы удалить последнюю мутацию
  2. Очистим состояние store с помощью специальной мутации EMPTY_STATE (объяснено ниже)
  3. Повторяем каждую оставшуюся мутацию, фиксируя ее снова в новом store. Обратите внимание, что метод подписки все еще активен во время этого процесса, то есть каждая мутация будет добавляться повторно. Удалим это сразу с помощью 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();
      });
    }
  }
});

Очистка store


Всякий раз, когда этот плагин используется, разработчик должен реализовать мутацию в своем хранилище под названием emptyState. Задача состоит в том, чтобы вернуть store обратно в исходное состояние, чтобы он был готов к восстановлению с нуля.


Разработчик должен сделать это самостоятельно, потому что плагин, который мы создаем, не имеет доступа к store, только к экземпляру Vue. Вот пример реализации:


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

Возвращаясь к нашему плагину, emptyState мутация не должна быть добавлена ??в наш список done, так как мы не хотим повторно фиксировать это в процессе отката. Предотвратим это с помощью следующей логики:


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

Метод возврата


Давайте создадим новое свойство данных, undone которое будет массивом. Когда мы удаляем последнюю мутацию из done в процессе отката, мы помещаем ее в этот массив:


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

Теперь мы можем создать redo метод, который будет просто брать последнюю добавленную мутацию undone и повторно фиксировать ее.


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

Возврат дальше не возможен


Если пользователь инициирует отмену один или несколько раз, а затем делает новую новую фиксацию, содержимое undone будет признано недействительным. Если это произойдет, мы должны опустошить undone.


Мы можем обнаружить новые коммиты из нашего обратного вызова подписки при добавлении коммита. Однако логика хитрая, так как обратный вызов не имеет никакого очевидного способа узнать, что такое новый коммит и что такое отмена / повтор.


Самый простой подход — установить флаг newMutation. Он будет true по умолчанию, но методы отката и возврата временно установят для него значение false. Если при фиксации мутации установлено значение true, обратный вызов subscribe очистит массив 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;
        }
      }
    });
  },
}

Основной функционал теперь завершен! Добавьте плагин в свой собственный проект или в мою демку, чтобы протестировать его.


Публичный API


В моей демонстрации вы заметите, что кнопки отмены и возврата отключены, если их функциональность в данное время не возможна. Например, если еще не было коммитов, вы, очевидно, не можете отменить или повторить. Разработчик, использующий этот плагин, может захотеть реализовать подобную функциональность.


Чтобы разрешить это, плагин может предоставлять два вычисляемых свойства canUndo и canRedo как часть публичного API. Это тривиально для реализации:


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

Комментарии (12)


  1. vintage
    22.12.2018 17:26

    Алгоритмическая сложность undo: O(времени проведённом пользователем на странице). Такое себе решение. Почитайте лучше про diffTree: www.microsoft.com/en-us/research/wp-content/uploads/2015/02/paper-full.pdf


    1. bad4iz Автор
      22.12.2018 21:26

      Спасибо отличная статья дала о многом задуматься.


  1. AlexeyCaTHaR
    22.12.2018 21:19

    А не проще ли применять мутацию обратную последней? чтобы постоянно не пересчитывать?


  1. Sirion
    22.12.2018 21:39

    Если хранить состояние с помощью Immutable.js, можно сохранять не мутации, а сами состояния (Immutable.js хитрый, и это, скорее всего, не вызовет большого оверхеда по памяти), и всё станет сильно проще. Но возникнут трудности с сериализацией.


    1. vintage
      22.12.2018 21:50

      Возникнут сложности с изменениями полученными от сервера, которые, внезапно, не надо откатывать.


      1. Sirion
        24.12.2018 01:18

        Поправьте меня, если я ошибаюсь: если мутации юзера не коммутативны с мутациями сервера, то всё так и так плохо. А если коммутативны, то можно выделить отдельное подмножество состояния, за которое отвечает юзер и которое можно сохранять в истории для undo/redo.


        1. vintage
          24.12.2018 08:55

          Любое состояние может меняться разными пользователями, так что ничего выделить не получится.


          1. Sirion
            24.12.2018 19:45

            Можно пример?


            1. vintage
              24.12.2018 23:00

              Пример чего? Данное сообщение, например, может быть отредактировано мною, админом. А в перспективе ещё и ботом каким-нибудь.


              1. Sirion
                25.12.2018 00:20

                Логично. И как в таком случае вы себе представляете undo/redo? Получается, максимум можно откатиться до последней правки, пришедшей из другого источника. Этот же результат вполне достижим с Immutable.js.


                1. vintage
                  25.12.2018 04:10

                  Ключевое в undo/redo — глагол do. Пользователь совершает какие-то действия, отменяет их и повторяет. Именно собственные действия, а не действия других пользователей и не состояние всего мира, которое меняется не только и не столько пользователем.


  1. gnaeus
    23.12.2018 19:20

    В принципе, эта проблема решена в MobX State Tree «из коробки». Изменения можно хранить в виде набора immutable снапшотов (как в Redux). Или в виде потока операций JSON Patch: можно применить операцию к стору, или применить обратную операцию, или отпарвить на сервер, и т.д. Жаль, что для Vue нет подобной библиотеки.