По некоторым источникам еще в 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 = 0 const 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 //> 2 dispose() //> '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 # then whatsup project
Попробовать
WhatsUp
легко испытать где-то на задворках react
-приложения. Для этого существует небольшой пакет @whatsup/react, который позволяет сделать это максимально легко и просто.
Примеры
Todos — всем известный пример с TodoMVC
Loadable — пример, показывающий работу метода ctx.defer
, здесь с его помощью организуется показ лоадеров в то время, как в фоне производится загрузка, я специально добавил там небольшие задержки для того, чтоб немного замедлить процессы
Antistress — просто игрушка, щёлкаем шарики, красим их в разные цвета и получаем прикольные картинки. По факту это фрактал, который показывает внутри себя кружок, либо три таких же фрактала вписанных в периметр круга. Клик — покрасить, долгий клик — раздавить, долгий клик в центре раздавленного кружка — возврат в исходное состояние. Если раздавить кружки до достаточно глубокого уровня, можно разглядеть треугольник Серпинского
Sierpinski — перфоманс тест, который команда реакта показывала презентуя файберы
Напоследок
На первый взгляд может показаться, что whatsup
— это очередной плод очередного извращенного разума, но подождите отвергать не глядя — не так страшен чёрт и ночь не так темна… быть может вы найдёте в нём что-то интересное для себя.
Спасибо за потраченное время, надеюсь я отнял его у вас не зря. Буду рад вашим комментариям, конструктивной критике и помощи в развитии проекта. Да что уж тут — даже звездочка на github — уже награда.
Кроме того — хочу выразить слова благодарности всем тем, кто меня поддерживал, писал в личку, на e-mail, в vk, telegram. Я не ожидал такой реакции после публикации первой статьи, это стало для меня приятной неожиданностью и дополнительным стимулом к развитию проекта. Спасибо!
С уважением, Денис Ч.
"Большая часть моих трудов — это муки рождения новой научной дисциплины" Бенуа Мандельброт
ссылка на оригинал статьи https://habr.com/ru/post/550090/
Добавить комментарий