По некоторым источникам еще в IV до нашей эры Аристотель задался одним простым вопросом Что было раньше? Курица или яйцо? Сам он в итоге пришел к выводу, что и то, и другое появилось одновременно вот это поворот! Не правда ли?
Ладно, шутки в сторону, тут есть кое-что интересное. Данная простая дилемма хорошо описывает ситуацию, в которой не ясно, какое из двух явлений считать причиной, а какое следствием, но в тоже время позволяет сделать вывод о том, что причина может являться следствием и наоборот следствие может являться причиной, смотря с какой стороны посмотреть (относительность, позиция наблюдателя и т.д.).
Прошло полгода с момента первой публикации моих шизоидных фантазий. Я всё также продолжаю пытаться понять непонятное и аппроксимировать это в код. Принцип причинности пополнил список моих интересов. Я перелопатил всё, что сделал ранее, но сохранил и преумножил идею.
Сегодня я не буду терзать ваш разум водопадом отборного бреда. Выпуск будет чуть суровее ибо лирики меньше, а кода больше. Тем не менее местами он будет вкуснее ибо вишенки я вам приготовил отменные. Поехали?
What's up guys?
Кажется моя лаборатория по производству генераторов-мутантов слепила что-то действительно годное.
npm i whatsup
Знакомьтесь фронтенд фреймворк вдохновленный идеями фракталов и потоков энергии. С реактивной душой. С минимальным api. С максимальным использованием нативных конструкций языка.
Построен он на генераторах, из коробки даёт функционал
аналогичный react
+ mobx
, не уступает по
производительности, при этом весит менее 5kb gzip
.
Архитектурная идея заключается в том, что всё наше приложение это древовидная структура, по ветвям которой в направлении корня организовано течение данных, отражающих внутреннее состояние. В процессе разработки мы описываем узлы этой структуры. Каждый узел простая самоподобная сущность, полноценное законченное приложение, вся работа которого сводится к тому, чтобы принять данные от других узлов, переработать и отправить следующим.
Cause & Conse
Причина и следствие. Два базовых потока для организации
реактивного состояния данных. Для простоты понимания их можно
ассоциировать с привычными computed
и
observable
, они, конечно же, отличаются, но для начала
сойдёт.
Начнём со следствия. По сути это одноимённая причина, которой из вне можно задавать значение возвращаемого следствия (маслянистое определение получилось, но чисто технически всё так и есть).
const name = conse('John')// И мы ему такие - What`s up name?whatsUp(name, (v) => console.log(v))// а он нам://> "John"name.set('Barry')//> "Barry"
Ничего особенного, правда? conse
создает поток с
начальным значением, whatsUp
"вешает" наблюдателя. С
помощью .set(...)
меняем значение наблюдатель
реагирует в консоли появляется новая запись.
На самом деле Conse
это частный случай потока
Cause
. Последний создается из генератора, внутри
которого выражение yield*
это "подключение" стороннего
потока к текущему, иными словами обстановку внутри генератора можно
рассмотреть так, как будто бы мы находимся внутри изолированной
комнаты, в которую есть несколько входов yield*
и
всего один выход return
(конечно же yield
ещё, но об этом позже)
const name = conse('John')const user = cause(function* () { return { name: yield* name, // ^^^^^^ подключаем поток name // пускаем его данные в комнату }})// И мы ему такие - What`s up user? :)whatsUp(user, (v) => console.log(v))// а он нам://> {name: "John"}name.set('Barry')//> {name: "Barry"}
Помимо извлечения данных yield* name
устанавливает
зависимость потока user
от потока name
,
что в свою очередь также приводит к вполне ожидаемым результатам, а
именно меняем name
меняется user
реагирует наблюдатель консоль показывает новую запись.
И в чем тут соль генераторов?
Действительно, пока что всё это выглядит как-то необоснованно
"стероидно". Давайте немного усложним наш пример. Представим, что в
данных потока user
мы хотим видеть некоторый
дополнительный параметр revision
, отражающий текущую
ревизию.
Сделать это просто мы объявляем переменную
revision
, значение которой включаем в набор данных
потока user
и каждый раз в процессе перерасчета
увеличиваем на единицу.
const name = conse('John')let revision = 0const user = cause(function* () { return { name: yield* name, revision: revision++, }})whatsUp(user, (v) => console.log(v))//> {name: "John", revision: 0}name.set('Barry')//> {name: "Barry", revision: 1}
Что-то подсказывает мне, что так не красиво
revision
выглядит оторваной от контекста и
незащищенной от воздействия из вне. Этому есть решение мы можем
поместить определение этой переменной в тело генератора, а для
отправки нового значения в поток (выхода из комнаты) использовать
yield
вместо return
, что позволит нам не
завершать выполнение генератора, а приостанавливать и возобновлять
с места последней остановки при следующем обновлении.
const name = conse('John')const user = cause(function* () { let revision = 0 while (true) { yield { name: yield* name, revision: revision++, } }})whatsUp(user, (v) => console.log(v))//> {name: "John", revision: 0}name.set('Barry')//> {name: "Barry", revision: 1}
Как вам? Не завершая генератор, мы получаем дополнительную
изолированную область видимости, которая создается и уничтожается
вместе с генератором. В ней мы можем определить переменную
revision
, доступную от вычисления к вычислению, но при
этом не доступную из вне. При завершении генератора
revision
уйдет в мусор, при создании будет создана
вместе с ним.
Расширенный пример
Функции cause
и conse
это шорты для
создания потоков. Существуют одноименные базовые классы, доступные
для расширения.
import { Cause, Conse, whatsUp } from 'whatsup'type UserData = { name: string }class Name extends Conse<string> {}class User extends Cause<UserData> { readonly name: Name constructor(name: string) { super() this.name = new Name(name) } *whatsUp() { while (true) { yield { name: yield* this.name, } } }}const user = new User('John')whatsUp(user, (v) => console.log(v))//> {name: "John"}user.name.set('Barry')//> {name: "Barry"}
При расширении нам необходимо реализовать метод
whatsUp
, возвращающий генератор.
Контекст и диспозинг
Единственный агрумент принимаемый методом whatsUp
является текущий контекст. В нём есть несколько полезных методов,
один из которых update
позволяет принудительно
инициировать процедуру обновления.
Для избежания ненужных и повторных вычислений все зависимости
между потоками отслеживаются динамически. Когда наступает момент,
при котором у потока отсутствуют наблюдатели, происходит
автоматическое уничтожение генератора. Наступление этого события
можно обработать, используя стандартную языковую конструкцию
try {} finally {}
.
Рассмотрим пример потока-таймера, который с задержкой в 1
секунду, используя setTimeout
, генерирует новое
значение, а при уничтожении вызывает clearTimeout
для
очистки таймаута.
const timer = cause(function* (ctx: Context) { let timeoutId: number let i = 0 try { while (true) { timeoutId = setTimeout(() => ctx.update(), 1000) // устанавливаем таймер перезапуска с задержкой 1 сек yield i++ // отправляем в поток текущее значение счетчика // заодно инкрементим его } } finally { clearTimeout(timeoutId) // удаляем таймаут console.log('Timer disposed') }})const dispose = whatsUp(timer, (v) => console.log(v))//> 0//> 1//> 2dispose()//> 'Timer disposed'
Мутаторы всё из ничего
Простой механизм, позволяющий генерировать новое значение на основе предыдущего. Рассмотрим тот же пример с таймером на основе мутатора.
const increment = mutator((i = -1) => i + 1)const timer = cause(function* (ctx: Context) { // ... while (true) { // ... // отправляем мутатор в поток yield increment } // ...})
Мутатор устроен очень просто это метод, который принимает
предыдущее значение и возвращает новое. Чтобы он заработал нужно
всего лишь вернуть его в качестве результата вычислений, вся
остальная магия произойдет под капотом. Поскольку при первом
запуске предыдущего значения не существует, мутатор получит
undefined
, параметр i
по умолчанию примет
значение -1
, а результатом вычислений будет
0
. В следующий раз ноль мутирует в единицу и т.д. Как
вы уже заметили increment
позволил нам отказаться от
хранения локальной переменной i
в теле генератора.
Это ещё не всё. В процессе распространения обновлений по
зависимостям, происходит пересчет значений в потоках, при этом
новое и старое значение сравниваются с использованием оператора
строгого равенства ===
. Если значения равны пересчёт
останавливается. Это означает, что два массива или объекта с
одинаковым набором данных, хоть и эквивалентны, но всё же не равны
и будут провоцировать бессмысленные пересчеты. В каких-то случаях
это необходимо, в остальных это можно остановить, используя
мутатор, как фильтр.
class EqualArr<T> extends Mutator<T[]> { constructor(readonly next: T[]) {} mutate(prev?: T[]) { const { next } = this if ( prev && prev.length === next.length && prev.every((item, i) => item === next[i]) ) { /* Возвращаем старый массив, если он эквивалентен новому, планировщик сравнит значения, увидит, что они равны и остановит бессмысленные пересчеты */ return prev } return next }}const some = cause(function* () { while (true) { yield new EqualArr([ /*...*/ ]) }})
Т.е. таким способом мы получаем эквивалент того, что в других
реактивных библиотеках задается опциями типа
shallowEqual
, в тоже время мы не ограничены набором
опций, заложенных разработчиком библиотеки, а сами можем определять
работу фильтров и их поведение в каждом конкретном случае. В
дальнейшем я планирую создать отдельный пакет с набором базовых,
наиболее востребованных фильтров.
Также как cause
и conse
функция
mutator
это шорт для краткого определения простого
мутатора. Более сложные мутаторы можно описать, расширяя базовый
класс Mutator
, в котором необходимо реализовать метод
mutate
.
Смотрите вот так можно создать мутатор dom-элемента. И поверьте
элемент будет создан и вставлен в body
однократно, всё
остальное сведётся к обновлению его свойств.
class Div extends Mutator<HTMLDivElement> { constructor(readonly text: string) { super() } mutate(node = document.createElement('div')) { node.textContent = this.text return node }}const name = conse('John')const nameElement = cause(function* () { while (true) { yield new Div(yield* name) }})whatsUp(nameElement, (div) => document.body.append(div))/*<body> <div>John</div></body>*/name.set('Barry')/*<body> <div>Barry</div></body>*/
Так это ж стейт менеджер на генераторах
Да с одной стороны WhatsUp это стейт менеджер на генераторах, в
нём есть аналоги привычных observable
,
computed
, reaction
. Есть и
action
, позволяющий внести несколько изменений и
провести обновление за один проход. Пока что ничего необычного, но
то что вы увидите дальше, выгодно отличает его от других систем
управления состоянием.
Фракталы
Я же говорил, что сохранил идею :) Особенность фрактала
заключается в том, что для каждого потребителя он создает
персональный генератор и контекст. Он как по лекалу создает новую,
параллельную вселенную, в которой своя жизнь, но те же правила.
Контексты соединяются друг с другом в отношения
parent-child
получается дерево контекстов, по которому
организуется спуск данных вниз к листьям и всплытие событий вверх к
корню. Контекст и система событий, Карл! Пример
ниже длинный, но наглядно демонстрирует и то, и другое.
import { Fractal, Conse, Event, Context } from 'whatsup'import { render } from '@whatsup/jsx'class Theme extends Conse<string> {}class ChangeThemeEvent extends Event { constructor(readonly name: string) { super() }}class App extends Fractal<JSX.Element> { readonly theme = new Theme('light'); readonly settings = new Settings() *whatsUp(ctx: Context) { // расшариваем поток this.theme для всех нижележащих фракталов // т.е. "спускаем его" вниз по контексту ctx.share(this.theme) // создаем обработчик события ChangeThemeEvent, которое можно // инициировать в любом нижележащем фрактале и перехватить тут ctx.on(ChangeThemeEvent, (e) => this.theme.set(e.name)) while (true) { yield (<div>{yield* this.settings}</div>) } }}class Settings extends Fractal<JSX.Element> { *whatsUp(ctx: Context) { // берем поток Theme, расшаренный где-то в верхних фракталах const theme = ctx.get(Theme) // инициируем всплытие события, используя ctx.dispath const change = (name: string) => ctx.dispath(new ChangeThemeEvent(name)) while (true) { yield ( <div> <h1>Current</h1> <span>{yield* theme}</span> <h1>Choose</h1> <button onClick={() => change('light')}>light</button> <button onClick={() => change('dark')}>dark</button> </div> ) } }}const app = new App()render(app)
Метод ctx.share
используется для того, чтобы
сделать экземпляр какого-то класса доступным для фракталов стоящих
ниже по контексту, а вызов ctx.get
с конструктором
этого экземпляра позволяет нам найти его.
Метод ctx.on
принимает конструктор события и его
обработчик, ctx.dispatch
в свою очередь принимает
инстанс события и инициирует его всплытие вверх по контексту. Для
отмены обработки события существует ctx.off
, но в
большинстве случаев им не приходится пользоваться, поскольку все
обработчики уничтожаются автоматически при уничтожении
генератора.
Я настолько заморочился, что написал свой jsx-рендер и
babel-плагин для трансформации jsx-кода. Уже догадываетесь что под
капотом? Да мутаторы. Принцип тот же, что и в примере с мутатором
dom-элемента, только тут создается и в дальнейшем мутируется
определенный фрагмент html-разметки. Создания и сравнения всего
виртуального dom (как в react, например) не происходит. Всё
сводится к локальным пересчетам, что даёт хороший прирост в
производительности. Иными словами в примере выше, при изменении
темы оформления, перерасчеты и обновление dom произойдут только во
фрактале Settings
(потому что yield*
theme
поток подключен только там).
Механизм реконсиляции получил свой уникальный алгоритм, побочным
эффектом которого оказалась возможность рендерить напрямую в
элемент <body>
. Вторым аргументом функция
render
конечно же принимает контейнер, но, как видите
в примере выше он не обязательный.
Фрагменты и их синтаксис также поддерживаются, а компоненты определяются только как чистые функции, не имеют внутреннего состояния и отвечают исключительно за разметку.
Обработка ошибок
Возникновение исключения на уровне фреймворка является
стандартной ситуацией. Ошибка распространяется по потокам, как и
любые другие данные, а обрабатывается стандартной конструкцией
try {} catch {}
. При этом система реактивности
сохраняет состояние зависимостей таким образом, что при исправлении
ситуации и исчезновении ошибки, потоки пересчитывают свои данные и
возвращаются в нормальное рабочее состояние.
import { conse, Fractal } from 'whatsup'import { render } from '@whatsup/jsx'class CounterMoreThan10Error extends Error {}class App extends Fractal<JSX.Element> { *whatsUp() { const clicker = new Clicker() const reset = () => clicker.reset() while (true) { try { yield (<div>{yield* clicker}</div>) } catch (e) { // ловим ошибку, если "наша" - обрабатываем, // иначе отправляем дальше в поток и даем возможность // перехватить её где-то в вышестоящих фракталах if (e instanceof CounterMoreThan10Error) { yield ( <div> <div>Counter more than 10, need reset</div> <button onClick={reset}>Reset</button> </div> ) } else { throw e } } } }}class Clicker extends Fractal<JSX.Element> { readonly count = conse(0) reset() { this.count.set(0) } increment() { const value = this.count.get() + 1 this.count.set(value) } *whatsUp() { while (true) { const count = yield* this.count if (count > 10) { throw new CounterMoreThan10Error() } yield ( <div> <div>Count: {count}</div> <button onClick={() => this.increment()}>increment</button> </div> ) } }}const app = new App()render(app)
Мне банально непонятен весь этот звездочный код
Согласен, с непривычки это действительно может тяжело восприниматься, поэтому я решил сгруппировать правила в отдельный блок, тем более что их не так много:
yield*
подключить поток и извлечь из него данныеyield
отправить данные в потокreturn
отправить данные в поток и пересоздать генераторthrow
отправить ошибку в поток и пересоздать генератор
Производительность
Естественно этот вопрос нельзя обойти стороной, поэтому я
добавил whatsup
в проект js-framework-benchmark.
Думаю кому-то он известен, но вкратце поясню суть этого проекта
заключается в сравнении производительности фреймворков при решении
различных задач, как то: создание тысячи строк, их замена,
частичное обновление, выбор отдельной строки, обмен двух строк
местами, удаление и прочее. По итогам тестирования собирается
подробная таблица
результатов. Ниже приведена выдержка из этой таблицы, в которой
видно положение whatsup
на фоне наиболее популярных
библиотек и фреймворков таких, как inferno
,
preact
, vue
, react
и
angular
На мой взгляд, учитывая "зародышевое" состояние, позиция уже вполне достойная. Поле оптимизаций ещё не пахано, поэтому нет никаких сомнений, что "выжать ещё" можно.
Прочие тактико-технические характеристики
Размер
Менее 3 kb gzip. Да это размер самого whatsup
.
Рендер добавит еще пару кило, что в сумме даст не более 5-ти.
Glitch free
Распространением обновлений занимается специальный планировщик, который производит оценку зависимостей и перерасчет данных в потоках в топологическом порядке. Это позволяет избежать двойных вычислений и "глюков". Не буду дублировать сюда информацию и примеры из википедии, просто оставлю ссылку на статью (Раздел Glitches).
Глубина связей
В комментариях к прошлой статье JustDont справедливо высказался по этому поводу:
Глубина связей данных не может превышать глубину стека вызовов. Да, для современных браузеров это не то, чтоб прямо очень страшно, поскольку счёт идёт минимум на десятки тысяч. Но, например, в хроме некоторой степени лежалости глубина стека вызовов всего лишь в районе 20К. Наивная попытка запилить на этом объемный граф может легко обрушиться в maximum call stack size exceeded.
Я поработал над этим моментом и теперь глубина стека не играет
никакой роли. Для сравнения я реализовал один и тот же пример на
mobx и whatsup (названия
кликабельны). Суть примера заключается в следующем: создаётся
"сеть", состоящая из нескольких слоёв. Каждый слой состоит из
четырёх ячеек a
, b
, c
,
d
. Значение каждой ячейки рассчитывается на основе
значений ячеек предыдущего слоя по формуле a2 = b1
,
b2 = a1-c1
, c2 = b1+d1
, d2 =
c1
. После создания "сети" происходит вычисление значений
ячеек последнего слоя. Затем значения ячеек первого слоя
изменяются, что приводит к лавинообразному пересчету во всех
ячейках "сети".
Так вот в Chrome 88.0.4324.104 (64-бит) mobx
вывозит 1653 слоя, а дальше падает в Maximum call stack size
exceeded
. В своей практике я однажды столкнулся с этим в
одном огромном приложении это был долгий и мучительный дебаг.
Whatsup
осилит и 5, и 10 и даже 100 000 слоёв тут
уже зависит от размера оперативной памяти компьютера ибо out
of memory
всё же наступит. Считаю, что такого запаса более
чем достаточно. Поиграйтесь в примерах со значением
layersCount
.
Основу для данного теста я взял из репозитория реактивной
библиотеки cellx
(Riim спасибо).
О чем я ещё не рассказал
Делегирование
Простой и полезный механизм, благодаря которому один поток может
поручить свою работу другому потоку. Всё, что для этого нужно это
вызвать yield delegate(otherStream)
.
Асинхронные задачи
В контексте имеется метод defer
, который принимает
асинхронный коллбек. Данный коллбек автоматически запускается,
возвращает промис, по завершении которого инициируется процедура
обновления данных потока.
Роутинг
Вынесен в отдельный пакет @whatsup/route
и
пока что содержит в себе всего пару методов route
и
redirect
. Для описания шаблона маршрута используются
регулярные выражения, не знаю как вам, но в
react-router
третьей версии мне порой этого очень не
хватало. Поддерживаются вложеные роуты, совпадения типа
([0-9]+)
и их передача в виде потоков. Там
действительно есть прикольные фишки, но рассказывать о них в рамках
этой статьи мне кажется уже слишком.
CLI
Не так давно к разработке проекта подключился парень из Бразилии
Andr Lins. Наличие интерфейса
командной строки для быстрого старта
whatsup
-приложения целиком и полностью его
заслуга.
npm i -g @whatsup/cli# thenwhatsup project
Попробовать
WhatsUp
легко испытать где-то на задворках
react
-приложения. Для этого существует небольшой пакет
@whatsup/react, который
позволяет сделать это максимально легко и просто.
Примеры
Todos всем известный пример с TodoMVC
Loadable пример, показывающий
работу метода ctx.defer
, здесь с его помощью
организуется показ лоадеров в то время, как в фоне производится
загрузка, я специально добавил там небольшие задержки для того,
чтоб немного замедлить процессы
Antistress просто игрушка, щёлкаем шарики, красим их в разные цвета и получаем прикольные картинки. По факту это фрактал, который показывает внутри себя кружок, либо три таких же фрактала вписанных в периметр круга. Клик покрасить, долгий клик раздавить, долгий клик в центре раздавленного кружка возврат в исходное состояние. Если раздавить кружки до достаточно глубокого уровня, можно разглядеть треугольник Серпинского
Sierpinski перфоманс тест, который команда реакта показывала презентуя файберы
Напоследок
На первый взгляд может показаться, что whatsup
это
очередной плод очередного извращенного разума, но подождите
отвергать не глядя не так страшен чёрт и ночь не так темна быть
может вы найдёте в нём что-то интересное для себя.
Спасибо за потраченное время, надеюсь я отнял его у вас не зря. Буду рад вашим комментариям, конструктивной критике и помощи в развитии проекта. Да что уж тут даже звездочка на github уже награда.
Кроме того хочу выразить слова благодарности всем тем, кто меня поддерживал, писал в личку, на e-mail, в vk, telegram. Я не ожидал такой реакции после публикации первой статьи, это стало для меня приятной неожиданностью и дополнительным стимулом к развитию проекта. Спасибо!
С уважением, Денис Ч.
"Большая часть моих трудов это муки рождения новой научной дисциплины" Бенуа Мандельброт