Upd: Поиском по "Upd" можно найти все корректировки, внесенные в статью после публикации в результате жаркой дискуссии в чате Effector.

Меня зовут Андрес, я руководитель команды разработки внутреннего UI-кита ВКонтакте. А это ещё одна статья про инструменты управления состоянием. Сегодня мы не будем изобретать ничего нового, а поговорим про библиотеку Effector.

TL;DR

Почти год мы ВКонтакте пытались внедрить Effector, но пришли к выводу, что пока это достаточно сырая библиотека. Её недостатки зачастую проявляются сильно позже, чем хотелось бы, и, по нашему мнению, перевешивают достоинства… А последние местами преувеличены. Было больно осознавать количество потерянного времени, но, как говорится, лучше поздно, чем никогда.

В сети мало альтернативной информации про 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

Справедливости ради стоит отметить, что если по какой-то причине в результате вычислений узла 4 каждый раз будет получаться одно и то же значение, то зависимые узлы обновляться не будут.

Выглядит так, что проблема решается простой заменой обхода в глубину на обход в ширину (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-стратегию, в которой зависимость только инвалидирует цепочку зависимых узлов, не приводя к их обновлению. После этого, если значение какого-то узла запрашивается (то есть если у него есть потребитель), оно вычисляется заново, только когда инвалидировано.

Обновление зависимостей с применением push- и pull-стратегий
Обновление зависимостей с применением push- и pull-стратегий

Pull-стратегия чуть сложнее в реализации, но у неё есть весомые преимущества:

  • Позволяет избежать избыточных вычислений за счёт вычисления «справа налево» (и в ромбе, и в треугольнике).

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

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

  • Вычисления значений не зависят от порядка установки связей благодаря тому, что они начинаются с зависимых узлов, а не с зависимостей (на рисунке «справа налево»).

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

  • По этой же причине:

    • зависимости могут определяться автоматически — их не нужно указывать руками, как source в семпле;

    • зависимости могут определяться динамически — если от вычисления к вычислению они меняются;

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

Upd: Ленивые вычисления обещают в следующей версии.

Циклы

Здесь можно заметить, что мы говорим о простых случаях, и даже обход графа в глубину не так тривиален, если в нём, например, есть циклы. А теперь угадайте, как себя поведёт Effector в случае наличия в графе цикла?

Библиотеки, которые используют pull-стратегию, обычно автоматически определяют циклы в runtime, помечая все соответствующие узлы как сломанные. Попытка прочитать значение такого узла приводит к исключению. Так, например, поступает MobX.

Определение цикла в runtime
// ✅ MobX
class Store {
  // Чтение любого свойства приведёт к исключению.
  get a() { return this.b; }
 
  get b() { return this.a; }
}

MobX автоматически помещают все узлы цикла как сломанные
MobX автоматически помещают все узлы цикла как сломанные

Используя некоторые библиотеки, цикл в принципе создать сложно (или даже невозможно) из-за того, что зависимости всегда определяются ДО зависимых (например, обзерваблы 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 не постоянно (вторая половина поста тут).

Upd: В чате мне рассказали, что ребята знают об этой проблеме и работают над ней.

Effector как инструмент

[Внимание, дальше может быть субъективно!]

Effector действительно абстрагирует сложность обновления состояния внутри себя, но, как мы уже говорили выше, использует достаточно простые оптимизации. Сложность всё равно протекает наружу в виде достаточно объёмного API, которого зачастую не хватает. В любой непонятной ситуации комьюнити рекомендует использовать дополнительные библиотеки типа patronum. 

При этом нам приходится делать достаточно много лишней работы в виде бойлерплейта. Тот же пример с главной страницы состоит из него примерно наполовину. Вы можете самостоятельно попробовать переписать его на ванильный JS (или с использованием вашего любимого менеджера состояния) и сравнить читаемость, надёжности и расширяемость.

Когда мы используем Effector, нам может казаться, что код, который у нас получается, какой-то особенно «правильный» или «изящный», ведь он выглядит таким «декларативным», а при его написании мы используем чистые функции и иммутабельность. Но тут не стоит забывать, что эти плюшки сами по себе не являются плюсами — это просто инструменты или даже ограничения (не бесплатные), которые в определённых случаях позволяют получить некоторые преимущества. 

Например, полагаясь на чистоту функции, можно было бы легко организовать кеширование её результата, но если в Effector мы используем события (вроде это рекомендуется), то кеширования не будет. Получается, что «чистота» в данном случае облегчает жизнь не нам, пользователям, а авторам библиотеки, позволяя не думать об обработке исключений.

Делаем выводы

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

  • хладнокровно — убрать эмоции и не вестись на хайп и модные термины;

  • критично — проверять все заявления относительно «продаваемой» библиотеки (согласитесь, на официальных сайтах обычно написаны одни плюсы);

  • объективно — не игнорировать недостатки «симпатичного» решения и достоинства «несимпатичного»;

  • взвешенно — при принятии итогового решения оценивать совокупность актуальных именно для вас ЗА и ПРОТИВ (велика вероятность, что «декларативность» и «иммутабельность» сами по себе вам не нужны). 

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

Если сделанный выбор всё-таки не оправдал ожидания — не затягивайте с признанием ошибок. Разбирайтесь, где вы ошиблись и, главное, почему (что не так с процессом принятия решения). Учитывайте прошлые косяки, чтобы не допустить их вновь. 

Эти выводы наверняка звучат как что-то очевидное, тем не менее ошибки иногда случаются. Так, мы, например, выбрали Effector на волне хайпа, не уделив должного внимания изучению его особенностей. В результате некоторые плюсы, которые мы нашли, оказались для нас неактуальными, а некоторые актуальные минусы, наоборот, оказались незамеченными. Спустя почти год попыток внедрить Effector мы пришли к выводу, что для нас эта библиотека сыровата в плане:

  • производительности — делает лишние вычисления, препятствует сборке мусора;

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

  • реализации — использует самую простую стратегию обновления;

  • удобства — не хватает документации, требует писать много бойлерплейта.

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

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


  1. markelov69
    02.09.2024 14:59
    +6

    В результате в лидеры выбились Redux, MobX и Effector.

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

    Серьезно? Покажите пожалуйста конкретный код этих коротких приложений, прям очень интересно, это как же надо написать код с MobX'ом чтобы предпочесть ему Effector... Сдается мне, что тот кто писал это приложение с MobX'ом вообще либо первый раз с ним знаком, либо не понимает в чем его суть и как нужно писать код используя MobX.

    В голове не укладывается, как вот это


    Променять на не это.

    Вот например тут есть и фетчинг данных и подписки на события т.п.

    https://stackblitz.com/edit/vitejs-vite-ffchmx?file=src%2Fpages%2Fmain%2Findex.tsx&terminal=dev

    Бонусом там же ещё сделал чтобы все асинхронные функции в классе имели реактивные свойства fetching, error, callsCount (лежит в helpers/makeReactive)


    1. andres_kovalev Автор
      02.09.2024 14:59
      +2

      Здравствуйте. Я рассчитывал получить от Вас комментарий =)

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

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

      const incr = createEvent();
      
      const counter$ = createStore(0)
        .on(incr, value => value + 1);
      
      const handleTitleChange = createEvent();
      const updateTitle = handleTitleChange.map(e => e.target.value)
      
      const title$ = createStore('title')
        .on(updateTitle, (_, value) => value);

      Учитывая, что в современном FE разработчики (почему-то) избегают классы и предпочитают react-like код, я допускаю что мнения сторонних наблюдателей по поводу приведенных примеров может разделиться.

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


  1. Alexandroppolus
    02.09.2024 14:59

    Функция filter в семпле принимает на вход два аргумента, из-за чего её не всегда можно использовать как TypeGuard.

    filter: ([ animal1, animal2 ]) => isCat(animal1) && isDog(animal2),

    А если вот так:

    filter: (p): p is [Cat, Dog] => isCat(p[0]) && isDog(p[1]),


    1. Dimensi
      02.09.2024 14:59
      +2

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


    1. andres_kovalev Автор
      02.09.2024 14:59

      Пример в статье немного не соответствует описанию - спасибо что обратили внимание. Акцент был на том, что:

      Функция filter в семпле принимает на вход два аргумента...

      Т.е. проблема возникает в случаях, когда TypeGuard'ы нужны и для clock и для source :

      sample({
        clock: event,
        source: store$,
        filter: (value, payload) => isSmth(value) && isSmth(payload),
        fn: (value, payload) => ???,
        target: ...
      });


  1. buldo
    02.09.2024 14:59
    +1

    Ещё немного, ещё чуть-чуть и веб разработка сделает полный аналог wpf + mvvm + reactive extensions.

    P. S. Долго не мог вкурить что это за менеджеры состояний на фронте, пока не понял, что это всё было в wpf, только без хитрых названий


    1. Animila
      02.09.2024 14:59

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


    1. Bromles
      02.09.2024 14:59

      Все это было в Ангуларе, но фронтендерам претят ООП, всякие архитектурные паттерны, принудительная типизация и подобное. Там сейчас тренд такой, от одного слова class плеваться, изобретать костыли поверх функциональщины, а потом гордо пытаться годами чинить то, что изначально работало до "переосмысления". Вот и получается хождение по спирали


      1. Zalechi
        02.09.2024 14:59
        +1

        По ходу это план «Анунаков», пересадить весь бездарный состав программистов во фронт-энд, и уже оставить эти игры с штмл + цсс конструкциями в прошлом, как и их производные штмл-конструкторы))

        нефиг и сайты админить и самому их рисовать, — идите учите курсы питона и вперед «За Родину», как говорится)) . Всё, «кирдык» домашнее сайто-строение.

        (Ну я фиг его знает, так чисто поболтать. «Орнул» короче)


  1. ZothOmmog
    02.09.2024 14:59
    +5

    Во-первых, благодаря «декларативности» семплов их порядок как будто бы становится неважным (хотя на самом деле он имеет значение). Это приводит к тому, что попытка переписать на них простую линейную логику может привести к появлению больших модулей, состоящих из мелких семплов. Среди них очень сложно понять, где начало и конец логического блока, особенно когда ты видишь этот код впервые. Тут легко попасть в ловушку: писать такие семплы может быть просто и даже приятно, но читать чужой код на семплах может оказаться сильно сложнее. В нашем коде встречаются модули, состоящие из нескольких десятков семплов, которые понимает только их автор.

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

    Чем дольше работаю с эффектором, тем меньше остаётся надежды в то, что это просто я не умею его правильно готовить


  1. supercat1337
    02.09.2024 14:59

    Вот смотрю на статью и думаю.

    С одной стороны большинство разработчиков мечтает, чтобы их библиотеки использовали гиганты. Это повышает репутацию разработчика, дает возможность лишний раз рекламировать свои навыки или продукты, ссылаясь на факт доверия со стороны крупных узнаваемых брендов. С другой - компании в свою очередь не тратят время и деньги на разработку подобных велосипедов, потому что в соответствии с лицензией на код они могут юзать библиотеку по своему усмотрению. И вроде бы все ок, пока организация с известным брендом не напишет на популярном Хабре негативный фидбэк, на который впоследствии многие будут ссылаться, почему не стоит брать effector в прод. Получается, антиреклама.

    И вот спустя год, в VK пришли к выводу, что библиотека не торт или возможно ее неправильно готовили. Собственно хочу спросить, а пробовал ли кто-то из VK написать issue или обратиться в чатик в телеграме (там много участников) по выявленным проблемам? Был ли положительный фидбэк от авторов или адептов? Или покупка поддержки, которая была направлена на исправление архитектурных просчетов? Я хоть далеко не любитель эффектора, но со стороны это смотрится: поматросил и бросил (публично). Была бы статья не от компании, наверное было бы другое впечатление. Популярность эффектора сыглало с ним же самим злую шутку.

    А так, интересная публикация, спасибо. Есть над чем подумать и что взять на заметку )


    1. kubk
      02.09.2024 14:59
      +1

      Любой опыт от большой компании полезен, неважно позитивный или негативный. Такие статьи хорошо отрезвляют после десятков других с примитивными примерами на todo-списках. Однако верно, что негативный опыт действительно может быть объяснён по-разному - либо не разобрались, либо действительно неудачный выбор. Тогда в ход должны идти аргументы и примеры. Например в статье есть "Задача на порядок подписок", которая хорошо иллюстрирует что понимать такой код тяжело, больше похоже на птичий язык, а не JavaScript.

      Случаи когда большие компании публично отказываются от инструментов нередки, например в блоге Basecamp есть статьи о причинах ухода от облачных провайдеров вроде AWS и Google Cloud.


    1. Gary_Ihar
      02.09.2024 14:59
      +4

      В статье есть даже ссылки на переписку в том самом чатике. Автора спутали с небезызвестным участником хабра и кикнули за "неправильные вопросы"


      1. andres_kovalev Автор
        02.09.2024 14:59

        Не так важно, но там не меня забанили, а именно того самого небезызвестного участника хабра.


    1. andres_kovalev Автор
      02.09.2024 14:59
      +1

      Здравствуйте. Спасибо, что прочитали. Мы конечно же приходили со всеми вопросами в чат, когда не могли найти ответа в документации. В блоке с историей я рассказываю, что на этапе повторного анализа я отдельно пришел в чат со всеми моментами, описанными выше (ссылки на переписку есть в статье). В итоге для каких-то проблем мы решения нашли, но на большую часть - нет. Например, так необходимая нам динамика пока что реализуется с помощью громоздкого key-value (ждем модели), а циклические зависимости не обнаруживаются и авторы пишут что это невозможно.

      Чат гораздо оперативнее и удобнее, поэтому отдали предпочтение ему перед issues. Там почти на каждый вопрос отвечали в том числе лично создатели. По многим важным вопросам они сами создавали issue. Но в наших вопросах ничего особо нового не было и они в основном относились к одному из следующих случаев:

      • проблема известна, мы над этим работаем (тут issue уже не нужно)

      • проблема легко решается / это не проблема эффектора / это не проблема вообще (тут issue как будто не имеет смысла)


  1. Safort
    02.09.2024 14:59
    +5

    Когда я вижу такие статьи, то у меня возникает вопрос: а как бы поступил Наруто Карловский?
    Мне нехватает в комментариях примеров о выгодных отличиях $mol.


    1. nin-jin
      02.09.2024 14:59
      +3

      Я просто оставлю это здесь:

      https://page.hyoo.ru/#!=3yox7h_7f2axu/View'3yox7h_7f2axu'.Details=%D0%94%D0%B5%D0%BA%D0%BE%D0%BC%D0%BF%D0%BE%D0%B7%D0%B8%D1%82%D0%BE%D1%80%20%D0%BB%D0%BE%D0%B3%D0%B8%D0%BA%D0%B8%20Effector

      https://mol.hyoo.ru/#!section=docs/=yj0h42_ixzv4p

      https://page.hyoo.ru/#!=3ia3ll_rcpl7b


      1. Safort
        02.09.2024 14:59
        +2

        Спасибо, маэстро!


      1. andres_kovalev Автор
        02.09.2024 14:59
        +1

        Спасибо за Ваш труд. Все три материала нам знакомы и информация из них была очень полезна на этапе анализа проблем.


    1. supercat1337
      02.09.2024 14:59

      А причем тут Карловский? Если интересует как выглядят решения задач с использованием других стейт-менеджеров, то можно попросить написать в комментариях. Какая задача интересует?


  1. artalar
    02.09.2024 14:59
    +2

    Писал подчти о том же еще в конце 2022, но тлг каналы не гуглятся. Видимо, надо переезжать на сайт...
    https://t.me/artalog/568


    1. andres_kovalev Автор
      02.09.2024 14:59

      Действительно, выглядит как краткий пересказ большой части статьи. Жаль мы не наткнулись на этот пост раньше. С Вашего позволения добавлю в статью.


      1. artalar
        02.09.2024 14:59

        Да конечно. Еще вот очень рекомендую для личного ознакомления: https://habr.com/ru/companies/ruvds/articles/737114/


        1. andres_kovalev Автор
          02.09.2024 14:59
          +1

          Читал Все Ваши статьи на хабре, спасибо за то что собрали в компактном виде большое количество полезной информации и интересные идеи. Моя любимая - про act :)

          Конкретно в этой статье не со всеми пунктами и определениями согласен, но все равно было полезно почитать)


        1. markelov69
          02.09.2024 14:59
          +1

          Да конечно. Еще вот очень рекомендую для личного ознакомления: https://habr.com/ru/companies/ruvds/articles/737114/

          Уважаемый @artalar будьте так любезны ответить на комментарии:
          Раз
          https://habr.com/ru/companies/ruvds/articles/737114/#comment_25586780
          Два
          https://habr.com/ru/companies/ruvds/articles/737114/#comment_25786550
          Три (Ответить нормально на этот комментарий)
          https://habr.com/ru/companies/ruvds/articles/737114/#comment_25582614

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


          1. artalar
            02.09.2024 14:59

            Я вижу все комментарии и не отвечаю на те, которые уже зашли в тупик, потому что кто-то ходит по кругу и не слышит ответов которые ему уже давали :)


  1. Alexandroppolus
    02.09.2024 14:59

    Вопрос по таблице сравнений (которая где-то вверху): а как у вас так вышло, что на мобиксе нельзя покрыть код линтерами и тестами?


    1. andres_kovalev Автор
      02.09.2024 14:59

      Здравствуйте. На всякий случай повторю тезис из статьи, что эта табличка является примером именно плохого подхода к анализу. Стоит воспринимать ее как "экспонат в музее ошибок".

      Конкретно про пункт с линтерами/тестами для MobX - если не ошибаюсь, это о том, что в MobX нет из коробки инструментов типа fork/allSettled. Как сказано в статье - мы тогда на волне хайпа тяготели к тому, чтобы выбрать Effector, поэтому многие критерии оценивали с этой позиции - если нет чего-то, что есть в Effector, то это минус.


  1. DmitryKazakov8
    02.09.2024 14:59
    +2

    Спасибо за статью. Под "в этот раз опирались на более объективные критерии, и выбор был очевидным", как понимаю, имеется в виду MobX? Если так - могу посоветовать пару удобных подходов mobx-stateful-fn и mobx-use-store - они дают очень удобные механизмы. Там же рядом и неплохой роутер, вдруг пригодится)


    1. andres_kovalev Автор
      02.09.2024 14:59

      Спасибо за ссылки. У нас поверх MobX накручена своя архитектура, поэтому многие вопросы уже решены или должны решаться на ее уровне. Но вот асинхронные компьютеды мы как раз обсуждаем, поэтому смотрим на разные варианты решения, в том числе mobx-stateful-fn.


  1. keireira
    02.09.2024 14:59

    Ребят, это всё хорошо, но вы типа одна из топ корпораций на просторах пост-СССР, у вас огромные ресурсы, есть люди, деньги. Почему вам не сделать мры с предлагаемыми улучшениями или не взять проект под патронаж (раз год с ним возитесь) и выделить хотя бы технического писателя для доки, а не сваливать всё на пару человек мейнтейнеров, которые и так х без соли доедают? Лайк, там литералли стори с донатами на еду есть. Тем более это свои и типа сплочение, я там не знаю, солидарность проявить можно же?

    Просто сравнивая даже с мобиксом каким — там авторам по 30к только с донатов прилетает в месяц. Думаю у них из-за этого и мотивация повыше будет ту же проверку на ацикличность делать


    1. andres_kovalev Автор
      02.09.2024 14:59
      +1

      Тем более это свои и типа сплочение, я там не знаю, солидарность проявить можно же?

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

      Про контрибьют - как описано в статье, в основном нам не хватало функционала, который зачастую в принципе противоречил концепции эффектора (динамическое создание и уничтожение сторов). Поэтому такие костыли остались в нашей кодовой базе.

      (дальше может быть не точно)

      По поводу финансирования - как у любой топ-корпорации все финансовые вопросы четко планируются. На сколько мне известно, на определенном этапе планирования бюджета на финансирование этот вопрос поднимался, но так вышло что это примерно совпало с нашей "стадией принятия".


  1. Telichkin
    02.09.2024 14:59

    Огонь! Ждал подобной статьи с самого первого дня, как познакомился с Эффектором.

    Теперь жду чего-то подобного про FSD. Как раз недавно была статья от ВК о начале использования FSD в каком-то проекте :)