nin-jin / HabHub

Peering social blog
The Unlicense
62 stars 0 forks source link

Проектируем идеальную систему реактивности #48

Open nin-jin opened 2 years ago

nin-jin commented 2 years ago

https://page.hyoo.ru/#!=eh2o9_cl9nuy

Здравствуйте, меня зовут Дмитрий Карловский и я.. крайне плох в построение социальных связей, но чуть менее плох в построении программных. Недавно я подытожил свой шестилетний опыт реактивного программирования, проведя обстоятельный анализ различных подходов к решению типичных детских болячек:

Main Aspects of Reactivity

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

Вторая стадия принятия мола в своё сердце: всё ещё пригорает, но уже не можешь остановиться.

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

Повествование будет долгим, но если вы продержитесь до конца, то сможете смело идти к начальнику за повышением. Даже если вы сам себе начальник. Так что поехали!

Origin

Origin: 🚂Pull

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

В нативном JS для этого есть специальный синтаксис - геттеры и сеттеры:

_title = ''
get title() { return this._title } // pull
set title( text: string ) { this._title = text } // push

Но у них очень много ограничений:

В некоторых фреймворках распространены хуки вида:

//               push         pull
const [ title, setTitle ] = useState( '' )

Но они не эффективны и не удобны в использовании, так как не разделяют фазу инициализации (настройка дефолта, получение проталкивающего колбека) от фазы работы со значениями (затягивание и проталкивание значения). К тому же для двустороннего связывания приходится писать много кода вида:

<Input value={ title } onChange={ setTitle } />

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

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

let _title = ''
const title = ( text = _title )=> _title = text

title()                  // ''
title( 'Buy some milk' ) // 'Buy some milk'
title()                  // 'Buy some milk'

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

Например, мы легко можем игнорировать запись значения, а при чтении генерировать его на лету, получая таким образом канал только для чтения:

let id = ( dur?: number )=> Math.floor( Math.random() * 100 )

id()     // 34
id( 18 ) // 83
id()     // 13

Можем сделать его только для записи событий, используя как обработчик событий:

let _completed = false
complete( event: Event ) {
    _completed = true
}

А можем полностью делегировать один канал другому:

let details = ( text?: string )=> title( text )

details()                  // ''
details( 'Buy some milk' ) // 'Buy some milk'
details()                  // 'Buy some milk'

Таким образом, каналы могут выстраиваться в цепочки, трансформируя данные по мере пересечения слоёв абстракций:

let details_html = ( html?: string )=>
    html_encode(
        details(
            html && html_decode( html )
        )
    )

details()                              // ''
details_html( 'Buy milk &amp; bread' ) // 'Buy milk &amp; bread'
details()                              // 'Buy milk & bread'
details_html()                         // 'Buy milk &amp; bread'

Каналы могут быть:

Style

Style: 🤓Obj

Заметим, что каналы-синглтоны, не очень практичны, ибо не дают нам декомпозировать приложение. Поэтому нам нужен ООП...

Property

Объекты позволяют нам группировать связанные по смыслу каналы в единую капсулу. Более того, они позволяют нам иметь много таких капсул, построенных из одного кода (экземпляры классов). Простой пример:

class Task extends Object {

    // stateful channel
    _title = ''
    title( title = this._title ) {
        return this._title = title
    }

    // stateless channel
    details( details?: string ) {
        return this.title( details )
    }

}

Заметим, что если с делегатами всё довольно просто, то вот с каналами, хранящими состояние, всё не так радужно: имя канала приходится повторять по 4 раза. А это значит, что при копипасте неизбежно будут проблемы, ибо придётся по 4 раза вносить одинаковые правки. И авторефакторинг нам тут ни чем не поможет.

Что ж, заметим, что мы всегда возвращаем то значение, которое сохраняем, и всегда сохраняем то, что хотим вернуть. А это значит, что мы можем написать декоратор, который возьмёт всё это на себя:

class Task extends Object {

    @ $mol_wire_solo
    title( title = '' ) {
        return title
    }

    details( details?: string ) {
        return this.title( details )
    }

}

Ну, или так, если нам не повезло с языком программирования:

class Task extends Object {

    title( title = '' ) {
        return title
    }

    details( details ) {
        return this.title( details )
    }

}
$mol_wire_solo( Task.prototype, 'title' )

Всё, что нам надо сейчас знать, так это то, что декоратор $mol_wire_solo мемоизирует возвращённое из метода значение, независимо от того, передали мы ему аргумент или нет. Однако, если аргумент не передали, а в кеше уже что-то есть, то вызов метода пропускается и сразу возвращается значение из кеша. И так будет действовать пока кеш не будет очищен. А если кеш пустой - метод всё же вызывается для получения значения по умолчанию. Ну а если аргумент передан, то метод будет вызван в любом случае, чтобы обновить кеш.

Помимо лаконичности кода, мемоизация даёт нам и ещё пару важных свойств: экономию вычислений и идемпотентность. Проиллюстрируем это следующим примером:

class Task_extended extends Task {

    @ $mol_wire_solo
    title_for_mirror() {
        const segmenter = new Intl.Segmenter()
        const segments = [ ... segmenter.segment( this.title() ) ]
        return segments.map( s => s.segment ).reverse().join('')
    }

    @ $mol_wire_solo
    Duration() {
        return new $mol_time_duration({ hour: this.duration() })
    }

}

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

Второй же метод не столько тяжёлый, сколько возвращающий каждый раз новый объект, что зачастую является неприемлемым. Тут уже мемоизация позволяет гарантировать, что сколько бы раз мы ни обратились к методу, результат его вызова будет одним и тем же. Это свойство называется идемпотентностью.

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

Пока же вспомним известное высказывание Phil Karlton:

There are only two hard things in Computer Science: cache invalidation and naming things.

Тут упоминаются две типичные проблемы в любой области программирования. Так вот, первую мы в этой статье решим полностью, а вторую.. не полностью, но сделаем её менее проблемной.

Recomposition

Канал может выдавать не только простые типы данных, но и составные, в том числе и собранные из других каналов. Например, соберём все данные объекта в виде одного DTO:

class Task extends Object {

    @ $mol_wire_solo
    title( title = '' ) {
        return title
    }

    @ $mol_wire_solo
    duration( dur = 0 ) {
        return dur
    }

    @ $mol_wire_solo
    data(
        data?: Readonly< Partial< {
            title: string
            dur: number
        } > >
    ) {
        return {
            title: this.title( data?.title ),
            dur: this.duration( data?.dur ),
        } as const
    }

}

Обратите внимание, что через составной канал мы можем разом обновлять сразу несколько простых каналов:

const task = new Task
task.data() // { title: '', dur: 0 }

const data = task.data({
    title: 'Buy milk',
    dur: 2,
})
task.title() // 'Buy milk'
task.duration() // 2

И наоборот, каналы могут быть линзами, позволяющими работать с частью большой имутабельной структуры, как с самостоятельной мутабельной сущностью:

class Task extends Object {

    @ $mol_wire_solo
    data( data = {
        title: '',
        dur: 0,
    } ) {
        return data
    }

    @ $mol_wire_solo
    title( title?: string ) {
        return this.data(
            //title === undefined ? undefined : { ... this.data(), title }
            title?.valueOf && { ... this.data(), title }
        ).title
    }

    @ $mol_wire_solo
    duration( dur?: number ) {
        return this.data(
            dur?.valueOf && { ... this.data(), dur }
        ).dur
    }

}

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

Multiplexing

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

class Entity extends Object {

    constructor(
        readonly id: number
    ) { super() }

    destructor() {}

    @ $mol_wire_solo
    data( data = {} ) {
        return data
    }

    @ $mol_wire_plex
    value<
        Field extends keyof ReturnType< this['data'] >
    >(
        field: Field,
        value?: ReturnType< this['data'] >[ Field ],
    ): ReturnType< this['data'] >[ Field ] {

        return this.data( value === undefined
            ? undefined
            : {
                ... this.data(),
                [ field ]: value,
            }
        )[ field as never ]

    }

}

Теперь, мы можем работать с каналом data через мультиплексированный канал value, не занимаясь ручной (де)структуризацией:

class Task extends Entity {

    @ $mol_wire_solo
    data( data = { title: '', dur: 0 } ) {
        return data
    }

    title( title?: string ) {
        return this.value( 'title', title )
    }

    duration( dur?: number ) {
        return this.value( 'dur', dur )
    }

}

А теперь давайте перенаправим хранение состояния из памяти в локальное хранилище:

class Task_persist extends Task {

    @ $mol_wire_solo
    data( data?: Readonly<{
        title: string
        dur: number
    }> ) {
        return $mol_state_local.value( `task=${ this.id() }`, data )
            ?? { title: '', cost: 0, dur: 0 }
    }

}

// At first tab
const task = new Task_persist( 777 )
task.title( 'Buy some milk' ) // 'Buy some milk'

// At second tab
const task = new Task_persist( 777 )
task.title()                  // 'Buy some milk'

А так как каналы у нас реактивные, то приложения в разных табах получают мгновенную синхронизацию автоматически:

ToDoMVC на $mol - автоматическая синхронизация данных между вкладками

Keys

Просто передавать ключи - дело не хитрое, но когда нам нужно по этим ключам что-то сохранять и находить, начинаются сложности. Нам нужна возможность использования не только примитивных ключей, но и массивов, словарей, объектов в любой комбинации. Record и Tuple в JS ещё не завезли, поэтому будем импровизировать..

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

key string
[ /foo/ ] [{}]
[ new Task ] [{}]

