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

16d5a23fb8c1413027a247408c703aec.jpg

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

class User extends Cause {
    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 extends Mutator {
    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 {
    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))
/*

    
John
*/ name.set('Barry') /*
Barry
*/

Пример на CodeSandbox


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

Да с одной стороны — WhatsUp — это стейт менеджер на генераторах, в нём есть аналоги привычных observable, computed, reaction. Есть и action, позволяющий внести несколько изменений и провести обновление за один проход. Пока что ничего необычного, но то что вы увидите дальше, выгодно отличает его от других систем управления состоянием.


Фракталы

Я же говорил, что сохранил идею :) Особенность фрактала заключается в том, что для каждого потребителя он создает персональный генератор и контекст. Он как по лекалу создает новую, параллельную вселенную, в которой своя жизнь, но те же правила. Контексты соединяются друг с другом в отношения parent-child — получается дерево контекстов, по которому организуется спуск данных вниз к листьям и всплытие событий вверх к корню. Контекст и система событий, Карл! Пример ниже длинный, но наглядно демонстрирует и то, и другое.

import { Fractal, Conse, Event, Context } from 'whatsup'
import { render } from '@whatsup/jsx'

class Theme extends Conse {}

class ChangeThemeEvent extends Event {
    constructor(readonly name: string) {
        super()
    }
}

class App extends Fractal {
    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 (
{yield* this.settings}
) } } } class Settings extends Fractal { *whatsUp(ctx: Context) { // берем поток Theme, расшаренный где-то в верхних фракталах const theme = ctx.get(Theme) // инициируем всплытие события, используя ctx.dispath const change = (name: string) => ctx.dispath(new ChangeThemeEvent(name)) while (true) { yield (

Current

{yield* theme}

Choose

) } } } 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 — поток подключен только там).

Механизм реконсиляции получил свой уникальный алгоритм, побочным эффектом которого оказалась возможность рендерить напрямую в элемент . Вторым аргументом функция render конечно же принимает контейнер, но, как видите в примере выше — он не обязательный.

Фрагменты и их синтаксис также поддерживаются, а компоненты определяются только как чистые функции, не имеют внутреннего состояния и отвечают исключительно за разметку.


Обработка ошибок

Возникновение исключения на уровне фреймворка является стандартной ситуацией. Ошибка распространяется по потокам, как и любые другие данные, а обрабатывается стандартной конструкцией try {} catch {}. При этом система реактивности сохраняет состояние зависимостей таким образом, что при исправлении ситуации и исчезновении ошибки, потоки пересчитывают свои данные и возвращаются в нормальное рабочее состояние.

import { conse, Fractal } from 'whatsup'
import { render } from '@whatsup/jsx'

class CounterMoreThan10Error extends Error {}

class App extends Fractal {
    *whatsUp() {
        const clicker = new Clicker()
        const reset = () => clicker.reset()

        while (true) {
            try {
                yield (
{yield* clicker}
) } catch (e) { // ловим ошибку, если "наша" - обрабатываем, // иначе отправляем дальше в поток и даем возможность // перехватить её где-то в вышестоящих фракталах if (e instanceof CounterMoreThan10Error) { yield (
Counter more than 10, need reset
) } else { throw e } } } } } class Clicker extends Fractal { 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 (
Count: {count}
) } } } const app = new App() render(app)

Пример на CodeSandbox


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

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


  • yield* — подключить поток и извлечь из него данные
  • yield — отправить данные в поток
  • return — отправить данные в поток и пересоздать генератор
  • throw — отправить ошибку в поток и пересоздать генератор

Производительность

Естественно — этот вопрос нельзя обойти стороной, поэтому я добавил whatsup в проект js-framework-benchmark. Думаю кому-то он известен, но вкратце поясню — суть этого проекта заключается в сравнении производительности фреймворков при решении различных задач, как то: создание тысячи строк, их замена, частичное обновление, выбор отдельной строки, обмен двух строк местами, удаление и прочее. По итогам тестирования собирается подробная таблица результатов. Ниже приведена выдержка из этой таблицы, в которой видно положение whatsup на фоне наиболее популярных библиотек и фреймворков таких, как inferno, preact, vue, react и angular

jty6sdnkq_dmohr2dzpxaornse4.jpeg

На мой взгляд, учитывая «зародышевое» состояние, позиция уже вполне достойная. Поле оптимизаций ещё не пахано, поэтому нет никаких сомнений, что «выжать ещё» — можно.


Прочие тактико-технические характеристики


Размер

Менее 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. Я не ожидал такой реакции после публикации первой статьи, это стало для меня приятной неожиданностью и дополнительным стимулом к развитию проекта. Спасибо!


С уважением, Денис Ч.

«Большая часть моих трудов — это муки рождения новой научной дисциплины» Бенуа Мандельброт

© Habrahabr.ru