Нет, я не болен. По крайней мере так говорит голос в моей голове. Я наркоман. Вот уже более 15 лет я сижу на игле. Употребляю много, жёстко, до обморочного состояния. Докатился до того, что в последнее время не стесняюсь ни друзей, ни жены, ни детей… Двоих детей! Не люблю бадяженый, люблю чистый, без примесей. За годы перепробовал многое, но в последнее время остановился в поисках. Забавно осознавать, что от одного и того же получаешь одновременно и боль, и радость. Мне бы в лечебку, я даже хочу, я даже знаю в какую. Знаете такие, где продолжаешь употреблять, но под присмотром?


Я жёстко сижу на typescript. Именно программирование мой наркотик — его я люблю и ненавижу. Оно никогда не было моей основной работой, это моё хобби, и только в этом амплуа я от него кайфую. Тем не менее, писать код из необходимости подзаработать порой приходится, и это боль, выгорание ещё на старте, и ощущение себя в теле бурлака на Волге. Я фанатик-перфекционист, тратящий кучу времени на поиск идеального решения, затирающий код до дыр, стремящийся всё переделать под себя, из-за чего в команде чаще всего токсичен для окружающих. Я мечтатель, движимый желанием сделать мир лучше, привнести в него что-то полезное, не славы ради, но чтоб себя найти и душу успокоить...


Помню тот день, когда узнал о новой es-фишке — генераторах и итераторах. Я только пересел с ruby на javascript, втянулся в него, как в голове что-то щёлкнуло — а как же yield? Восторгу не было предела! Он есть, да ещё и работает по другому — это что ж получается: можно код останавливать? можно возобновлять? а ещё и параметры по ходу выполнения вводить/выводить? Oh My God!


Теперь я пихал их везде. Извращался над ними, как мог, чувствовал в них что-то магическое, не понимал, что именно, но банальная генерация бесконечных коллекций казалась лишь вершиной айсберга их функционала. Посему упорно раз за разом я пытался вывернуть их наизнанку, ставил опыты, как, например, yield первого подать на ввод второго и обратно yield второго подать на ввод первого, зациклить, да ещё и какой-то КПД с этого получить. В общем изголялся настолько, насколько фантазии хватало. В какой-то момент идеи закончились, все подопытные были замучены насмерть, новый гибрид вывести не удалось...


Шёл третий год моей удаленной работы фуллтайм в роли фронтенд-разработчика. Основа стека — react + mobx. Проект растёт как на дрожжах, решения по добавлению/удалению функционала принимаются на лету, дизайны на лету, согласования на лету, сроки горят, что неделю делали сегодня откатили, время потеряно, времени нет, деньги инвестора кончаются, проект замораживается… К этому моменту я изрядно вымотан, выжат и даже немного рад, что появилась такая бессрочная пауза на отдых от боли и удовольствие от хобби — как ни крути, но ценного опыта за плечами поднакопилось, как и идей, вызывающих зуд в черепной коробке.


Взгляды на фронтенд-разработку довольно сильно разнились с текущими трендами. Принципы MV* казались, не то что притянутыми — вытянутыми за уши с сервера в браузер. Чёрт возьми — да мы до сих пор с третьей буквой * определиться не можем! А стейт? — Мы слепо верим, что скелет M должен висеть в шкафу, а плоть V лежать на полке и чем дальше друг от друга тем лучше, при этом оба должны обмениваться приветами и хлопать в ладоши, потому что нам лучше знать, как вам лучше жить!


С этими мыслями я вернулся в лабораторию генераторов, а заодно перешёл на typescript. Человек я разносторонний, попав в википедию, переходами по ссылкам могу погружаться в глубь стека до заветного Maximum call stack size exceeded с последующей потерей контекста и вопросом типа — а чё меня сюда вообще понесло? Собственно эта разностороность сначала добавила в список моих интересов астрономию, квантовую механику и теорию эфира, а затем труды Бенуа Мандельброта и, соответственно, фракталы.