Благо в JSON.stringify можно передать свой примитивизатор, через который мы можем подсказать ему, как именно в нашем ключе должны быть представлены те или иные объекты:

key string
[ /foo/ ] ["/foo/"]
[ new Task ] ["GIYBAK10"]
[ new Date( '2022-02-22' ) ] ["2022-02-22T00:00:00.000Z"]

Ага, наши задачи не реализуют метод toJSON, поэтому к ним был сгенерирован и привязан через WeakMap уникальный идентификатор, гарантирующий, что невзаимозаменяемые объекты не будут внезапно давать одинаковый ключ.

А вот объект Date реализует toJSON, выдавая ISO8601 представление времени, так что все объекты указывающие на одно и то же время будут давать один и тот же ключ.

В результате у нас получилась библиотека $mol_key, ростом всего в 1 килобайт. Благодаря ей мы можем идентифицировать каналы не только примитивами, но и всякими сложными структурами:

@ $mol_wire_plex
task_search( params: {
    query?: string
    author?: Person[],
    assignee?: Person[],
    created?: { from?: Date, to?: Date }
    updated?: { from?: Date, to?: Date }
    order?: { field: string, asc: boolean }[]
} ) {
    return this.api().search( 'task', params )
}

Factory

Канал, который создаёт, настраивает и мемоизирует объект, будем называть фабрикой. Потребителю не важно когда и как будет создан объект. Как и не важно, когда этот объект надо будет уничтожить. Ему достаточно лишь знать как его получать при очередном пересчёте. А фабрика уж сама разберётся, когда его создать, а когда уничтожить.

Для примера, создадим проект, который будет владеть всеми нашими задачами и аккаунт, который будет владеть всеми нашими проектами:

class Project extends Entity {

    @ $mol_wire_plex
    task( id: number ) {
        return new Task( id )
    }

}

class Account extends Entity {

    @ $mol_wire_plex
    project( id: number ) {
        return new Project( id )
    }

}

Теперь мы можем через один канал получить объект, а через него обратиться к другому каналу, чтобы получить третий объект, и быть уверенными, что не будет ни Null Pointer Exception, ни утечек памяти:

class App extends Object {

    @ $mol_wire_plex
    static account( id: number ) {
        return new Account( id )
    }

    @ $mol_wire_method
    static main() {
        console.log( this.account(1) )
        console.log( this.account(1).project(23) )
        console.log( this.account(1).project(23).task(456) )
    }

    static [ Symbol.toStringTag ] = 'App'

}

App.main()
// logs
//      App.account(1)
//      App.account(1).project(23)
//      App.account(1).project(23).task(456)

В данном примере, будут автоматически созданы 3 объекта, которые будут держаться в памяти до завершения функции, а потом будут автоматически уничтожены.

Чтобы всё это работало, надо, чтобы объекты реализовывали метод destructor, который сигнализирует фабрике, что это не просто структура, а объект, временем жизни которого можно управлять. При его создании constructor вызывается автоматически JS-рантаймом. А destructor для уничтожения вызывается автоматически уже фабрикой, когда она через систему отслеживания зависимостей понимает, что объект никому больше не нужен.

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

class Test {

    @ $mol_wire_method
    data_fields_should_be_independen() {

        // given
        cosnt task = App.account(1).project(23).task(456)
        $mol_assert_unique( task.duration(), 3 )
        $mol_assert_unique( task.title(), 'test title' )

        // when
        task.title( 'test title' )
        task.duration( 3 )

        // then
        $mol_assert_equal( task.duration(), 3 )
        $mol_assert_equal( task.title(), 'test title' )

    }

}

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

Hacking

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

class Project_limited extends Project {

    @ $mol_wire_plex
    task( id: number ) {
        const task = new Task( id )
        task.duration = duration => this.task_duration( id, duration )
        return task
    }

    @ $mol_wire_plex
    task_duration( id: number, duration = 1 ) {
        return Math.min( duration, this.duration_max() )
    }

    duration_max() {
        return 10
    }

}

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

class Account_limited extends Account {

    @ $mol_wire_plex
    project( id: number ) {
        const project = new Project_limited( id )
        project.duration_max = ()=> this.duration_max()
        return project
    }

    duration_max( max = Number.POSITIVE_INFINITY ) {
        return max
    }

}

Хакинг - это мощная техника, позволяющая провязывать объекты друг с другом в самых разных направлениях. При этом не теряя ни в скорости, ни в надёжности, так как:

На следующей диаграмме вы видите дерево владения из 5 объектов, которые совместно работают с общим состоянием, расположенном в одном из них в середине иерархии:

Binding

Во многих фреймворках весьма распространена такая вещь как связывание - синхронизация нескольких состояний 1-к-1. Обычно различают два типа связывания:

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

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

Простой пример, иллюстрирующий все эти варианты:

class Project extends Object {

    @ $mol_wire_plex
    task( id: number ) {
        const task = new Task( id )

        // Hacking one-way
        // duration <= task_duration*
        task.duration = ()=> this.task_duration( id )

        // Hacking two-way
        // cost <=> task_cost*
        task.cost = next => this.task_cost( id, next )

        return task
    }

    // task's duration source of truth
    @ $mol_wire_plex
    task_duration( id: number, next = 0 ) {
        return next
    }

    // task's cost source of truth
    @ $mol_wire_plex
    task_cost( id: number, next = 0 ) {
        return next
    }

    // Delegation one-way
    // status => task_status*
    task_status( id: number ) {
        return this.task( id ).status()
    }

    // Delegation two-way
    // title = task_title*
    task_title( id: number, next?: string ) {
        return this.task( id ).title( next )
    }

}

Debug

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

Простой пример, где мы получаем несколько объектов и какие у них получаются имена:

class App extends Object {

    @ $mol_wire_plex
    account( id: number ) {
        return new Account( id )
    }

}

const app = new App

// define name of root object directly
app[ Symbol.toStringTag ] = 'app'

const account = app.account(1) // app.account(1)
const project = account.project(23) // app.account(1).project(23)
const task = project.task(456) // app.account(1).project(23).task(456)

Введя такое имя в консоль мы получаем соответствующий объект:

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

Самый простой способ объяснить отладчику, как отображать наш объект - прописать имя oбъекта через Symbol.toStringTag. В Хроме это имя будет отображаться в том числе и в стектрейсах:

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

Более продвинутый путь - использование custom formatters для динамического рисования контента в отладчике. С их помощью мы можем сделать навигацию по нашему дереву зависимостей более наглядной:

Тут мы видим 6 подписок на 4 издателей, один из которых имеет значение true, но оно устаревшее (красный), поэтому оно будет автоматически обновлено, как только кто-то к нему обратится, а остальные - актуальные (зелёные), так что их пересчёта не будет.

Наконец, мы можем мониторить изменения в подграфе реактивного графа состояний, и, например, логировать их:

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

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

Фрактал имён элементов

Watch

Watch: 🤝Links

Все состояния в нашем приложении должны быть связаны между собой и образовывать таким образом связный направленный ациклический граф (DAG). У каждого узла в этом графе есть два типа соседей:

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

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

Fiber

Объединим состояние, формулу его вычисления, списки соседей и прочую мета информацию в одну абстракцию: волокно - мемоизируемая задача с реактивной ревалидацией. Наивно в коде его можно представить так:

// Meta Size: 216B+
// Edge Cost: 16B
// Allocations: 6
interface Fiber<
    State = unknown,
    Host = unknown,
    Args = unknown[]
> extends Object { // 12B
    host: Host // 4B for calculation context
    task: ( this: Host, args: Args )=> State // 4B for calculation function
    args: Args // 4B + 16B + 8B+ for calculation arguments
    state: State // 4B for calculation result
    cursor: number // 4B for current status or publisher tracking position
    pubs: Set< Fiber > // 4B + 16B + 60B+ for publishers
    subs: Set< Fiber > // 4B + 16B + 60B+ for subscribers
}

Но тут есть следующие проблемы:

Самое оптимальное - избавиться от хеш-таблиц и объединить все динамические данные в одном единственном массиве, что потребует всего 3 аллокации памяти (на сам объект, на статическую часть массива и динамическую):

// Meta Size: 64B+
// Edge Cost: 16B
// Allocations: 3
interface Fiber<
    State = unknown,
    Host = unknown,
    Args = unknown[]
> extends Object { // 12B
    host: Host // 4B for context
    task: ( this: Host, args: Args )=> State // 4B for calculation
    data: Array< Args[number] | Fiber | number > // 28B+ for arguments, publishers, subscribers
    state: State // 4B for result
    cursor: number // 4B for current status or publisher tracking position
    pub_from: number // 4B for offset of publishers in data
    sub_from: number // 4B for offset of subscribers in data
}

Как видите, все вариативные по размеру части (аргументы, издатели, подписчики) мы вынесли в элементы массива, в полях объекта оставив лишь индексы разделяющие массив на секции. Это позволило нам уменьшить накладные расходы на реактивность в несколько раз.

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

Графически, структуру связей между двумя соседями можно представить на следующем примере, где волокно A зависит от B:

Каждая связь занимает два элемента массива: первый содержит ссылку на соседа, а второй содержит индекс по которому в этом соседе находится обратная ссылка. Таким образом все связи у нас получаются не просто двусторонними, а нам всегда известны смещения в массиве, что позволяет легко добавлять, удалять и перемещать их по массиву за O(1).

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

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

title_double() {
    const title = this.title()
    return `${title}:${title}`
}

Publisher

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

