Фрактальная шизофрения. What`s up?

от автора

По некоторым источникам еще в 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"

Пример на CodeSandbox

Ничего особенного, правда? 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"}

Пример на CodeSandbox

Помимо извлечения данных 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}

Пример на CodeSandbox

Что-то подсказывает мне, что так не красиво — 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}

Пример на CodeSandbox

Как вам? Не завершая генератор, мы получаем дополнительную изолированную область видимости, которая создается и уничтожается вместе с генератором. В ней мы можем определить переменную 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"}

Пример на CodeSandbox

При расширении нам необходимо реализовать метод 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'

Пример на CodeSandbox

Мутаторы — всё из ничего

Простой механизм, позволяющий генерировать новое значение на основе предыдущего. Рассмотрим тот же пример с таймером на основе мутатора.

const increment = mutator((i = -1) => i + 1)  const timer = cause(function* (ctx: Context) {     // ...     while (true) {         // ...         // отправляем мутатор в поток         yield increment     }     // ... })

Пример на CodeSandbox

Мутатор устроен очень просто — это метод, который принимает предыдущее значение и возвращает новое. Чтобы он заработал нужно всего лишь вернуть его в качестве результата вычислений, вся остальная магия произойдет под капотом. Поскольку при первом запуске предыдущего значения не существует, мутатор получит 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> */

Пример на CodeSandbox

Так это ж стейт менеджер на генераторах

Да с одной стороны — 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)

Пример на CodeSandbox

Метод 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)

Пример на CodeSandbox

Мне банально непонятен весь этот звездочный код

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

  • 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *