Как не надо писать state management

Некоторое время назад я пытался написать свой велосипед в области state management. В общем, это статья в категории “а я умею так фейлить, а вам слабо?” )


Причины были следующие:

  • knockout имеет слишком ужасный синтаксис;
  • mobx имеет кучу багов (я писал об этом в прошлых статьях);
  • knockout и mobx не очень дружат с undo/redo (причем на концептуальном уровне);
  • redux слишком бойлерплетистый;

И я решил напедалить свой велосипед. Основные концепты были такие:

  • есть неизменяемое дерево состояния;
  • изменение состояния осуществляется за счет создания нового дерева;
  • над иммутабельным деревом есть мутабельный стор, который и обеспечивает доступ к текущему состоянию;
  • “курсор” — путь к объекту. К примеру, ['form', 'controls', 0, 'name'];
  • курсор типизирован (тогда как раз вышел Typescript 2.1 с Mapped Types, что позволило это реализовать);
  • курсор привязан к стору (и к актуальному текущему состоянию);
  • курсор позволяет считать актуальное значение и изменить его;
  • “селектор” — курсор только с возможностью чтения, в качестве пути используется заданная пользователем функция;
  • курсор должен расширять Observable, чтобы мы могли использовать async pipe (в ng2);

Короче, концептуально выглядело все хорошо. Основная концепция честно украдена из om (см. статью в вики этого проекта: “om: Cursors”).

Я пытался писать проекты с помощью om: и я радовался как маленькое дитя. Настолько все было логично и просто.

В общем, это была попытка портировать адекватную библиотеку адекватного языка (clojuescript) на убогий язык (Javascript/Typescript). Кроме того, om был построен поверх реакта, а мой стейт-менеджмент должен был адекватно работать в пределах ангуляра.


Особенный смак задумки был в том, что я потенциально могу уменьшить количество фейковых UI-апдейтов.

Представим, что у нас есть какой-то сложный объект хотя-бы из двух полей. И вот такой вот ng2-темплейт: <h1>{{titleCursor | async}}</h1><span>{{descriptionCursor | async}}</span>

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


Оказалось, что писать историю для undo/redo можно только с помощью команд. Но это я изначально понимал, это не фейл. Фейл заключался в том, что мне пришлось вводить вспомогательный класс (концепт “акцессор”), который должен эмитить команду изменения объекта.

И добиться адекватного синтаксиса не получилось. В частности, мне не нравится необходимость писать [value]="cursor|async" (value)=cursor.set($event) вместо [(value)]="field". Так пишут только ебланы, а мне таким быть не хочется. Поэтому заодно я добавил в этот вспомогательный класс кастомное свойство “value”, которое читало и переписывало актуальное значение… Но это похерило изначальную идею точечных апдейтов. Нельзя было выставить ChangeDetection в правильный режим.

И у меня получилось предоставлять самому себе замечательный выбор между плохим сиснтаксисом и плохим change detection.


Я нифига не уменьшил количество бойлерплейт-кода. Курсоры приходилось описывать заново и заново. Селекторы приходилось копировать туды-сюды. Акцессоры туды-сюды. “Нарушаем DRY с 1994 года” — готовый слоган для любого разработчика.

Сначала я по глупости пытался сделать вынос такого общего кода в отдельный объект, доступный из стора. А потом понял, что мне приходится таскать везде за собой стор и это было печально. Кроме того, у меня пропадала возможность модульной работы над достаточно сложной формой.

В конечном счете я заставил курсоры уметь создавать и держать ссылки на пользовательские кастомные классы. Получилось прикольно. Я уменьшил количество бойлерплейта, но все равно его было еще много. Что еще хуже — я не смог переписать весь код, который был написан под старый бойлерплейт.


А теперь самый грустный и эпичный косяк всей этой задумки. Одной из главных фишек было уменьшение количества UI-апдейтов.

Мы такие пишем cursor | async и радуемся. Эта милая концепция сломалась сначала на первом же *ngIf=”parentOfCursor != null” , а потом на сложных селекторах. Но сперва мне везло на этот косяк не натыкаться.

В кратце, что происходит:

  1. Юзер тыкает на UI;
  2. Мы меняем стейт;
  3. Абсолютно все компоненты, которые содержали соответствующие курсоры решают, что им нужно проапдейтиться;
  4. Даже если предок-компонент удаляет из DOM-дерева ребенка, то это не мешает ‘async’ пайпу попытаться проапдейтить этого ребенка;
  5. При попытке узнать текущее значение курсора мы ловим типичный undefined;

У меня почему-то ощущение, что если взять тот же redux, то в нем потенциально будет такая же проблема для всяких @asyncConnect. Только redux не предполагал, что везде будет долбеж асинхронностью, а мое решение на это завязалось.

Идей как это пофиксить у меня аж пять штук. Только ни одна из них не кажется мне в полной мере адекватной или хотя-бы реализуемой в рамках ангуляра.

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


Сейчас я немного по-другому смотрю на авторов ангуляра. Если раньше они мне казались дебилами, то сейчас я понимаю почему они сделали Change Detection именно такой. Да, он тупой как валенок. Но:

  • он работает максимально простым способом, что исключает огромное количество возможных косяков;
  • не заставляет программист городить сранный бойлерплейт;
  • он может работать медленно, но при этом скорость хоть сколь-нибудь предсказуема;

Ну и в плюсы ангуляру добавим: каждый имеет возможность отказаться от этого механизма Change Detection в пользу своего механизма (хоть управлению в ручном режиме).

Но все равно делать проверку изменений на каждый mousemove — хреновая идея.


P.S: Меня все еще не устраивает ни один state management контейнер. Я все еще пытаюсь придумать, как написать адекватно свой контейнер.