// Meta Size: 44B
// Edge Cost: 16B
// Allocations: 3
interface Pub extends Object { // 12B 
    data: Array< Args[number] | Fiber | number > // 28B
    sub_from: number // 4B
}

Для демонстрации его работы, давайте сделаем реактивной обычную локальную переменную:

const pub = new $mol_wire_pub

let _counter = 0

const counter = ( next?: number )=> {

    if( next === undefined ) {
        pub.promote()
        return _counter
    }

    if( Object.is( next, _counter ) ) return _counter

    pub.emit()

    return _counter = next
}

Теперь, если мы будем работать с этой переменной через созданный нами канал, то:

Таким образом мы можем делать реактивным любое состояние, даже которое нам не принадлежит. Например, текущий адрес страницы:

const pub = new $mol_wire_pub

window.addEventListener( 'popstate', ()=> pub.emit() )
window.addEventListener( 'hashchange', ()=> pub.emit() )

const href = ( next?: string )=> {

    if( next === undefined ) {
        pub.promote()
    } else if( document.location.href !== next ) {
        document.location.href = next
        pub.emit()
    }

    return document.location.href
}

Соберём этот модуль в отдельный бандл и выложим в NPM: mol_wire_pub. Он позволяет сделать любую вашу структуру наблюдаемой, добавив к весу вашей библиотеки всего 1.5КБ.

Так, например, у нас есть собственная реализация CRDT $hyoo_crowd, которую можно использовать и по старинке, без реактивности, но в реактивной среде она автоматически становится наблюдаемой без каких-либо танцев с бубном.

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

namespace $ {
    export class $mol_wire_set< Value > extends Set< Value > {

        pub = new $mol_wire_pub

        // Some accessors

        has( value: Value ) {
            this.pub.promote()
            return super.has( value )
        }

        get size() {
            this.pub.promote()
            return super.size   
        }

        // Some mutators

        add( value: Value ) {
            if( super.has( value ) ) return this
            super.add( value )
            this.pub.emit()
            return this
        }

        delete( value: Value ) {
            const res = super.delete( value )
            if( res ) this.pub.emit()
            return res
        }

    }

}

Как видите, все методы можно разделить на два типа: читающие и изменяющие. В читающих вызываем .promote(), а в изменяющих - .emit(), если изменения действительно произошли.

Часть методов мы тут опустили. Полный набор можно найти в исходниках $mol_wire_set.

Dupes

Dupes: 🎭Equality

Когда волокно принимает новое значение, мы должны уведомить подписчиков об изменении. Но если изменение даёт эквивалентный результат, то можно ничего и не делать. Нам надо лишь понять: является ли изменение эквивалентным, или нет.

Например, если мы получили новый массив, но он поэлементно равен предыдущему, то даже если мы пересчитаем всё, зависящее от него, приложение, то для пользователя ничего не поменяется.

Получается нам надо уметь глубоко сравнивать новые данные и старые. Причём делать это быстро. Но тут на нашем пути может возникнуть ряд трудностей..

Некоторые объекты (например, Value Object) можно сравнивать структурно, другие же (например, DOM элементы или бизнес-сущности) - нельзя. Как их отличать?

Ну, стандартные типы (массивы, структуры, регулярки и тп) можно просто детектировать и сравнивать структурно.

С пользовательскими же чуть сложнее. По умолчанию не будем рисковать, а будем сравнивать их по ссылке. Но если в объекте объявлен метод Symbol.toPrimitive, то считаем, что это сериализуемый объект, а значит такие объекты можно сравнивать через сравнение их сериализованных представлений.

class Moment {

    iso8601: string
    timestamp: number
    native: Date

    [ Symbol.toPrimitive ]( mode: 'number' | 'string' | 'default' ) {
        switch( mode ) {
            case 'number': return this.timestamp
            case 'string': return this.iso8601
            case 'default': return this.iso8601
        }
    }

}

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

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

Наконец, в данных могут встретится циклические ссылки. Как минимум тут нельзя уходить в бесконечный цикл. А желательно правильно их сравнивать.

Например, следующие два объекта структурно эквивалентны:

const left = { id: 'leaf', kids: [] }
left.kids.push({ id: 'son', parent: left })

const right = { id: 'leaf', kids: [] }
right.kids.push({ id: 'son', parent: right })

Оказывается, поддержать циклические ссылки совсем не сложно, когда у нас уже есть кеш. Сперва пишем в него, что объекты эквивалентны, и погружаемся в глубь. Если снова наткнёмся на эту пару объектов, то возьмём значение из кеша и пойдём дальше. Если же где-то найдём отличия, то и в кеше потом поправим, что объекты всё-таки не эквивалентны.

В результате, у нас получилась библиотека $mol_compare_deep, размером в 1 килобайт, которая в разы быстрее любых других, представленных в NPM:

Flow

Flow: 🚕Auto

Пришла пора детально рассмотреть процесс автоматического отслеживания изменений..

Subscriber

Чтобы следить за наблюдаемыми состояниями нам может потребоваться другая легковесная абстракция - подписчик:

// Meta Size: 52B
// Edge Cost: 16B
// Allocations: 3
interface Sub extends Object { // 12B
    data: Array< Args[number] | Fiber | number > // 28B
    sub_from: number // 4B
    pub_from: number // 4B
    cursor: number // 4B
}

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

// Meta Size: 52B
// Edge Cost: 16B
// Allocations: 3
interface PubSub extends Pub { // 44B
    pub_from: number // 4B
    cursor: number // 4B
}

Теперь, рассмотрим цикл отслеживания зависимостей. Идея тут простая:

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

Простой пример:

const susi = new $mol_wire_pub_sub
const pepe = new $mol_wire_pub
const lola = new $mol_wire_pub

const backup = susi.track_on() // Begin auto wire
try {
    touch() // Auto subscribe Susi to Pepe and sometimes to Lola
} finally {
    susi.track_cut() // Unsubscribe Susi from unpromoted pubs
    susi.track_off( backup ) // Stop auto wire
}

function touch() {

    // Dynamic subscriber
    if( Math.random() < .5 ) lola.promote()

    // Static subscriber
    pepe.promote()

}

Обратите внимание, что мы сначала бэкапим предыдущее значение глобальной переменной, а потом восстанавливаем его из бэкапа. Нужно это чтобы автоотслеживания можно было вкладывать друг в друга.

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

Можно было бы возвращать не ссылку, а легковесный объект с методом для отката:

const tracker = susi.track()
try {
    touch()
} finally {
    tracker.stop( !!'cut needless pubs' )
}

Но тогда мы получили бы лишнюю аллокацию памяти в очень горячем коде.

Отдельно стоит рассказать про вызов .track_cut(). Когда мы заканчиваем отслеживать издателей, у нас есть выбор: отписаться от издателей, которых отслеживали раньше, но до которых сейчас не дошли, или нет. Так вот, сей метод пробегается по нетронутым издателям и выполняет эти отписки.

Обычно вызывать его имеет смысл при нормальном завершении вычисления. Если же мы вышли из вычисления досрочно не завершив его (например, вернули управление, так как ждём загрузки от сервера), то лучше ни от кого не отписываться, так как те издатели могут нам вскоре понадобиться, когда загрузка будет завершена.

PubSub

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

interface PubSub extends Pub, Sub {}

// Meta Size: 64B+
// Edge Cost: 16B
// Allocations: 3
interface Fiber<
    Result = unknown,
    Host = unknown,
    Args = unknown[]
> extends PubSub { // 52B
    host: Host // 4B
    task: ( this: Host, args: Args )=> Result // 4B
    cache: Result // 4B
}

Можно выделить два основных типа волокон:

Task

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

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

Было бы классно, чтобы Fibers Proposal был принят в стандарт, но он уже несколько лет находится на стадии -1, ибо его не то, что не реализуют, а наоброт, даже расширение для NodeJS node-fibers недавно капитально сломали.

Но есть и воркэраунд в рамках текущего стандарта, известный под названием SuspenseAPI. Сперва он был реализован во фреймворке $mol, где он используется активнее всего. А со временем его реализовали и в ReactJS, но с кучей ограничений:

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

Идея эта довольно элегантная, но как и любой воркэраунд, имеет ряд ограничений:

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

В качестве примера, давайте загрузим данные, логируя процесс:

// Auto wrap method call to task
@ $mol_wire_method
main() {

    // Convert async api to sync
    const syncFetch = $mol_wire_sync( fetch )

    this.log( 'Request' ) // 3 calls, 1 log
    const response = syncFetch( 'https://example.org' ) // Sync but non-blocking

    // Synchronize response too
    const syncResponse = $mol_wire_sync( response )

    this.log( 'Parse' ) // 2 calls, 1 log
    const response = syncResponse.json() // Sync but non-blocking

    this.log( 'Done' ) // 1 call, 1 log
}

// Auto wrap method call to sub-task
@ $mol_wire_method
log( ... args: any[] ) {

    console.log( ... args )
    // No restarts because console api isn't idempotent

}

Декоратор $mol_wire_method автоматически заворачивает вызов метода в задачу. Но если мы уже находимся в какой-то задаче, то он пытается реиспользовать ту же по порядку подзадачу из предыдущего запуска внешнего волокна. И если это удаётся, происходит либо продолжение работы прошлой задачи, либо мгновенное возвращение значения из неё без запуска метода.

Но что может пойти не так?

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

Если бы мы использовали не методы объектов, а замыкания:

if( Math.random() > .5 ) {
    return useMemo( ()=> 'foo' ) // 1
} else {
    return useMemo( ()=> 'bar' ) // 2
}

.. то при каждом вызове создавалась бы новая функция, и мы не могли бы сравнивать ни функции, ни замкнутые аргументы, а значит попытка реиспользовать существующие задачи могла бы приводить, например, к тому, что исполнение строки (2) может вернуть "foo".

Atom

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

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

В качестве примера, можно привести задачу переключения флага:

@ $mol_wire_method
toggle() {
    this.completed( !this.completed() ) // read then write
}

@ $mol_wire_solo
completed( next = false ) {
    $mol_wait_timeout( 1000 ) // 1s debounce
    return next
}

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

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

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

Abstraction Leakage

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

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

Мы могли бы очищать подписки при проталкивании. Но тогда инварианты перестают работать, что является неожиданным и нежелательным эффектом.

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

@ $mol_wire_solo
left( next = false ) {
    return next
}

@ $mol_wire_solo
right( next = false ) {
    return next
}

@ $mol_wire_solo
res( next?: boolean ) {
    // Auto call `this.res()` when next is defined
    return this.left( next ) && this.right()
}

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

Можно зайти с другого конца и автоматически инициировать затягивание актуального значения сразу после проталкивания. Тогда список подписок будет соответствовать их значениям. Но тогда не понятно какое значение записывать при их отличии. Если записывать то, что вернулось из канала при проталкивании, то это значение получается не соответствует инварианту. Если же записывать затянутое, то мы теряем контроль над кешируемым значением в такого рода методах, где ожидается запись в кеш того, что вернул метод:

@ $mol_wire_solo
count( next: number | string ) {
    return String( next || 0 )
}

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

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

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

Можно было бы усложнить абстракции. Например, разделить каналы на первичные observable и производные computed, как в MobX. Или разделить обработчики затягивания и проталкивания по разным методам как в линзах. Или отделить инварианты от эффектов и попросить прикладных разработчиков очень внимательно за этим разделением следить, как в effector.

Однако, важно понимать, что дополнительные абстракции решая одни проблемы создают новые (например, сложности с делегированием или ограничения на логику). А когнитивная сложность работы с ними меньше не становится. Поэтому, чем проще и понятнее абстракция, тем проще работать и с её слабыми местами.

Итак, вернёмся на исходную и заметим, что как правило проталкивание происходит в канал, из которого уже было затягивание (изменение текущего значения). Либо затягивание не предусмотрено вообще (обработка события). Либо затягивание просто возвращает протолкнутое значение (первичное состояние).

Жёстко обозначим, что в кеш будет записано ровно то значение, что было возвращено из метода. Это может не соответствовать инварианту, но это даёт предсказуемость поведения, а следовательно и доверие программиста написанному им коду.

Когда же при проталкивании надо актуализовать подписки, разработчик может явным образом инициировать затягивание там, где посчитает нужным: до, после и даже в середине работы метода.

@ $mol_wire_solo
page( next?: number ) {

    // enforce pull before push to subscribe to publishers
    if( next !== undefined ) this.page()

    // push to publisher or pull from it
    return this.local_store( 'page', next )

}
@ $mol_wire_solo
res( next?: boolean ): boolean {

    // pull from publishers
    if( next == undefined ) return this.left() && this.right()

    // push to publisher
    this.left( next )

    // enforce pull to subscribe to actual publishers
    return this.res()

}

Tonus

Tonus: 🦥Lazy

Возьмём не сложное приложение со стабилизировавшимся состоянием.

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

Когда меняется какое-либо состояние, тут же, синхронно происходит пометка всех прямых зависимостей как "устаревших", а всех косвенных как "подозреваемых".

Нужно это чтобы каждое состояние знало свой статус актуальности в любой момент времени. Всего таких статусов 4:

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

Распространение уведомлений о смене статуса до корня приложения может быть ресурсоёмкой операцией. Особенно в случае, когда от одного состояния зависят тысячи других. Но это разумная плата за гарантию согласованности.

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

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

Сначала идёт погружение по подозреваемым до устаревших, которые вычисляют новое значение. Если значение поменялось, то все зависимости тоже становятся устаревшими и начинают обновляться. И так далее вычисления поднимаются до запрошенного нами значения. Или останавливаются где-то на пол пути, а все состояния выше помечаются актуальными.

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

Order

Order: 👨‍💻Code

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

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

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

Интересная особенность использования фреймов анимации заключается в том, что когда интерфейс не виден, его рендеринг замирает автоматически, не тратя ресурсы на невидимую работу. Однако, некоторые задачи всё же требуют работы даже в фоне. Такие просто нужно дёргать по нужному вам временному интервалу.

Depth

Depth: 🗻Stack

Допустим у нас длинная цепочка зависимостей. Ходим мы по ней в следующих случаях:

  1. При первичном вычислении.
  2. При распространении уведомлений.
  3. При (частичном) пересчёте.

Есть два способа реализовать эти алгоритмы: рекурсия и цикл.

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

Цикл же сам по себе быстрее рекурсии, но из-за необходимости ручной реализации стека в куче, получается не так-то быстро. А стектрейс получается соответственно неинформативным. Зато глубина зависимостей ограничена лишь объёмом доступной памяти.

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

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

Ну а если мы не упали на переполнении стека в первом и третьем сценарии, то и во втором не упадём. Так что нет смысла замедлять его работу, стараясь поддержать неограниченную глубину.

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

Зато мы получаем стектрейсы, из которых понятно в каком порядке шла обработка состояний:

Тут мы видим, что сработало автообновление корневого атома $mol_view.autobind(), отвечающего за автоматическую привязку приложений к DOM. Он передал эстафету приложению $hyoo_todomvc.Root(0), которое уже передало её своей странице $hyoo_todomvc.Root(0).Page(), минимальная высота которой зависит от минимальной высоты панели $hyoo_todomvc.Root(0).Panel(), которая зависит от списка задач, хранящегося в локальном хранилище, доступном через $mol_state_local.value("mol-todos"), которое при вычислении уже и упало с ошибкой.

Обратите внимание, что все эти промежуточные подозреваемые атомы пропускали свои вычисления, передавая управление всё глубже, пока не дошли до устаревшего $mol_state_local.value("mol-todos"), который и стал вычисляться первым. Если бы он вернул значение, эквивалентное предыдущему, то стек был бы раскручен назад с пометкой подозреваемых атомов как свежих.

Но в нашем случае он вернул ошибку, изменив своё состояние, а значит и все промежуточные атомы по пути до него тоже будут перевычислены, чтобы ошибка пролетела через их прикладные обработчики, если, конечно, по пути она не будет остановлена через try-catch - для прикладного программиста всё как в обычном JS. Особенно, если заигнорить в отладчике все библиотечные модули.

Error

Error: 🦺Store

Реактивное состояние может принимать одно из 3 возможных типов значений:

Прилетать эти значения могут следующими путями:

При этом не особо важно, каким путём мы доставили значение - оно записывается в кеш. При обращении за состоянием поведение зависит только от типа значения:

То есть реактивная мемоизация методов получается не совсем прозрачной: если вы вернёте экземпляр Error, то он всё равно будет потом кинут, а если кинете, например, строку, то она всё равно будет потом возвращена. Получается этакая нормализация поведения.

Можно было бы, конечно, сделать и прозрачное поведение, но в JS мы уже имеем нормализацию с асинхронными функциями: что бы мы ни вернули - они всегда возвращают обещание. А обещания, кстати, в нашем случае нужно уже всё равно не возвращать, а кидать. И об этом далее..

Extern

Extern: 🏇Async

Есть два интерфейса доступа к значению: sync и async. Разберём их особенности..

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

something(): string {

    try {

        // returns allways string
        return do_something()

    } catch( cause: unknown ) {

        if( cause instanceof Error ) {
            // Usual error handling
        }

        if( cause instanceof Promise ) {
            // Suspense API
        }

        // Something wrong
    }

}

Обратите внимание, что в качестве исключения может прилететь не только ошибка, но и обещание. Это так называемый Suspense API, который позволяет работать с асинхронным кодом, как с синхронным. И это может быть поддержано в самых разных библиотеках. Работает он так:

  1. Вызывается синхронная идемпотентная функция.
  2. Если надо подождать, то бросается обещание в качестве исключения.
  3. Это исключение перехватывается вне той синхронной функции и осуществляется подписка на финализацию обещания.
  4. Как только обещание будет финализировано, функция автоматически вызывается вновь.
  5. На этот раз вместо бросания обещания сразу возвращается результат обещяния, либо бросается ошибка из него.

Асинхронный доступ, напротив, всегда возвращает Promise даже если уже есть валидное значение и асинхронность не нужна:

async something(): Promise< string > {

    try {

        // returns allways string
        return await do_something()

    } catch( cause: unknown ) {

        if( cause instanceof Error ) {
            // Usual error handling
        }

        // Something wrong
    }

}

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

function tracked() {
    let backup = current
    current = new Subscriber
    try {
        // sync code with tracking deps
    } finally {
        current = backup
    }
}

С асинхронным же это не прокатит, так как на первом же await мы выйдем из функции не откатив глобальную переменную, что сломает на всё:

async function tracked() {
    let backup = current
    current = new Subscriber
    try {
        // sync part with tracking deps
        await something // dirty current
        // other path with wrong current
    } finally {
        // break others current
        current = backup
    }
}

Можно отказаться от асинхронных функций и перейти на генераторы, как это сделали в MobX:

const tracked = suspendable( function*() {
    let backup = current
    current = new Subscriber
    try {
        // sync part with tracking deps
        yield something // suspendable temp rollbaks current
        // other part with continue tracking deps
    } finally {
        current = backup
    }
} )

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

Резюмируем, почему большую часть кода лучше всё же оставить синхронной:

Recoloring

Многие нативные API и сторонние библиотеки являются асинхронными и мы должны уметь прозрачно с ними интегрироваться. То есть нам нужны механизмы прозрачной трансформации синхронного API в асинхронный и обратно.

Для этого реализуем пару обёрток:

Например, реализуем простейшую синхронную функцию загрузки json:

function getData( uri: string ): { lucky: number } {
    const request = $mol_wire_sync( fetch )
    const response = $mol_wire_sync( request( uri ) )
    return response.json().data
}

И наоборот, реализуем асинхронную функцию, совместимую с SuspenseAPI, а не падающую с логированием Promise в консоль:

async function lucky_update( uri: string ): Promise< undefined > {

    const fetchData = $mol_wire_async( getData )
    const data = await fetchData( uri )
    data.lucky = 777

    await fetch( uri, {
        method: 'put',
        body: JSON.stringify({ data }),
    } )

}

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

Concurrency

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

С инвариантами всё тривиально: если изменилось какое-либо состояние, от которого зависит приостановившийся атом, то этот атом просто перезапускается в новых условиях, так что никакой конкуренции не возникает.

А вот с экшенами всё интересней: они запускаются извне в ответ на событие:

button.onclick = function() {
    // runmultiple tasks concurrently
    $mol_wire_async( counter ).sendIncrement()
}

Тут каждый раз будет запускаться новая задача никак не связанная с уже запущенными. Сколько раз кликнул - столько задач и пойдут одновременно работать. Но часто это лишняя трата ресурсов, а то и даже вредное поведение. Поэтому как правило лучше запускать экшены так, чтобы новый запуск отменял предыдущий:

// next run aborts previous
button.onclick = $mol_wire_async( function() {
    counter.sendIncrement()
} )

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

А если мы вставим в начало задачи задержку по времени, то за счёт этого поведения мы элементарно получаем debounce:

button.onclick = $mol_wire_async( function() {
    $mol_wait_timeout( 1000 )
    // no last-second calls if we're here
    counter.sendIncrement()
} )

Abort

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

Однако, у нас же все волокна образуют связный граф, а значит отмена любого волокна должна приводить к отпискам с последующей автоматической каскадной отменой других волокон, остающихся без подписчиков. А если волокно владеет объектом, то при своём уничтожении оно вызывает и его деструктор, в котором мы уже можем реализовать свою логику. Для примера, давайте реализуем простейший HTTP загрузчик данных с возможностью отмены незаконченного запроса:

const fetchJSON = $mol_wire_sync( function fetch_abortable(
    input: RequestInfo,
    init: RequestInit = {}
) {

    const controller = new AbortController
    init.signal ||= controller.signal

    const promise = fetch( input, init )
        .then( response => response.json() )

    const destructor = ()=> controller.abort()
    return Object.assign( promise, { destructor } )

} )

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

button.onclick = $mol_wire_async( function() {

    const { profile } = fetchJSON( 'https://example.org/input' )

    fetchJSON( 'https://example.org/output', {
        method: 'PUT',
        body: JSON.stringify( profile ),
    } )

} )

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

Cycle

Cycle: 🌋Fail

В процессе работы приложения зависимости постоянно перестраиваются в плоть до диаметрально противоположного направления. Например, возьмём конвертер температур, где в завивимости от того, какую температуру мы задали явно, вторая температура должна вычисляться как производное состояние. Наивная реализация может выглядеть как-то так:

class Converter extends Object {

    @ $mol_wire_solo
    fahrenheit( fahrenheit?: number ) {
        return fahrenheit ?? this.celsius() * 9 / 5 + 32
    }

    @ $mol_wire_solo
    celsius( celsius?: number ) {
        return celsius ?? ( this.fahrenheit() - 32 ) * 5 / 9
    }

}
const conv = new Converter

conv.fahrenheit( 32 ) // 32 ✅
conv.celsius()        // 0  ✅

Но тут есть две беды. Первая - это бесконечная рекурсия, если обратиться к одному из свойств, не установив значение хотя бы одного из них. С обычными методами это подвесило бы браузер, скушало кучу памяти и упало с переполнением стека. Но у нас-то мемоизированные методы - они для конкретного метода, вызванного с конкретными ключами в конкретном объекте, создают уникальный персистентный атом. Так что повторное обращение к атому в то время как он уже вычисляется означает бесконечную рекурсию, которую мы можем пресекать сразу же, не допуская циклически зависимостей:

const conv = new Converter
conv.celsius() // Error: Circular subscription ❌

В то же время, если вызовы методов хоть чем-то отличаются, то никакой бесконечной рекурсии нет. Для примера можно привести классическое рекурсивное вычисление числа Фибоначчи:

class Fibonacci extends Object {

    @ $mol_wire_plex
    static value( index ) {
        if( index < 2 ) return 1
        return this.value( index - 2 ) + this.value( index - 1 )
    }

}

Благодаря мемоизации вычисление даже тысячного числа происходит мгновенно. Но цена этому, конечно, - создание тысячи кеширующих атомов.

Другая беда - это два конфликтующих источника истины. Когда мы устанавливаем значения обоих состояний - оба становятся первичными и не факт, что согласованными:

const conv = new Converter

conv.fahrenheit( 32 ) // 32 ✅
conv.celsius()        // 0  ✅

conv.celsius(32)      // 32 ✅
conv.fahrenheit()     // 32 ❌

Исправить этот код не сложно - достаточно вынести источник истины в отдельное свойство, а оба наших сделать производными:

class Converter extends $mol_object2 {

    @ $mol_wire_solo
    source( value = { celsius: 0 } ) {
        return value
    }

    @ $mol_wire_solo
    fahrenheit( fahrenheit ) {
        const source = this.source( fahrenheit?.valueOf && { fahrenheit } )
        return source.fahrenheit ?? source.celsius * 9 / 5 + 32
    }

    @ $mol_wire_solo
    celsius( celsius ) {
        const source = this.source( celsius?.valueOf && { celsius } )
        return source.celsius ?? ( source.fahrenheit - 32 ) * 5 / 9
    }

}
const conv = new Converter

conv.celsius()       // 0   ✅
conv.fahrenheit()    // 32  ✅

conv.fahrenheit( 0 ) // 0   ✅
conv.celsius()       // -18 ✅

conv.celsius( 0 )    // 0   ✅
conv.fahrenheit()    // 32  ✅

Однако, объединение источников истины возможно не всегда..

Atomic

Atomic: 👻Alone

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

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

Теперь рассмотрим один из основных сценариев: взаимная связь двух заметок друг с другом. Для этого надо согласованно обновить обе заметки, пролинковав их. Но вот беда, если мы просто выполним запись в первую, а во время записи второй случится непредвиденное (например, будет достигнут лимит на объём хранилища), то мы получим несогласованное состояние: одна заметка считает другую связанной с ней, а вторая ничего про это не знает.

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

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

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

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

К тому же далеко не все побочные эффекты вообще возможно отменить. Например, эффект "запустить ракету" после запуска оной уже не отменить. В лучшем случае её можно подорвать в воздухе, но ракеты мы всё равно лишимся.

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

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

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

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

Economy

Wish: 💸Economy

Давайте сравним получившуюся у нас реализацию ($mol_wire) с ближайшим популярным конкурентом (MobX):

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

И не удивительно, ведь, MobX хранит очень много какой-то не понятной мета информации:

Поди разберись, что это за состояние, и какие у него соседи.

Нам же требуется хранить гораздо меньше данных, где легко разобраться, что есть что:

Если же мы начнём менять состояния, то заметим двукратное превосходство по скорости:

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

Такое же преимущество сохраняется не только в использовании кучи, но и стека, и даже бандла:

Показатель mol_wire_lib MobX
Максимальная глубина зависимостей 2800 1600
Размер сжатого полного бандла 8.6 KB 16.3 KB

Обратите внимание на уникальные человекопонятные идентификаторы у каждого состояния. Их формирование весьма не бесплатно как по скорости, так и по памяти, но даже с такими утяжелителями на ногах нам удаётся идти ноздря в ноздрю с push-конкурентами на их же территории (статический граф полностью обновляющихся на каждой итерации состояний, что не очень реалистично):

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

Но, по мере роста проекта, влияние спичек становится всё значительнее, а менять коней на переправе всё сложнее. Ведь система реактивности - она как кровеносная система приложения - её нельзя просто взять и пересадить как почку.

Например, я разрабатывал WYSIWYG редактор (убийцу Google Docs) с полностью ручным рендерингом на холсте ещё до того, как это научился делать сам Google Docs. И когда загружаешь документ страниц на 200 сложной вёрстки, эти оптимизации становятся очень даже заметны, так как по чуть-чуть влияют на каждое состояние огромного приложения.

Согласитесь, разница между 250 мегабайтами и 1 гигабайтом - довольно существенна. Как и разница между 60 и 10 FPS.

Integration

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

Reactive ReactJS

ReactJS сейчас самый популярный фреймворк, вопреки множеству архитектурных просчётов. Вот лишь некоторые из них:

Что ж, давайте вылечим больного, а заодно покажем простоту интеграции $mol_wire в совершенно инородную ему архитектуру.

Начнём издалека - напишем синхронную функцию, которая загружает JSON по ссылке. Для этого напишем асинхронную функцию и конвертируем её в синхронную:

export const getJSON = sync( async function getJSON( uri: string ){
    const resp = await fetch(uri)
    if( Math.floor( resp.status / 100 ) === 2 ) return resp.json()
    throw new Error( `${resp.status} ${resp.statusText}` )
} )

Теперь реализуем API для GitHub, с debounce и кешированием. Поддерживаться у нас будет лишь загрузка данных issue по его номеру:

export class GitHub extends Object {

    @mems static issue( value: number, reload?: "reload" ) {

        sleep(500) // for debounce

        return getJSON( `https://api.github.com/repos/nin-jin/HabHub/issues/${value}` ) as {
            title: string
            html_url: string
        }

    }

}

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

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

export abstract class Component<
    P = { id: string },
    S = {},
    SS = any
> extends React.Component<
    Partial<P> & { id: string },
    S,
    SS
> {

    // every component should have guid
    id!: string

    // override fields by props to configure
    constructor( props: P & { id: string } ) {
        Object.assign( super( props ), props )
    }

    // compose inner components as vdom
    abstract compose(): any

    // memoized render which notify react on recalc
    @mem render() {
        Promise.resolve().then( () => this.forceUpdate() )
        return this.compose()
    }

}

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

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

Инициализация происходит при конструировании класса, а динамическая работа - когда фреймворк вызывает render. ReactJS славится тем, что вызывает его слишком часто. Тут же, благодаря мемоизации, мы перехватываем у фреймворка контроль за тем, когда фактически будут происходить ререндеры. Когда поменяется любая зависимость от которой зависит результат рендеринга, реактивная система перевычислит его и уведомит фреймворк о необходимости реконцилиации, тогда фреймворк вызовет render и получит свежий VDOM. В остальных же случаях он будет получать VDOM из кеша и ничего дальше не делать.

Проще понять принцип работы на конкретных примерах, так что давайте создадим простой компонент - поле текстового ввода:

export class InputString extends Component<InputString> {

    // statefull!
    @mem value( next = "" ) {
        return next;
    }

    change( event: ChangeEvent<HTMLInputElement> ) {
        this.value( event.target.value )
        this.forceUpdate() // prevent caret jumping
    }

    compose() {
        return (
            <input
                id={ this.id }
                className="inputString"
                value={ this.value() }
                onInput={ action(this).change }
            />
        )
    }

}

Тут мы объявили состояние, в котором по умолчанию храним введённый текст, и экшен вызывающийся при вводе для обновления этого состояния. В конце экшена мы заставляем ReactJS немедленно подхватить наши изменения, иначе каретка улетит в конец поля ввода. В остальных случаях в этом нет необходимости. Ну а при передаче экшена в VDOM мы завернули его в обёртку, которая просто превращает синхронный метод в асинхронный.

Теперь давайте воспользуемся этим компонентом в поле ввода числа, в который и поднимем состояние поля ввода текста:

export class InputNumber extends Component<InputNumber> {

    // self state
    @mem numb( next = 0 ) {
        return next;
    }

    dec() {
        this.numb(this.numb() - 1);
    }

    inc() {
        this.numb(this.numb() + 1);
    }

    // lifted string state as delegate to number state!
    @mem str( str?: string ) {

        const next = str?.valueOf && Number(str)
        if( Object.is( next, NaN ) ) return str ?? ""

        const res = this.numb( next )
        if( next === res ) return str ?? String( res ?? "" )

        return String( res ?? "" )
    }

    compose() {
        return (
            <div
                id={this.id}
                className="inputNumber"
                >

                <Button
                    id={ `${this.id}-decrease` }
                    action={ ()=> this.dec() }
                    title={ ()=> "➖" }
                />

                <InputString
                    id={ `${this.id}-input` }
                    value={ next => this.str( next ) } // hack to lift state up
                />

                <Button
                    id={ `${this.id}-increase` }
                    action={ ()=> this.inc() }
                    title={ ()=> "➕" }
                />

            </div>
        )
    }

}

Обратите внимание, что мы переопределили у поля ввода текста свойство value, так что теперь оно будет хранить своё состояние не у себя, а в нашем свойстве str, которое на самом деле является кешированным делегатом уже к свойству numb. Логика его немного замысловатая, чтобы при вводе не валидного числа, мы не теряли пользовательский ввод из-за замены его на нормализованное значение.

Можно заметить, что сформированный нами VDOM не зависит ни от каких реактивных состояний, а значит он вычислится лишь один раз при первом рендере, и больше обновляться не будет. Но не смотря на это, текстовое поле будет корректно реагировать на изменения свойств numb и как следствие str.

Так же тут использованы компоненты Button у которых переопределены методы, вызываемые для получения названия кнопки и для выполнения действия при клике. Но о кнопках позже, а пока воспользуемся всеми нашими наработками, чтобы реализовать продвинутый Counter, который не просто переключает число кнопками, но и грузит данные с сервера:

export class Counter extends Component<Counter> {

    @mem numb( value = 48 ) {
        return value
    }

    issue( reload?: "reload" ) {
        return GitHub.issue( this.numb(), reload )
    }

    title() {
        return this.issue().title;
    }

    link() {
        return this.issue().html_url;
    }

    compose() {
        return (
            <div
                id={ this.id }
                className="counter"
                >

                <InputNumber
                    id={ `${this.id}-numb` }
                    numb={ next => this.numb( next ) } // hack to lift state up
                />

                <Safe
                    id={ `${this.id}-output-safe` }
                    task={ () => (

                        <a
                            id={ `${this.id}-link` }
                            className="counter-link"
                            href={ this.link() }
                            >
                            { this.title() }
                        </a>

                    ) }
                />

                <Button
                    id={ `${this.id}-reload` }
                    action={ () => this.issue("reload") }
                    title={ () => "Reload" }
                />

            </div>
        )
    }

}

Как не сложно заметить, состояние текстового поля ввода мы подняли ещё выше - теперь оно оперирует номером issue. По этому номеру мы через GitHub API грузим данные и показываем их рядом, завернув в специальный компонент Safe, задача которого обрабатывать исключительные ситуации в переданном ему коде: при ожидании показывать соответствующий индикатор, а при ошибке - текст ошибки. Реализуется он просто - обычным try-catch:

export abstract class Safe extends Component<Safe> {

    task() {}

    compose() {

        try {
            return this.task()
        } catch( error ) {

            if( error instanceof Promise ) return (
                <span
                    id={ `${this.id}-wait` }
                    className="safe-wait"
                    >
                    💤
                </span>
            )

            if( error instanceof Error ) return (
                <span
                    id={ `${this.id}-error` }
                    className="safe-error"
                    >
                    {error.message}
                </span>
            )

            throw error
        }

    }
}

Наконец, реализуем кнопку, но не простую, а умную, умеющую отображать статус выполняемой задачи:

export class Button extends Component<Button> {

    title() {
        return ""
    }

    action( event?: MouseEvent<HTMLButtonElement> ) {}

    @mem click( next?: MouseEvent<HTMLButtonElement> | null ) {
        if( next ) this.forceUpdate()
        return next;
    }

    @mem status() {

        const event = this.click()
        if( !event ) return

        this.action( event )
        this.click( null )

    }

    compose() {
        return (

            <button
                id={this.id}
                className="button"
                onClick={ action(this).click }
                >

                { this.title() } {" "}

                <Safe
                    id={ `${this.id}-safe` }
                    task={ () => this.status() }
                />

            </button>

        )
    }

}

Тут мы место того, чтобы сразу запускать действие, кладём событие в реактивное свойство click, от которого зависит свойство status, которое уже и занимается запуском обработчика события. А чтобы обработчик был вызван сразу, а не в следующем фрейме анимации (что важно для некоторых JS API типа clipboard), вызывается forceUpdate. Сам status в штатных ситуациях ничего не возвращает, но в случае ожидания или ошибки показывает соответствующие блоки благодаря Safe.

Весь код этого примера можно найти в песочнице:

Там добавлены ещё и логи, чтобы можно было понять что происходит. Например, вот так выглядит первичный рендеринг:

render #counter 
render #counter-numb 
render #counter-numb-decrease 
render #counter-numb-decrease-safe 
render #counter-numb-input 
render #counter-numb-increase 
render #counter-numb-increase-safe 
render #counter-title-safe 
render #counter-reload 
render #counter-reload-safe 

fetch GitHub.issue(48) 
render #counter-title-safe 
render #counter-title-safe 

Тут #counter-title-safe рендерился 3 раза так как сперва он показывал 💤 на debounce, потом на ожидании собственно загрузки данных, а в конце уже показал загруженные данные.

При нажатии Reaload опять же, не рендерится ничего лишнего - меняется лишь индикатор ожидания на кнопке, так как данные в итоге не поменялись:

render #counter-reload-safe 

fetch GitHub.issue(48) 
render #counter-reload-safe
render #counter-reload-safe

Ну а при быстром изменении номера - обновляется поле ввода текста и вывод зависящего от него заголовка:

render #counter-numb-input 
render #counter-title-safe 

render #counter-numb-input 
render #counter-title-safe 

fetch GitHub.issue(4) 
render #counter-title-safe 
render #counter-title-safe 

Итого, какие проблемы мы решили:

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

Reactive JSX

Не сложно заметить, что отбирая у ReactJS контроль за состоянием, мы фактически низвергаем его с пьедестала фреймворка до уровня библиотеки рендеринга DOM, которой он изначально и являлся. Но это получается очень тяжёлая библиотека рендеринга, делающая слишком много лишней работы и тратящая впустую много памяти.

Давайте возьмём голый строго типизированный JSX, и сделаем его реактивным с помощью $mol_wire, получив полную замену ReactJS, но без VirtualDOM, а с точечными обновлениями RealDOM и другими приятными плюшками.

Для этого мы сперва возьмём $mol_jsx, который так же как E4X создаёт реальные DOM узлы, а не виртуальные:

const title = <h1 class="title" dataset={{ slug: 'hello' }}>{ this.title() }</h1>
const text = title.innerText // Hello, World!
const html = title.outerHTML // <h1 class="title" data-slug="hello">Hello, World!</h1>

Опа, нам больше не нужен ref для получения DOM узла из JSX.

Если исполнять JSX не просто так, а в контексте документа, то вместо создания новых элементов, будут использоваться уже существующие, на основе их идентификаторов:

<body>
    <h1 id="title">...</h1>
</body>
$mol_jsx_attach( document, ()=> (
    <h1 id="title" class="header">Wow!</h1>
) )
<body>
    <h1 id="title" class="header">Wow!</h1>
</body>

Опа, мы получили ещё и гидратацию, но без разделения на первичный и вторичный рендеринг. Мы просто рендерим, а существующие элементы реиспользуются, если они есть.

Опа, да мы ж получили ещё и корректные перемещения элементов, вместо их пересоздания в новом месте. Причём уже не в рамках одного родителя, а в рамках всего документа:

<body>
    <article id="todo">
        <h1 id="task/1">Complete article about $mol_wire</h1>
    <article>
    <article id="done"></article>
</body>
$mol_jsx_attach( document, ()=> (
    <article id="done">
        <h1 id="task/1">Complete article about $mol_wire</h1>
    <article>
) )
<body>
    <article id="todo"></article>
    <article id="done">
        <h1 id="task/1">Complete article about $mol_wire</h1>
    <article>
</body>

Обратите внимание на использование естественных для HTML атрибутов id и class вместо эфемерных key и className.

В качестве тегов можно использовать, разумеется, и шаблоны (stateless функции), и компоненты (stateful классы). Первые просто вызываются с правильным контекстом, а значит безусловно рендерят своё содержимое. А вторые создают экземпляр объекта, делегируют ему управление рендерингом, и сохраняют ссылку на него в полученном DOM узле, чтобы использовать его снова при следующем рендеринге. В рантайме выглядит это как-то так:

Тут мы видим два компонента, которые в результате рендеринга вернули один и тот же DOM элемент. Получить экземпляры компонент из DOM элемента не сложно:

const input = InputString.of( element )

Итак, давайте создадим простейший компонент - поле ввода текста:

export class InputString extends View {

    // statefull!
    @mem value( next = "" ) {
        return next
    }

    // event handler
    change( event: InputEvent ) {
        this.value( ( event.target as HTMLInputElement ).value )
    }

    // apply state to DOM
    render() {
        return (
            <input
                value={ this.value() }
                oninput={ action(this).change }
            />
        )
    }

}

Почти тот же код, что и с ReactJS, но:

Для иллюстрации последних пунктов, давайте рассмотрим более сложный компонент - поле ввода числа:

export class InputNumber extends View {

    // self state
    @mem numb( next = 0 ) {
        return next
    }

    dec() {
        this.numb( this.numb() - 1 )
    }

    inc() {
        this.numb( this.numb() + 1 )
    }

    // lifted string state as delegate to number state!
    @mem str(str?: string) {

        const next = str?.valueOf && Number( str )
        if( Object.is( next, NaN ) ) return str ?? ""

        const res = this.numb(next)
        if( next === res ) return str ?? String( res ?? "" )

        return String( res ?? "" )
    }

    render() {
        return (
            <div>

                <Button
                    id="decrease"
                    action={ () => this.dec() }
                    title={ () => "➖" }
                />

                <InputString
                    id="input"
                    value={ next => this.str( next ) } // hack to lift state up
                />

                <Button
                    id="increase"
                    action={ () => this.inc() }
                    title={ () => "➕" }
                />

            </div>
        )
    }

}

По сгенерированным классам легко навешивать стили на любые элементы:

/** bem-block */
.InputNumber {
  border-radius: 0.25rem;
  box-shadow: 0 0 0 1px gray;
  display: flex;
  overflow: hidden;
}

/** bem-element */
.InputNumber_input {
  flex: 1 0 auto;
}

/** bem-element of bem-element */
.Counter_numb_input {
    color: red;
}

К сожалению, реализовать полноценный CSS-in-TS в JSX не представляется возможным, но даже только лишь автогенерация классов уже существенно упрощает стилизацию.

Чтобы всё это работало, надо реализовать лишь базовый класс для реактивных JSX компонент:

/** Reactive JSX component */
abstract class View extends $mol_object2 {

    /** Returns component instance for DOM node. */
    static of< This extends typeof $mol_jsx_view >( this: This, node: Element ) {
        return node[ this as any ] as InstanceType< This >
    }

    // Allow overriding of all fields via attributes
    attributes!: Partial< Pick< this, Exclude< keyof this, 'valueOf' > > >

    /** Document to reuse DOM elements by ID */
    ownerDocument!: typeof $mol_jsx_document

    /** Autogenerated class names */
    className = ''

    /** Children to render inside */
    @ $mol_wire_field
    get childNodes() {
        return [] as Array< Node | string >
    }

    /** Memoized render in right context */
    @ $mol_wire_solo
    valueOf() {

        const prefix = $mol_jsx_prefix
        const booked = $mol_jsx_booked
        const crumbs = $mol_jsx_crumbs
        const document = $mol_jsx_document

        try {

            $mol_jsx_prefix = this[ Symbol.toStringTag ]
            $mol_jsx_booked = new Set
            $mol_jsx_crumbs = this.className
            $mol_jsx_document = this.ownerDocument

            return this.render()

        } finally {

            $mol_jsx_prefix = prefix
            $mol_jsx_booked = booked
            $mol_jsx_crumbs = crumbs
            $mol_jsx_document = document

        }

    }

    /** Returns actual DOM tree */
    abstract render(): HTMLElement

}

Весь код этого примера можно найти в песочнице. Вот так вот за 1 вечер мы реализовали свой ReactJS на $mol, добавив кучу уникальных фичей, но уменьшив объём бандла в 5 раз. По скорости же мы лишь немного отстали от оригинала:

А как насчёт обратной задачи - написать аналог фреймворка $mol на ReactJS? Вам потребуется минимум 3 миллиона долларов, команда из десятка человек и несколько лет ожидания. Но мы не будем ждать, а отстыкуем и эту ступень..

Reactive DOM

Раньше DOM был медленным и не удобным. Чтобы с этим совладать были придуманы разные шаблонизаторы и техники VirtualDOM, IncrementalDOM, ShadowDOM. Однако, фундаментальные проблемы RealDOM никуда не деваются:

  1. Жадность. Браузер не может в любое время спросить прикладной код "хочу отрендерить эту часть страницы, сгенерируй мне элементов с середины пятого до конца седьмого". Нам приходится сначала сгенерировать огромный DOM, чтобы браузер показал лишь малую его часть. А это крайне ресурсоёмко.
  2. Безучастность. Состояние DOM логически зависит как от прикладных состояний, так и от состояний самого DOM. Но браузер не понимает этих зависимостей, не может их гарантировать, и не может оптимизировать обновление DOM.
  3. Тернистость. На самом деле DOM нам и не нужен. Нам нужен способ сказать браузеру как и когда рендерить наши компоненты.

Ну да ладно, давайте представим, что было бы, если бы DOM и весь остальной рантайм были реактивными. Мы могли бы безо всяких библиотек связать любые состояния через простые инварианты и браузер бы гарантировал их выполнения максимально оптимальным способом!

Я набросал небольшой пропозал, как это могло бы выглядеть. Для примера, давайте возьмём и привяжем текст параграфа к значению поля ввода:

<input id="input" />
<p id="output"></p>
const input = document.getElementById('input')
const output = document.getElementById('output')

Object.defineProperty( output, 'innerText', {
    get: ()=> 'Hello ' + input.value
} )

И всё, никаких библиотек, никаких обработчиков событий, никаких DOM-манипуляций. Только наши желания в чистом виде.

А хотите попробовать ReactiveDOM в деле уже сейчас? Я опубликовал прототип полифила $mol_wire_dom. Он не очень эффективен, много чего не поддерживает, но для демонстрации сойдёт:

<div id="root">

    <div id="form">
        <input id="nickname" value="Jin" />
        <button id="clear">Clear</button>
        <label>
            <input id="greet" type="checkbox" /> Greet
        </label>
    </div>

    <p id="greeting">...</p>

</div>
import { $mol_wire_dom, $mol_wire_patch } from "mol_wire_dom/web";

// Make DOM reactive
$mol_wire_dom(document.body);

// Make globals reactive
$mol_wire_patch(globalThis);

// Take references to elements
const root = document.getElementById("root") as HTMLDivElement;
const form = document.getElementById("form") as HTMLDivElement;
const nickname = document.getElementById("nickname") as HTMLInputElement;
const greet = document.getElementById("greet") as HTMLInputElement;
const greeting = document.getElementById("greeting") as HTMLParagraphElement;
const clear = document.getElementById("clear") as HTMLButtonElement;

// Setup invariants

Object.assign(root, {
  childNodes: () => (greet.checked ? [form, greeting] : [form]),
  style: () => ({
    zoom: 1 / devicePixelRatio
  })
});

