Как стать автором
Обновить
1602.56
Timeweb Cloud
То самое облако

Разбираемся в сортах реактивности

Время на прочтение27 мин
Количество просмотров36K

Здравствуйте, меня зовут Дмитрий Карловский и я… прилетел к вам на турбо-реактивном самолёте. Основная суть реактивного двигателя изображена на картинке.



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


Это — текстовая расшифровка выступления на 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()
}

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


А самое печальное: хранение массива из замыканий кушает много памяти. И с этим ничего не сделать.



Состояния хранят прямые ссылки друг на друга, образуя глобальный граф. Массивы ссылок — это относительно экономно по памяти, ведь каждая ссылка — это всего 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 в качестве кровеносной системы, а её мы уже разобрали ранее.


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


Ещё по теме



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


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


На конец: пара моих выступлений, разбирающих преимущества ОРП и механику реализации асинхронных инвариантов, помогут ещё глубже закопаться в тему.


Хотите добавки?


Для тех, кто добрался до конца, но ещё не устал, могу предложить глянуть так же и дискуссию о менеджерах состояний, которая развернулась после выступления, между мной и Сергеем Совой, мейнтейнером Effector-а.



Хотите больше точек зрения?


Если же и этого вам окажется мало, то приглашаю на прошлогоднюю дискуссию о стейт менеджерах в более расширенном составе..



Ещё не по теме


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



Внести свою лепту


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



У нас, в гильдии $hyoo, много интересных проектов, которые вскоре должны перевернуть мир. Так что самый ценный вклад, который вы можете внести — это даже не деньги, а участие в проектах.


Мы хотим сделать экосистему тесно интегрированных веб-сервисов, использующих самые передовые и дешёвые технологии. И потеснить оупенсорсом нынешних интернет-гигантов. Присоединяйтесь и вы к нашей маленькой революции!


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


Пишите письма!


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



А теперь, форсируем наши реактивные движки и летим в светлое будущее!


Из первых уст


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


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Какая у вас модель реактивности в проекте?
26.09% Затягивание18
17.39% Проталкивание12
17.39% Смешанная12
39.13% Никакая27
Проголосовали 69 пользователей. Воздержались 55 пользователей.
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 53: ↑45 и ↓8+50
Комментарии55

Публикации

Информация

Сайт
timeweb.cloud
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия
Представитель
Timeweb Cloud