[Ненормальное программирование, JavaScript, TypeScript] Фрактальная шизофрения. What`s up?

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
01-Апр-2021 05:30


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

С уважением, Денис Ч.
"Большая часть моих трудов — это муки рождения новой научной дисциплины" Бенуа Мандельброт
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_nenormalnoe_programmirovanie (Ненормальное программирование), #_javascript, #_typescript, #_frontend (фронтенд), #_frejmvork (фреймворк), #_nenormalnoe_programmirovanie (ненормальное программирование), #_shizofrenija (шизофрения), #_velosipedostroenie (велосипедостроение), #_arhitektura_prilozhenij (архитектура приложений), #_nenormalnoe_programmirovanie (
Ненормальное программирование
)
, #_javascript, #_typescript
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 19:47
Часовой пояс: UTC + 5