Про редьюсеры и деревья

Видимо я долго ошибался при формировании редьюсеров. И оно понятно, в большинстве случаев нет проблем с подходом разделения store на слои, за каждыми из которых следит свой reducer. Одно древовидные структуры открывают глаза.

Хороший пример есть в репозитории redux.

Здесь видно зачем нужно нормализация, и применение reducer’ов в качестве вложенных в другие.

Рендеринг, как и редьюсеры - рекурсивен и опирается на то, что все вложенные (дочерние) ноды лежат в общем списке нод в корне стейта, что позволяет получить любую ноду просто по id.

Undo/redo в лесу(снова про иерархичние структуры)

undo/redo через redux way

Сначала я предполагал, что undo/redo делется так же просто: при изменении какой-либо ноды, мы под ее id должны держать историю. Однако это привело к проблемам, что нужно было хранить историю не только каких-то ключей, но и списков в целом. Подход с redux предполагает создавать под ключем изменяемого значения тройку ключей: past, present, future. Эти ключи описыват все состояния в которых был или будет целевой ключ:


catalogs = [
  {
    id: 1,
    name: {
      past: ["c", "cat", "catalog"],
      present: "catalog",
      future: [],
  },
  {
    id: 2,
    name: {
      past: ["catalog"],
      present: "catalog2",
      future: [],
    },
  }
]

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

catalogs = {
  past: [
    [
      {
        id: 1,
        name: {
          past: ["c", "cat", "catalog"],
          present: "catalog",
          future: [],
      },
    ],
  ],
  present: [
    {
      id: 1,
      name: {
        past: ["c", "cat", "catalog"],
        present: "catalog",
        future: [],
    },
    {
      id: 2,
      name: {
        past: ["catalog"],
        present: "catalog2",
        future: [],
      },
    }
  ]

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

  1. Изменить название каталога 1
  2. Удалить каталог 1.
  3. undo
  4. undo

Повторное undo не сможет выполниться, т.к. мы потеряли состояние. Значит это не вариант.

А чем собственно плохо хранить все как есть с вложенными историями?

Перво-наперво можно предположить проблему с определением порядка событий. К примеру, мы можем изменять текущий редактируемый каталог, с помощью предварительного экшена setCurrentCatalog(id). При этом, пользователь нажимает undo без указания чего-то дополнительного - он и не должен, т.к. он просто отменяет последнее действие. Поэтому здесь можно развить тему в дву русла: глобальная история или undo/redo работает на текущем каталоге. Если на текущем - то в принципе ситуация несколько проще становится, потому что не нужно следить за изменениями в списке. Они конечно же будут, в случае с иерархичными структурами, опять же пример с файлами и папками (а симлинками интересно что будет…); но в целом, проще.

Допустим, логично делать undo/redo только в выбранном каталоге.


catalogs = [
  {
    id: 1,
    name: {
      past: ["c", "cat", "catalog"],
      present: "catalog",
      future: [],
    children: {
      past: [],
      present: [2],
      future: []
    }
  },

  {
    id: 2,
    name: {
      past: ["catalog"],
      present: "catalog2",
      future: [],
    },
    children: {
      past: [],
      present: [],
      future: []
    }
  }
]

Выглядит так, что проблем быть не должно. Требуется нормализация и все. Вот, вот когда понимаешь зачем нужны все эти темы с нормализацей, селекторами!

Ну ладно, допустим мы добавим каталог 3 во второй:


catalogs = {
  1: { ... },
  2: {
    name: { ... },
    children: {
      past: [[]],
      present: [3],
      future: []
    }
  },
  3: { ... }
]

Добавили…и убрали:


catalogs = {
  1: { ... },
  2: {
    name: { ... },
    children: {
      past: [],
      present: [],
      future: [[3]]
    }
  }
  3: { ... }
}

Если сделать сейчас аналогично с 4каталогом, то мы кажется получим утечку storage:

catalogs = {
  1: { ... },
  2: {
    name: {...},
    children: {
      past: [],
      present: [],
      future: [[4]]
    }
  },
  3: { ... },
  4: { ... }
}

Видите? мы больше нигде не ссылаемся на каталог 3. Мы удалили каталог 3 и создали вместо него 4. Нельзя сказать что каталог 3 стал корневым, нет. Он в мусорке может быть.

{
rootCatalogs: {
  1: { ... },
  2: { ... },
  4: { ... },
},
memCatalogs: {
  3: { ... },
}

Когда мы удаляем каталог, то должны проверить, есть ли он в других каталогах, есть ли он в их истории. Когда это условие не выполняется - можем почистить memCatalogs. Это начинает попахивать хаками у ручным управлением памятью. std::shared_ptr на них нет.

undo/redo через git way

Другой вариант, который мне предложил коллега, это делать diff дерева и операровать историей таких diff изменений.

Отступление: фильтрация откатываемых действий

Тут конечно нужно подумать какие события нам не нужно откатывать или нужно все (для сохранения визуальной составляющей)?

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