Здравствуйте, меня зовут Дмитрий Карловский и я… прилетел к вам на турбо-реактивном самолёте. Основная суть реактивного двигателя изображена на картинке.
Тут, казалось бы, хаотичное взаимодействие между молекулами, приводит к тому, что улетающие молекулы опосредованно передают импульс корпусу двигателя. Что ж, давайте подумаем, как реактивные принципы решают или наоборот усугубляют проблемы в программировании. Сравним различные подходы к реактивному программированию. И вытащим на поверхность все их подводные камни.
Это — текстовая расшифровка выступления на SECON.Weekend Frontend'21. Вы можете посмотреть видео запись, прочитать как статью, либо открыть в интерфейсе проведения презентаций.
Человек-реактив
Сперва вкратце о себе:
- ???? 15 лет во фронтенде
- ???? 6 лет с реактивами
- ???? Пилил на Angular, RXJS и MobX
- ✨ Свои реактивные либы с уникальными фичами
- ???? Целый фреймворк на их основе ($mol)
Реактивность я крутил вдоль и поперёк, словил на этой почве кучу инсайтов, которыми с вами далее и поделюсь.
Огнеопасно!
Я постараюсь быть максимально объективен, но… возможны побочные эффекты.
- ???? Жжение в нижних отделах спины
- ???? Зуд на кончиках пальцев
- ???? Повышение громкости речевого аппарата
- ???? Усиленная напряжённость в области извилин
Надеюсь вы хорошо подкрепились, ибо доклад будет долгим, насыщенным и во многом противоречащим привычной картине мира.
Виды активностей
Начнём издалека. Какие бывают виды активностей в нашем коде?
- ????Интерактивность
- ????Реактивность
????Интерактивность
Система выполнила только то, что просили… И ждёт дальнейших команд.
Все остальные части системы, если на них посмотреть, теперь находятся в неактуальном состоянии. Так что требуется явно пойти и попросить их тоже обновиться.
????Реактивность
Система выполнила то, что просили… Плюс сама обновила всё приложение, так как знает как разные состояния зависят друг от друга.
Теперь, если посмотреть на любое состояние, оно будет соответствовать внесённым изменениям. Хотя мы явно этого не просили.
Что нужно для реактивности?
Реактивность позволяет значительно снизить сложность реализации надёжных программ. Поэтому давайте разберём, что нам потребуется для её реализации:
- ????Состояния
- ????Акции
- ????Реакции
- ????Инварианты
- ????Каскад
- ????♂️Рантайм
????Состояния
Прежде всего нам нужны состояния (states) — контейнеры, хранящие некоторые значения.
????Акции
Сами по себе состояния бесполезны, пока мы не можем с ними взаимодействовать. Поэтому нам нужны действия (actions), чтобы их изменять.
????Реакции
Но изменение состояний, без возможности их увидеть, тоже не имеет смысл. Поэтому нам нужны реакции (reactions) — некоторые процедуры, которые запускаются при изменении состояния и производят побочные эффекты.
????Инварианты
Если побочным эффектом реакции является обновление другого состояния, то мы получаем инвариант (invariant) — соотношение, между состояниями, которое сохраняется неизменным при любых изменениях этих состояний.
Инвариант может быть выражен явно, как, например, формула в электронной таблице. Так и собираться в коде из других абстракций. Например, как комбинация из обработчика события, стрима трансформаций и побочного эффекта. Или, например, шаблон, формирующий DOM из параметров компонента.
????Каскад
Прелесть инвариантов в том, что мы можем провязать ими все состояния приложения в единый граф.
Таким образом изменение одного состояния каскадно (cascaded) отразится на всём приложении автоматически. То есть мы получили ту самую реактивность.
????♂️Рантайм
И чтобы реактивность, наконец, заработала, нам нужен некоторый рантайм (runtime), который будет отслеживать изменения одних состояний и обновлять значения других в соответствии с заданными нами инвариантами.
Если вы не понимаете как он работает, то для вас реактивность будет выглядеть как магия. Но стоит только разобраться, и это становится ещё одной технологией в вашем арсенале.
Общие пожелания к реактивности
Давайте сформулируем, какие качества мы хотим получить от реактивности, а какие, наоборот, избежать:
- ????♂️ Отсутствие ненужных вычислений
- ???? Стабильность поведения
- ???? Минимальное потребление памяти
- ???? Согласованность состояний
????♂️ Отсутствие ненужных вычислений
Лишние вычисления сами по себе постепенно замедляют приложение. Но это пол беды. Каждое лишнее вычисление приводит к другим лишним вычислениям. В результате чего лишние вычисления растут как снежный ком.
Поэтому, чем раньше мы их остановим, тем меньше ресурсов суммарно потратим. А значит получим более отзывчивое приложение, меньше жрущее батарейку.
???? Стабильность поведения
После изменения состояния, результат должен быть такой же, как старт с нуля в этом же состоянии. Иначе реальное поведение у пользователя может отличаться от того, на котором отлаживает разработчик.
Звучит, вроде бы, самоочевидно, но вы ужаснётесь, когда узнаете, что стабильность поведения почти нигде не гарантируется. В результате возможна ситуация, когда программист взял тот же самый код, открыл те же самые окошки, ввёл те же самые значения… но у пользователя баг есть, а у программиста он не воспроизводится. И тут начинается весёлая отладка.
???? Минимальное потребление памяти
Важно понимать, что вся эта реактивность весьма не бесплатна. Помимо собственно значений, приходится хранить разную мета-информацию, объём которой может быть в несколько раз больше.
Возьмём, например, V8 и посмотрим сколько требуют памяти разные типы данных в самом оптимистичном случае, когда JIT всё максимально оптимизировал.
Value | Place | Cost |
---|---|---|
Obj | Heap | 12 |
Array | Heap | 24 |
Unit | Inplace | 4 |
Int | Inplace | 4 |
Float | Heap | 12 |
BigInt | Heap | 16+ |
String | Heap | 12+ |
Ref | Inplace | 4 |
Closure | Heap | 24 |
Context | Heap | 16 |
То, что лежит в Heap кушает дополнительные 4 байта на ссылку (Ref). Unit — это всякие undefined, null, false, true и прочие малые невариативные примитивные значения. Int после миллиарда хранится уже как Float, мантисса которого — 48 бит. Обратите внимание, что это уже ссылочный тип, как и BigInt, а значит кушает дополнительно 4 байта на ссылку. Контекст для замыкания хранится, только если функция замкнута на какие-либо переменные. Размер контекста, соответственно, зависит от числа этих переменных. Как видно (Inplace), каждая переменная добавляет к контексту по 4 байта.
Несложно заметить, что объекты относительно дёшевы. Массивы уже подороже, ибо это фактически составные объекты. А вот замыкания — это очень дорогие штуки сами по себе, даже без учёта хранимых в них данных.
Приведу несколько примеров расчёта потребления памяти:
function make_ints_state( ... state: number[] ) {
return { get: ()=> state }
}
const state1 = make_ints_state( 777 )
// Ref + Obj + Ref + Closure + Ref + Context + Ref + Array + Int
// 4 + 12 + 4 + 24 + 4 + 16 + 4 + 24 + 4 = 96
const state2 = { state: 777 }
// Ref + Obj + Int
// 4 + 12 + 4 = 20
const state3 = 777
// Int
// 4
Резюмируя: в зависимости от выбранных абстракций, потребление памяти может отличаться на порядок. И если разница между 1 и 10 мегабайтами не особо заметна. То разница между 100 мегабайтами и гигабайтом заметна будет однозначно. В лучшем случае всё будет тормозить. А в худшем приложение просто закрешится.
Пример из жизни: открываем в Google Docs спецификацию XPath на 200 страниц и получаем пол гигабайта потребления памяти.
Или другой пример: я сейчас работаю над новой реализацией реактивности. И пока летел вчера в самолёте, медитировал над этой табличкой. В результате, я сообразил, как уменьшить потребление памяти в 2 раза, превратив объект с двумя массивами в… просто один массив. Разумеется, мне для этого потребовалось так не модное сейчас наследование.
???? Согласованность состояний
Ну и, конечно же, все состояния приложения должны быть согласованы между собой в любой момент времени.
Если пользователь (или другая программная система), пусть даже на мгновение, увидит рассогласование, то в лучшем случае он будет обескуражен. В худшем — и вы, и он потеряете деньги, репутацию и прочие плюшки.
Аспекты реактивности
Теперь разберём различные аспекты реализации реактивности, на которые стоит обратить внимание при выборе архитектуры, библиотек и фреймворков:
- Style: Стилистика кода
- Watch: Наблюдение за изменениями
- Dupes: Эквивалентные изменения
- Origin: Инициатор пересчёта
- Tonus: Энергичность реакций
- Order: Порядок реакций
- Flow: Конфигурация потоков данных
- Error: Нештатные ситуации
- Cycle: Циклические зависимости
- Depth: Ограничение глубины
- Atomic: Атомарность изменений
- Extern: Внешние взаимодействия
Style: Стилистика кода
Условно можно выделить 3 стиля написания кода:
- ????Proc: Процедурный
- ????Func: Функциональный
- ????Obj: Объектный
Разные библиотеки могу смешивать их в разных пропорциях, но как правило есть чёткое тяготение к одному из них.
????Proc: Процедурный стиль
Тут эпизодически запускается процедура обновления, которая читает одни состояния, вычисляет другие и записывает их. Напишем простейшую, хоть и не очень эффективную, реализацию:
let Name = 'Jin'
let Count
let Short
setInterval( ()=> {
Count = Name.length
} )
setInterval( ()=> {
Short = Count < 4
} )
Примерно так описываются инварианты, например, в Meteor и Angular по дефолту. Разумеется они запускают пересчёт не на каждую миллисекунду, а более оптимально, но общую суть это слабо меняет: рантайм периодически перезапускает инварианты, не зная какие состояния могут быть ими изменены. А ведь актуальные значения этих состояний могут нам быть не интересны, но вычисляются они в любом случае. Поэтому такой подход получается всё равно не очень эффективным.
????Func: Функциональный стиль
На волне хайпа многие упарываются по чистым функциям, превращая свой код в головоломку:
const Name = new BehaviorSubject( 'Jin' )
const Count = Name.pipe(
map( Name => Name.length ),
distinctUntilChanged(),
debounceTime(0),
share(),
)
const Short = Count.pipe(
map( Count => Count < 4 )
distinctUntilChanged(),
debounceTime(0),
share(),
)
Что и зачем делает этот код на RxJS не сможет сходу сказать даже опытный стример. А это ведь самый простой пример, далёкий от реальной жести.
Однако, умные программисты очень любят головоломки. Поэтому они тратят кучу времени на изучение хитрых абстракций, одинаково далёких как от того, как работает машина, так и от того, как работает мозг человека. Они пишут лаконичный, но замысловатый код. И гордятся тем, что они понимают то, что мало кто ещё способен понять. На проекте же это сказывается скорее негативно, привнося излишнюю сложность туда, где и без того полно не простых вещей.
Раньше я тоже писал хитрый код, но жизнь меня научила, что лучше писать максимально простой код, доступный даже новичку в программировании, а не только победителям олимпиад по информатике.
Кроме того, обилие замыканий, свойственных функциональному коду, приводит к повышенному потреблению памяти.
????Obj: Объектный стиль
Тут программа состоит из множества объектов, обладающих состояниями, связанных инвариантами в единый граф. Код в этом стиле выглядит так же, как и обычный ООП код, но с добавлением реактивных мемоизаторов:
class State {
@mem Name( next = 'Jin' ) {
return next
}
@mem Count() {
return this.Name().length
}
@mem Short() {
return this.Count() < 4
}
}
Многие, наверняка, слышали утверждение, что "инвалидация кешей — один из сложнейших вопросов в программировании". Так вот, в реактивном рантайме, такой вопрос вообще не стоит.
Этот подход мне видится наиболее оптимальным, так как он хорошо укладывается в то, как мыслит человек (а ему привычно взаимодействовать с объектами), и в то, как работает компьютер (объект — это просто мутабельная структура в памяти). Рантайм чётко понимает какой метод какое состояние вычисляет. А объектная декомпозиция позволяет легко это всё масштабировать. Именно поэтому объектный стиль и используется в $mol, MobX и Vue.
Watch: Наблюдение за изменениями
Как рантайм может узнать об изменениях?
- ????Polling: Периодическая сверка
- ????Events: Возникновение события
- ????Links: Список подписчиков
????Polling: Периодическая сверка
Состояния хранят лишь значения и всё. Рантайм периодически сверяет текущее значение с предыдущим. И если они отличаются — запускает реакции.
// sometimes
if( state !== state_prev ) reactions()
Так, например, работает Angular, Svelte, React. Беда этого подхода в том, что на каждый чих выполняется большой объём работы только лишь для того, чтобы выяснить, что почти ничего не поменялось.
Вам может показаться, что обычное сравнение — это плёвая операция. И это действительно так в синтетических бенчмарках. Но в реальности состояния разбросаны по памяти, что даёт посредственное использование процессорных кешей. А вишенка на торте — такие сверки приходится выполнять после каждой реакции, чтобы выяснить, что именно оные поменяли в состоянии.
????Events: Возбуждение события
Каждое состояние хранит дополнительно список функций обработчиков изменения. При каждом изменении состояния вызываются все подписчики.
// on change
for( const reaction of this.reactions ) {
reaction()
}
Это может быть инициировано вручную, через сеттер или прокси. Но в любом случае состояние ничего больше не знает про соседние состояния, а взаимодействие всегда одностороннее. Это сильно ограничивает возможные алгоритмы оптимизации. А также усложняет отладку, ведь чтобы узнать кто там от кого как зависит — это целый квест.
А самое печальное: хранение массива из замыканий кушает много памяти. И с этим ничего не сделать.
????Links: Список подписчиков
Состояния хранят прямые ссылки друг на друга, образуя глобальный граф. Массивы ссылок — это относительно экономно по памяти, ведь каждая ссылка — это всего 4-8 байта. Для коммуникации с соседями достаточно просто пробежаться по массиву и дёрнуть нужный метод у соседнего стейта.
// on change
for( const slave of this.slaves ) {
slave.obsolete()
}
// on complete
for( const master of this.masters ) {
master.finalize()
}
В первом примере вы видите, что при изменении одного состояния мы говорим всем зависимым, что они устарели. А во втором, что при завершении вычисления одного состояния, мы говорим всем зависимостям, что вычисление закончено, и можно освободить кеши, которые они могли держать на случай повторного обращения. Таких вариантов взаимодействия может быть много, что даёт максимум гибкости в поддерживаемых алгоритмах.
Кроме того, при отладке, гораздо проще ходить по прямым ссылкам между объектами, чем выцеплять нужную информацию из захваченных замыканиями контекстов.
Dupes: Эквивалентные изменения
Порой значение меняется на эквивалентное. И тут есть разные подходы к отсечению вырожденных вычислений..
- ????♀️Every: Реакция на каждое действие
- ????Identity: Сравнение по ссылке
- ????Equality: Структурное сравнение
????♀️Every: Реакция на каждое действие
В библиотеках типа RxJS каждое значение является уникальным событием, что приводит к ненужному запуску реакций.
777 != 777
Чтобы этого не происходило, нужно писать дополнительный код, который часто забывают, и потом огребают.
????Identity: Сравнение по ссылке
Многие библиотеки всё же умеют сравнивать значения. И если состояние не поменялось, то реакции не срабатывают. А если поменялось, даже на эквивалентное значение, то срабатывают.
777 == 777
[ 1, 2, 3 ] != [ 1, 2, 3 ]
Если мы нафильтровали новый массив, с тем же содержимым, то скорее всего нам не нужно запускать каскад вычислений. Но вручную уследить за всеми такими местами — мало реалистично.
????Equality: Структурное сравнение
Наиболее продвинутые библиотеки, типа $mol_atom2, делают глубокое сравнение нового и старого значения.
777 == 777
[ 1, 2, 3 ] == [ 1, 2, 3 ]
[ 1, 2, 3 ] != [ 3, 2, 1 ]
Это позволяет отсекать лишние вычисления как можно раньше — в момент внесения изменений. А не в момент рендеринга заново сгенерированного VDOM в реальный DOM, как это часто происходит в React, чтобы узнать, что в доме-то менять и нечего.
Глубокое сравнение — это, безусловно, сама по себе более дорогая операция, чем просто сравнение двух ссылок. Однако, рано или поздно, сравнить всё содержимое всё равно придётся. Но гораздо быстрее это сделать пока данные рядом, а не когда они разлетятся по тысяче компонент в процессе рендеринга.
Origin: Инициатор пересчёта
Не смотря на то, что начинается всё с того, что кто-то что-то поменял, финальное решение пересчитывать ли тот или иной инвариант может принимать как зависимость, так и зависимое состояние.
- ????Push: Зависимость проталкивает
- ????Pull: Зависимый затягивает
????Push: Зависимость проталкивает
При изменении зависимости безусловно срабатывают реакции, которые вычисляют и пишут в зависимые состояния новые значения. Так, например, работает RxJS, Effector и другие процедурные/функциональные библиотеки/фреймворки.
И это отлично работает для статичного графа инвариантов. Однако, в любом не совсем тривиальном приложении у нас есть динамика. Ну, банально: если мы переключаемся между страницами, то надо освободить ресурсы предыдущей страницы (и в частности отписаться от изменения данных) и захватить ресурсы для новой страницы (и в частности подписаться на изменения данных).
То есть наш граф инвариантов должен уметь меняться в процессе пересчёта этих инвариантов. А это значит, что действуя по принципу проталкивания мы будем часто попадать в ситуации вида: долго-долго вычисляли какое-то значение, а оно в итоге никому не понадобилось, ибо потребитель был уничтожен.
????Pull: Зависимый затягивает
При обращении к зависимому состоянию, происходит вычисление инварианта, который вытягивает значения из зависимостей и возвращает актуальное значение. Так работают $mol_atom2, CellX, MobX и Vue.
Тут уже чисто логически нам всегда известно, что если вычисление произошло, то его результат кому-то нужен. А если не нужен, то и вычисления не произойдёт. Поэтому подход с затягиванием видится мне более практичным.
Tonus: Энергичность вычислений
Вычислять зависимые состояния можно как можно раньше, а можно как можно позже, вплоть до отказа от вычислений, если это возможно.
- ????Instant: Мгновенные
- ⏰Defer: Отложенные
- ????Lazy: Ленивые
????Instant: Мгновенные реакции
В таких библиотеках, как RxJS, пересчёт зависимых состояний происходит сразу же при изменении зависимости. Если нам нужно изменить несколько состояний подряд, то это может привести к лишним вычислениям.
Более того, эти лишние промежуточные вычисления производят неконсистентное состояние, вычисляемое частично из уже обновлённых состояний, а частично из ещё не обновлённых. А неконсистентное состояние, пусть даже и временно, — это очень опасная штука. В лучшем случае пользователь будет наблюдать глитчи — визуальное мерцание. В худшем — приложение будет работать не корректно и сыпать разнообразными ошибками.
⏰Defer: Отложенные реакции
Чтобы избежать глитчей пересчёт может откладываться на потом, чтобы выполнять его лишь один раз, сколько бы зависимостей ни было обновлено.
Однако, пересчёт будет произведён в любом случае, даже если результат нам не пригодится.
????Lazy: Ленивые реакции
В моделях реактивности с затягиванием возможно ленивое вычисление инвариантов — только в момент, когда зависимое состояние действительно потребовалось.
При изменении исходных состояний, мы не вычисляем зависимые и даже не планируем их вычисление, а лишь помечаем их как устаревшие. И если впоследствии к ним обратиться, то они начнут вычисляться.
Это одновременно и самый экономный подход и самый консистентный, так как гарантирует, что, когда бы мы ни обратились к состоянию, полученное значение будет актуальным.
Order: Порядок реакций
С порядком исполнения реакций есть свои особенности, которые зачастую отдаются на откуп рандому. Однако, давайте разберём все возможные варианты:
- ????Subscribe: По времени подписки
- ????Event: По времени возникновения события
- ????Deep: По глубине зависимости
- ????????Code: По положению в программе
????Subscribe: Реагирование по времени подписки
Какая реакция появилась раньше, та и срабатывает раньше. В любом нетривиальном приложении, список реакций меняется со временем, а значит выстроиться они могут практически в любом порядке.
Получается скрытое состояние, влияющее на работу приложения через разный порядок побочных эффектов, которые могут давать различные взаимные наводки. Получаем нестабильность поведения, что осложняет отладку и тестирование.
Это — типичная беда большинства библиотек.
????Event: Реагирование по времени возникновения события
Предположим нам удалось тем или иным способом зафиксировать порядок подписок. Однако, есть и другой источник нестабильности — порядок совершения действий.
Явно или неявно изменяя состояния в разном порядке мы опять же можем получить разный порядок срабатывания реакций. К сожалению, большинство библиотек подвержено и этой проблеме.
????Deep: По глубине зависимости
Некоторые библиотеки используют так называемую топологическую сортировку графа, чтобы пересчитывать инварианты в оптимальном порядке от менее зависимых к более зависимым.
В данном примере Post
меняется на такой, к которому у нас нет доступа. Сначала будет обновлено содержимое этой страницы, что мало того, что приведёт к лишним пересчётам, так они ещё и могут закончиться ошибками или просто мусором в качестве побочных эффектов. И только потом, при при вычислении Page
будет выяснено, что PostPage
надо вообще уничтожить, а вместо неё следует отобразить сообщение об ошибке доступа Forbidden
.
Обратите внимание, что существование Title
и Body
зависит от значения Page
. Но сами значения Title
и Body
от значения Page
уже не зависят. И наоборот, значение Page
не зависит от значения Title
и Body
. То есть связь между ними нереактивная. Но она есть. И это уже связь "владелец — имущество". То есть значение Page
владеет реактивными состояниями Title
и Body
, а значит и контролирует их время жизни.
Одним лишь анализом реактивного графа эту проблему не решить. Разве что можно дополнить его графом владения. Но это потребует ещё большего усложнения логики рантайма. И я не уверен, что топологическую сортировку такого двойного графа можно осуществить с приемлемой алгоритмической сложностью. Иначе вся эта наша борьба за эффективность будет работать медленнее, чем куда более тупая, но простая архитектура.
????????Code: Реагирование по положению в программе
Предпочтительнее, чтобы реакции отрабатывали в том порядке, который в явном виде задан в коде. Это гарантирует, что владелец будет актуализирован раньше, чем всё, чем он владеет.
Тут уже сначала будет обновлён Allow
, потом Page
, что приведёт к потере PostPage
и, как следствие, уничтожению PostPage
со всеми состояниями внутри, без их вычисления.
Flow: Конфигурация потоков данных
В реактивной системе все состояния связаны друг с другом инвариантами в единый граф. Когда мы что-то меняем с одной стороны этого графа, рантайм обеспечивает каскадный пересчёт зависимых состояний. Такие последовательности пересчётов являются ни чем иным, как информационными потоками (data flow). Чем более эти потоки прямолинейны, чем меньше они разветвляются и задевают нерелевантные изменениям состояния, тем эффективней работает система. И тут есть два подхода к оптимизации информационных потоков.
- ????Manual: Ручная
- ????Auto: Автоматическая
????Manual: Ручная конфигурация информационных потоков
В библиотеках на основе проталкивания, автоматизировать потоки сложно, поэтому тут процветает ручное управление ими. А значит мы получаем ошибки двух типов.
Во-первых, мы можем забыть на что-то подписаться, в результате чего получаем неконсистентность. В примере мы забыли подписаться на Title
, и при его изменении, Greeting
не пересчитывается.
Во-вторых, мы можем забыть от чего-то отписаться, в результате чего получаем лишние вычисления. В примере мы забыли отписаться от Name
, и при его изменении, Greeting
вычисляется заново, но получает то же самое значение.
Но, если с ошибками ещё можно как-то совладать, то со сложностью ручных оптимизаций справиться уже не просто. Для банального логического ветвления нужно руками реализовать фактически транзистор, где у нас есть управляющий поток, который переключает выход между двумя входами. Для циклов и непрямой адресации же всё становится настолько сложно, что мало кто вообще способен адекватно это описать. В итоге всё сводится к тому, что, вместо точечных пересчётов, идёт пересчёт многих состояний на любой чих, что довольно медленно.
????Auto: Автоматическая конфигурация информационных потоков
В библиотеках, основанных на затягивании, обычно применяется автотрекинг зависимостей. Это мало того, что гораздо надёжней, так ещё и крайне просто для прикладного программиста. Ему не надо думать о потоках данных вообще — они динамически конфигурируются рантаймом наиболее оптимальным (для данного состояния приложения) образом.
Тут прикладные программисты делятся на два лагеря: одни боятся этой "магии" ибо не понимают как она работает, другие же просто не парятся — работает и работает, одной головной болью меньше.
Ну и в сторонке стоит лагерь тех, кто просто знает как оно работает и использует это знание с пользой. Ведь как известно: любая достаточно развитая технология неотличима от магии… для непосвящённого человека.
Практика показывает, что (при автоматизации) прикладного кода получается на порядки меньше, сам он гораздо проще и надёжнее, а приложение работает быстрее.
Error: Нештатные ситуации
Очень часто программисты не думают про нештатные ситуации. Особенно печально, когда это не прикладники, а авторы библиотек и фреймворков.
Например, когда я готовил этот материал, я спросил в чате Эффектора, как ведёт себя система при возникновении исключений. На что мне ответили, что исключений в чистых функциях быть не должно (исключения, кстати, на самом деле чистоте не противоречат, но это уже другая история) и если ты их допустил, то сам дурак. Когда же я уточнил, знает ли автор вообще, как поведёт себя его библиотека в нештатной ситуации, меня обвинили в токсичности и забанили.
Ну да мы отвлеклись. Багов тоже быть не должно, как и прочих плохих вещей в жизни, однако, они порой случаются. По вине программиста, браузера, расширений к нему, операционной системы, звёздного ветра — не важно. Надо уметь держать удар, а не прятать голову в песок. Но в разных библиотеках какого только поведения мы ни встретим..
- ????Unstable: Нестабильная работа
- ⛔Stop: Прекращение работы
- ????Store: Индикация ошибки и ожидание восстановления
- ⏮Revert: Откат к стабильному состоянию
????Unstable: Нестабильная работа при ошибке
Часто, в случае исключения, приложение переходит в неконсистентное состояние, что приводит к нестабильной работе.
В примере, допустим, в имени закрался некорректный codepoint
. И, допустим, попытка взять длину строки приводит в этом случае к исключению. Пример довольно синтетический, позже я покажу более реалистичные, но пока так.
И вот, при вычислении инварианта произошло исключение, из-за чего рантайм не обновил Count
. В результате, все состояния распались на 2 подграфа, которые сами по себе-то консистентны, но между собой уже не согласованы.
⛔Stop: Прекращение работы при ошибке
Не менее странное решение — просто перестать функционировать, как это делает, например, RxJS. Если где-либо в стриме возникает исключение, то все стримы после него финализируются и уже никогда не заработают.
Эта стратегия годится для одиночной задачи — её либо сделал, либо упал с ошибкой. Но реактивность — оно для долгоживущих систем, постоянно что-то отображающих. А значит, если отвалится реактивность, то приложение просто сломается, никак не сигнализируя об этом пользователю.
Причём сломается лишь на половину. И чтобы восстановить работу потребуется перезапуск либо всего приложения, либо как минимум этой половины.
Случай из жизни: Со мной на этаже в гостинице заселилась толпа спортсменов. А это такие парни под 100 кг чистого мяса. И вот, забились мы с ними сегодня с утра в лифт, что ожидаемо привело к перегрузу. Лифт поднял лапки и сказал "всё".
Ну, ладно, парочка вышла — ничего не происходит. Ок, вышла ещё половина — тоже ничего. Таак, вышли все — лифт так и не заработал. И пришлось нам всем устроить сегодня пробежку по лестнице. Думаю софт для этого лифта написали на RxJS, не иначе.
⏮Revert: Откат к стабильному состоянию при ошибке
Библиотеки типа reatom в принципе не допускают неконсистентности, выполняя пересчёт инвариантов в рамках транзакции. Так что в случае чего, все состояния откатываются к последнему согласованному.
Формально звучит не плохо. Но для пользователя это ужасное поведение, ведь из-за одной паршивой овцы где-то в углу приложения, которая постоянно кидает исключения, всё наше приложение встаёт колом и никак не реагирует на действия пользователя. Или попросту — намертво виснет. Что никуда не годится.
????Store: Индикация ошибки и ожидание восстановления
Гораздо практичнее рассматривать ошибку как возможный результат вычисления, наравне с возвращаемым значением.
Тут все состояния, которые зависят от не корректного, тоже помечаются как не корректные. А система рендеринга может автоматически показывать индикатор сбоя для частей приложения, которые не удалось обновить. Ну, либо вы можете перехватить исключение и нарисовать своё красивое сообщение. В любом случае пользователь будет понимать, что происходит, и что на сбойную часть приложения не стоит полагаться. А вот другими частями вполне можно продолжать пользоваться.
Не смотря на то, что часть приложения сломана, состояние приложение всё ещё согласованно. Ибо сообщение об ошибке на выходе как раз таки согласуется с некорректными значением на входе.
При этом, устранение причины сбоя, автоматически восстановит корректную работу этой части приложения. Без дополнительных телодвижений со стороны программиста!
Cycle: Циклические зависимости
Иногда у нас могут получаться циклические зависимости. Порой мы их можем захотеть сделать намеренно. Например, при реализации конвертера между градусами Цельсия и Фаренгейта, где пользователь может менять любое из двух значений, а второе должно пересчитываться автоматически.
Однако, в подавляющем большинстве случаев циклически зависимости свидетельствуют о проблеме с логикой, так что их обычно стараются избегать. Благо логику даже конвертера градусов всегда можно переписать так, чтобы циклических зависимостей не было.
Итак, давайте рассмотрим, как разные системы реагируют на эту нештатную ситуацию.
- ????Unreal: Невозможны
- ????Infinite: Бесконечный цикл
- ????Limbo: Произвольный результат
- ????Fail: Приводят к ошибке
????Unreal: Циклы невозможны
Довольно соблазнительна мысль сделать так, чтобы синтаксически невозможно было создавать циклы. Например, мы можем требовать при создании состояния, чтобы все его зависимости уже существовали. Как правило, это свойственно библиотекам с проталкиванием.
Звучит, вроде бы, не плохо. Однако вместе с водой мы выплеснули и ребёнка. То есть крайне ограничили себя в том, какую логику инвариантов мы способны описать. В частности, это практически ставит крест на динамической конфигурации потоков данных. Например, электронную таблицу на такой архитектуре реализовать уже не получится.
????Infinite: Бесконечный цикл
Ряд библиотек просто уходят в бесконечный цикл, постоянно обновляя одни и те же состояния.
Для Angular и React, например, это типичное поведение. Там даже костыль есть — ограничение на число пересчётов одного инварианта. Но об этом мы ещё поговорим.
????Limbo: Произвольный результат цикла
Бывает и совсем странное решение — при косвенном обращении к тому стейту, который сейчас вычисляется, используется его предыдущее значение.
В зависимости от порядка вычислений, этот подход даёт разные результаты. То есть состояние мало того, что получается несогласованным, так ещё и поведение приложения становится не стабильным, а начинает зависеть от погоды на Марсе.
????Fail: Цикл приводит к исключению
Наилучшее решение — детектирование цикла в рантайме и выбрасывание исключения.
Далее обработка уже идёт так же, как и с любыми другими нештатными ситуациями. Так что тут особенно важно, чтобы система правильно работала с исключениями.
Depth: Ограничение глубины
Как правило, глубина зависимостей остаётся сравнительно не большой, не превышающей пары десятков состояний.
Но порой зависимости могут вырастать на неприличную глубину. Это особенно характерно для приложений, где сам пользователь может управлять тем, кто от кого и как зависит. Типичные примеры: электронная таблица или диаграмма Ганта.
И далеко не все модели реактивности вообще позволят вам это реализовать. А узнаёшь об этом порой лишь, когда уже поздно менять лошадей. И начинается костылеварение. Так что присмотримся к этому аспекту повнимательнее.
- ????Limit: Ограничена константой
- ????Stack: Ограничена стеком
- ????Heap: Не ограничена
????Limit: Глубина ограничена константой
Некоторые библиотеки борются с циклическими зависимостями путём введения ограничения на число пересчётов за раз. Обычно это десяток-другой пересчётов.
for( let i = 0; i < MAX_REPEATS; ++i ) {
if( !dirty ) return
changeDetection()
}
throw new Error( 'Too many change detection repeats' )
Это предотвращает полное зависание приложения. Но и капитально ограничивает глубину зависимостей. Электронную таблицу в таких условиях реализовывать будет больно.
????Stack: Глубина ограничена стеком
Чуть лучше обстоит ситуация с моделями реактивности, где нет никаких искусственных ограничений. Однако, они инициируют одни вычисления внутри других, что приводит к росту стека.
first() {
this.second()
}
second() {
this.third()
}
thisrd() {
this.etc()
}
А так как размер стека не бесконечен, то его хватает лишь для глубины в несколько тысяч состояний. Этого уже может хватить даже для средних электронных таблиц. Однако, стоит выйти за пределы стека, и всё, приехали, вылетает исключение.
Причём оно может вылететь, а может не вылететь в зависимости от того в каком порядке пошли пересчёты. То есть мы получаем ещё и нестабильность поведения. Например, это может проявляться так: при открытии приложения всё хорошо, но стоит изменить одно состояние, пересчёт глубоко зависимого от него падает.
Однако, преимущество такого подхода в том, что по стеку видно в каком порядке производился пересчёт, что может быть полезно при отладке.
????Heap: Не ограниченная глубина
Наилучший же вариант не наращивает стек, что позволяет ему работать с зависимостями произвольной глубины. Ну, на сколько хватит оперативки, конечно же.
while( reactions.length ) {
reactions.shift().execute()
}
К сожалению, тут стек-трейсы становятся уже малоинформативными. Но на помощь при отладке может прийти уже логирование, которое при желание можно даже подклеивать в стек-трейс вручную.
Atomic: Атомарность изменений
Пока что мы говорили про нештатные ситуации при вычислении инвариантов. Однако, они могут возникнуть и на подлёте — во время внесения изменений в несколько исходных состояний одновременно. Давайте разберём, что тут может пойти не так...
- ????Alone: Одного отдельного состояния
- ????Base: Для первичных состояний
- ????♂️Full: Для всех состояний
????Alone: Атомарность изменения лишь одного состояния
Как правило, изменение одного состояния везде атомарно. То есть оно либо произойдёт, либо не произойдёт. Рассмотрим простой пример: нам надо обновить два состояния, но после обновления первого возникла нештатная ситуация.
Name = 'John'
Count = 4
Name = 'Jin'
throw 'function is not a function'
Count = 3 // still 4
В результате мы получаем несогласованное состояние приложения. Ведь одно состояние обновилось, а второе — нет.
Эту проблему можно обойти, если хранить оба значения в одном состоянии. Но это возможно не всегда.
????Base: Атомарность изменения первичных состояний
Хорошо, если рантайм поддерживает транзакции. Они гарантируют, что либо все исходные состояния получат свои обновления, и пойдут обновляться зависимые состояния, либо не изменится никто, и зависимые состояния обновляться тоже не пойдут.
Name = 'John'
Count = 4
@transaction update() {
this.Name = 'Jin' // will still 'John'
throw 'function is not a function'
this.Count = 3
}
????♂️Full: Атомарность изменения всех состояний
В некоторых библиотеках транзакцию могут откатить не только исключения возникшие непосредственно при внесении изменений, но и исключения в инвариантах, которые пошли вычисляться в результате внесённых изменений.
Name = 'John'
Count = 4
@derived get Greeting() {
// Fails on 'Jin' name
return this.Name.split('')[3].toUppercase()
}
@transaction update() {
this.Name = 'Jin' // will still 'John'
this.Count = 3 // will still 4
}
В примере, у нас есть вторичное состояние Greeting
, которое при коротком имени кидает исключение, и не может быть вычислено. Рантайм, видя это, откатывает всю транзакцию. В результате, мы снова получаем ситуацию, когда одна кривая вьюшка где-нибудь в углу приложения не даёт нам обновить модель и всё приложение встаёт колом.
Extern: Внешние взаимодействия
Порой инвариант требует асинхронной коммуникации. Например, при тяжёлых расчётах в отдельном воркере. Большинство реактивных библиотек не поддерживает асинхронные инварианты, но есть и такие, которые поддерживают. Рассмотрим оба варианта..
- ????♂️Sync: Синхронные инварианты
- ????Async: (А)синхронные инварианты
????♂️Sync: Поддерживаются только синхронные инварианты
Если поддерживается лишь синхронная реактивность, а нам нужно выполнить какой-то асинхронный вызов, то он обычно идёт где-то в сторонке. Возьмём простой пример на RxJS..
const image = source_element.pipe( map( capture ) )
const data = image.pipe( map( recognize ) )
const text = data.pipe( map( data => data.text ) )
text.subscribe( text => {
output.innerText = text
} )
Функции capture
и recognize
асинхронные, так как первой надо дождаться загрузки изображения, а вторая запускает нейронки на пуле воркеров. Когда мы поменяем source_element
, то output.innerText
никак не поменяется. То есть состояния перестанут быть согласованными. И к согласованности они придут лишь когда все асинхронные операции завершатся.
Решается эта проблема обычно интерактивной установкой какого-нибудь флага isLoading
вначале и интерактивным сбросом его в конце. И когда этот флаг поднят — реактивно рисуется индикатор ожидания.
Мало того, что это рутина, так она ещё и зачастую подвержена багам, когда на один индикатор завязывается несколько выполняемых задач. Что при интерактивной логике может вызывать так называемое состояние гонки.
????Async: Поддерживаются асинхронные инварианты
Если же поддерживаются и асинхронные инварианты, то рантайм поддерживает согласованность автоматически. Типичное решение — через механизм работы с нештатными ситуациями. Давайте напишем, как может выглядеть код с использованием, например, генераторов:
@computed
text*() {
const image = yield capture( this.source_element )
const data = yield recognize( image )
return data.text
}
Почему не асинхронные функции? Да потому, что они в JS сделаны через задницу. Вот авторам библиотек и приходится костылять на генераторах, которые сделаны через противоположное место, но тоже не через то, что следовало бы.
На самом деле можно обойтись даже и без генераторов. В $mol, Vue и React поддерживается SuspenseAPI, позволяющий писать псевдосинхронный код и не мучаться с yield
и await
. Ну да не важно, генераторы для моего повествования будут нагляднее.
Когда рантайм вызывает генератор text
ему вместо строки йелдится промис. Он понимает, что финальный результат будет позже, подписывается на финализацию промиса, а тем временем помечает состояние как "ожидающее значения". Этот флаг ожидания распространяется на все зависимые состояния. А система рендеринга, видя это, автоматически рисует индикатор ожидания. Классно же!
Оценка практичности
Давайте теперь возьмём все наши знания о реактивности и попробуем сформулировать, как могла бы выглядеть наиболее практичная модель реактивности. Какими свойствами она должна обладать, чтобы пользоваться ею было приятно, чтобы она доставляла нам минимум проблем, чтобы у пользователя всё было стабильно, быстро, и всегда было понятно, что происходит.
Aspect | ✅Usable | ❌Unusable |
---|---|---|
Style | ????Obj | ????Proc ????Func |
Watch | ????Links | ????Polling ????Events |
Dupes | ????Equality | ????Identity ????♀️Every |
Origin | ????Pull | ????Push |
Order | ????????Code | ????Subscribe ????Event ????Deep |
Flow | ????Auto | ????Manual |
Aspect | ✅Usable | ❌Unusable |
---|---|---|
Tonus | ????Lazy | ????Instant ⏰Defer |
Error | ????Store | ⛔Stop ⏮Revert ????Unstable |
Cycle | ????Fail | ????Infinite ????Limbo ????Unreal |
Depth | ????Heap | ????Stack ????Limit |
Atomic | ????Base | ????♂️Full ????Alone |
Extern | ????Async | ????♂️Sync |
Давайте теперь возьмём разные известные библиотеки и фреймворки и посмотрим, насколько они близки к идеалу. Но сперва, небольшая ремарка...
Поведение по умолчанию
Далее рассматривается лишь поведение по умолчанию и рекомендуемый автором стиль кода. Понятное дело, что всегда можно как-то обойти проблемы. Где-то поведение можно поменять параметром конфига. Где-то нужно не забывать писать дополнительный код тут и там. Где-то нужно креативить адские костыли. А где-то вообще придётся отказаться от одной библиотеки, и прикрутить сбоку другую.
Однако, важно понимать, что автор библиотеки, даже если он глубоко не прав, скорее всего имеет большую экспертизу, чем обычный прикладной разработчик. В этой теме вообще, и в своей библиотеке в особенности. Поэтому большинство стороннего кода с её помощью будет написано именно в каноничном стиле, рассчитанном на поведение по умолчанию. А любое отхождение от дефолта потребует дополнительного кода, который надо и не забыть написать, и потратить время, чтобы написать его правильно.
- ???? Выбор эксперта
- ???? Минимум кода
- ???? Повышенное внимание
- ???? Сторонний код
Дальнейшее сравнение, полезно не столько для того, чтобы понимать, какую либу надо срочно брать, а какую немедленно выбрасывать. Но и для того, чтобы понимать, к чему нужно быть готовым, затевая проект на той или иной технологии.
Какие-то аспекты могут быть для вас совершенно не важными. Некоторые могут оказаться оказаться шоу-стоперами. А некоторые можно легко обойти. И хорошо бы заранее подложить себе соломки, чтобы не заниматься потом мучительной отладкой и оптимизацией.
Реактивные библиотеки
Lib | Style | Watch | Dupes | Origin | Tonus | Order | Flow | Error | Cycle | Depth | Atomic | Extern |
---|---|---|---|---|---|---|---|---|---|---|---|---|
CellX | ????✅ | ????✅ | ????❌ | ????✅ | ????✅ | ????????✅ | ????✅ | ????✅ | ????✅ | ????❌ | ????✅ | ????✅ |
$mol_atom2 | ????✅ | ????✅ | ????✅ | ????✅ | ????✅ | ????????✅ | ????✅ | ????✅ | ????✅ | ????❌ | ????❌ | ????✅ |
MobX | ????✅ | ????✅ | ????❌ | ????✅ | ????✅ | ????????✅ | ????✅ | ????✅ | ????✅ | ????❌ | ????❌ | ????♂️❌ |
ChronoGraph | ????❌ | ????✅ | ????❌ | ????✅ | ⏰❌ | ????????✅ | ????✅ | ⏮❌ | ????✅ | ????✅ | ????♂️❌ | ????♂️❌ |
Reatom | ????❌ | ????✅ | ????❌ | ????✅ | ????✅ | ????❌ | ????❌ | ⏮❌ | ????❌ | ????❌ | ????♂️❌ | ????♂️❌ |
Effector | ????❌ | ????✅ | ????❌ | ????❌ | ????❌ | ????❌ | ????❌ | ????❌ | ????❌ | ????❌ | ????❌ | ????♂️❌ |
RxJS | ????❌ | ????✅ | ????♀️❌ | ????❌ | ????❌ | ????❌ | ????❌ | ⛔❌ | ????❌ | ????❌ | ????❌ | ????♂️❌ |
Тут видно два основных лагеря: "Объектное Реактивное Программирование" и "Функциональное Реактивное Программирование". Как видите, модный сейчас функциональный подход не очень практичен, в отличие от более олдскульного подхода с объектами.
Пока я готовил этот материал, побеждал, как обычно, $mol. Но за пару дней CellX вырвался таки вперёд. Ну да не страшно, я всё-равно пока не рекомендую завязываться на $mol_atom2, ибо готовлю новую реализацию основанную на Auto Wire JS Proposal, который позволяет разным реактивным библиотекам взаимодействовать как друг с другом, так и с нативным браузерным API через единые интерфейсы. Так что следите за новостями!
Стоит так же отметить, что сам по себе RxJS не про реактивность. Он, в основе своей, про контроль потока исполнения. Однако, с его помощью можно описывать инварианты, связывающие состояния, и тогда мы получаем реактивную систему.
Большое спасибо авторам библиотек за помощь в подготовке этой таблицы. Пишите мне, если хотите добавить и свою к сравнению. Я постараюсь поддерживать эту табличку в актуальном состоянии, если комьюнити, конечно, поможет мне уследить за всеми новостями.
Реактивные фреймворки
Lib | Style | Watch | Dupes | Origin | Tonus | Order | Flow | Error | Cycle | Depth | Atomic | Extern |
---|---|---|---|---|---|---|---|---|---|---|---|---|
Vue | ????✅ | ????✅ | ????❌ | ????✅ | ????✅ | ????????✅ | ????✅ | ????✅ | ????❌ | ????❌ | ????❌ | ????✅ |
React | ????❌ | ????❌ | ????❌ | ????❌ | ⏰❌ | ????????✅ | ????❌ | ⛔❌ | ????✅ | ????❌ | ????❌ | ????✅ |
Angular | ????❌ | ????❌ | ????❌ | ????❌ | ⏰❌ | ????????✅ | ????✅ | ????❌ | ????❌ | ????❌ | ????❌ | ????♂️❌ |
Svelte | ????❌ | ????❌ | ????❌ | ????❌ | ⏰❌ | ????????✅ | ????✅ | ⛔❌ | ????❌ | ????✅ | ????❌ | ????♂️❌ |
Анализ фреймворков с точки зрения реактивности является несколько условным. Проявляется она обычно в двух аспектах: инварианты между состояниями одного компонента, и связь состояния одного компонента с параметрами другого.
Как видите, тут более популярна процедурщина, которая является тоже не самым практичным подходом. Самым практичным тут оказывается объектный Vue. Круче него только $mol, но отдельно как фреймворк его тут рассматривать нет смысла, ибо он просто использует библиотеку $mol_atom2 в качестве кровеносной системы, а её мы уже разобрали ранее.
Важно отметить, что не стоит слепо доверять этим табличкам, ибо составлены они вручную. Я, конечно, старался всё отразить максимально точно, но мог где-то и накосячить. Поэтому...
Ещё по теме
- state-management-specification / Артём Арутюнян
- A General Theory of Reactivity / Kris Kowal
- Объектное Реактивное Программирование @ FrontendConf'17
- Квантовая механика вычислений на JS @ HolyJS'18
У Артёма (автора Reatom) есть интересный проект по классификации стейт-менеджеров с помощью тестов. Это чуть более широкая тема, так как, например, Redux — это стейт-менеджер, но он не реактивный. Это просто транзакционное изменение дерева состояний и всё, никаких каскадных инвариантов между состояниями. Если вас заинтересовала эта тема, то подключайтесь к написанию тестов — это будет полезно для всего комьюнити.
Обстоятельная статья Криса Ковала рассматривает вопрос реактивности с иных позиций. На мой взгляд он не прав, но для расширения кругозора можно почитать.
На конец: пара моих выступлений, разбирающих преимущества ОРП и механику реализации асинхронных инвариантов, помогут ещё глубже закопаться в тему.
Хотите добавки?
Для тех, кто добрался до конца, но ещё не устал, могу предложить глянуть так же и дискуссию о менеджерах состояний, которая развернулась после выступления, между мной и Сергеем Совой, мейнтейнером Effector-а.
Хотите больше точек зрения?
Если же и этого вам окажется мало, то приглашаю на прошлогоднюю дискуссию о стейт менеджерах в более расширенном составе..
Ещё не по теме
Вот уже 10 лет я активно делюсь знаниями, идеями, пилю оупенсорс. И каждый мой материал — это оригинальная, и порой радикальная, идея, проверенная на практике. Так что стоит ознакомиться со всем этим, даже если не планируете использовать.
- slides.hyoo.ru — мои выступления (10+)
- Core Dump — видео о фундаментальном (4+)
- habhub.hyoo.ru — мои статьи (40+)
-
_jin_nin_
— новости о разработке (>9000)
Внести свою лепту
Второй год я уже не работаю, а занимаюсь компьютерной наукой и оуперсорс проектами. Так что если вы найдёте мою работу полезной, то поблагодарить меня можно задонатив или даже запатронив...
- yasobe.ru/na/mol — разовое спасибо
- patreon.com/hyoo — постоянная поддержка
- hyoo-ru/hyoo.ru/wiki — наши открытые проекты
У нас, в гильдии $hyoo, много интересных проектов, которые вскоре должны перевернуть мир. Так что самый ценный вклад, который вы можете внести — это даже не деньги, а участие в проектах.
Мы хотим сделать экосистему тесно интегрированных веб-сервисов, использующих самые передовые и дешёвые технологии. И потеснить оупенсорсом нынешних интернет-гигантов. Присоединяйтесь и вы к нашей маленькой революции!
А ещё вы можете позвать меня провести семинар в вашей компании. Не только о реактивности, конечно. Мне есть что рассказать по многим вопросам. Причём не поверхностно, а с глубоким анализом.
Пишите письма!
Вот теперь уж точно всё. Спасибо за внимание. Надеюсь сей разбор оказался для вас полезным.
А теперь, форсируем наши реактивные движки и летим в светлое будущее!
Из первых уст
- Было интересно наблюдать за спором сеньоров, из которого наблюдатель может для себя извлечь информационную пользу.
- Информативно и развернуто.
- Интересно и доходчиво.
- Сам интересуюсь этой темой много лет как хобби, а тут человек основательно этим занимался и подробно осветил тему.
Комментарии (55)
Alexufo
04.11.2021 14:52+4Акции... Хотяб экшены) или действия, ну никак не акции)
Раньше я тоже писал хитрый код, но жизнь меня научила, что лучше писать максимально простой код, доступный даже новичку в программировании, а не только победителям олимпиад по информатике.
Дима, это же камминг аут!)хм. Код оказывается тоже имеет свойство эмпатии.
nin-jin Автор
04.11.2021 15:04-1Если reaction - реакция, то action - акция. Логично же.
Alexufo
04.11.2021 15:36+7да, но в рамках своих ассоциативных связей.
Можно так же по логике перевести как реактьён и актьён )) А то и вообще не переводить. Это огромная нагрузка на восприятие — принятие словаря автора и поиска с ним уже существующей у себя терминологии.
action — действие, reaction — реакция, то есть действие, затронутое от какого то другого триггера. Да, на английском это круто сокращается и родственно звучит, но не переводится так оно гладко на русский. Я бы в скобках указывал привычную терминологию.
funca
04.11.2021 18:28-3Реактивное программирование это довольно обширная тема. Тут не только стейт менеджмент, но и очереди, микросервисы и ещё куча всего. Могу порекомендовать книжку, где системно описаны разные юзкейсы: Reactive Design Patterns. Базовые термины и антологии есть в https://github.com/kriskowal/gtor . В вашей статье мне запомнились иллюстрации. Но текст, в местах где идёт отсылка к предметной области, выглядит немного дилетантским - как будто термины добавлены просто для красноречия.
Стейт менеджмент на фронтенде это довольно специфическая задача. Почему по-вашему ее решение нужно сводить к какому-то одному виду программирования, отбрасывая по пути все другие? Выглядит как якорение. Видимо с RxJS у вас какие-то личные счёты, но вы, например, смотрели IxJS, где как раз используется модель pull вместо push?
nin-jin Автор
04.11.2021 18:55+1Прочитайте раздел "Ещё по теме" чуть более внимательно.
IxJS про однократное вычисление маленькими порциями. К реактивности оно имеет ещё более далёкое отношение, чем RxJS.
MetromDouble
04.11.2021 19:01+1Как вы относитесь к solid.js?
nin-jin Автор
04.11.2021 19:12За попытку исправления родовой травмы JXS, конечно, зачёт. За выбор JXS и хуков вообще - неуд. А за прекращение работы в нештатной ситуации - вообще на кол.
MetromDouble
04.11.2021 19:42+2Можно подробнее о ваших претензиях к JSX и хукам?
Насколько я понял, вам не совсем нравится функциональный формат описания компонентов, правильно ли я понял?
Какие методики шаблонизации вместо JSX и обработки состояний вместо хуков вы считаете приемлемыми?
YuryScript
06.11.2021 22:08+1Спасибо за статью, довольно структурировано.
Хочется чтобы автор пообщался с создателями реатома, может получится что-то интересное!nin-jin Автор
07.11.2021 07:10Он, к сожалению, не смог прилететь на эту дискуссию. Зато однажды я залетел на эту:
fransua
08.11.2021 07:33+4Попробую побыть адвокатом RxJS (хотя тоже его не люблю за излишнюю сложность)
Style. Если сравнивать два приведенных примера, то объектный вариант выглядит, конечно, проще. Но вот насчет "сказать, что конкретно происходит" - RxJS однозначно выигрывает, там все конкретно написано, что происходит. Если исходить из того, что объектный вариант делает то же самое, то как там изменить debounceTime(0) на `throttleTime(40, animationFrame, {trailing: true})` - не понятно. Насчет потребления памяти - обычно все-таки гигабайты не на это тратятся, а на видео, канвасы, анимации: похоже на преждевременную оптимизацию.
Dupes. Ну вот в RxJS достаточно легко выбрать, как сравнивать, по значению, структуре или через метод equals, например. И в каждом конкретном случае выбрать наиболее подходящий вариант. Даты из moment.js, например, не оч хорошо сравнивать структурно.
Origin. Все равно нужен некоторый код в disconnectedCallback для отписки/удаления из slave, разве нет? И Pull реализуется на RxJS через shareReplay(1)
Tonus. Defer реализуется через debounceTime(0), Lazy через shareReplay(1)
Order. Пример с PostPage/Forbidden решается созданием верхнеуровнего компонента/роутера/guard. Если страница запрещена, то компонент демонтируется и отписывается. Ну и по-честному, Title и Body зависят от результата функции Guard. Если мы смогли получить данные без авторизации, то проблема на сервере.
Flow. Кажется, что проблема свойственна OnPull. Сложное решение, похожее на магию - это лучше, чем ничего, но лучше бы не было и проблемы. Пример с забыли подписаться/отписаться сильно выдуманный: greeting$ = byTitle$.pipe( switchMap( byTitle => byTitle ? title$ : name$ )). Тут нечего забывать и нет магии.
Error. Есть оператор catchError. Можно реализовать и Store, если функция подсчета длины слова будет возвращать number | Error.
Extern. text$ = source_element$.pipe( async source => { await capture, await recognize...}). Точно так же возвращается промис и система рендеринга с ним справляется или нет. Если async|await не нравится, то можно source => new Promise.
Прелесть RxJS на мой взгляд, что на нем можно сделать все что угодно примерно одинаково сложно. И менять его на либы, где базовые вещи делать очень просто, а специфичные архисложно не хочется.
nin-jin Автор
08.11.2021 09:43+2RxJS однозначно выигрывает, там все конкретно написано, что происходит
Только если ты помнишь, что делают все эти операторы. А их сотни в RxJS. И то надо потратить время на их мысленную интерпретацию и не перепутать.
Если исходить из того, что объектный вариант делает то же самое, то как там изменить debounceTime(0) на
throttleTime(40, animationFrame, {trailing: true})
- не понятно.Точно так же, вставляете в нужном вам месте:
throttleTime(40, animationFrame)
И в каждом конкретном случае выбрать наиболее подходящий вариант.
В реальном коде выбор обычно либо не делают вообще (и тогда работает поведение по умолчанию), либо делают неправильный выбор, что ещё хуже.
Даты из moment.js, например, не оч хорошо сравнивать структурно.
moment.js вообще не очень хорошо использовать. Есть куда более адекватные библиотеки. Впрочем, алгоритм глубокого сравнения может быть настраиваемым.
Все равно нужен некоторый код в disconnectedCallback для отписки/удаления из slave, разве нет?
С затягиванием обычно это всё автоматизировано.
И Pull реализуется на RxJS через shareReplay(1)
Это всё же не Pull, а шаринг стрима. Значения безусловно проталкиваются по стриму, пока на него есть подписки. При Pull, значения затягиваются при обращении к ним.
Пример с PostPage/Forbidden решается созданием верхнеуровнего компонента/роутера/guard.
Allow
на диаграмме - это и есть тот самыйguard
.Ну и по-честному, Title и Body зависят от результата функции Guard.
Это не поможет.
Page
должен быть вычислен доTitle
, а не толькоAllow
. Да и что будет результатом вычисленияTitle
приAllow
=true
? Либо мусор, либо ошибка. Данных нет, с сервером всё в порядке.Пример с забыли подписаться/отписаться сильно выдуманный
Я видел очень много кода с ручным subscribe. И часто с забытым unsubscribe. "Фу, дилетанты!" скажите вы? Да, люди не совершенны.
Сложное решение, похожее на магию - это лучше, чем ничего
В автотрекинге зависимостей и обновлениях в корректном порядке нет ничего сложного. Понять их даже проще, чем RxJS.
greeting$ = byTitle$.pipe( switchMap( byTitle => byTitle ? title$ : name$ )). Тут нечего забывать и нет магии.
Мне особенно нравится, когда после утверждения об отсутствии магии показывают такие вот заклинания, чтобы хоть как-то объяснить, как оно работает:
Интернет просто переполнен вопросами в духе "Чем switchMap оличается от flatMap?". Люди читают документацию и не могут понять.
Есть оператор catchError
Весь стрим до catchError будет прибит. А после него переключится на стрим-фоллбэк и уже не вернётся к работоспособному состоянию.
Можно реализовать и Store, если функция подсчета длины слова будет возвращать number | Error.
Предлагаете все колбэки заворачивать в
try-catch
, чтобы исключения не долетали до RxJS, а конвертировались в события, а также в каждом колбэке проверять а не пришло ли нам исключение место данных?Точно так же возвращается промис и система рендеринга с ним справляется или нет.
Ну вот система рендеринга в Ангуляр сначала нарисует undefined, ибо промис пушит в стрим только один раз - при финализации. При обновлении же будет показывать устаревшие данные, пока новый промис не соизволит финализироваться.
Прелесть RxJS на мой взгляд, что на нем можно сделать все что угодно примерно одинаково сложно.
mayorovp
08.11.2021 12:08+1Только если ты помнишь, что делают все эти операторы. А их сотни в RxJS. И то надо потратить время на их мысленную интерпретацию и не перепутать.
А в языке JavaScript 1000 страниц спецификации, но это не мешает писать на нём.
Сотни стандартных операторов — это достоинство Rx, а не его недостаток.
Точно так же, вставляете в нужном вам месте: throttleTime(40, animationFrame)
А как этот самый
throttleTime(40, animationFrame)
вы реализуете, если его в коробке нет? А никак, ему нужно хранить своё состояние в вызвавшем атоме, что доступно только для операторов из коробки.Интернет просто переполнен вопросами в духе "Чем switchMap оличается от flatMap?". Люди читают документацию и не могут понять.
Ну, такие люди. Картинка-то понятная, между прочим, особенно если взять картинку для flatMap и сравнить.
Весь стрим до catchError будет прибит. А после него переключится на стрим-фоллбэк и уже не вернётся к работоспособному состоянию.
А в исходное состояние его вернёт оператор switchMap или flatMap уровнем выше.
Ну вот система рендеринга в Ангуляр [...]
Это проблема системы рендеринга в Ангуляре, а не Rx.
nin-jin Автор
08.11.2021 13:47+1А в языке JavaScript 1000 страниц спецификации, но это не мешает писать на нём.
Поэтому давайте удвоим это число, чтобы жизнь раем не казалась?
А никак, ему нужно хранить своё состояние в вызвавшем атоме, что доступно только для операторов из коробки.
Тут я рассказывал, как работают файберы. А тут можете увидеть пример реализации дебонса на файберах.
Это проблема системы рендеринга в Ангуляре, а не Rx.
Она такая тупая не просто так, а потому, что в Rx не получить информацию о том, что где-то в стриме асинхронная операция идёт.
fransua
08.11.2021 13:05+1если ты помнишь, что делают все эти операторы, а их сотни
Из них штук 20 реально нужных и куча ерунды. Из этих 20 - 10 простых вроде map и filter. Но я соглашусь, что у RxJS слишком высокий порог входа. А у $mol разве нет?
Точно так же, вставляете в нужном вам месте:
throttleTime(40, animationFrame)
Звучит круто) А можете кинуть документацию? А то не оч понимаю, про какую это билиотеку.
Это не поможет.
Page
должен быть вычислен доTitle
, а не толькоAllow
. Да и что будет результатом вычисленияTitle
приAllow
=true
? Либо мусор, либо ошибка. Данных нет, с сервером всё в порядке.Тут непонятно.
Page
послеTitle,
он ведь его содержит. При Allow=true в Title должен придти Post.Title, почему мусор/ошибка? При false ничего не должно придти.Магия
В статье написано, что есть лагерь программистов, которые боятся магии автотрекинга зависимостей, только поэтому использовал это слово. Ну и уж точно автотрекинг сложнее понять, чем switchMap, в реализации которого 30 строк. Marble-диаграммы плохо помогают в понимании, да.
Ну вот система рендеринга в Ангуляр
Плохая, но это не относится к RxJS.
Я бы это решал в бизнес логике на обычном JS - пришло обновление "важного" свойства - фильтруем, отдаем в ui. Если прям надо на RxJS, то из фильтра получаем компаратор игрушки и используем его в distinctUntilChanged перед фильтрацией.
upd: промахнулся веткой
nin-jin Автор
08.11.2021 14:09Из них штук 20 реально нужных и куча ерунды.
Каждый выбирает себе 20 операторов, которыми решает все задачи, объявляя все остальные ерундой. Потом читает чужой код и не понимает его, ибо там человек выбрал другой набор нужных операторов.
А у $mol разве нет?
Нет, в $mol число абстракций меньше, чем операторов в Rx.
При false ничего не должно придти.
Вот именно, что не должно. Поэтому важно задавать в коде порядок обновления состояний, а не давать на откуп произвольно получившемуся динамическому графу.
Ну и уж точно автотрекинг сложнее понять, чем switchMap, в реализации которого 30 строк.
Возможно эта статья вам поможет с пониманием. Там автотрекинг уместился в те же 30 строк.
Я бы это решал в бизнес логике на обычном JS - пришло обновление "важного" свойства - фильтруем, отдаем в ui.
Список "важных свойств" зависит от содержимого функции фильтрации. Функция фильтрации выбирается пользователем в форме. При автотрекинге эффективная реализация тривиальна.
fransua
08.11.2021 16:50+5другой набор нужных операторов.
Ну не настолько там все плохо, зачем набрасывать-то
в $mol число абстракций меньше, чем операторов в Rx
Так давайте число абстракций $mol сравнивать с числом абстракций RxJS, а не с числом операторов. Так можно и с числом букв в сорцах сравнить. Но если Вы считаете, что $mol проще RxJS и порог входа ниже, то круто. Но пока я не вошел, спорить не могу)
Там автотрекинг уместился в те же 30 строк.
Спасибо, действительно стало понятнее и не так страшен черт, как его малюют. Но switchMap все равно проще, ну.
При автотрекинге эффективная реализация тривиальна.
и правда здорово выглядит. Так и быть, попробую cellX
fransua
12.11.2021 12:51+2Таки попробовал cellx, удобно когда нет асинхронности. Можно использовать глобальную переменную для отслеживания зависимостей. С асинхронным кодом такое не сработает, т.к. другой "поток" может вмешаться и поломать. А делать разные контексты для разных потоков JS не умеет, точнее умеет для исключений, чем и можно воспользоваться и сделать псевдофайберы. Только вот как при этом перестать писать хитровымученный код?
Написал фильтрацию-сортировку товаров на RxJS, https://stackblitz.com/edit/react-ts-bwezv4?file=store.ts Да, это некрасиво, но если фильтр/сортировка вдруг станут асинхронными ничего не сломается.
А вот как сделать драг-н-дроп в cellx или $mol я совсем не понимаю. Для конкретизации: юзер нажимает мышью на div, и может его перемещать. При отпускании мыши/выходе за пределы области перетаскивание заканчивается.
nin-jin Автор
12.11.2021 17:42Прекрасно. Меня вот эта вот строчка особенно порадовала на каждое изменение любого продукта:
map((x) => new Map(x.map((p) => [p.Id, p]))),
Не говоря уж о том, что для изменения одного продукта нужно каждый раз пересоздавать весь массив продуктов.
Ну да ладно, тормоза - это проблемы пользователя, вас не волнуют. Но что вы будете делать, когда потребуется чуть более сложная фильтрация? Несколько фильтров, фильтр по диапазону, сортировка по нескольким полям, сортировка/фильтрация по производным полям, с разными алгоритмами сравнения в зависимости от типа поля, по разным полям в зависимости от значений других полей. Каждый раз вам придётся существенно рефакторить код. А если ещё и группировка потребуется - вообще туши свет.
А вот как сделать драг-н-дроп в cellx или $mol я совсем не понимаю.
Заводите реактивную переменную "я дрегендроплюсь" и если она true - перемещаете вслед за координатами мыши, которые тоже реактивные.
При выходе за пределы области перетаскивание заканчивается.
fransua
12.11.2021 20:07+2То, что меня не волнуют тормоза пользователя - это Ваши догадки и необоснованные обвинения. Если что, минусы не я Вам ставлю.
Что я буду делать, если требования существенно изменятся? Выбирать инструмент исходя из требований. В этой задаче RxJS не нужен, а cellx/$mol/mobx похоже, что отлично подходят. Правда меня смущают O(N) reactions и O(N*logN) добавлений зависимостей в Set. Но это ладно, я скорей всего ошибаюсь с оценками и это не оч страшно. Я повторюсь, что решал бы на уровне бизнес-логики, используя любую библиотеку, которая подойдет. Но не строил бы все приложение на этой библиотеке.
перемещаете вслед за координатами мыши
Вот тут есть проблема. Нужен асинхронный requestAnimationFrame, верно? А еще суммировать сдвиги mousemove между соседними фреймами или считать разницу координат мыши - в любом случае нужно несколько значений координат мыши. В rxjs это решается через buffer, window и подобные. Я представляю, что это получится сделать через псевдофайберы, но код должно быть будет пахнуть.
За setPointerCapture спасибо, не знал.
faiwer
12.11.2021 20:14+1А еще суммировать сдвиги mousemove между соседними фреймами
А нельзя брать изначальные (на момент начала dnd) координаты мыши и текущие? Т.е. игнорировать "соседние фреймы" от слова совсем. Или в этом случае не нравится поведение когда курсор выходит за границы экрана и возвращается?
nin-jin Автор
12.11.2021 22:43В правильной реализации зависимость добавляется за О(1).
Вы предлагаете при каждом незначительном изменении требований менять библиотеку и переписывать весь код?
Не понял, при чём тут rAF. Но, как ниже заметили, достаточно запомнить координаты на начало перемещения. Вообще, dnd - это довольно лайтовая задача. Есть куда более интересная: синхронизация состояния в памяти с indexedDB, с другими пирами по webrtc и с сервером по веб сокетам. При этом соединения надо автоматически восстанавливать при их подвисании, и при этом не потерять данные.
fransua
13.11.2021 04:58+2Вы предлагаете при каждом незначительном изменении требований менять библиотеку и переписывать весь код?
Не при каждом, не незначительном, и не переписывать весь код, т.к. буду ограничивать использование библиотеки конкретным куском функционала. Но если сначала была задача - вывести список товаров, а потом - сделать эксель, то да, я выброшу весь код и начну заново.
при чем тут rAF
При том, что мне непонятно, как это сделать, ну и он определенно нужен для плавного перемещения. Возможно я не очень правильную задачу придумал, но идея была вот в чем: как использовать cellx/$mol, когда нужно несколько последних состояний или в середине цепочки должен быть асинхронный инвариант, от которого зависят дальнейшие подписки.
nin-jin Автор
13.11.2021 06:17Как правило состояние нужно только одно - текущее. И прелесть атомов как раз в том, что вы легко можете видеть всё текущее состояние. А дальнейшее поведение приложения зависит от текущего состояния, а не от истории переходов.
Если же вам всё же нужно несколько последних значений (например, последовательность нажатий для суперудара в файтинге), то просто заводите массив для n последних значений и аппендите к нему очередное значение.
fransua
13.11.2021 06:38+1Видимо у нас с Вами разная специфика задач - в моих есть сложность в обработке нескольких последовательных действий пользователя с учетом их временных характеристик: долгий клик отличается от короткого клика и от клика с перемещением мыши. И я хотел бы уйти от RxJS в этом кейсе, но пока что не вижу, куда. А проблем с клонированием массивов или создания мапы из массива нет - не такие большие массивы. Ну и это не сравнимо с постоянной перерисовкой нескольких канвасов размером в 4 экрана.
Спасибо за беседу, почерпнул из нее много полезного.
nin-jin Автор
13.11.2021 07:22А что за специфика с огромными холстами?
fransua
13.11.2021 07:58+1Карта вроде гугловской, с большим количеством метеоданных, которые периодически меняются. Разные слои на разные канвасы или svg. Много алгоритмов в воркере, оттуда и рендеринг черз offscreencanvas.
nin-jin Автор
13.11.2021 08:19Так а огромные холсты зачем? Тут лучше рендрить лишь то, что попадает в видимую область. И на pull рактивности это делать проще всего. Я так редактор документов на сотни страниц делал с рендерингом на холсте.
fransua
13.11.2021 08:24+2Чтобы был запас по краям и юзер не смотрел на белые полосы. Ну и в случае ретины нужно удваивать еще.
nin-jin Автор
13.11.2021 08:45Зачем на ретине удваивать? Не думаю, что на карте пользователи заметят разницу. Если, конечно, они делом занимаются, а не на скриншоты любуются.
fransua
13.11.2021 08:59Дизайнеры дотошные с макбуками и гендир. Ну и незначительно это утяжеляет внезапно
fransua
13.11.2021 09:06А еще там много мелкого текста и значков, которые при мыле сильно плохо читаются. Что вообще странно, там и без ретины хорошее разрешение.
nin-jin Автор
13.11.2021 09:19Размытие скорее всего из-за непопадания в пиксели. Решается подбором соответствующего размера и позиции. В случае шрифтов - хинтами, которых у кастомых шрифтов обычно нет.
fransua
13.11.2021 09:47Да, непопадание в писели было, сейчас нет и не на ретине все супер четко. Ну и там ведь все картинки нужно двойного разрешения. По крайней мере раньше так было
nin-jin Автор
13.11.2021 10:06Я, когда в роуминге интернетом пользуюсь, очень радуюсь, что у меня не ретина.
faiwer
13.11.2021 15:14Не думаю, что на карте пользователи заметят разницу.
Людям с хорошим зрением это сразу бросается в глаза. Говорю как человек которому прилетел тикет про "мыло" в рендере SVG на канве mapbox-а :) Оказалось, я не совсем правильно задействовал API.
Я, когда в роуминге интернетом пользуюсь, очень радуюсь, что у меня не ретина.
Хехе. Это точно. "Правильно" сделанный медиа-сайт для ретины грузит 4х картинки и видео. Хардкорный удар по трафику.
nin-jin Автор
13.11.2021 15:19Дело не в зрении, а в том, на что у человека направлено внимание. Если он сам продуктом не пользуется, а только проверяет, то и не такие тикеты могут прилетать. Бизнес велью они продукту не добавляют.
faiwer
13.11.2021 15:22Смотря что у вас за проект. У нас "business value" это как раз и есть картинки (проект вроде instagram-а).
P.S. минус не от меня
nin-jin Автор
13.11.2021 16:25Ну вот у того же инстаграма плотность пикселей не влияет на бизнес велью. Иначе бы они так адово не жали jpeg. А вот что именно изображено - очень даже.
Riim
13.11.2021 11:41+1А вот как сделать драг-н-дроп в cellx или $mol я совсем не понимаю
ОРП реализации из статьи заточены в первую очередь на использование в стейт-менеджерах, для обработки пользовательского ввода нужно будет самому дописать некоторые вещи идущие в RxJS из коробки. С другой стороны RxJS не очень годится для использования в стейт-менеджерах, т.к. в основе стримы вместо атомов/ячеек и это создаёт дополнительные трудности, порой довольно неприятные.
Набросал простейшую реализацию dnd на cellx, она конечно не идеальна, но как пример вполне сгодится: https://riim.github.io/cellx/docs/examples/simple-dnd.html .
EuRusik
10.11.2021 19:35-3const Name = new BehaviorSubject( 'Jin' )
const Count = Name.pipe( map( Name => Name.length ), distinctUntilChanged(), debounceTime(0), share(), )
const Short = Count.pipe( map( Count => Count < 4 ) distinctUntilChanged(), debounceTime(0), share(), )
Что и зачем делает этот код на RxJS не сможет сходу сказать даже опытный стример. А это ведь самый простой пример, далёкий от реальной жести.
Расшифровка rxjs кода:
Создаем subject с дефолтным состоянием Jin;
Создаем Observable и кладем в константу Count;
В операторе map возвращаем длину строки Jin
Добавляем distinctUntilChanged для предотвращения дублирования если в потоке будет постоянно одно и тоже значение;
Вызываем задержку в 0 секунд непонятно зачем, и делаем multicasting для перевода Observable из холодного состояние в горячее;
Создаем Observable и кладем в константу Short;
В операторе map возвращаем boolean из выражения Count < 4;
Добавляем distinctUntilChanged для предотвращения дублирования если в потоке будет постоянно одно и тоже значение;
Вызываем задержку в 0 секунд непонятно зачем, и делаем multicasting для перевода Observable из холодного состояние в горячее;
nin-jin Автор
10.11.2021 19:38Вызываем задержку в 0 секунд непонятно зачем
О чём и речь.
делаем multicasting для перевода Observable из холодного состояние в горячее;
Не для этого.
mayorovp
У RxJS аспект Extern должен быть Async.
nin-jin Автор
В RxJS на время асинхронного запроса состояние становится несогласованным.
mayorovp
Его не настолько сложно сделать согласованным чтобы считать это проблемой Rx:
nin-jin Автор
Слайд "Поведение по умолчанию" именно про это.
mayorovp
А что вообще такое "поведение по умолчанию" в случае Rx? Rx — это набор примитивов, которые гораздо мельче обсуждаемых вами, у них бесполезно искать поведение по умолчанию в рамках задач ОРП.
nin-jin Автор
Поведение по умолчанию - это поведение, когда разработчик не написал дополнительного технического кода, чтобы изменить это поведение.