Object.assign(greeting, {
  textContent: () => `Hello ${nickname.value}!`
});

// Set up handlers
clear.onclick = () => (nickname.value = "");

Тут мы применили ещё и $mol_wire_patch чтобы сделать глобальные свойства реактивными. Поэтому при изменении зума браузера размер интерфейса будет меняться так, чтобы это компенсировать. При нажатии на кнопку введённое в поле имя будет очищаться. А отображаться текущее имя будет в приветствии, которое показывается только, когда чекбокс взведён.

Lazy DOM

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

Вы только гляньте, как фреймворк, построенный на $mol_wire, просто уничтожает как низкоуровневых конкурентов, так даже и VanillaJS:

И дело тут не в том, что он так быстро рендерит DOM, а как раз наоборот, в том, что он не рендерит DOM, когда он вне видимой области. А представьте как ускорился бы web, если сами браузеры научились бы так делать - запрашивать у прикладного кода ровно то, что необходимо для отображения, и самостоятельно следить за зависимостями.

Когда я показываю подобные картинки, меня часто обвиняют в нечестности, ведь к другим фреймворкам тоже можно прикрутить virtual-scroll и будет быстро. Или предлагают отключить виртуальный рендеринг, чтобы уравнять реализации по самому низкому уровню. Это всё равно что делать лоботомию Каспарову для уравнения шансов, так как он слишком хорошо играет в шахматы.

Однако, важно понимать разницу между поведением по умолчанию и поведением, требующем долгой и аккуратной реализации, да ещё и с кучей ограничений:

Именно поэтому вы почти не встретите виртуального рендеринга в приложениях на других фреймворках. И именно поэтому вы почти не встретите приложений без виртуализации на $mol. Грамотная реализация виртуального рендеринга - не самая простая задача, особенно учитывая не оптимизированную для этого архитектуру большинства фреймворков. Я подробно рассказывал об этом в докладе:

Автоматическая виртуализация произвольной вёрстки

На мой взгляд только LazyDOM может обеспечить нас отзывчивыми интерфейсами во всё более раздувающихся объёмах данных и во всё более снижающемся уровне подготовки прикладных разработчиков. Потому нам нужно продавить его внедрение в браузеры.

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

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

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

Reactive Framework

Наконец, позвольте показать вам, как тот же продвинутый счётчик реализуется на $mol, который я всю статью тизерил..

Для загрузки данных есть стандартный модуль ($mol_fetch). Более того, для работы с GitHub есть стандартный модуль ($mol_github). Так же возьмём стандартные кнопки ($mol_button), стандартные ссылки ($mol_link), стандартные поля ввода текста ($mol_string) и числа ($mol_number), завернём всё в вертикальный список ($mol_list) и вуаля:

$my_counter $mol_list
    sub /
        <= Numb $mol_number
            value? <=> numb? 48
        <= Title $mol_link
            title <= title \
            uri <= link \
        <= Reload $mol_button_minor
            title @ \Reload
            click? <=> reload? null
namespace $.$$ {
    export class $my_counter extends $.$my_counter {

        issue() {
            return this.$.$mol_github_issue.item(
                `https://api.github.com/repos/nin-jin/HabHub/issues/${ this.numb() }`
            )
        }

        title() {
            return this.issue().title()
        }

        link() {
            return this.issue().web_uri()
        }

        reload( event: Event ) {
            this.issue().json( null )
        }

    }
}

При даже чуть большей функциональности (например, поддержка цветовых тем, локализации и пр), кода на $mol получилось в 3 раза меньше, чем в варианте с JSX. А главное - уменьшилась когнитивная сложность. Но это уже совсем другая история..

Results

Итого, введя простую, но гибкую абстракцию каналов, мы проработали множество паттернов их использования для достижения самых разных целей. Единожды разобравшись в этом, мы можем создавать приложения любой сложности, и весело интегрироваться с самыми разными API.

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

А для тех, кто по каким-либо причинам ещё не готов полностью переходить на фреймворк $mol, мы подготовили несколько независимых микробиблиотек:

Хватайте их в руки и давайте зажигать вместе!

$mol $mol $moool

Growth

Ещё не убежили опять переписывать свой фронтенд? Ну ладно, поговорим напоследок ещё и о перспективах.

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

Прежде всего это фреймворк $mol, который недавно здорово похорошел:

Ему уже почти 7 годиков, но он всё ещё технологически обгоняет даже самые современные фреймворки (привет, AngularJS, который месяц не дожил до 6 лет). У него есть все перспективы повысить эффективность отечественной разработки. Компания, которая решится стать первой - здорово обгонит конкурентов. Вот лишь несколько реальных кейсов:

Ну а пока все топчутся на месте, переизобретая очередной UI-Kit на ReactJS, мы уже пилим оупенсорс web-платформу нового поколения:

Уже есть зачатки десятка приложений:

Тем временем, мы выпустили уже четыре десятка инновационных статей и десяток хардкорных докладов.

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

Более подробный рассказ про проекты

Я против пейволов, рекламы, слежки и прочих бесчеловечных техник. Так что все наши материалы и разработки вы всё равно получите совершенно бесплатно. Но если вам нравится то, что мы делаем, находите это полезным, и хотите поскорее приблизить светлое будущее, то поддержите рублём.. нет, не меня, а гильдию $hyoo, где получаемый доход распределяется пропорционально вкладу каждого участника в развитие экосистемы.

А то и хватайте клавиатуру, чтобы вместе вершить эту $mol революцию!

Дискуссии в интернетах: двое препираются - остальные внимательно слушают

PavelZubkov commented 2 years ago
//               push         pull
const [ title, setTitle ] = useState( '' )

Но они не эффективны и не удобны в использовании, так как не разделяют фазу инициализации (настройка дефолта, получение проталкивающего колбека) от фазы работы со значениями (затягивание и проталкивание значения).

Тут наверно будет не понятно, почему разделение не удобно/не эффективно

еще такой пример всплыл из реакта:

const [value, setValue] = useState('')
<Input onChange={setValue} value={value} /> 
// vs
const channel = useChannel('')
<Input channel={channel} /> 

типа, задвоено количество пропсов

PavelZubkov commented 2 years ago

Всё, что нам надо сейчас знать, так это то, что декоратор $mol_wire_mem(0) мемоизирует возвращённое из метода значение, независимо от того, передали мы ему аргумент или нет. Однако, если аргумент не передали, а в кеше уже что-то есть, то вызов метода пропускается и сразу возвращается значение из кеша. А если кеш пустой - метод всё же вызывается для получения значения по умолчанию.

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

PavelZubkov commented 2 years ago
class App extends Object {

    @ $mol_wire_mem(0)
    account( id: number ) {
        return new Account( id )
    }

}

тут единичка забыта - $mol_wire_mem(*1*)

PavelZubkov commented 2 years ago

image

Тут мы видим 6 подписок на 4 издателей, один из которых имеет значение true, но оно устаревшее, поэтому оно будет автоматически обновлено, как только кто-то к нему обратится, а остальные - актуальные, так что их пересчёта не будет.

тут мб чуть подробнее описать, что-что на скрине означает(и там сразу след пример с логированием, кмк тоже можно чуть добавить текста)? Не смог сообразить как посчитать издателей, там два true, что значит цифра в скобке пере кружком, цвета кружков

PavelZubkov commented 2 years ago
// Meta Size: 212B+
// Edge Cost: 16B
// Allocations: 6
interface Fiber<
    State = unknown,
    Host = unknown,
    Args = unknown[]
> extends Object { // 12B
    host: Host // 4B
    args: Args // 4B + 16B + 8B+
    task: ( this: Host, args: Args )=> State // 4B
    state: State // 4B
    pubs: Set< Fiber > // 4B + 16B + 60B+
    subs: Set< Fiber > // 4B + 16B + 60B+
}

тут можно наверно будет непонятно что значат(что лежит в) host/args/task/state

PavelZubkov commented 2 years ago

Подписчик помещает себя в глобальный канал $mol_wire_auto() и ставит свой cursor в 0.

где-нибудь добавить пару строчек про курсор и какие значения у него бывают

PavelZubkov commented 2 years ago

Но это тоже актуально, так как данных у него может пока что актуально не быть.

кажется опечатка

PavelZubkov commented 2 years ago

final - значение больше никогда не изменится.

не доконца понятно для чего нужен final и как используется

PavelZubkov commented 2 years ago

Ээто гарантирует, что даже если в процессе пересчётов

опечатка

nin-jin commented 2 years ago

Но они не эффективны и не удобны в использовании, так как не разделяют фазу инициализации (настройка дефолта, получение проталкивающего колбека) от фазы работы со значениями (затягивание и проталкивание значения). Тут наверно будет не понятно, почему разделение не удобно/не эффективно

То там же сразу и написано почему.

типа, задвоено количество пропсов

Угу, добавил про это.

nin-jin commented 2 years ago

Остальное тоже поправил, спасибо.

PavelZubkov commented 2 years ago

Но они не эффективны и не удобны в использовании, так как не разделяют фазу инициализации (настройка дефолта, получение проталкивающего колбека) от фазы работы со значениями (затягивание и проталкивание значения). Тут наверно будет не понятно, почему разделение не удобно/не эффективно

То там же сразу и написано почему.

Я про то что, не совсем понятно чем не_разеделение/разделение фаз (не разделяют фазу инициализации от фазы работы со значениями) не удобно/неэфективно

и там не разделяют, возможно не случайно там окозалось?

nin-jin commented 2 years ago

Там именно нет разделения и это вызывает проблемы.