Я помешался. Сознание будто трансформировалось. Отныне во всём я искал и находил фрактальную составляющую, в программном коде, в грамматиках парсеров. Html-код — это же фрактал, разве нет? Почему бы не сделать всё приложение фракталом? А результат? Мы разрабатываем программу, для того чтобы получить от нее ожидаемый результат, а то и серию результатов — последовательность кадров отрисовки на экране, сообщений в логе консоли — а ведь это по сути контрольные точки во времени её жизни — поток отражений её состояния в ходе выполнения, дающий возможность представить происходящее внутри...


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


Безумные идеи а-ля "наш мир один большой фрактал" то и дело теребили разум и не только мой — я задолбал этими разговорами жену и друзей, дети, к их счастью, не попадали под эту раздачу отборного бреда. Однажды я заявил супруге: "Заведу себе пса, назову — Фрактал" — долго смеялись — "Фрактал! Ко мне!". Звучит! Не правда ли?)


Как-то февральским вечером я стоял на балконе и втыкал в звёзды. Небо на удивление было необычайно чистым. Сириус, самая яркая, всегда в это время висящая над крышей дома напротив, казалось, была близка, как никогда. А что если рвануть навстречу звезде? И, чисто гипотетически — что если, во время приближения, с определенного расстояния мы обнаружим, что это не звезда, а целое созвездие или система звёзд? Ведь с Сириусом так и было — до середины 19 века считалось, что это самостоятельная звезда, пока в 1844 немецкий астроном Фридрих Бессель не предположил, а в 1862 американский астроном Альван Кларк не подтвердил, что Сириус это система из двух звёзд, вращающихся вокруг общего центра масс. Впоследствии они получили названия Сириус А и Сириус В. Две близко расположенных звезды, свет которых ввиду огромного расстояния воспринимается нами, как излучаемый одним источником. А что если выбрать одну из этих звёзд и лететь уже навстречу ей, а с какого-то расстояния опять обнаружить созвездие? А что если так будет повторяться бесконечно? Мы бесконечно будем видеть поток света, проецируемый на сетчатку нашего глаза, но при всём желании не сможем достичь его источника, а именно так и получается при постоянном погружении во множество Мандельброта, Жулиа… Мы постоянно видим… Поток? Проекций? Фрактала?


Поток проекций фрактала! Вот оно! Мгновение, когда решение всплывает перед глазами, осознается и в миг разбивается на осколки где-то в подкорке сознания. На момент истина предстаёт как есть, во всей красе и тут же скрывается за поворотом. Ты пытаешься схватить её за руку, но все усилия тщетны. Всё что тебе остается — собирать разбитый паззл её образа, стараясь не растерять детали.


Должен признаться, что на сбор этого паззла ушло несколько месяцев, 3 репозитория, ~800 git-веток и тысячи строк экспериментального кода. Теперь мне не давал покоя yield*. Да-да, со звёздочкой. Вы часто используете return внутри генератора? С вашего позволения я разбавлю этот эпос небольшим куском кода, в котором, используя генераторы, мы опишем формирование проекции Сириуса в созвездии Большого Пса.


async function* SiriusA() {
    return '*A' // Проекция звезды Сириус А
}

async function* SiriusB() {
    return '*B' // Проекция звезды Сириус В
}

async function* Sirius() {
    // ['*A', '*B'] Проекция звёздной системы Сириус
    return [yield* SiriusA(), yield* SiriusB()]
}

async function* CanisMajor() {
    const sirius = yield* Sirius() // ['*A', '*B']
    // а где-то тут создается проекция созвездия Большого Пса
}

И так до бесконечности можно собирать всё более и более сложные структуры-проекции. Вы скажете: "Что тут особенного? Все тоже самое можно описать обычными функциями" — и будете правы, но одна особенность тут всё же есть. Вся фишка в yield*. Дело в том, что на пути следования к return-значению данное выражение попутно будет "выкидывать наверх" встречающиеся yield-значения, что можно задействовать в служебных целях — скрытно, за кулисами, определить контекст выполнения, режим работы и прочие внутренние системные параметры. Не буду утомлять вас подробностями реализации — на самом деле, как сказал Йозеф Геббельс: "Всё гениальное просто", а все мои танцы с бубном в итоге вылились в ~300 строк элементарного кода. Нет я не гений, это самокритика, из-за которой какое-то время я чувствовал себя идиотом, ведь как оказалось решение лежало на поверхности, нужно было лишь взглянуть под нужным углом. Видать угол долго искал.


Всё есть фрактал


Именно так звучит моя идея, попытка переосмыслить существующие паттерны проектирования приложений. Способ организации кода, в котором каждый элемент огромного приложения, как и само приложение — простая самоподобная сущность. Этой идеей я хочу поделиться с вами, подкрепить её рабочим прототипом, привести примеры и в самом идеальном раскладе — найти единомышленников.


Суть идеи заключается в том, чтобы делить приложение не горизонтально на модели, виды, контроллеры и т.д., а вглубь на фрактально-древовидную структуру, где каждый узел является самостоятельным законченным приложением. Результат работы каждого такого приложения — это поток информации, отражающей его внутреннее состояние.



@fract/core — это реализация — небольшая библиотека, предоставляющая два простых строительных блока: fractal и fraction. С их помощью можно описать сколь угодно сложную я надеюсь структуру, которая будет являться фрактальным приложением. Последнее можно запустить в браузере или на сервере, а можно упаковать в библиотеку, поделиться ею в npm и подключить через yield*.


Простой фрактальный Hello world может выглядеть, например, так


import { fractal, fraction } from '@fract/core'

const Title = fraction('Hello world')

const HelloWorld = fractal(async function* () {
    while (true) yield `App: ${yield* Title}`
})

Всё, что нужно для создания фрактала — это асинхронный генератор, определяющий порядок работы. Фракция — это тоже фрактал, которому из вне можно указывать какие данные использовать в качестве своей проекции, для этого она имеет единственный метод .use(data).


Жизненный цикл


Внутри генератора ключевое слово yield определяет, а выражение yield* извлекает текущую проекцию фрактала, другими словами — если представить всё в виде трубопровода, то yield* — это тянуть снизу, а yield толкать наверх. Этакий pull & push.



Ни каких хуков или методов жизненного цикла не существует, а то как фрактал обращается со своим генератором можно описать в трёх шагах:


  1. с помощью генератора создается новый итератор
  2. итератор выполняется до ближайшего yield или return — полученное значение будет принято за текущую проекцию, во время этой работы все вызовы yield* автоматически устанавливают наблюдаемые зависимости; как только новая проекция сгенерирована, фрактал переходит в режим ожидания обновлений, а вышестоящий фрактал узнаёт об изменениях
  3. узнав об изменениях в нижестоящих фракталах, список зависимостей очищается и, если на предыдущем шаге проекция была получена с помощью yield, фрактал продолжает свою работу со второго шага; если же был return работа продолжается с первого шага

Таким образом return делает то же самое, что и yield — определяет текущую проекцию, но при этом происходит "перезагрузка" и всё начинается с начала.


Проекции и потоки


Вся работа фрактала заключается в генерации проекций, отражающих его внутреннее состояние. Проекции упаковываются в так называемые фреймы, которые бывают двух видов.


Обычный фрейм соответствует следующему интерфейсу и содержит в себе текущую проекцию фрактала


interface Frame<T> {
    data: T;
}

Живой фрейм расширяет интерфейс обычного и помимо текущей проекции, содержит промис следующего фрейма. Данный промис зарезолвится, как только произойдут какие-либо изменения во фракталах, принимавших участие в генерации текущей проекции.


interface LiveFrame<T> extends Frame<T> {
    next: Promise<LiveFrame<T>>;
}

Живой фрейм — это и есть тот самый поток проекций фрактала, иными словами: последовательность снимков его состояния.


Методы запуска


Для запуска фрактального приложения существует два простых метода exec и live. Оба принимают на вход фрактал или асинхронный генератор (такой же как при создании фрактала) и возвращают промис, который зарезолвится фреймом.


import { exec, live } from '@fract/core'

exec<T>(target: Fractal<T> | AsyncGenerator<T>): Promise<Frame<T>>


Простой запуск, позволяющий получить обычный фрейм с текущей проекцией.


const frame = await exec(HelloWorld)

frame.data // 'App: Hello world'

live<T>(target: Fractal<T> | AsyncGenerator<T>): Promise<LiveFrame<T>>


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


const frame = await live(HelloWorld)

frame.data // 'App: Hello world'

Title.use('Fractal Demo')

const nextFrame = await frame.next

nextFrame.data // 'App: Fractal Demo'

Фрактальное приложение


Опишем фрактал некоторого пользователя, имеющего имя, возраст и банковскую карту


const Name = fraction('John')
const Age = fraction(33)
const Balance = fraction(100)

const Card = fractal(async function* () {
    while (true) {
        yield {
            balance: yield* Balance,
        }
    }
})

const User = fractal(async function* () {
    while (true) {
        yield {
            name: yield* Name,
            age: yield* Age,
            card: yield* Card,
        }
    }
})

Каждый описанный выше фрактал является полноценным приложением и может входить в состав более сложных фракталов


const frame = await exec(Balance)
frame.data //> 100

const frame = await exec(Card)
frame.data //> {balance: 100}

const frame = await exec(User)
frame.data
/*
> {
    name: 'John',
    age: 33,
    wallet: {
        balance: 100
    }
}
*/

С помощью exec получаются разовые снимки текущего состояния, а с помощью live — живые


const frame = await live(User)

console.log(frame.data)
/*
> {
    name: 'John',
    age: 33,
    card: {
        balance: 100
    }
}
*/

Name.use('Barry')
Balance.use(200)

const nextFrame = await frame.next

console.log(nextFrame.data)
/*
> {
    name: 'Barry',
    age: 33,
    card: {
        balance: 200
    }
}
*/

Чтобы получать живые проекции и не заниматься перебором цепочки фреймов, можно написать фрактал, который будет передавать проекцию от User потребителю (выводить в консоль например), а в качестве своей отдавать undefined (выше всё равно уже никого нет)


const App = fractal(async function* () {
    while (true) {
        console.log(yield* User)
        yield
    }
})

live(App) // запускаем приложение

Реактивность


Система реактивности фрактала сама по себе — фрактал, по своей структуре напоминающий фрактал Кантора



Построена она на обещаниях и их гонках, да да — то самое ненавистное порой состояние гонки тут работает на нас.


В каждом живом фрейме промис следующего фрейма является по сути, промисом актуальности текущей проекции, пока он не зарезолвился — проекция жива. Список зависимостей, собираемый на втором шаге жизненного цикла, — это массив таких промисов Promise<LiveFrame<T>>[], именуемый racers, когда он составлен, создается гонка Promise.race(racers) — эта гонка тоже промис — racer текущей проекции, и в вышестоящем фрактале он опять попадает в массив racers — петля замыкается. Визуально это можно выразить так



То же самое в коде будет выглядеть следующим образом



Promise.race([
    // level 1 
    Promise.race([/* ... */]),
    Promise.race([/* ... */]),
    Promise.race([
        // level 2
        Promise.race([/* ... */]),
        Promise.race([/* ... */]),
        Promise.race([/* ... */]),
        Promise.race([/* ... */]),
        Promise.race([/* ... */]), 
        Promise.race([ 
            // level 3
            Promise.race([/* ... */]),
            Promise.race([/* ... */]) 
        ])
    ])
])

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


const Name = fraction('John')

const User = fractal(async function* () {
    while (true) {
        yield `User ${yield* Name}`
    }
})

const Title = fraction('Hello')

const Post = fractal(async function* () {
    while (true) {
        delay(5000) // что-то долго делаем
        yield `Post ${yield* Title}`
    }
})

const App = fractal(async function* () {
    while (true) {
        console.log(`App | ${yield* User} | ${yield* Post}`)
        yield
    }
})

live(App)

//> 'App | User John | Post Hello'

Name.use('Barry')
Title.use('Bye')

//> 'App | User Barry | Post Hello'
// через 5 секунд
//> 'App | User Barry | Post Bye'

Здесь мы одновременно внесли изменения во фракции Name и Title, после чего фракталы User и Post начинают обновлять свои проекции, User сделает это первым, затем App обновится не дожидаясь обновления Post — на самом деле App вообще не знает, что Post сейчас обновляется. App обновится ещё раз после того, как Post завершит работу над своей новой проекцией. Ключевой момент тут в том, что один медленный фрактал не "вешает" работу всего приложения.


Временные проекции


Довольно простой в употреблении и очень полезный механизм. Он позволяет организовать фоновое выполнение работы в то время, как вышестоящий фрактал довольствуется временным результатом. Создаются временные проекции с помощью функции tmp(data), а отдаются как обычные с помощью yield.


Один из вариантов использования — организация "лоадеров". Работая во фронтенде, я всегда люто ненавидел лепить эти крутилки-вертелки, хотя понимал их необходимость.


import { fractal, tmp } from '@fract/core'

const User = fractal(async function* () {
    yield tmp('Loading...')

    delay(5000) // что-то долго делаем

    while (true) {
        yield `User John`
    }
})

const App = fractal(async function* () {
    while (true) {
        console.log(yield* User)
        yield
    }
})

live(App)

//> 'Loading...'
// через 5 секунд
//> 'User John'

Здесь фрактал User является "медленным", прежде чем отдать свою проекцию ему надо сходить на сервер, по пути зайти в магазин и т.д. А кто-то сверху в это время ждёт его проекцию. Так вот, чтобы не заставлять себя ждать User отдаёт временную проекцию 'Loading...' и продолжает генерировать основную, которую отдаст по мере готовности, т.е. код генератора после yield tmp(...) продолжает выполняться, но уже в фоне.


Это ещё не всё — вот так, например, можно сделать фрактал-таймер


import { fractal, tmp } from '@fract/core'

const Timer = fractal(async function* () {
    let i = 0

    while (true) {
        yield tmp(i++)
        await new Promise((r) => setTimeout(r, 1000))
    }
})

const App = fractal(async function* () {
    while (true) {
        console.log(yield* Timer)
        yield
    }
})

live(App)

//> 0
//> 1
//> 2
//> ...

Здесь фрактал Timer отдаёт текущее значение переменной i в качестве своей временной проекции и продолжает вычисление следующей, в процессе чего инкрементит i, дожидается окончания задержки в 1 секунду и цикл повторяется. Кстати говоря фракция именно так и устроена — она отдаёт временную проекцию с текущим значением, и ждёт завершения промиса, который зарезолвится новым значением переданным в метод .use(data), после чего цикл повторится.


Делегирование


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


Допустим у нас есть фабрика newEditor, которая создает фрактал, отвечающий за редактирование профиля пользователя. Также у нас есть фрактал Manager, который в зависимости от фракции ProfileId переключает редактируемый профиль.



function newEditor(id) {
    return fractal(async function* () {
        const { name } = await loadUserInfo(id)
        const Name = fraction(name)

        while (true) {
            // где-то в глубине этого фрактала генерируется
            // интерфейс редактирования имени пользователя
            yield <input 
                placeholder="Input name" 
                value={yield* Name} 
                onChange={(e) => Name.use(e.target.value)} 
            />
        }
    })
}

const ProfileId = fraction(1)

const Manager = fractal(async function* () {
    while (true) {
        const id = yield* ProfileId
        const Editor = newEditor(id)
        yield Editor // <-- делегируем работу фракталу Editor
    }
})

const App = fractal(async function* () {
    while (true) {
        yield yield* Manager
    }
})

Фрактальное дерево будет производить пересборку проекций изнутри-наружу каждый раз, когда где-то в его глубине при редактировании будут происходить изменения, в данном примере во фракции Name. Пересборка неизбежно будет перезапускать циклы while(true) на всех уровнях до самого корня App, за исключением фрактала Manager. Последний делегирует работу над своей проекцией фракталу Editor, и как бы выталкивается из цепочки регенерации.



Повлиять на Manager может только фракция ProfileId. Как только она изменится Manager запустит цикл пересборки, в котором создаст новый фрактал Editor и снова делегирует ему дальнейшую работу.


Без механизма делегирования нам приходилось бы вручную определять, что изменилось — фракция ProfileId или что-то другое в глубине фрактала, ведь нам не нужно создавать новый Editor, если id редактируемого профиля не изменился. Подобный код выглядел бы довольно многословно и не особо красиво на мой взгляд.


const ProfileId = fraction(1)

const Manager = fractal(async function* () {
    let lastProfileId
    let Editor

    while (true) {
        const id = yield* ProfileId

        if (id !== lastProfileId) {
            lastProfileId = id
            Editor = newEditor(id)
        }

        yield yield* Editor
    }
})

В следующем примере вы можете увидеть, что произойдет если фракции в качестве новой проекции передать фрактал.


const BarryName = fractal(async function* () {
    while (true) yield 'Barry'
})

const Name = fraction('John')

const App = fractal(async function* () {
    while (true) {
        console.log(yield* Name)
        yield
    }
})

live(App)

//> 'John'
Name.use(BarryName)
//> 'Barry'

Произойдет опять же — делегирование, поскольку фракция это обычный фрактал и внутри её генератора происходит yield BarryName.


Факторы


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



import { factor } from '@fract/core'

const API_VERSION = factor('v2') // 'v2' | 'v3'
// необязательное значение ^^^^ по умолчанию

/* далее код из тела генератора */

yield* API_VERSION('v3')    // устанавливаем значение фактора
yield* API_VERSION          // 'v3' - получаем
yield* API_VERSION.is('v3') // boolean - сравниваем

// установка без аргументов эквивалентна
// сброcу до значения по умолчанию
yield* API_VERSION()
yield* API_VERSION          // 'v2' 

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


const Page = fractal(async function* () {
    const apiVersion = yield* API_VERSION

    while (true) {
        yield `Work on api "${apiVersion}"`
    }
})

const Modern = fractal(async function* () {
    yield* API_VERSION('v3')
    // всем нижележащим фракталам изпользовать api v3

    while (true) {
        yield yield* Page
    }
})

const Legacy = fractal(async function* () {
    yield* API_VERSION('v2')
    // всем нижележащим фракталам изпользовать api v2

    while (true) {
        yield yield* Page
    }
})

const App = fractal(async function* () {
    while (true) {
        console.log(`
            Modern: ${yield* Modern}
            Legacy: ${yield* Legacy}
        `)
        yield
    }
})

live(App)

/*
> `
    Modern: Work on api "v3"
    Legacy: Work on api "v2"
`
*/

Скрытый текст

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



const Top = fractal(async function* () {
    yield* API_VERSION('v3')

    while (true) {
        yield yield* Middle
    }
})

const Middle = fractal(async function* () {
    yield* API_VERSION       // 'v3' - определено во фрактале Top
    yield* API_VERSION('v2') // переопределяем, но для нижних уровней
    yield* API_VERSION       // на своем уровне у нас остается 'v3'

    while (true) {
        yield yield* Bottom
    }
})

const Bottom = fractal(async function* () {
    yield* API_VERSION       // 'v2' - переопределено в Middle

    while (true) {
        yield /*...*/
    }
})

И кость и плоть


Один из вариантов использования факторов — это указание режима работы, в зависимости от которого фрактал генерирует проекцию определенного типа. Допустим нам нужно построить приложение, которое умеет выводить информацию на экран, параллельно сохранять свой стейт в локальное хранилище, а при обновлении страницы восстанавливаться из последнего сохраненного состояния.



const APP_STORE = 'APP'

function newApp({ name = 'Hello world' } /* AppState {name: string} */) {
    const Name = fraction(name)

    return fractal(async function* App() {
        while (true) {
            switch (yield* MODE) {
                case 'asString':
                    yield `App ${yield* Name}`
                    continue
                case 'asData':
                    yield { name: yield* Name } // as AppState {name: string}
                    continue
            }
        }
    })
}

const Dispatcher = fractal(async function* () {
    // берем сохраненное состояние из локального хранилища
    const data = JSON.parse(localStorage.getItem(APP_STORE) || '{}')

    // создаем фрактал нашего приложения
    const App = newApp(data)

    // создаем фрактал с предопределенным режимом работы 'asString'
    const AsString = fractal(async function* () {
        yield* MODE('asString')
        while (true) yield yield* App
    })

    // создаем фрактал с предопределенным режимом работы 'asData'
    const AsData = fractal(async function* () {
        yield* MODE('asData')
        while (true) yield yield* App
    })

    while (true) {
        const asString = yield* AsString // это мы выведем на экран
        const asData = yield* AsData     // а это сохраним в хранилище
        // выводим
        console.log(asString)
        // сохранияем
        localStorage.setItem(APP_STORE, JSON.stringify(asData))
        yield
    }
})

Что тут происходит: один и тот же фрактал App по разному генерирует свои проекции в зависимости от фактора MODE, зная это мы подключаем его к фракталам AsString и AsData, которые в свою очередь подключаем к Dispatcher. В результате мы получаем две разных проекции, принадлежащих одному и тому же фракталу — одна в текстовом виде, вторая в виде данных.



Именно этот вариант использования факторов позволяет нам не отделять плоть от костей в масштабе всего приложения (не делить на модели и виды). Фрактал — это более абстрактная сущность, для которой и модель, и вид — всего лишь проекции, слепки текущего внутреннего состояния фрактала. Эти слепки мы можем получать одновременно и использовать по разному — один для вывода на экран, второй для сохранения. Их может быть сколько угодно, так же просто можно реализовать генерирование слепка, предоставляющего централизованное api управления фракталом и т.д.


MV* MVVM MVP


Вот мы и подошли к сравнению фрактального подхода в архитектуре приложений с традиционными, привычными нам MV* MVVM MVP и т.д. Последние я попытался изобразить в левой части. Не претендую на точность и правильность, чисто для освежения вашей памяти, честно говоря до сих пор путаюсь какая стрелка куда должна идти



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


Фрактальный модуль


Это обычный javascript модуль, который экспортирует непосредственно сам фрактал и факторы, необходимые для его работы.


import { fractal, factor } from '@fract/core'

// app.js
export const API_URI = factor()
export const THEME = factor('light')

export const App = fractal(async function* () {
    const apiUri = yield* API_URI
    const theme = yield* THEME

    if (!apiUri) {
        // обязательный фактор
        throw new Error('Factor API_URI is not defined')
    }

    while (true) {
        /*...*/
    }
})

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


Асинхронность и code splitting


Под капотом вся работа фрактала происходит асинхронно, тем не менее внутри генератора её довольно просто контролировать с помощью привычного await. Фракталы, находящиеся в разных ветвях "фрактального дерева" работают не зависимо друг от друга и теоретически могут делать свою работу одновременно. Теоретически — потому что всем нам извесно, что js однопоточен, а асинхронные операции всё равно происходят в порядке очереди. Сюда бы воркеры задействовать, но это уже отдельная тема для размышлений...


Говоря о разделении кода на мелкие куски, стоит наверное сделать небольшое отступление и поговорить о правильной загрузке приложения. Идеальную, в моём понимании, загрузку я попытался изобразить на следующей картинке



Представьте, что у нас на сервере, как на столе, лежит граф зависимостей (модулей). При этом момент загрузки приложения сравним с тем, что мы с вами подошли и начали тянуть его вверх за точку входа в приложение, отрывая узлы от стола, что поднялось — загрузилось, что осталось лежать — пускай лежит, пока не потребуется.


Добиться этого можно импортируя зависимости по мере надобности прямо в теле генератора, что позволяет сборщикам, типа webpack, безболезненно разбивать код на мелкие части, загрузка которых будет происходить исключительно по требованию


// ./user.js
export const User = fractal(async function* () {
    while (true) yield `User John`
})

// ./app.js
export const App = fractal(async function* () {
    // импортируем зависимость, когда она нам действительно нужна
    const { User } = await import('./user')

    while (true) yield `User ${yield* User}`
})

Никакой скрытой магии, специальных загрузчиков и прочего, всё решается нативными средствами, с сохранением IntelliSense в редакторе.


Смотри вглубь


Когда разработка первой версии была завершена, настало время писать демки, первым делом я конечно же хотел реализовать знакомый всем пример с TodoMVC. Я привычным образом создал репозиторий, настроил сборку, подтянул зависимости и… впал в ступор — я только, что создал инструмент, в котором знаю каждую шестеренку, но не умею им пользоваться. Да-да — я споткнулся на пороге у входа в собственный дом. Возился пол дня, но ничего годного так и не получилось — по инерции я пытался создать стор и натянуть виды на модели. В какой-то момент меня стала охватывать паника, появилось ощущение, что всё это время я был в забвении, кодил свои мечты, которые вмиг разбились о простейшее практическое задание. Я не мог в это поверить...


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


