Upd: Поиском по "Upd" можно найти все корректировки, внесенные в статью после публикации в результате жаркой дискуссии в чате Effector.
Меня зовут Андрес, я руководитель команды разработки внутреннего UI-кита ВКонтакте. А это ещё одна статья про инструменты управления состоянием. Сегодня мы не будем изобретать ничего нового, а поговорим про библиотеку Effector.
(Внимание, под катом много картинок.)
TL;DR
Почти год мы ВКонтакте пытались внедрить Effector, но пришли к выводу, что пока это достаточно сырая библиотека. Её недостатки зачастую проявляются сильно позже, чем хотелось бы, и, по нашему мнению, перевешивают достоинства… А последние местами преувеличены. Было больно осознавать количество потерянного времени, но, как говорится, лучше поздно, чем никогда.
О чём расскажу
Эта статья — расширенная версия моего доклада на CodeFest 2024. Изначально я планировал рассказать в ней исключительно об особенностях Effector, но в процессе подготовки материала мне показалось, что наша история тоже может быть кому-то полезна. Поэтому вас ждёт несколько тем.
-
Как мы ВКонтакте выбрали Effector и начали его внедрять, почему спустя год передумали и что предприняли дальше.
-
Какие важные особенности Effector мы открыли для себя и почему библиотека у нас не взлетела (тут будут
душинтересные технические детали). -
Как мы теперь интерпретируем ключевые преимущества Effector, получив реальный опыт использования библиотеки.
Disclaimer
Вся эта история происходила во времена 22-й версии Effector, поэтому речь в статье пойдёт про её особенности. Часть из них уже доработали в 23-й версии (что я отмечу в статье отдельно), но тем не менее эти проблемы достаточно показательны, чтобы их тоже упомянуть.
Также стоит сказать, что я не эксперт по Effector. Во время анализа (до появления этой статьи) я часто ходил в официальный чат и в том числе непосредственно к авторам библиотеки. Поэтому материал содержит не только результат изысканий и фантазии, но и выдержки из ответов ребят из чата Effector.
Минутка рефлексии
Прежде чем перейти непосредственно к истории, хочу задать несколько вопросов тем, кто использует Effector или планирует его где-то у себя внедрить. Это нужно, чтобы обратить ваше внимание на некоторые вещи, о которых полезно подумать, а ещё — подсветить ряд заблуждений. Так сказать, порефлексировать на тему Effector. Поэтому постарайтесь быть честными, ведь никто ответы проверять не будет (разве что вы захотите поделиться ими в комментариях).
-
Откуда вы узнали про Effector?
-
Что конкретно в нём нравится и какие основные плюсы для себя видите? Если это что-то измеримое (например, производительность), удалось ли оценить выбранный плюс или исходите из ощущений/убеждений?
-
Согласны ли вы с описанием ключевых преимуществ на официальном сайте Effector (производительность, простота, типобезопасность, дружелюбие)? Удалось ли в них убедиться?
-
Был ли у вас опыт работы с чем-то кроме Effector — есть ли с чем сравнить? А кроме Redux?
-
Есть ли в Effector что-то, что вас не устраивает? Если да, то что заставляет мириться с недостатками?
Вот ещё пара технических вопросов на знание Effector:
-
Знаете ли вы, как поведёт себя Effector в случае, если ваш код выкинет исключение (в разных местах)? А как бы вы хотели, чтобы он себя в таком случае вёл?
-
Знаете ли вы, как поведёт себя Effector в случае циклической зависимости? Как, по вашему мнению, он должен себя повести?
-
Знаете ли вы, чем отличается подписка на стор от подписки на событие?
-
Знаете ли вы разницу между различными видами подписок (watch/on/sample)? А как вы считаете, какое должно быть отличие?
Вы наверняка знаете классическую задачу на порядок выполнения микро- и макрозадач. Внизу я оставил похожую на знание разных видов подписок в Effector.
Задача на порядок подписок
// Расскажите, что и в каком порядке выведется в консоль
// в следующем примере:
const event = createEvent<number>();
sample({
source: event,
fn: () => {
console.log(1);
},
});
event.watch(() => {
console.log(2);
});
sample({
source: event,
fn: () => {
console.log(3);
},
});
const $store = createStore<number>(1);
$store.watch(() => {
console.log(4);
});
$store
.on(event, (_, value) => {
console.log(5);
return value;
})
.on(event, (_, value) => {
console.log(6);
return value;
});
sample({
source: event,
fn: () => {
console.log(7);
},
});
sample({
source: $store,
fn: () => {
console.log(8);
return null;
},
});
console.log(9);
event(2);
console.log(10);
event(2);
// Правильный ответ: 4, 8, 9, 6, 1, 3, 7, 8, 2, 4, 10, 6, 1, 3, 7, 2.
// Подписки на стор срабатывают сразу при объявлении.
// Далее после отправки события в первую очередь происходит обновление стора. При этом:
// - неважно, когда подписка была создана, — судя по всему, она имеет наивысший приоритет;
// - каждая следующая подписка отменяет предыдущую, поэтому мы никогда не увидим console.log(5).
// Затем сработают все семплы, созданные для события в порядке объявления.
// Мы ещё не закончили с событием, но тем не менее сначала сработают семплы стора в порядке объявления.
// После этого будут запущены все watch() события в порядке их объявления.
// Только после этого будет обработан watch() стора.
// Повторный вызов события с тем же значением приведёт к немного другому результату.
// В отличие от события, которое считается уникальным независимо от его значения для стора, обновлением считается изменение значения.
// Поэтому в новой цепочке будут отсутствовать вызовы, зависящие от значения стора.
[Депрессия] Тем не менее «костылей» со временем становилось больше, их нужно было интегрировать между собой и при этом как-то не терять удобства использования. Параллельно копились сообщения о разных ограничениях и недоработках, таких как невозможность писать в производные сторы (было исправлено в 23-й версии).
Как мы выбирали и внедряли менеджер состояний
Перейдём, собственно, к истории. Я присоединился к команде, когда уже был выбран единый инструмент для управления состоянием. Ребята проанализировали рынок и составили список кандидатов на роль менеджера состояний. В него попали Redux, Effector, MobX, Rematch, Recoil и Zustand. Затем они определили критерии, по которым эти кандидаты будут оцениваться. По информации из интернета и с помощью экспертизы коллег, имевших опыт работы с некоторыми менеджерами состояния, команда оценила каждого кандидата.
В результате в лидеры выбились Redux, MobX и Effector.
Для принятия окончательного решения с использованием каждого инструмента было написано одно и то же крохотное приложение, после чего участники сравнили получившийся код и проголосовали за вариант, который понравился больше всего. Так мы выбрали Effector.
ВКонтакте — большой проект с огромной кодовой базой и непрекращающимся потоком новых фич. Поэтому, чтобы как можно раньше перестать копить технический долг, мы сразу же приступили к внедрению Effector. На этом пути нам пришлось пройти через пять стадий принятия.
[Отрицание] Естественно, как и с любой технологией, сначала у разработчиков возникало много вопросов по использованию, поэтому первые негативные отзывы в то время списывались на «привыкание». А всё, чего не хватало, закрывалось самописными «костылями».
[Гнев] Одной из проблем (и на самом деле очень серьёзной для нас) стала невозможность создавать динамические экземпляры хранилищ. Например, когда на странице может быть несколько экземпляров одного и того же компонента, идентичных по поведению, но разных по наполнению (как наши плейлисты).
[Торг] Чтобы реализовать такую штуку, мы придумали базовые фабрики списков, на основе которых создавались конкретные фабрики списков, а они уже генерировали сервисы списков для манипуляции собственно самими списками. Звучит немного запутанно? На самом деле так и было 😅
Спустя пару месяцев после начала внедрения мы провели опрос, который показал, что кто-то продолжал сталкиваться с различными проблемами. Но были и те, кому Effector начинал нравиться, что выглядело многообещающе и успокаивающе.
[Депрессия] Тем не менее «костылей» со временем становилось больше, их нужно было интегрировать между собой и при этом как-то не терять удобства использования. Параллельно копились сообщения о разных ограничениях и недоработках, таких как возможность писать в производные сторы (было исправлено в 23-й версии).
Пишем в производный стор
const $a = createStore(1);
const $2a = $a.map(a => a * 2);
// Интуитивно мы ожидаем, что значение в $2a
// всегда равно значению $a, умноженному на 2.
// Но на самом деле можно сделать так:
sample({
source: event,
target: $2a
});
В какой-то момент вопросов стало так много, что мы решили собрать их в один большой список и попробовать углубиться во внутреннее устройство Effector. Нам казалось, понимание того, что там под капотом, поможет легче находить ответы на возникающие вопросы и решать проблемы. Но вместо этого мы обнаружили ещё больше особенностей, о которых раньше не думали и которые могли оказать негативное влияние на наш продукт (в частности, стратегия обновления и вытекающие из неё последствия, об этом далее). В итоге список особенностей только вырос.
Для решения проблем из списка мы использовали не только собственную экспертизу, но и информацию из документации и чата Effector. Также посмотрели на аналоги, чтобы понять, актуальны ли для них эти проблемы и как они решаются. К сожалению, оказалось, что большая часть из них либо решается ценой значительного усложнения кода, либо не решается вовсе.
[Принятие] Наверное, в этот момент мы и осознали, что, скорее всего, совершили ошибку. На то, чтобы пройти все пять стадий принятия нам потребовался почти год. Чтобы не терять ещё больше времени, нужно было понять, что в первый раз было сделано не так. Поэтому мы внимательно посмотрели на наш процесс принятия решения и сделали ряд выводов.
-
Критерии, по которым мы выбирали инструмент, достаточно общие. Нам стоило уделить больше внимания специфике именно нашего продукта. В частности, учесть потребность в динамических сторах и сборке мусора.
-
Оценка некоторых критериев проводилась поверхностно на основе информации с официальных сайтов или нашей интуиции/представления, как оно там работает внутри. Тестовое приложение было достаточно простым и не позволяло полностью убедиться в справедливости наших оценок.
-
Чтобы как можно раньше перестать копить технический долг, мы приняли решение и начали внедрение быстро, без должной подготовки. Возможно, если бы мы изначально лучше продумали процесс внедрения, то заметили бы многие проблемы уже тогда.
-
Ну и чего греха таить — на волне хайпа Effector мы закрыли глаза на некоторые особенности, которые могли бы стать «первыми звоночками».
В итоге поступили следующим образом.
-
Провели несколько открытых встреч с продуктовыми командами. Обсудили найденные проблемы и возможные пути решения. На этих встречах мы получили множество конкретных (иногда непростых) вопросов, ответив на которые смогли лучше понять реальные потребности продукта и сформировать более качественные критерии отбора менеджера состояния. Так, например, в этот раз в список критериев мы, помимо прочего, добавили сопровождаемость получаемого кода.
-
Это, в свою очередь, позволило нам провести повторный, более глубокий анализ аналогов применительно именно к нашим реалиям. На этом этапе мы дошли до того, что для каждой проблемы/потребности, рассмотренной ранее, расписали подходы к её решению с использованием разных инструментов, в том числе с примерами кода.
-
Затем мы выбрали новый инструмент, что с учётом проведённого анализа было легко — в этот раз опирались на более объективные критерии, и выбор был очевидным.
-
Ну и наконец, в этот раз мы продумали новую архитектуру и начали подготовку инструментов и документации до начала внедрения, что позволило заранее убедиться в том, что как минимум имеющиеся требования закрываются, а проблемы — решаются.
Особенности Effector
А теперь предлагаю обсудить подробнее те самые особенности, которые я до этого упоминал. Чтобы то, о чём мы будем говорить дальше, было понятно всем, пару слов об Effector.
Effector — это событийно-ориентированная система управления состоянием (кто бы что ни говорил). Она относится к той группе инструментов, которые предлагают сразу создавать экземпляры хранилищ и настраивать связи между ними, вместо того чтобы описывать структуру хранилищ, а потом создавать их экземпляры при необходимости. Основные сущности, с которыми нам предстоит работать, — это сторы, события и эффекты.
Стор по своей сути — хранилище какого-то значения. Его можно изменять и подписываться на изменения, чтобы, например, обновить UI, когда значение изменилось. Проще говоря, совокупность значений всех сторов — это и есть состояние нашего приложения.
Событие можно выбрасывать, на него можно подписаться, у каждого события тоже может быть какая-то полезная нагрузка — то есть данные, ассоциированные с этим событием. В отличие от стора, экземпляр события не хранит своё последнее значение (хотя это не совсем так 😜), то есть мы можем узнать только о тех событиях и получить те их данные, которые были «выброшены» после того, как мы подписались. Чисто технически с небольшими допущениями вместо событий можно использовать сторы.
Эффект — это функция, которая что-то делает. Обычно она выполняет какой-то сайд-эффект (обращается к серверу, отправляет статистику, выводит логи и т. д.). У эффекта есть поле, содержащее состояние выполнения, а также поля с результатом выполнения и ошибкой. Первое поле — это стор, а два других — события. Соответственно, на них можно также подписаться, чтобы как-то реагировать на их изменения.
Чтобы связать эти сущности между собой, обычно используют семпл. Это функция, которая позволяет «декларативно» описать связь между сущностями примерно следующим образом: если произошло событие или обновился стор, нужно взять значение из какого-то одного стора (не обязательно того же), как-то его трансформировать при необходимости и записать в другой стор или вызвать с этим значением какое-то событие/эффект. Опционально можно настроить фильтрацию, чтобы связь работала только при определённых условиях.
Таким образом, описание хранилища нашего приложения на Effector состоит из создания событий, сторов, эффектов и связывания их между собой семплами.
Описываем стор на Effector
const event = createEvent<EventType>();
const $store = createStore(initialValue);
const effect = createEffect((...) => { ... });
sample({
source: event,
target: effect
});
sample({
source: effect.doneData,
fn: (...) => { // трансформируем результат эффекта },
target: $store
});
Библиотека содержит и другие методы, но такого упрощённого описания должно быть достаточно, чтобы поговорить об особенностях Effector, которые почему-то мало кто обсуждает.
Для их оценки лично я ориентировался на базовые свойства инструментов, которые мы обычно ожидаем получить от их использования:
-
Инструмент должен забирать сложность из продукта на себя (иначе зачем он нужен?).
-
Инструмент не должен вынуждать нас делать много лишней работы (особенно превышающей пользу от его использования).
-
Инструмент не должен ограничивать нас больше, чем нужно. Все ограничения должны быть оправданны и приносить какую-то пользу.
Интересный факт: в статьях и стримах об Effector авторы часто, демонстрируя какие-то его особенности, предлагают «сравнивать его с другими менеджерами состояния», но почти всегда по факту сравнивают с Redux. Для того чтобы взглянуть на ситуацию шире, мы будем использовать примеры реализации с применением разных инструментов.
Статическая инициализация
Одной из самых больших проблем именно для нас стала статическая инициализация, поэтому предлагаю начать с неё. На сайте 22-й версии написано, что «статическая инициализация улучшает производительность». Из тезиса может показаться, что это какой-то алгоритм или техника, которую Effector использует под капотом, чтобы получить некий положительный эффект.
На самом деле статическая инициализация — это «инициализация всей логики при запуске» (описание с сайта 23-й версии), то есть создание всех экземпляров сущностей и связей между ними в самом начале. По заявлению авторов библиотеки, это должно «обеспечить быструю работу приложения», но давайте посмотрим, какой ценой.
Во-первых, статическая инициализация означает, что мы не должны создавать экземпляры сторов динамически. Представьте компонент списка со своим состоянием, которое в случае Effector может быть выражено в виде отдельного стора. А теперь вообразите, что нам хочется переиспользовать логику компонента в нескольких местах, чтобы при этом наполнение (то есть состояние) компонента было разным.
В нашем случае примером такого компонента может быть музыкальный плейлист, который представляет собой список треков. У каждого списка есть состояние (например, режим воспроизведения) и поведение. На странице может быть одновременно несколько независимых плейлистов, каждый из которых может получать данные из разных источников (например, избранные треки, плейлист друзей, популярное и т. д.).
Если число экземпляров заранее неизвестно, то мы не можем создать соответствующее количество сторов на этапе запуска приложения. Как тогда поступить? MobX, например, предлагает вместо создания экземпляров описывать типы хранилищ (объявлять классы), которые уже далее можно создавать по требованию в нужное время и в нужном количестве.
Описываем хранилище
// Объявляем тип/структуру хранилища:
class Playlist {
constructor(public readonly tracks: Track[]) {
makeAutoObservable(this);
}
}
// Создаём экземпляр:
const recommended = new Playlist(recommendedTracks);
В терминах UI описание типов сущностей с последующим динамическим созданием их экземпляров динамически называется компонентным подходом, который в свое время стал прорывом в разработке интерфейсов, и большинство современных UI-библиотек используют именно его. В случае Effector нам как будто приходится сделать шаг назад и снова начать описывать каждый экземпляр отдельно, перейти от компонентов к фабрикам.
Так, в данном случае сообщество рекомендует использовать хранилище типа «ключ-значение», в котором будут храниться данные всех плейлистов, а каждый компонент будет вытаскивать свои по ключу. Ребята сами понимают, что это недостаток, и, насколько мне известно, в новой версии могут появиться модели, которые должны помочь решить проблему, но пока как есть.
Во-вторых, статическая инициализация означает, что мы не можем не только создавать, но и уничтожать сторы динамически. Другими словами, однажды созданные стор/событие/эффект будут вечно потреблять память и процессорное время, даже если они больше не нужны.
Другие библиотеки позволяют либо отписаться руками (как в RxJS), либо положиться на сборку мусора (как в MobX). Redux предлагает создавать единое хранилище, которое всегда висит в памяти, тем не менее внутри стора отдельные части друг на друга не подписываются. Подписки создаются только в компонентах и удаляются вместе с их уничтожением.
// ✅ RxJS
class Store {
constructor(...) {
this.unsubscribe = commentsService.comments.subscribe(...);
}
dispose() {
// Отписываемся, когда не надо.
this.unsubscribe();
}
}
// ✅ MobX
function foo() {
// После выхода из функции стор будет доступен сборщику мусора.
const store = new Store();
}
В Effector ни того ни другого нет. И комьюнити не считает это проблемой. Задумайтесь об этом ещё раз — всё состояние нашего приложения целиком висит в памяти, пока пользователь не закроет вкладку 🤔 Если провести аналогию с деревом DOM-элементов — это равносильно тому, что все страницы приложения срендерены с самого начала, просто все, кроме активной, скрыты от пользователя, но при этом подписки на события, таймауты и анимации продолжают работать где-то в фоне.
Возможно, это не такая большая проблема для одностраничного приложения, но, если вы работаете над большим и динамичным сервисом, в котором каждый раздел может быть сколь угодно сложным (как ВКонтакте), стоит это учитывать.
Мне нравится описывать эту проблему такой фразой:
Если мы не отписались от DOM-события — это утечка памяти, а если мы не отписались от стора — это статическая инициализация 🤷♂️
На самом деле технически мы можем уничтожить стор с помощью clearNode (собственно, именно так мы и делали в ситуациях, когда нам нужны были динамические хранилища), но это API считается низкоуровневым, не особо подробно описано в документации и не рекомендуется к использованию сообществом, потому что может привести к «различным проблемам реактивности».
В-третьих, несмотря на то что мы ограничены и в создании, и в уничтожении сущностей, мы не можем их переиспользовать. Разве это не звучит контринтуитивно?
Переиспользование невозможно из-за того, что все сущности статически связаны друг с другоми и попытка их использовать в другом контексте приведёт к тому, что все прежние зависимости неминуемо тоже обновятся. Например, представим, что я создал эффект, который загружает список плейлистов по id пользователя, и семплом складываю его результаты в стор моих плейлистов. Я не могу использовать этот же эффект для загрузки плейлистов на странице другого пользователя, потому что он уже привязан к стору моих плейлистов. Мне нужно создать ещё один эффект (и семпл) с той же самой логикой и использовать его. И так на каждое место, где мне нужны плейлисты.
Нет переиспользованию
const loadMyPlaylistsFx = createEffect(loadPlaylists);
sample({
source: loadMyPlaylistsFx.doneData,
fn: (...) => { ... },
target: $myPlaylists
});
...
const loadFriendsPlaylistsFx = createEffect(loadPlaylists);
sample({
source: loadFriendsPlaylistsFx.doneData,
fn: (...) => { ... },
target: $friendsPlaylists
});
Ещё одна вещь, которую следует помнить про статическую инициализацию: в отличие от того же MobX, который предлагает организовывать хранилища в реактивные классы, в документации Effector нет ничего про организацию хранилищ. Из примеров может показаться, что создавать сторы, события и эффекты прямо на уровне модуля — это хорошая идея. Но в этом случае инициализация происходит неявно за счёт импортов, поэтому любое изменение в структуре или порядке модулей может привести к:
-
случайной инициализации хранилища;
-
изменению порядка инициализации (а он может иметь значение);
-
запуску эффектов или других вычислений, которые сейчас не нужны.
Семпл
Так мы плавно переходим ко второй особенности — семплу.
Это мой любимец. Когда я впервые увидел этот метод и его документацию, мне показалось, что он перегружен и имеет сразу несколько обязанностей, которые обычно назначаются отдельным атомарным функциям, что позволяет в дальнейшем комбинировать их в любой последовательности. Например, иногда нам может понадобиться сделать фильтрацию не только до, но и после маппинга данных. Наверное, это мелочь, которая решается созданием дополнительного стора, но это первое, что меня зацепило. Дальше — больше.
Во-первых, благодаря «декларативности» семплов их порядок как будто бы становится неважным (хотя на самом деле он имеет значение). Это приводит к тому, что попытка переписать на них простую линейную логику может привести к появлению больших модулей, состоящих из мелких семплов. Среди них очень сложно понять, где начало и конец логического блока, особенно когда ты видишь этот код впервые. Тут легко попасть в ловушку: писать такие семплы может быть просто и даже приятно, но читать чужой код на семплах может оказаться сильно сложнее. В нашем коде встречаются модули, состоящие из нескольких десятков семплов, которые понимает только их автор.
Иногда (например, когда необходимо обновить несколько сторов за раз), напротив, вместо множества мелких семплов получаются огромные монструозные. Они читаются с трудом из-за вложенности и необходимости использовать дополнительные утилитарные методы.
Обновление нескольких сторов
sample({
clock: event,
source: combine([ $store1, $store2, $store3, $store4, $store5, $store6, ... ]),
fn: (
[ store1, store2, store3, store4, store5, store6, ... ],
) => {
...
return {
store1: ...,
store2: ...,
store3: ...,
store4: ...,
store5: ...,
store6: ...,
...
};
},
target: spread({
targets: {
store1: $store1,
store2: $store2,
store3: $store3,
store4: $store4,
store5: $store5,
store6: $store6,
...
},
}),
});
В таких случаях обычный императивный код выглядит гораздо понятнее и проще, а другие менеджеры состояния не гонятся за декларативностью.
Простой императивный код
store1.value = /* тут и ниже можно просто использовать store1.value, store2.value, ... */
store2.value = /* ... */
store3.value = /* ... */
store4.value = /* ... */
store5.value = /* ... */
store6.value = /* ... */
Во-вторых, возможность с помощью семпла обновить любую другую сущность из любого места усложняет определение зависимостей. Другие менеджеры состояния предлагают описывать зависимости при создании сущности, поэтому, чтобы определить список зависимостей элемента, достаточно посмотреть его определение. Семпл же позволяет описывать сущности и зависимости отдельно, в том числе добавить зависимости позже и в другом месте. Это значит, что информация о зависимостях может быть размазана по всему коду нашего приложения.
// ✅ A зависит от B и ни от кого другого, без вариантов.
// RxJS
const A = B.pipe(map(...));
// MobX
class Store {
get A() { return this.B }
}
// Redux (селекторы)
const A = createSelector(B, b => { ... });
// ❌ A зависит от B.
// Но может быть, и от чего-то другого — кто знает.
sample({
source: $B,
target: $A
});
В-третьих, порядок семплов имеет значение. Тот семпл, который объявлен раньше, раньше выполняет обновление. Иногда это может существенно влиять на логику приложения.
Например, в таком простом случае, когда какое-то событие обновляет стор, а потом вызывает эффект, который, в свою очередь, читает значения из стора. Если порядок семплов, связывающих событие со стором и эффектом, изменится, то эффект может начать использовать неактуальные данные (до того, как событие их обновит). На небольшом примере вероятность такого исхода может выглядеть ничтожной, но это лишь иллюстрация. Всё становится иначе в случае реального приложения со множеством связей, где семплы разбросаны по разным модулям, структура которых может постоянно меняться.
Порядок обновления
Раз уж мы упомянули порядок обновления, то можем вернуться к вопросу о том, что Effector позволяет создавать подписки разными способами.
Как думаете, в каком порядке выведутся логи в примере ниже? Особенно интересно, как на этот вопрос отвечают новички в Effector.
event.watch(() => console.log(1));
sample({
source: event,
fn: () => console.log(2),
});
$store.on(event, () => console.log(3));
Не знаю, как вы, а лично я до того, как прочитал соответствующий раздел в документации, ожидал, что подписки выполняются в порядке объявления. Но в нашем случае всё будет с точностью до наоборот. В документации описано, почему сделано именно так, но тем не менее, чтобы быть уверенными в правильности выполнения нашего кода, нам нужно следить за тем, какие виды подписок уже используются, либо договариваться использовать какую-то одну. Собственно, вторым способом решается эта проблема в большинстве других библиотек — зачастую там только один вид связи.
Тут я пытаюсь сказать, что можно знать инструмент, а можно считать, что знаешь. Когда инструмент простой, в нём мало подводных камней априори. Но если он сложный, если в нём есть несколько способов сделать одно и то же, то было бы неплохо, чтобы инструмент работал ожидаемо.
Стратегия обновления
Тема с порядком обновления достаточно интересная, и я предлагаю погрузиться в неё чуть глубже и рассмотреть детали реализации Effector, а конкретно — стратегию обновления. Зачастую внутренности инструмента не должны нас интересовать, но на этом примере я хотел бы показать, что иногда они во многом определяют важные особенности поведения.
Давайте представим хранилище нашего приложения как набор значений (например, сторов и событий в случае Effector) и связей между ними. Если между двумя элементами хранилища есть связь — значит, одно значение зависит от другого. Пример ниже показывает хранилище, в котором содержатся имя, фамилия и зависящее от них полное имя (fullName = firstName + lastName).
Задача инструмента по обновлению состояния состоит в том, чтобы значения всех узлов всегда были согласованы. Тогда при изменении имени или фамилии автоматически обновится полное имя.
Те, кто уже интересовались темой управления состоянием, наверняка знают, что стратегия обновления определяет, когда и в каком порядке будут обновляться зависимые углы в случае изменения их зависимостей. Если мы представим чуть более сложное хранилище, состоящее из шести сторов, связанных между собой способом, показанным на схеме ниже, то в случае обновления стора 1 должны обновиться и сторы 2, 4 и 5, но не сторы 3 и 6 (так как они не зависят от стора 1).
К сожалению, документация Effector (в отличие от других библиотек) не раскрывает детали реализации, в том числе и стратегию обновления. В попытках разобраться в этом вопросе я обнаружил, что информация в интернете про это противоречивая. Например, в одном из докладов про внутреннее устройство Effector говорится про обход в ширину, а в чате на вопрос про стратегию автор библиотеки пишет про push и pull одновременно. Мне не хотелось разбираться в исходном коде библиотеки, поэтому я выбрал самый простой путь — делать предположения о вариантах реализации и проверять их с помощью контрольных примеров.
Самая простая реализация, которую я могу представить, может выглядеть следующим образом:
class Node {
constructor(private dependants: Node[]) {}
update() {
// В случае обновления просто заставляем все зависимые узлы обновиться.
this.dependants.forEach(
node => node.update();
);
}
}
В терминах теории грифов получился обход в глубину (DFS). Мы сначала проходим по одной ветке графа до самого конца, потом возвращаемся и идём до конца в следующую ветку и т. д. В случае нашего графа порядок обновления будет следующий: 2, 5, 4.
У этого метода есть недостаток: если у узла больше одной зависимости, то он может быть обновлён более одного раза. Эту проблему можно наглядно проиллюстрировать на примере ромба.
Сначала обновление будет распространяться по пути 1-2-4, а потом по пути 1-3-4. Таким образом, узел 4 будет обновлён дважды, причём первый раз с неактуальным значением зависимости 3. Это плохо, потому что может приводить к ненужным вычислениям или эффектам.
Если мы попробуем проверить, как происходят обновления в Effector, воссоздав такое же хранилище на сторах и семплах, увидим, что стор 4 обновляется только один раз. Но если поменять порядок семплов, то он начнёт обновляться дважды, что как бы намекает на обновление в глубину (автор библиотеки подтверждает).
Ромб на Effector
const update = createEvent<number>();
const $store1 = createStore(0).on(update, (_, value) => value);
const $store2 = createStore(0);
const $store3 = createStore(0);
const $store4 = createStore(0);
$store4.watch((value) => {
// Смотрим, сколько раз обновится стор.
console.log(value);
});
// Связь 2-4 и 3-4 объявим раньше.
sample({
source: [ $store2, $store3 ],
fn: ([ store2, store3 ]) => store2 + store3,
target: $store4
});
sample({
source: $store1,
fn: store1 => store1 + 1,
target: $store2
});
sample({
source: $store1,
fn: store1 => store1 + 1,
target: $store3
});
update(1); // 2, 4
Справедливости ради стоит заметить: когда мы используем Effector вместе с React, то useStore()/useStoreMap не будут рендерить компонент повторно, если возвращаемое значение не изменилось. Но это позволяет оптимизировать только рендер, а не вызов эффектов и другие вычисления, которые могут быть на стороне хранилища.
Выглядит так, что проблема решается простой заменой обхода в глубину на обход в ширину (BFS).
interface Node {
dependants: Node[];
update();
}
const update = (node: Node) => {
let layer = [ node ];
while (layer) {
layer.forEach(node => node.update());
layer = layer.flatMap(node => node.dependants());
layer = [...new Set(layer)];
}
}
Код не стал сильно сложнее, но зато ненужных вычислений больше нет, потому что теперь обновления происходят по слоям, в которых все узлы равно удалены от исходного. После изменения сначала обновятся сторы 2 и 3, расположенные на 1-м слое, а потом стор 4, лежащий на 2-м слое, — и только один раз.
К сожалению, эта оптимизация только кажется решением и не работает в случае, когда один и тот же узел лежит одновременно на разных слоях. А в реальных проектах это очень частая ситуация. Например, в треугольном графе узел 3 лежит одновременно на 1-м и на 2-м слоях относительно 1-го. Соответственно, сначала будут обновлены сторы 1-го слоя (2 и 3), а потом стор 3 будет обновлён ещё раз, как узел 2-го слоя.
В этом случае нужно менять не порядок обхода, а саму стратегию обновления. До этого мы рассматривали push-стратегию, когда зависимость заталкивает своё значение во все зависимые узлы, вызывая их обновления. А те, в свою очередь, толкают дальше своё обновлённое значение, таким образом распространяя изменения по графу зависимостей. Большинство современных (и не очень) библиотек используют pull-стратегию, в которой зависимость только инвалидирует цепочку зависимых узлов, не приводя к их обновлению. После этого, если значение какого-то узла запрашивается (то есть если у него есть потребитель), оно вычисляется заново, только когда инвалидировано.
Pull-стратегия чуть сложнее в реализации, но у неё есть весомые преимущества:
-
Позволяет избежать избыточных вычислений за счёт вычисления «справа налево» (и в ромбе, и в треугольнике).
-
Позволяет избегать ненужных вычислений — на рисунке серые узлы, у которых нет потребителя, можно не вычислять.
-
Позволяет вычислять значение лениво — оно будет вычислено только в тот момент, когда понадобится и когда у него появится потребитель.
-
Вычисления значений не зависят от порядка установки связей благодаря тому, что они начинаются с зависимых узлов, а не с зависимостей (на рисунке «справа налево»).
-
Сборщик мусора может автоматически удалять недостижимые узлы благодаря тому, что зависимостям не обязательно иметь ссылку на зависимые сущности.
-
По этой же причине:
-
зависимости могут определяться автоматически — их не нужно указывать руками, как source в семпле;
-
зависимости могут определяться динамически — если от вычисления к вычислению они меняются;
-
В случае Effector мы эти преимущества не получаем, потому что библиотека использует самую простую стратегию обновления и самый тривиальный алгоритм обхода графа.
Upd: Ленивые вычисления обещают в следующей версии.
Циклы
Здесь можно заметить, что мы говорим о простых случаях, и даже обход графа в глубину не так тривиален, если в нём, например, есть циклы. А теперь угадайте, как себя поведёт Effector в случае наличия в графе цикла?
Библиотеки, которые используют pull-стратегию, обычно автоматически определяют циклы в runtime, помечая все соответствующие узлы как сломанные. Попытка прочитать значение такого узла приводит к исключению. Так, например, поступает MobX.
Определение цикла в runtime
// ✅ MobX
class Store {
// Чтение любого свойства приведёт к исключению.
get a() { return this.b; }
get b() { return this.a; }
}
Используя некоторые библиотеки, цикл в принципе создать сложно (или даже невозможно) из-за того, что зависимости всегда определяются ДО зависимых (например, обзерваблы RxJS и селекторы в Redux).
Зависимости определяются раньше зависимых
// ✅ RxJS
const dependant = dependency.pipe(...);
// ✅ Redux
const dependant = createSelector(dependency, store => store.a);
В Effector же есть семпл, который позволяет легко добавить новую связь в любой момент и из любого места, что усложняет обнаружение таких циклов. Можно даже запросто создать цикл, состоящий из одного узла!
sample({
source: $source,
fn: (...) => { ... },
target: $source
});
Учитывая, что обновления выполняются синхронно, цикл полностью вешает вкладку с приложением.
Интересно, что, задав вопрос про циклы в чате Effector, я узнал от одного из авторов, что это нерешаемая задача, и даже получил в доказательство ссылку на нерелевантную проблему. Что ж, хорошо, что теории графов об этом неизвестно.
Ошибки
Ещё одна важная особенность стратегии обновления Effector — распространение ошибок. Как вы считаете, если в результате вычисления одного из узлов произошла ошибка, что должно случиться с зависимыми от него узлами? Корректно ли считать их валидными? Представим пример хранилища, состоящего из стора с номером поста, эффекта, загружающего комментарии к посту, и стора, куда эти комментарии будут сохраняться. А теперь представьте, что при маппинге данных комментариев произошла ошибка и они не обновились.
Можно ли считать такое состояние, когда комментарии не соответствуют посту, согласованным и корректным? Как бы вы хотели, чтобы граф состояния выглядел в такой ситуации?
Многие менеджеры состояния (например, MobX и RxJS) помечают все узлы, у которых есть сломанные зависимости, тоже сломанными.
Effector же рассчитывает, что разработчики используют только чистые функции везде, кроме эффектов. При этом под чистой функцией понимается функция, которая в том числе не выкидывает исключения. Если же исключение было выброшено, Effector не распространяет его дальше по графу и не откатывает изменения. Ошибка просто выводится в консоль, в результате чего весь граф может оказаться в несогласованном состоянии (как в нашем примере).
Как вы считаете, корректно ли вообще со стороны современного инструмента требовать писать «безошибочный» код? Возьмём в качестве странного примера циркулярную пилу — когда она только появилась, она тоже не давала слесарю права на ошибку. Но прогресс не стоит на месте, и современные циркулярные пилы давно научились не пилить сосиску.
Effector же говорит нам: никаких сосисок. Пилить можно только дерево, а если по неосторожности мы сунем палец — всё взорвётся.
Представим теперь, что всё-таки мы нашли место, где возникает исключение, и хотим его обработать. Причём где-то дальше по графу зависимостей — допустим, показать в блоке комментариев какое-то сообщение. Как нам это сделать?
Ванильный JS позволяет обработать исключения там, где нам это удобно, — в случае асинхронного кода мы можем это сделать в любом месте дальше по цепочке промисов. И это работает в обе стороны — то есть каждый обработчик может ловить исключения, возникшие в любом месте раньше по цепочке. В синхронном JS всё аналогично, только вместо цепочки промисов там стек вызовов. В Effector же обработать исключение можно только там, где она возникла. Если вдруг я хочу это сделать где-то в другом месте, мне придётся поменять весь флоу, возвращая вместо результата объект, содержащий результат и ошибку.
Обработка исключений в «чистых функциях»
sample({
...,
fn: (...) => {
try {
const result = ...;
return { result };
} catch (error) {
// Чтобы другие узлы могли узнать об ошибке,
// нужно вернуть её в качестве результата.
return { error }
}
},
})
Это наводит меня на мысль о том, что требование использовать чистые функции появилось как раз из-за невозможности изящно обработать ошибку в этих местах. На самом деле, если присмотреться, можно заметить, что основные преимущества чистоты Effector зачастую и не использует. Например, одна из характеристик чистоты — это идемпотентность, или свойство функции возвращать один и тот же результат при одних и тех же входных значениях. Получается, что если аргументы не изменились, то вместо повторного вызова такой функции можно просто вернуть её последний результат из кеша, верно? Да, но вместе с этим каждое событие считается уникальным, и если мы используем его значение в качестве аргумента — чистая функция будет вызываться всегда (возможно, это просто недоработка).
sample({
source: event,
fn: value => {
// Эта чистая функция будет вызвана независимо от значения события,
// а могла бы вызываться только при изменении этого значения.
},
target: $store
});
В эффектах обработка исключений есть, но нам понадобится отдельный семпл на каждую пару «источник ошибки — обработчик ошибки» (или большой семпл, куда нужно добавить все эффекты), структуру которого нужно поддерживать в синхроне со структурой основного семпла успешного сценария. И это всё независимо от того, где мы вызвали эффект. А что, если нужно обработать несколько ошибок?
Обработка ошибок с помощью семпла
// ❌ Обработка ошибок в каждом эффекте — отдельно.
// Каждая обработка будет срабатывать независимо от того,
// где эффект вызван.
sample({
source: fooFx.fail,
target: handleError
});
sample({
source: barFx.fail,
target: handleError
});
// Даже если они входят в одну цепочку вызовов.
sample({
source: fooDx.doneData,
target: barFx
});
Пример с главной страницы
На самом деле хранилище, о котором мы говорим, я не придумывал специально — это пример с главной страницы сайта 22-й версии Effector.
Пример с главной страницы
const nextPost = createEvent()
const getCommentsFx = createEffect(async postId => {
const url = `posts/${postId}/comments`
const base = 'https://jsonplaceholder.typicode.com'
const req = await fetch(`${base}/${url}`)
return req.json()
})
const $postComments = createStore([])
.on(getCommentsFx.doneData, (_, comments) => comments)
const $currentPost = createStore(1)
.on(getCommentsFx.done, (_, {params: postId}) => postId)
const $status = combine(
$currentPost, $postComments, getCommentsFx.pending,
(postId, comments, isLoading) => isLoading
? 'Loading post...'
: `Post ${postId} has ${comments.length} comments`
)
sample({
source: $currentPost,
clock: nextPost,
fn: postId => postId + 1,
target: getCommentsFx,
})
$status.watch(status => {
console.log(status)
})
// => Пост 1 имеет 0 комментариев.
nextPost()
// => Загрузка поста...
// => Пост 2 имеет 5 комментариев.
Если к нему присмотреться, можно заметить несколько интересных вещей.
Во-первых, он работает не совсем корректно — в строке 33 мы видим, что для первого поста всегда выводится нулевое количество комментариев, хотя это совсем не так. Чтобы это исправить с минимальными изменениями, можно в конце инициализации хранилища вызвать эффект. Возможно, это не очень красиво — напишите в комментариях, как лучше поступить в данном случае.
Инициализация хранилища
getCommentsFx($currentPost.getState())
Во-вторых, в нём не хватает обработки ошибок. В случае неудачного запроса мы просто увидим первоначальное состояние и даже не узнаем, что что-то пошло не так. Чтобы это исправить, нам придётся либо немного переписать код, либо добавить новый стор.
Стор состояния загрузки
// Можно использовать комбинацию sample+merge
// (или готовый метод из библиотеки patronum),
// но такой вариант мне показался проще.
const $isFailed = createStore(false)
.on(getCommentsFx.fail, () => true)
.reset(getCommentsFx.done)
Интересно, это просто случайная недоработка или просто полноценный пример выглядел бы уже не так изящно 🤔
Скорее всего, в реальной жизни мы будем использовать хранилища вместе с какой-нибудь UI-библиотекой или фреймворком, в нашем случае это React. Тогда стор со статусом не нужен, так как его логика уйдёт в React-компонент. Таким образом, остаётся следующий код.
const nextPost = createEvent()
const getCommentsFx = createEffect(loadComments)
const $postComments = createStore([])
.on(getCommentsFx.doneData, (_, comments) => comments)
const $currentPost = createStore(1)
.on(getCommentsFx.done, (_, {params: postId}) => postId)
sample({
source: $currentPost,
clock: nextPost,
fn: postId => postId + 1,
target: getCommentsFx,
})
const $isFailed = createStore(false)
.on(getCommentsFx.fail, () => true)
.reset(getCommentsFx.done)
getCommentsFx($currentPost.getState())
А теперь давайте посмотрим, как бы выглядела примерно та же самая логика без Effector - для наглядности вынесем её в отдельный хук.
const usePost = (initialPostId: number) => {
const [ currentPostId, setCurrentPostId ] = useState(null);
const [ nextPostId, setNextPostId ] = useState(initialPostId);
const [ state, setState ] = useState({ kind: 'loading' });
useEffect(() => {
if (nextPostId === currentPostId) return;
setState({ kind: 'loading' });
const { signal, abort } = new AbortController();
loadComments(nextPostId, signal)
.then(comments => {
setCurrentPostId(nextPostId);
setState({ kind: 'loaded', comments });
})
.catch(error => {
setNextPostId(currentPostId);
setState({ kind: 'error', error });
});
return abort;
}, [nextPostId]);
const showNextPost = () => setNextPostId(currentPostId + 1);
return { currentPostId, state, showNextPost };
}
Написав на несколько строк кода больше, мы получили логику, которая:
-
может быть переиспользована с разными входными данными, то есть на странице может быть одновременно несколько независимых компонентов поста, каждый в своём состоянии;
-
может быть с лёгкостью модифицирована благодаря тому, что весь логически связанный код находится в одном месте и у нас нет ограничений на использование значений переменных. Например, мы добавили отмену устаревших запросов с помощью AbortController (напишите в комментариях, как то же самое сделать в коде на Effector);
-
работает чуть более эффективно, потому что вся инициализация и загрузка данных происходят только тогда, когда они действительно нужны;
-
проще читается и понимается большинством JS-разработчиков, которые видят её впервые, благодаря тому, что код более линейный и не требует знания Effector.
Ключевые преимущества Effector
Так мы плавно переходим к тому, чтобы присмотреться к ключевым преимуществам Effector, описанным на его главной странице, и подумать, что же они на самом деле для нас значат.
Просто JavaScript
На сайте 23-й версии указано, что Effector позволит писать «просто JavaScript-код, который работает» без «классов, прокси и какой-то магии». Глядя на пример с главной страницы, я не могу сказать, что это просто JS. Мне также не до конца понятно, что авторы подразумевают под «магией» и почему в данном случае прокси и классы — это плохо.
Чистота и предсказуемость
На сайте 22-й версии в том же пункте ещё было про «чистоту» и «предсказуемость», но мы уже обсуждали, что на самом деле благодаря семплам линейная логика может превращаться в нелинейный код, из-за чего непонятно, где начало и где конец каждого логического блока. Простые вещи пишутся сложно и запутанно, и в написанном другим человеком коде бывает непросто разобраться. А забытые подписки навсегда остаются в памяти и в любой момент могут подкинуть неприятный сюрприз.
Типобезопасность
Все сущности Effector действительно имеют «поддержку TypeScript из коробки», но эта типизация, к сожалению, не всегда полноценная. Приведу пару примеров.
До 23-й версии производные сущности нельзя было отличить от оригинальных (в них даже можно было производить запись, что прямо противоречит их сути).
Функция filter в семпле принимает на вход два аргумента, из-за чего её не всегда можно использовать как TypeGuard. Стало быть, нам может понадобиться повторно выполнять проверки типов, чтобы TypeScript понимал с чем мы работаем. Проще эту проблему продемонстрировать на следующем примере.
// ✅ Ванильный TS
function chase(animal1: Animal, animal2: Animal) {
if (isDog(animal1) && isCat(animal2)) {
// Собака гонится за кошкой.
return animal1.chase(animal2);
}
}
// ❌ Семпл
sample({
clock: animal2,
source: animal1$,
filter: (animal1, animal2) => isCat(animal1) && isDog(animal2),
fn: (animal1, animal2) => {
// Тут animal1 и animal2 снова просто Animal — придётся повторить проверку.
if (isCat(animal1) && isDog(animal2)) {
return animal2.chase(animal1);
}
},
...
});
Производительность
Лично для меня тезис «инициализация всей логики при запуске обеспечивает быструю работу приложения» звучит примерно как «отрицательный рост». Нам предлагают отказаться от динамического создания сторов и сборки мусора, которые невероятно полезны для больших динамичных приложений, чтобы получить взамен медленную инициализацию и «быструю работу приложения». При этом медленная инициализация — жирный минус, ведь скорость запуска приложения на стороне конечного пользователя критически важна. На это намекают основные метрики производительности, за которыми мы гоняемся, разбивая код на чанки, применяя ленивую и инкрементальную загрузки и так далее. Учитывая, что в случае статической инициализации вся логика и все сущности будут потреблять ресурсы, даже когда они не нужны, «быстрая работа» будет доступна разве что простым и статичным приложениям.
Дружелюбие
Ребята в официальном чате Effector действительно охотно ответят на все вопросы. Но не стоит забывать, что чат — это не документация. Последняя обычно описывает взвешенные, более или менее универсальные и рабочие решения. В чате:
-
на один и тот же вопрос в разное время можно получить разные и даже противоположные ответы;
-
никто не гарантирует, что ответы не будут содержать плохие практики;
-
если инструмент не решает проблему (даже если она в его спектре задач), вас могут отправить менять архитектуру.
При этом документация Effector содержит в основном тривиальные, далёкие от реальности примеры вроде счётчика. Раздела с лучшими практиками, к сожалению, нет. Возможно, если бы такой раздел был, наша история могла бы сложиться по-другому.
Upd: В чате мне рассказали, что ребята знают об этой проблеме и работают над ней.
Effector как инструмент
[Внимание, дальше может быть субъективно!]
Effector действительно абстрагирует сложность обновления состояния внутри себя, но, как мы уже говорили выше, использует достаточно простые оптимизации. Сложность всё равно протекает наружу в виде достаточно объёмного API, которого зачастую не хватает. В любой непонятной ситуации комьюнити рекомендует использовать дополнительные библиотеки типа patronum.
При этом нам приходится делать достаточно много лишней работы в виде бойлерплейта. Тот же пример с главной страницы состоит из него примерно наполовину. Вы можете самостоятельно попробовать переписать его на ванильный JS (или с использованием вашего любимого менеджера состояния) и сравнить читаемость, надёжности и расширяемость.
Когда мы используем Effector, нам может казаться, что код, который у нас получается, какой-то особенно «правильный» или «изящный», ведь он выглядит таким «декларативным», а при его написании мы используем чистые функции и иммутабельность. Но тут не стоит забывать, что эти плюшки сами по себе не являются плюсами — это просто инструменты или даже ограничения (не бесплатные), которые в определённых случаях позволяют получить некоторые преимущества.
Например, полагаясь на чистоту функции, можно было бы легко организовать кеширование её результата, но если в Effector мы используем события (вроде это рекомендуется), то кеширования не будет. Получается, что «чистота» в данном случае облегчает жизнь не нам, пользователям, а авторам библиотеки, позволяя не думать об обработке исключений.
Делаем выводы
На примере нашей истории я хотел показать, что к принятию глобальных решений, особенно таких важных, как выбор инструмента для управления состоянием, надо подходить ответственно и делать это:
-
хладнокровно — убрать эмоции и не вестись на хайп и модные термины;
-
критично — проверять все заявления относительно «продаваемой» библиотеки (согласитесь, на официальных сайтах обычно написаны одни плюсы);
-
объективно — не игнорировать недостатки «симпатичного» решения и достоинства «несимпатичного»;
-
взвешенно — при принятии итогового решения оценивать совокупность актуальных именно для вас ЗА и ПРОТИВ (велика вероятность, что «декларативность» и «иммутабельность» сами по себе вам не нужны).
Иногда для объективной оценки может понадобиться покопаться во внутреннем устройстве библиотеки, как это сделали мы, ведь оно определяет важные особенности поведения, о которых вы могли даже не задумываться.
Если сделанный выбор всё-таки не оправдал ожидания — не затягивайте с признанием ошибок. Разбирайтесь, где вы ошиблись и, главное, почему (что не так с процессом принятия решения). Учитывайте прошлые косяки, чтобы не допустить их вновь.
Эти выводы наверняка звучат как что-то очевидное, тем не менее ошибки иногда случаются. Так, мы, например, выбрали Effector на волне хайпа, не уделив должного внимания изучению его особенностей. В результате некоторые плюсы, которые мы нашли, оказались для нас неактуальными, а некоторые актуальные минусы, наоборот, оказались незамеченными. Спустя почти год попыток внедрить Effector мы пришли к выводу, что для нас эта библиотека сыровата в плане:
-
производительности — делает лишние вычисления, препятствует сборке мусора;
-
надёжности — в случае исключения может вести себя непредсказуемо, не позволяет гибко их обрабатывать, а изменение порядка подписок может влиять на работу итогового продукта;
-
реализации — использует самую простую стратегию обновления;
-
удобства — не хватает документации, требует писать много бойлерплейта.
Мы смирились с тем, что ошиблись, проанализировали и решение, и процесс его принятия, обсудили его в более широком кругу и учли ошибки прошлого в новом процессе. Сейчас движемся в направлении, которое выглядит более перспективным. Но об этом, возможно, расскажем в будущих статьях.
Автор: andres_kovalev