[Ненормальное программирование, JavaScript, TypeScript] Фрактальная шизофрения. What`s up?
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
По некоторым источникам еще в 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
===========
Похожие новости:
- [Разработка веб-сайтов, Open source, JavaScript, Node.JS] Создатель Node.js анонсирует замену — Deno (перевод)
- [Разработка веб-сайтов, JavaScript] Опыт разработки виджетов для сторонних сайтов
- [JavaScript, Git, Управление разработкой, Управление продуктом, DevOps] Введение в непрерывную поставку (CD) при помощи GitLab (перевод)
- [JavaScript, Google Chrome, HTML, Расширения для браузеров] Расширение для Google Chrome: управляем скиллами друзей в LinkedIn
- [JavaScript, Разработка под Arduino, Разработка на Raspberry Pi, DIY или Сделай сам] Умная квартира на JavaScript. От светодиода до распознавания лица в камере домофона
- [JavaScript, Серверная оптимизация, Node.JS] Профилирование Node.js. Доклад Яндекса
- [JavaScript, Angular, TypeScript] Глобальные объекты в Angular
- [Ненормальное программирование, Программирование, Софт] Я пользуюсь Excel, чтобы писать код (перевод)
- [Программирование, Анализ и проектирование систем, Проектирование и рефакторинг, Микросервисы] Разложение монолита: Декомпозиция БД (часть 2)
- [Ненормальное программирование] Концепция: Faultable types
Теги для поиска: #_nenormalnoe_programmirovanie (Ненормальное программирование), #_javascript, #_typescript, #_frontend (фронтенд), #_frejmvork (фреймворк), #_nenormalnoe_programmirovanie (ненормальное программирование), #_shizofrenija (шизофрения), #_velosipedostroenie (велосипедостроение), #_arhitektura_prilozhenij (архитектура приложений), #_nenormalnoe_programmirovanie (
Ненормальное программирование
), #_javascript, #_typescript
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 23-Ноя 01:16
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
По некоторым источникам еще в 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 Мне банально непонятен весь этот звездочный код Согласен, с непривычки это действительно может тяжело восприниматься, поэтому я решил сгруппировать правила в отдельный блок, тем более что их не так много:
Производительность Естественно — этот вопрос нельзя обойти стороной, поэтому я добавил 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.
Так вот — в 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 =========== Похожие новости:
Ненормальное программирование ), #_javascript, #_typescript |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 23-Ноя 01:16
Часовой пояс: UTC + 5