После усвоения этого правила, демка была написана довольно быстро. В азарте от гордости за проделанную работу я написал ещё парочку, чтоб придти сюда не с пустыми руками. Все они написаны с использованием react и styled-components — это привычные для меня элементы стека разработки и надеюсь, что их присутствие не повлияет на ваше восприятие, поскольку использованы они исключительно в целях визуализации.


  • Todos — думаю этот пример не нуждается в представлении
  • Loadable — пример, показывающий работу временных проекций, в исходниках можно увидеть, как с помощью yield tmp(...) организуется показ лоадеров в то время, как в фоне производится загрузка, я специально добавил там небольшие задержки для того, чтоб немного замедлить процессы
  • Factors — работа в разных условиях. Один и тот же фрактал в зависимости от установленного в контексте фактора отдаёт три разные проекции, а также поддерживает их актуальность. Попробуйте поредактировать имя и возраст.
  • Antistress — просто игрушка, щёлкаем шарики, красим их в разные цвета и получаем прикольные картинки. По факту это фрактал, который показывает внутри себя кружок, либо три таких же фрактала вписанных в периметр круга. Клик — покрасить, долгий клик — раздавить, долгий клик в центре раздавленного кружка — возврат в исходное состояние. Если раздавить кружки до достаточно глубокого уровня, можно разглядеть треугольник Серпинского

Планы на будущее


  • попробовать внедрить возможность использования синхронных генераторов для повышения производительности в тех местах приложения, где асинхронность не требуется
  • написать рендер jsx -> html заточенный именно под фрактальную структуру, react годится только для демок, ибо по факту вся его работа заключается только в том, чтобы вычислить diff и применить изменения, остальной код простаивает
  • а может даже рассмотреть вариант создания собственной фрактальной системы компонентов и их стилизации — амбициозно не правда ли? ещё амбициознее то, что браузер также может быть фракталом, как и другие приложения операционной системы, как и сама операционная система в целом
  • поэкпериментировать с grahpql, где-то на горизонте мне мерещится элегантное решение с организацией подписок на события сервера а-ля yield* gql'...'
  • связать свою жизнь с open source — это и есть та самая лечебка, о которой я говорил в самом начале
  • выучить английский :) Кстати, поскольку у меня с ним сейчас трудности — буду рад любой помощи по переводу и дополнению readme

Попробовать


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


Разрабатывать сейчас коммерческий продукт с нуля на фрактале я бы не рекомендовал, всё таки пока что это в первую очередь идея и концепт, требующий ухода и заботы для правильного роста во что-то зрелое, а вот попробовать где-то на кусочке своего react-приложения можно, для этого я создал простой компонент и оформил его ввиде библиотеки @fract/react-alive


import { fractal } from '@fract/core'
import { Alive } from '@fract/react-alive'

const App = fractal(async function* () {
    while (true) {
        yield <div>Hello world</div>
    }
})

function Render() {
    return <Alive target={App} />
}

Насчёт библиотек — я говорил, что можно создать фрактальное приложение, упаковать его в библиотеку и подключить через yield*? В качестве примера я сделал библиотеку @fract/browser-pathname. Она экспортирует фрактал, проекцией которого является параметр window.location.pathname, и метод redirect(p: string) позволяющий его менять. Её исходники находятся тут, а то, как с её помощью можно организовать простейший роутер, можно увидеть в исходниках главной демо-страницы.


Напоследок


Фрактал — это не убийца всего и вся. Это новая мутация, которая либо будет отброшена на задворки эволюции, либо станет её новым витком и займет своё достойное место в этом мире. Повторюсь о том, что react использован в демках только, как средство визуализации, никакой физической связи у фрактала с ним нет, а заточка под фронтенд, указанная в планах на будущее — это одно из направлений развития фрактальной экосистемы.



В заключение хотелось бы выразить благодарность всем читателям и надежду на то, что не зря потратил ваше время, а может даже сделал ваш день чуточку позитивнее. Буду рад вашим комментариям, конструктивной критике и помощи в развитии проекта.


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


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