Open nin-jin opened 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} />
типа, задвоено количество пропсов
Всё, что нам надо сейчас знать, так это то, что декоратор $mol_wire_mem(0) мемоизирует возвращённое из метода значение, независимо от того, передали мы ему аргумент или нет. Однако, если аргумент не передали, а в кеше уже что-то есть, то вызов метода пропускается и сразу возвращается значение из кеша. А если кеш пустой - метод всё же вызывается для получения значения по умолчанию.
раз там далее сразу идет упоминание про сброс кеша, то мб сюда сразу добавить что-нибудь в двух словах, типа: а если кеш стал невалидным, то метод забудет кеш и запустится
class App extends Object {
@ $mol_wire_mem(0)
account( id: number ) {
return new Account( id )
}
}
тут единичка забыта - $mol_wire_mem(*1*)
Тут мы видим 6 подписок на 4 издателей, один из которых имеет значение true, но оно устаревшее, поэтому оно будет автоматически обновлено, как только кто-то к нему обратится, а остальные - актуальные, так что их пересчёта не будет.
тут мб чуть подробнее описать, что-что на скрине означает(и там сразу след пример с логированием, кмк тоже можно чуть добавить текста)? Не смог сообразить как посчитать издателей, там два true, что значит цифра в скобке пере кружком, цвета кружков
// 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
Подписчик помещает себя в глобальный канал $mol_wire_auto() и ставит свой cursor в 0.
где-нибудь добавить пару строчек про курсор и какие значения у него бывают
Но это тоже актуально, так как данных у него может пока что актуально не быть.
кажется опечатка
final - значение больше никогда не изменится.
не доконца понятно для чего нужен final и как используется
Ээто гарантирует, что даже если в процессе пересчётов
опечатка
Но они не эффективны и не удобны в использовании, так как не разделяют фазу инициализации (настройка дефолта, получение проталкивающего колбека) от фазы работы со значениями (затягивание и проталкивание значения). Тут наверно будет не понятно, почему разделение не удобно/не эффективно
То там же сразу и написано почему.
типа, задвоено количество пропсов
Угу, добавил про это.
Остальное тоже поправил, спасибо.
Но они не эффективны и не удобны в использовании, так как не разделяют фазу инициализации (настройка дефолта, получение проталкивающего колбека) от фазы работы со значениями (затягивание и проталкивание значения). Тут наверно будет не понятно, почему разделение не удобно/не эффективно
То там же сразу и написано почему.
Я про то что, не совсем понятно чем не_разеделение/разделение фаз (не разделяют фазу инициализации от фазы работы со значениями
) не удобно/неэфективно
и там не разделяют
, возможно не
случайно там окозалось?
Там именно нет разделения и это вызывает проблемы.
https://page.hyoo.ru/#!=eh2o9_cl9nuy
Здравствуйте, меня зовут Дмитрий Карловский и я.. крайне плох в построение социальных связей, но чуть менее плох в построении программных. Недавно я подытожил свой шестилетний опыт реактивного программирования, проведя обстоятельный анализ различных подходов к решению типичных детских болячек:
Я очень рекомендую прочитать сперва ту статью, чтобы лучше понимать дальнейшее повествование, где мы с нуля разработаем совершенно новую TypeScript реализацию, вобравшую в себя все самые крутые идеи, позволяющие достигнуть беспрецедентной выразительности, компактности, скорости, надёжности, простоты, гибкости, экономности..
Статья разбита на главы, перелинкованные с соответствующими аспектами из упомянутого выше анализа. Так что если вдруг потеряетесь - сможете быстро восстановить контекст.
Повествование будет долгим, но если вы продержитесь до конца, то сможете смело идти к начальнику за повышением. Даже если вы сам себе начальник. Так что поехали!
Origin
Реактивные инварианты хорошо описывать в семантике затягивания, чтобы не вычислять их лишний раз. Но для обработки экшенов нужна уже семантика проталкивания, чтобы не пропустить ни одного события, а доставить его до нужного места в правильном виде, откуда уже и пойдёт дальнейше затягивание.
В нативном JS для этого есть специальный синтаксис - геттеры и сеттеры:
Но у них очень много ограничений:
В некоторых фреймворках распространены хуки вида:
Но они не эффективны и не удобны в использовании, так как не разделяют фазу инициализации (настройка дефолта, получение проталкивающего колбека) от фазы работы со значениями (затягивание и проталкивание значения). К тому же для двустороннего связывания приходится писать много кода вида:
Мы же введём абстракцию "канала" - это такая функция, которая, в зависимости от числа аргументов, может выступать как геттер, так и геттер-сеттер. Из него можно как затягивать актуальное значение, так и проталкивать в него новое, получая актуальный результат.
Создадим простейший канал, чтобы понять его суть:
Опыт работы с различными абстракциями в JS показал, что каналы - наиболее практичная из них. Они абстрагируют потребителя, который хочет читать и/или писать какое-то значение, от того, как на самом деле это значение получается и сохраняется.
Например, мы легко можем игнорировать запись значения, а при чтении генерировать его на лету, получая таким образом канал только для чтения:
Можем сделать его только для записи событий, используя как обработчик событий:
А можем полностью делегировать один канал другому:
Таким образом, каналы могут выстраиваться в цепочки, трансформируя данные по мере пересечения слоёв абстракций:
Каналы могут быть:
Style
Заметим, что каналы-синглтоны, не очень практичны, ибо не дают нам декомпозировать приложение. Поэтому нам нужен ООП...
Property
Объекты позволяют нам группировать связанные по смыслу каналы в единую капсулу. Более того, они позволяют нам иметь много таких капсул, построенных из одного кода (экземпляры классов). Простой пример:
Заметим, что если с делегатами всё довольно просто, то вот с каналами, хранящими состояние, всё не так радужно: имя канала приходится повторять по 4 раза. А это значит, что при копипасте неизбежно будут проблемы, ибо придётся по 4 раза вносить одинаковые правки. И авторефакторинг нам тут ни чем не поможет.
Что ж, заметим, что мы всегда возвращаем то значение, которое сохраняем, и всегда сохраняем то, что хотим вернуть. А это значит, что мы можем написать декоратор, который возьмёт всё это на себя:
Ну, или так, если нам не повезло с языком программирования:
Всё, что нам надо сейчас знать, так это то, что декоратор $mol_wire_solo мемоизирует возвращённое из метода значение, независимо от того, передали мы ему аргумент или нет. Однако, если аргумент не передали, а в кеше уже что-то есть, то вызов метода пропускается и сразу возвращается значение из кеша. И так будет действовать пока кеш не будет очищен. А если кеш пустой - метод всё же вызывается для получения значения по умолчанию. Ну а если аргумент передан, то метод будет вызван в любом случае, чтобы обновить кеш.
Помимо лаконичности кода, мемоизация даёт нам и ещё пару важных свойств: экономию вычислений и идемпотентность. Проиллюстрируем это следующим примером:
Первый метод довольно тяжёлый, но благодаря мемоизации будет выполнен всего один раз, причём лениво, в момент первого обращения, а дальше значение будет возвращаться из кеша, пока не изменится значение
title
, разумеется.Второй же метод не столько тяжёлый, сколько возвращающий каждый раз новый объект, что зачастую является неприемлемым. Тут уже мемоизация позволяет гарантировать, что сколько бы раз мы ни обратились к методу, результат его вызова будет одним и тем же. Это свойство называется идемпотентностью.
Опыт подсказывает, что при проектировании архитектуры приложения крайне важно, чтобы она была по максимуму идемпотентна, ибо любая неидемпотентность - мина замедленного действия. Подробно мы сейчас это обосновывать не будем. Однако, позже идемпотентность нам здорово пригодится.
Пока же вспомним известное высказывание Phil Karlton:
Тут упоминаются две типичные проблемы в любой области программирования. Так вот, первую мы в этой статье решим полностью, а вторую.. не полностью, но сделаем её менее проблемной.
Recomposition
Канал может выдавать не только простые типы данных, но и составные, в том числе и собранные из других каналов. Например, соберём все данные объекта в виде одного DTO:
Обратите внимание, что через составной канал мы можем разом обновлять сразу несколько простых каналов:
И наоборот, каналы могут быть линзами, позволяющими работать с частью большой имутабельной структуры, как с самостоятельной мутабельной сущностью:
Обратите внимание, эти два диаметрально противоположных подхода, приводят к одному и тому же интерфейсу объекта. В этом основное достоинство каналов - они позволяют менять внутреннюю реализацию в довольно широких пределах, не влияя при этом на потребителей. Более того, они позволяют настраивать внутреннюю работу объектов из вне, не ломая их работу. Но об этом позже.
Multiplexing
Пока что мы каждый канал именовали индивидуально. Но порой нам нужно написать один код для неограниченного набора каналов. Тут на помощь нам приходят мультиплексированные каналы, где первым параметром идёт ключ, идентифицирующий канал. Для примера, давайте избавимся от копипасты относительно сложной логики из предыдущего раздела, вынеся её в общий супер класс:
Теперь, мы можем работать с каналом
data
через мультиплексированный каналvalue
, не занимаясь ручной (де)структуризацией:А теперь давайте перенаправим хранение состояния из памяти в локальное хранилище:
А так как каналы у нас реактивные, то приложения в разных табах получают мгновенную синхронизацию автоматически:
Keys
Просто передавать ключи - дело не хитрое, но когда нам нужно по этим ключам что-то сохранять и находить, начинаются сложности. Нам нужна возможность использования не только примитивных ключей, но и массивов, словарей, объектов в любой комбинации. Record и Tuple в JS ещё не завезли, поэтому будем импровизировать..
Наша задача: взять произвольный по структуре ключ и преобразовать его в примитивный тип как можно быстрее.
JSON.stringify
для этого отлично подходит, но есть нюансы:[ /foo/ ]
[{}]
[ new Task ]
[{}]
Благо в
JSON.stringify
можно передать свой примитивизатор, через который мы можем подсказать ему, как именно в нашем ключе должны быть представлены те или иные объекты:[ /foo/ ]
["/foo/"]
[ new Task ]
["GIYBAK10"]
[ new Date( '2022-02-22' ) ]
["2022-02-22T00:00:00.000Z"]
Ага, наши задачи не реализуют метод
toJSON
, поэтому к ним был сгенерирован и привязан черезWeakMap
уникальный идентификатор, гарантирующий, что невзаимозаменяемые объекты не будут внезапно давать одинаковый ключ.А вот объект
Date
реализуетtoJSON
, выдаваяISO8601
представление времени, так что все объекты указывающие на одно и то же время будут давать один и тот же ключ.В результате у нас получилась библиотека $mol_key, ростом всего в 1 килобайт. Благодаря ей мы можем идентифицировать каналы не только примитивами, но и всякими сложными структурами:
Factory
Канал, который создаёт, настраивает и мемоизирует объект, будем называть фабрикой. Потребителю не важно когда и как будет создан объект. Как и не важно, когда этот объект надо будет уничтожить. Ему достаточно лишь знать как его получать при очередном пересчёте. А фабрика уж сама разберётся, когда его создать, а когда уничтожить.
Для примера, создадим проект, который будет владеть всеми нашими задачами и аккаунт, который будет владеть всеми нашими проектами:
Теперь мы можем через один канал получить объект, а через него обратиться к другому каналу, чтобы получить третий объект, и быть уверенными, что не будет ни Null Pointer Exception, ни утечек памяти:
В данном примере, будут автоматически созданы 3 объекта, которые будут держаться в памяти до завершения функции, а потом будут автоматически уничтожены.
Чтобы всё это работало, надо, чтобы объекты реализовывали метод
destructor
, который сигнализирует фабрике, что это не просто структура, а объект, временем жизни которого можно управлять. При его созданииconstructor
вызывается автоматически JS-рантаймом. Аdestructor
для уничтожения вызывается автоматически уже фабрикой, когда она через систему отслеживания зависимостей понимает, что объект никому больше не нужен.Помимо автоматического контроля жизненного цикла объектов, эти ленивые фабрики дают нам и ещё один бонус: возможность поднимать лишь часть приложения, а не всё его целиком. Это особенно актуально при компонентном тестировании, где мы можем взять всё приложение, дёрнуть интересующую нас логику, и быть уверенными, что поднимается лишь нужная для неё часть приложения. Это даёт крайне малое время исполнения теста, даже без использования моков.
В этом тесте создаётся всего 3 объекта, независимо от того, сколько ещё каналов, фабрик, зависимостей и прочих абстракций есть в нашем приложении.
Hacking
С помощью делегирования мы можем создавать локальные алиасы для сторонних каналов. Но порой нам надо наоборот, перенести управление каналом к себе, а уже в подведомственном объекте оставить алиас к своему каналу. Сделать это не сложно, подменив канал при создании объекта. Например, захватим у задачи управление каналом продолжительности:
Теперь продолжительность задачи из проекта при всём желании не сможет превысить заданного нами лимита. А так как и продолжительности задач и лимит сами являются каналами, то управление ими точно так же может быть перехвачено аккаунтом, который владеет проектом, поднимая состояние ещё выше по иерархии приложения:
Хакинг - это мощная техника, позволяющая провязывать объекты друг с другом в самых разных направлениях. При этом не теряя ни в скорости, ни в надёжности, так как:
На следующей диаграмме вы видите дерево владения из 5 объектов, которые совместно работают с общим состоянием, расположенном в одном из них в середине иерархии:
Binding
Во многих фреймворках весьма распространена такая вещь как связывание - синхронизация нескольких состояний 1-к-1. Обычно различают два типа связывания:
У двустороннего связывания есть серьёзная проблема: если синхронизация происходит не сразу в момент изменения одного из состояний, то возможно получение конфликта, когда несколько состояний изменены несовместимым образом. Поэтому часто можно встретить отказ от двустороннего связывания. Тем не менее, даже одностороннее связывание в этих условиях подвержено проблеме наблюдаемой временной рассинхронизации, что может приводить к непредсказуемым последствиям.
Подход с делегированием и хакингом на каналах, позволяет решить упомянутые проблемы в корне за счёт отказа от дублирования состояния. При этом нам доступны уже не два, а четыре типа связывания, через которые мы можем очень точно контролировать информационные потоки между объектами:
Простой пример, иллюстрирующий все эти варианты:
Debug
Когда все объекты создаются через локальные фабрики, захватывающие над ними владение, мы получаем полезный бонус: фабрика знает своё имя, параметры создания объекта, имеет ссылку на объект-владельца, и может получить его имя. Это даёт ей возможность присвоить захваченному объекту уникальное имя, отражающее его семантику.
Простой пример, где мы получаем несколько объектов и какие у них получаются имена:
Введя такое имя в консоль мы получаем соответствующий объект:
Как видите, фабрики хранят созданные ими объекты во владельце, что позволяет легко и просто навигироваться по объектам, используя средства разработчика, и в любой момент времени понимать что именно за объект перед нами.
Самый простой способ объяснить отладчику, как отображать наш объект - прописать имя oбъекта через
Symbol.toStringTag
. В Хроме это имя будет отображаться в том числе и в стектрейсах:И даже в сервисе мониторинга, вам достаточно мельком взглянуть на стектрейс, чтобы понять: ага, пользователь нажал на "завершить все задачи" в шапке, но при записи в локальное хранилище схватил исключение.
Более продвинутый путь - использование custom formatters для динамического рисования контента в отладчике. С их помощью мы можем сделать навигацию по нашему дереву зависимостей более наглядной:
Тут мы видим 6 подписок на 4 издателей, один из которых имеет значение
true
, но оно устаревшее (красный), поэтому оно будет автоматически обновлено, как только кто-то к нему обратится, а остальные - актуальные (зелёные), так что их пересчёта не будет.Наконец, мы можем мониторить изменения в подграфе реактивного графа состояний, и, например, логировать их:
Тут мы видим, что пользователь перешёл по ссылке, что привело к точечному обновлению состояний приложения в наиболее оптимальном порядке.
И это всего-лишь частный случай использования таких идентификаторов. У них ещё много разных применений в тестировании, стилизации, статистике и тд. Подробнее об этом я писал в статье:
Watch
Все состояния в нашем приложении должны быть связаны между собой и образовывать таким образом связный направленный ациклический граф (DAG). У каждого узла в этом графе есть два типа соседей:
Список издателей как правило обновляется целиком. Если какого-то узла в новом списке не казалось, то должна произойти отписка. Состав подписчиков же меняется произвольным образом, но связи между издателями и подписчиками всегда двусторонние.
Список издателей нам нужен, чтобы знать от кого отписываться. А список подписчиков, чтобы вовремя уведомлять их об изменениях. У этих ссылок есть и другие полезные применения. В частности, при отладке по ним можно ходить и смотреть кто от кого реально зависит.
Fiber
Объединим состояние, формулу его вычисления, списки соседей и прочую мета информацию в одну абстракцию: волокно - мемоизируемая задача с реактивной ревалидацией. Наивно в коде его можно представить так:
Но тут есть следующие проблемы:
Самое оптимальное - избавиться от хеш-таблиц и объединить все динамические данные в одном единственном массиве, что потребует всего 3 аллокации памяти (на сам объект, на статическую часть массива и динамическую):
Как видите, все вариативные по размеру части (аргументы, издатели, подписчики) мы вынесли в элементы массива, в полях объекта оставив лишь индексы разделяющие массив на секции. Это позволило нам уменьшить накладные расходы на реактивность в несколько раз.
Можно было бы и ещё сильнее сэкономить память, заодно уменьшив число аллокаций памяти до 2, если отнаследоваться от нативного массива, а не агрегировать его, но это несколько медленнее, захламляет объект методами массива, и не очень удобно при отладке.
Графически, структуру связей между двумя соседями можно представить на следующем примере, где волокно
A
зависит отB
:Каждая связь занимает два элемента массива: первый содержит ссылку на соседа, а второй содержит индекс по которому в этом соседе находится обратная ссылка. Таким образом все связи у нас получаются не просто двусторонними, а нам всегда известны смещения в массиве, что позволяет легко добавлять, удалять и перемещать их по массиву за O(1).
Обратите внимание, что подписчики всегда лежат непрерывной неупорядоченной кучей в конце. А вот издатели всегда упорядочены, поэтому могут содержать дырки. Появляются эти дырки от временных издателей, которые самоуничтожаются после вычисления подписчика, так что бесконтрольно эти дырки не разрастаются.
Также можно заметить, что одни и те же волокна могут быть перелинкованы несколько раз. Случается это в случае, если к одному и тому же волокну несколько раз происходит обращение из другого одного и того же волокна. Этого можно было бы избежать, но полученная выгода не перекроет затраты на дедупликацию. В любом случае, можно порекомендовать, если это возможно, сохранять полученное из канала значение в локальную переменную, чтобы лишний раз не дёргать реактивную машинерию:
Publisher
Порой нам нужно не целое волокно с его формулами, состояниями и тп вещами, а что-то по проще. Например, мы хотим сделать какое-то произвольное состояние наблюдаемым и всё. Давайте выкинем всё лишнее, чтобы остался минимальный узел нашего реактивного графа - издатель:
Для демонстрации его работы, давайте сделаем реактивной обычную локальную переменную:
Теперь, если мы будем работать с этой переменной через созданный нами канал, то:
Таким образом мы можем делать реактивным любое состояние, даже которое нам не принадлежит. Например, текущий адрес страницы:
Соберём этот модуль в отдельный бандл и выложим в NPM: mol_wire_pub. Он позволяет сделать любую вашу структуру наблюдаемой, добавив к весу вашей библиотеки всего 1.5КБ.
Так, например, у нас есть собственная реализация CRDT $hyoo_crowd, которую можно использовать и по старинке, без реактивности, но в реактивной среде она автоматически становится наблюдаемой без каких-либо танцев с бубном.
Поэтому, если вы автор библиотеки, работающей с состоянием, то призываю вас добавить интеграцию с
mol_wire_pub
, чтобы у ваших пользователей не болела голова на тему отслеживания изменений. Для иллюстрации, давайте сделаем обычное множество наблюдаемым. Для этого отнаследуемся от нативного и перегрузим все методы:Как видите, все методы можно разделить на два типа: читающие и изменяющие. В читающих вызываем
.promote()
, а в изменяющих -.emit()
, если изменения действительно произошли.Часть методов мы тут опустили. Полный набор можно найти в исходниках $mol_wire_set.
Dupes
Когда волокно принимает новое значение, мы должны уведомить подписчиков об изменении. Но если изменение даёт эквивалентный результат, то можно ничего и не делать. Нам надо лишь понять: является ли изменение эквивалентным, или нет.
Например, если мы получили новый массив, но он поэлементно равен предыдущему, то даже если мы пересчитаем всё, зависящее от него, приложение, то для пользователя ничего не поменяется.
Получается нам надо уметь глубоко сравнивать новые данные и старые. Причём делать это быстро. Но тут на нашем пути может возникнуть ряд трудностей..
Некоторые объекты (например, Value Object) можно сравнивать структурно, другие же (например, DOM элементы или бизнес-сущности) - нельзя. Как их отличать?
Ну, стандартные типы (массивы, структуры, регулярки и тп) можно просто детектировать и сравнивать структурно.
С пользовательскими же чуть сложнее. По умолчанию не будем рисковать, а будем сравнивать их по ссылке. Но если в объекте объявлен метод
Symbol.toPrimitive
, то считаем, что это сериализуемый объект, а значит такие объекты можно сравнивать через сравнение их сериализованных представлений.Если мы сравнили глубокие структуры и выяснили, что они в целом отличаются, то, когда будем их поддеревья передавать дальше, было бы опрометчиво сравнивать их снова и снова, ведь мы это уже сделали ранее. Поэтому результат глубокого сравнения пар объектов мы будем кешировать в двойных WeakMap, что обеспечит нам автоматическую очистку кеша по мере сборки объектов сборщиком мусора.
После сравнения, один из объектов
Left
иRight
обычно выкидывается. В первом случае это освободит и кеш целиком, и все его данные, а значит при изменении значения, у нас ничего лишнего в памяти не останется. Во втором же случае освободятся только данные, а сам кеш останется для будущих сравнений, что предотвращает лишнюю аллокацию кешей при частом прилёте эквивалентных значений.Наконец, в данных могут встретится циклические ссылки. Как минимум тут нельзя уходить в бесконечный цикл. А желательно правильно их сравнивать.
Например, следующие два объекта структурно эквивалентны:
Оказывается, поддержать циклические ссылки совсем не сложно, когда у нас уже есть кеш. Сперва пишем в него, что объекты эквивалентны, и погружаемся в глубь. Если снова наткнёмся на эту пару объектов, то возьмём значение из кеша и пойдём дальше. Если же где-то найдём отличия, то и в кеше потом поправим, что объекты всё-таки не эквивалентны.
В результате, у нас получилась библиотека $mol_compare_deep, размером в 1 килобайт, которая в разы быстрее любых других, представленных в NPM:
Flow
Пришла пора детально рассмотреть процесс автоматического отслеживания изменений..
Subscriber
Чтобы следить за наблюдаемыми состояниями нам может потребоваться другая легковесная абстракция - подписчик:
Обычно подписчик и сам может выступать в роли издателя, а логика двусторонних ссылок у них общая, поэтому имеет смысл их объединить:
Теперь, рассмотрим цикл отслеживания зависимостей. Идея тут простая:
$mol_wire_auto()
и ставит свойcursor
в0
..promote()
.В результате мы получаем упорядоченный актуальный список издателей и автоматическую отписку от издателей, которые в этот список более не входят.
Простой пример:
Обратите внимание, что мы сначала бэкапим предыдущее значение глобальной переменной, а потом восстанавливаем его из бэкапа. Нужно это чтобы автоотслеживания можно было вкладывать друг в друга.
Можно было бы вынести логику работы с бэкапом и
try-finally
в обёртку вокруг замыкания, но тогда у нас в стеке появилось бы множество промежуточных функций, что значительно уменьшило бы доступную прикладному программисту глубину вызовов.Можно было бы возвращать не ссылку, а легковесный объект с методом для отката:
Но тогда мы получили бы лишнюю аллокацию памяти в очень горячем коде.
Отдельно стоит рассказать про вызов
.track_cut()
. Когда мы заканчиваем отслеживать издателей, у нас есть выбор: отписаться от издателей, которых отслеживали раньше, но до которых сейчас не дошли, или нет. Так вот, сей метод пробегается по нетронутым издателям и выполняет эти отписки.Обычно вызывать его имеет смысл при нормальном завершении вычисления. Если же мы вышли из вычисления досрочно не завершив его (например, вернули управление, так как ждём загрузки от сервера), то лучше ни от кого не отписываться, так как те издатели могут нам вскоре понадобиться, когда загрузка будет завершена.
PubSub
Если подписчик не просто выполняет какой-то эффект, а меняет какое-то состояние, то он может выступать и как издатель, что позволяет ему находиться не только на концах реактивного графа, но и образовывать его тело. А любое волокно - это как раз расширенный PubSub.
Можно выделить два основных типа волокон:
Task
Задача имеет ту же семантику, что и обычный вызов функции. Но если обычная функция возвращает управление лишь когда завершится, то задача может быть временно приостановлена на пол пути, а потом продолжена с места остановки, в ответ на какое-либо событие.
Нативный JS рантайм поддерживает задачи лишь в виде абстракций генератор и асинхронная функция, но:
Было бы классно, чтобы Fibers Proposal был принят в стандарт, но он уже несколько лет находится на стадии -1, ибо его не то, что не реализуют, а наоброт, даже расширение для NodeJS
node-fibers
недавно капитально сломали.Но есть и воркэраунд в рамках текущего стандарта, известный под названием SuspenseAPI. Сперва он был реализован во фреймворке $mol, где он используется активнее всего. А со временем его реализовали и в ReactJS, но с кучей ограничений:
Позже я расскажу как это нормально реализовать, а пока опишу основную идею: код пишется обычный, синхронный, но когда надо приостановиться, кидается обещание в качестве исключения, а когда обещание финализируется, код перезапускается.
Идея эта довольно элегантная, но как и любой воркэраунд, имеет ряд ограничений:
Преодолеваются эти ограничения просто: тяжёлый или неидемпотентный код выносится в отдельные подзадачи, которые уже если завершились, то финализируются и больше никогда не перезапускаются. А при перезапусках внешней задачи они немедленно возвращают значение из кеша.
В качестве примера, давайте загрузим данные, логируя процесс:
Декоратор
$mol_wire_method
автоматически заворачивает вызов метода в задачу. Но если мы уже находимся в какой-то задаче, то он пытается реиспользовать ту же по порядку подзадачу из предыдущего запуска внешнего волокна. И если это удаётся, происходит либо продолжение работы прошлой задачи, либо мгновенное возвращение значения из неё без запуска метода.Но что может пойти не так?
$mol_compare_deep
).В этих случаях мы не можем реиспользовать прошлую подзадачу, а вынуждены создавать новую. То есть даже если программист допустил некоторую неидемпотентность мы не начинаем исполнять задачи не соответствующую текущему вызову метода - это было бы просто сумасшествием. Вместо этого мы выбрасываем устаревшие задачи и создаём новые под новый расклад.
Если бы мы использовали не методы объектов, а замыкания:
.. то при каждом вызове создавалась бы новая функция, и мы не могли бы сравнивать ни функции, ни замкнутые аргументы, а значит попытка реиспользовать существующие задачи могла бы приводить, например, к тому, что исполнение строки (2) может вернуть
"foo"
.Atom
Атом имеет семантику изменяемой реактивной переменной инкапсулированной вместе с формулой пересчёта своего значения. Когда бы мы ни обратились к значению атома - он всегда выдаёт актуальное значение. Ну либо просит подождать, но это тоже актуально, так как данных у него может пока что и не быть.
Если при затягивании значения из одного атома, происходит обращение к другому, то они автоматически связываются как подписчик-издатель. Если же обращение к атому происходит из задачи, то напрямую связывать их уже нельзя, так как затягивание из атома не является идемпотентным, ибо атом может обновить своё значение между перезапусками задачи.
В качестве примера, можно привести задачу переключения флага:
Если задача будет подписываться на атом, который под капотом создаёт
$mol_wire_solo
, то при каждом перезапуске она будет получать новое значение флага, пытаться установить противоположное значение, и засыпать до следующего перезапуска. И так до скончания времён.Поэтому любое обращение к атому из задачи автоматически заворачивается в ещё одну задачу, которая мемоизирует выполненную работу, и, как любая задача, по завершении отписывается от всех своих зависимостей. То есть при чтении мы получаем как бы снепшот значения атома, который для текущей задачи уже никогда не изменится. Но при множественном чтении получится множество же и снепшотов, каждый из которых может иметь своё значение.
Проталкивание в атом нового значения точно так же мемоизируется через промежуточную задачу, так как проталкивание не является идемпотентным. Даже если канал, завёрнутый в атом, тривиален, и просто возвращает переданное значение, проталкивать в атом могут конкурентно несколько задач, при перезапусках которых не должно быть неожиданных откатов состояний атомов.
Abstraction Leakage
Другой интересный сценарий связан с порядком затягиваний и проталкиваний. Если значение сначала затянуть, то атом подпишется на издателей и будет обновляться по сигналам от них. И даже если протолкнуть в него новое значение, то подписки сохранятся.
Но если так случилось, что в атом сначала протолкнули новое значение, а только потом стали из него вытягивать, то при наивной реализации получается, что подписки сформированы не будут. Получается нестабильность в поведении, которую мы стараемся избегать.
Мы могли бы очищать подписки при проталкивании. Но тогда инварианты перестают работать, что является неожиданным и нежелательным эффектом.
Можно автоматически запускать затягивание перед проталкиванием, чтобы результат не зависел от их порядка в прикладном коде. Но тогда затягивание, получается, возможно будет произведено вхолостую. Ведь проталкиваем мы как раз для того, чтобы значение поменять. Кроме того, в некоторых конфигурациях каналов список зависимостей хоть и будет сформирован, но будет устаревшим, не соответствующим новым значениям этих зависимостей. Например:
Тут при проталкивании в
res
он будет сперва затянут, что сформирует зависимость отleft
, но неright
, так как значениеleft
ложно. Потом отработает проталкивание, которое изменитleft
и вычислит актуальное значениеres
, но подпискаres
наright
сформирована так и не будет. Получается, что изменениеright
не будет влиять на значениеres
, что не соответствует инварианту. То есть данный подход не решает проблему неожиданно ломающегося инварианта полностью.Можно зайти с другого конца и автоматически инициировать затягивание актуального значения сразу после проталкивания. Тогда список подписок будет соответствовать их значениям. Но тогда не понятно какое значение записывать при их отличии. Если записывать то, что вернулось из канала при проталкивании, то это значение получается не соответствует инварианту. Если же записывать затянутое, то мы теряем контроль над кешируемым значением в такого рода методах, где ожидается запись в кеш того, что вернул метод:
Оба варианта с автоматическим затягиванием, хотя и помогают с формированием подписок, дают пенальти по производительности, так как порой делают лишнюю работу. При однократном обращении к каналу, метод порой вызывается дважды, что может вызывать недоумение разработчика и нежелательные побочные эффекты.
И всё сильно усугубляется при появлении асинхронных операций. Согласитесь, когда мы хотим обновить данные на сервере, было бы странным сначала или после этого делать ещё один запрос на чтение, который мало того, что занимает время, так ещё и может закончиться неудачей, на которую не понятно как реагировать вообще.
К сожалению, у меня нет однозначного ответа какой из подходов лучше. Это - слабое место предельно простой абстракции "мемоизированного метода", позволяющей в остальном весьма элегантно строить системы любого масштаба.
Можно было бы усложнить абстракции. Например, разделить каналы на первичные
observable
и производныеcomputed
, как в MobX. Или разделить обработчики затягивания и проталкивания по разным методам как в линзах. Или отделить инварианты от эффектов и попросить прикладных разработчиков очень внимательно за этим разделением следить, как в effector.Однако, важно понимать, что дополнительные абстракции решая одни проблемы создают новые (например, сложности с делегированием или ограничения на логику). А когнитивная сложность работы с ними меньше не становится. Поэтому, чем проще и понятнее абстракция, тем проще работать и с её слабыми местами.
Итак, вернёмся на исходную и заметим, что как правило проталкивание происходит в канал, из которого уже было затягивание (изменение текущего значения). Либо затягивание не предусмотрено вообще (обработка события). Либо затягивание просто возвращает протолкнутое значение (первичное состояние).
Жёстко обозначим, что в кеш будет записано ровно то значение, что было возвращено из метода. Это может не соответствовать инварианту, но это даёт предсказуемость поведения, а следовательно и доверие программиста написанному им коду.
Когда же при проталкивании надо актуализовать подписки, разработчик может явным образом инициировать затягивание там, где посчитает нужным: до, после и даже в середине работы метода.
Tonus
Возьмём не сложное приложение со стабилизировавшимся состоянием.
Сверху подписчики, снизу издатели. Стрелочки показывают движение данных. Как правило у приложения есть один корень, который выступает как точка старта приложения.
Когда меняется какое-либо состояние, тут же, синхронно происходит пометка всех прямых зависимостей как "устаревших", а всех косвенных как "подозреваемых".
Нужно это чтобы каждое состояние знало свой статус актуальности в любой момент времени. Всего таких статусов 4:
Статус хранится в поле
cursor
. Отрицательные значения кодируют статус актуальности, а неотрицательные - что волокно вычисляется в данный момент и отслеживает свои зависимости. Положительное значение курсора означат сколько уже к этомум моменту было отслежено издателей.Распространение уведомлений о смене статуса до корня приложения может быть ресурсоёмкой операцией. Особенно в случае, когда от одного состояния зависят тысячи других. Но это разумная плата за гарантию согласованности.
Если мы тут же поменяем и другие состояния, то цепочки уведомлений будут уже короче, так как будут упираться в уже устаревший подграф.
Пока что мы лишь меняли статусы состояний. Если же мы по любой причине захотим прочитать значение устаревшего или подозреваемого состояния, то только в этот момент начнутся вычисления.
Сначала идёт погружение по подозреваемым до устаревших, которые вычисляют новое значение. Если значение поменялось, то все зависимости тоже становятся устаревшими и начинают обновляться. И так далее вычисления поднимаются до запрошенного нами значения. Или останавливаются где-то на пол пути, а все состояния выше помечаются актуальными.
Таким образом, не смотря на отложенные ленивые вычисления, мы в любой момент можем обратиться к любому состоянию и получить актуальное значение, согласованное со всеми косвенными зависимостями.
Order
На конец, даже если мы явно не запросили какое-либо состояние, происходит автоматическая отложенная актуализация всех корней строго в том же порядке, в каком они вычислялись при старте.
Это гарантирует, что даже если в процессе пересчётов будет перестройка графа состояний (что происходит довольно часто) никакое состояние не будет просто выброшено после вычисления, так как состояние, влияющие на его существование, вычислится всегда раньше.
Так как чаще чем FPS экрана, пользователь всё-равно не увидит результат, то имеет смысл откладывать автоматическую актуализацию состояний на следующий фрейм анимации. Таким образом все изменения состояний в рамках одного фрейма группируются вместе и приводят лишь к одному ререндеру. А все пересчёты происходят лишь тогда, когда они кому-то нужны.
Интересная особенность использования фреймов анимации заключается в том, что когда интерфейс не виден, его рендеринг замирает автоматически, не тратя ресурсы на невидимую работу. Однако, некоторые задачи всё же требуют работы даже в фоне. Такие просто нужно дёргать по нужному вам временному интервалу.
Depth
Допустим у нас длинная цепочка зависимостей. Ходим мы по ней в следующих случаях:
Есть два способа реализовать эти алгоритмы: рекурсия и цикл.
Рекурсия проще всего в реализации - мы просто вызываем методы, а что там за ними скрывается нас не волнует. Это позволяет легко интегрироваться разным библиотекам. Рекурсия аллоцирует память на стеке, что довольно быстро. При отладке это даёт нам информативный стектрейс. Но, размер стека сильно лимитирован, так что на большой глубине зависимостей это может привести к падению.
Цикл же сам по себе быстрее рекурсии, но из-за необходимости ручной реализации стека в куче, получается не так-то быстро. А стектрейс получается соответственно неинформативным. Зато глубина зависимостей ограничена лишь объёмом доступной памяти.
Вызовы же прикладных обработчиков мы всё равно не можем развернуть в цикл, так как они могут внутри себя вызывать дальнейшие вычисления. То есть в первом сценарии мы всё равно остаёмся ограничены стеком.
В третьем сценарии мы могли бы применить цикл, но тогда была бы нестабильность поведения: первичное вычисление заканчивается падением, но при обновлении уже нет, а при очередном обновлении может снова упасть. Лучше уж стабильно падать в предсказуемом месте, и предпринять меры на прикладном уровне, чтобы падения не было. Например, переписать код так, чтобы неограниченной рекурсии не возникало.
Ну а если мы не упали на переполнении стека в первом и третьем сценарии, то и во втором не упадём. Так что нет смысла замедлять его работу, стараясь поддержать неограниченную глубину.
Так что стоит признать, что не смотря на предпочтительность неограниченности глубины зависимостей, в реалиях 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
Реактивное состояние может принимать одно из 3 возможных типов значений:
Прилетать эти значения могут следующими путями:
return
- возвращённое функцией значение.throw
- прерывание исполнения.put
- прямая запись значения в кеш.При этом не особо важно, каким путём мы доставили значение - оно записывается в кеш. При обращении за состоянием поведение зависит только от типа значения:
throw
.return
.То есть реактивная мемоизация методов получается не совсем прозрачной: если вы вернёте экземпляр
Error
, то он всё равно будет потом кинут, а если кинете, например, строку, то она всё равно будет потом возвращена. Получается этакая нормализация поведения.Можно было бы, конечно, сделать и прозрачное поведение, но в JS мы уже имеем нормализацию с асинхронными функциями: что бы мы ни вернули - они всегда возвращают обещание. А обещания, кстати, в нашем случае нужно уже всё равно не возвращать, а кидать. И об этом далее..
Extern
Есть два интерфейса доступа к значению: sync и async. Разберём их особенности..
Синхронный доступ предполагает, что возвращённое значение всегда будет валидным. Если же такое значение взять неоткуда, то возникает исключение, которое можно либо пропустить, либо перехватить и обработать:
Обратите внимание, что в качестве исключения может прилететь не только ошибка, но и обещание. Это так называемый Suspense API, который позволяет работать с асинхронным кодом, как с синхронным. И это может быть поддержано в самых разных библиотеках. Работает он так:
Асинхронный доступ, напротив, всегда возвращает
Promise
даже если уже есть валидное значение и асинхронность не нужна:С синхронным кодом мы легко можем автоматически трекать зависимости, за счёт временного помещения подписчика в глобальную переменную:
С асинхронным же это не прокатит, так как на первом же await мы выйдем из функции не откатив глобальную переменную, что сломает на всё:
Можно отказаться от асинхронных функций и перейти на генераторы, как это сделали в MobX:
Но генераторы, как и асинхронные функции, не решают весьма изматывающей проблемы цветных функций, а только усугубляют её, вводя ещё один цвет. К тому же они ещё и сильно медленнее синхронного кода, так как не могут быть толком оптимизированы JIT компилятором:
Резюмируем, почему большую часть кода лучше всё же оставить синхронной:
Recoloring
Многие нативные API и сторонние библиотеки являются асинхронными и мы должны уметь прозрачно с ними интегрироваться. То есть нам нужны механизмы прозрачной трансформации синхронного API в асинхронный и обратно.
Для этого реализуем пару обёрток:
Например, реализуем простейшую синхронную функцию загрузки json:
И наоборот, реализуем асинхронную функцию, совместимую с SuspenseAPI, а не падающую с логированием
Promise
в консоль:Таким образом, синхронно описывая любую прикладную логику, будь то реактивные инварианты или же интерактивные экшены, мы всегда можем приостановиться на асинхронной задаче, и реактивная система адекватно на это отреагирует: отслеживание зависимостей не сломается, а волокно автоматически когда надо перезапустится.
Concurrency
Как только наши задачи начинают приостанавливаться, давая возможность исполниться другим задачам, у нас появляются проблемы конкуренции, когда одна и та же задача запускается несколько раз и нужно решать, что с этим делать.
С инвариантами всё тривиально: если изменилось какое-либо состояние, от которого зависит приостановившийся атом, то этот атом просто перезапускается в новых условиях, так что никакой конкуренции не возникает.
А вот с экшенами всё интересней: они запускаются извне в ответ на событие:
Тут каждый раз будет запускаться новая задача никак не связанная с уже запущенными. Сколько раз кликнул - столько задач и пойдут одновременно работать. Но часто это лишняя трата ресурсов, а то и даже вредное поведение. Поэтому как правило лучше запускать экшены так, чтобы новый запуск отменял предыдущий:
Поскольку тут обёртка вокруг функции не создаётся каждый раз, а используется одна и та же, то сколько бы раз мы ни кликнули по кнопке - до конца дойдёт лишь последняя запущенная задача, а остальные будут отменены. Если они не успели завершиться к моменту запуска новой, конечно же.
А если мы вставим в начало задачи задержку по времени, то за счёт этого поведения мы элементарно получаем debounce:
Abort
Когда обещания ещё только зарождались, было множество разных реализаций. Наиболее продвинутые из них поддерживали отмену асинхронных задач. К сожалению, в стандарт JavaScript эта функциональность не попала, и нам предлагают вручную простаскивать через параметры экземпляр AbortSignal, что весьма не удобно.
Однако, у нас же все волокна образуют связный граф, а значит отмена любого волокна должна приводить к отпискам с последующей автоматической каскадной отменой других волокон, остающихся без подписчиков. А если волокно владеет объектом, то при своём уничтожении оно вызывает и его деструктор, в котором мы уже можем реализовать свою логику. Для примера, давайте реализуем простейший HTTP загрузчик данных с возможностью отмены незаконченного запроса:
Теперь мы можем просто вызывать функции и не париться о хранении контроллеров и прокидывании сигналов, но быть уверенными, что все соединения корректно отменятся:
Важно отметить, что если сделать множественное преобразование синхронного API в асинхронное и обратно, то волокна не образуют единый граф, так как в точках асинхронности граф будет разрываться. Соответственно, ввиду отсутствия у нативных обещаний механизма отмены, прокидывать соответствующие сигналы придётся вручную. Но это достаточно маргинальный случай, так как в норме всё приложение должно образовывать единый граф с синхронным API, а асинхронность должна появляться лишь на стыке со внешними API.
Cycle
В процессе работы приложения зависимости постоянно перестраиваются в плоть до диаметрально противоположного направления. Например, возьмём конвертер температур, где в завивимости от того, какую температуру мы задали явно, вторая температура должна вычисляться как производное состояние. Наивная реализация может выглядеть как-то так:
Но тут есть две беды. Первая - это бесконечная рекурсия, если обратиться к одному из свойств, не установив значение хотя бы одного из них. С обычными методами это подвесило бы браузер, скушало кучу памяти и упало с переполнением стека. Но у нас-то мемоизированные методы - они для конкретного метода, вызванного с конкретными ключами в конкретном объекте, создают уникальный персистентный атом. Так что повторное обращение к атому в то время как он уже вычисляется означает бесконечную рекурсию, которую мы можем пресекать сразу же, не допуская циклически зависимостей:
В то же время, если вызовы методов хоть чем-то отличаются, то никакой бесконечной рекурсии нет. Для примера можно привести классическое рекурсивное вычисление числа Фибоначчи:
Благодаря мемоизации вычисление даже тысячного числа происходит мгновенно. Но цена этому, конечно, - создание тысячи кеширующих атомов.
Другая беда - это два конфликтующих источника истины. Когда мы устанавливаем значения обоих состояний - оба становятся первичными и не факт, что согласованными:
Исправить этот код не сложно - достаточно вынести источник истины в отдельное свойство, а оба наших сделать производными:
Однако, объединение источников истины возможно не всегда..
Atomic
Пока мы работаем с состоянием памяти, мы имеем над ним полный контроль, реализуя любые формы транзакций. Но приложения, живущие лишь в оперативной памяти мало полезны. А когда источник истины выносится во внешнее хранилище всё становится куда веселее. Особенно, если это несколько разнородных хранилищ, работу с которыми нельзя объединить в транзакцию.
Для примера возьмём простой кейс: приложение для ведения личных заметок по принципу зеттелькастен. Все данные хранятся в локальном хранилище, причём каждая заметка по отдельному ключу, чтобы не обновлять всю базу данных на каждый чих.
Теперь рассмотрим один из основных сценариев: взаимная связь двух заметок друг с другом. Для этого надо согласованно обновить обе заметки, пролинковав их. Но вот беда, если мы просто выполним запись в первую, а во время записи второй случится непредвиденное (например, будет достигнут лимит на объём хранилища), то мы получим несогласованное состояние: одна заметка считает другую связанной с ней, а вторая ничего про это не знает.
Если мы реализуем транзакции лишь на уровне реактивной системы, то при ошибке, состояние, конечно, вернётся ко внутренней согласованности. Но согласованность с хранилищем нарушится. То есть мы обманем пользователя будто всё хорошо, но при перезагрузке приложения несогласованность вернётся вновь. Получается мы не решили, а только усугубили проблему.
Некоторые библиотеки предлагают выносить все побочные эффекты в отдельные задачи. То есть сначала атомарно обновляется состояние в памяти, а потом происходит её синхронизация с хранилищем. Тут наблюдается та же проблема - интерфейс показывает, что транзакция прошла, а на самом деле - либо не прошла, либо прошла лишь частично.
Мы можем попробовать откатить изменения в памяти, если применение побочного эффекта упало, но это может ещё сильнее усугубить ситуацию, так как завершённая транзакция становится видна всему остальному приложению и к моменту отката повлиять на работу множества других задач. Откат этой лавины изменений затруднителен, а часто даже и вреден.
Та же проблема возникает и при попытке отката состояния хранилища, в случае, когда между изменением хранилища и его откатом встряла другая транзакция со своим изменением. В этом случае мы откатим не только свои изменения, но и, внезапно, чужие. И наоборот, другая транзакция может отменить наш откат.
К тому же далеко не все побочные эффекты вообще возможно отменить. Например, эффект "запустить ракету" после запуска оной уже не отменить. В лучшем случае её можно подорвать в воздухе, но ракеты мы всё равно лишимся.
Получается, пытаясь решить проблему согласованности мы наворачиваем кучу хитрой логики, создающей всё более сложные для отладки ситуации, а по итогу всё равно получая несогласованность. А так как мы не можем победить, то давайте возглавлять: признаем, что откаты в общем случае не возможны, так что все изменения либо применяются и сразу видны всем, либо не применяются и мы получаем сообщение об ошибке при записи.
Таким образом у нас получается крайне простая архитектура и предельно понятная прикладному программисту логика работы. Да, внешнее состояние может вдруг стать несогласованным. Но оно может таким стать и по множеству не зависящих от нас причин. Но даже в этом случае приложение должно уметь "выживать".
Например, в случае с зеттелькастен можно показывать в списке связанных заметок не все подряд, а лишь те, с которыми есть взаимная связь. То есть выглядеть это будет как "откат транзакции" не смотря на то, что никакого отката по сути не было, а база вообще находится в несогласованном состоянии.
У меня нет уверенности, что это лучшее решение. Возможно в будущем удастся решить вопрос атомарности множества изменений так, чтобы это решение приносило пользы больше, чем вреда. Но пока что это остаётся благодатной почвой для дальнейших исследований.
Economy
Давайте сравним получившуюся у нас реализацию ($mol_wire) с ближайшим популярным конкурентом (MobX):
Ага, инициализация графа состояний получилась в 6 раз быстрее, а расход памяти при этом оказался в 4 раз меньше.
И не удивительно, ведь, MobX хранит очень много какой-то не понятной мета информации:
Поди разберись, что это за состояние, и какие у него соседи.
Нам же требуется хранить гораздо меньше данных, где легко разобраться, что есть что:
Если же мы начнём менять состояния, то заметим двукратное превосходство по скорости:
Во многом это связано с тем, что нам не требуется выделять дополнительную память при обновлении, так по максимуму реиспользуется уже выделенная.
Такое же преимущество сохраняется не только в использовании кучи, но и стека, и даже бандла:
Обратите внимание на уникальные человекопонятные идентификаторы у каждого состояния. Их формирование весьма не бесплатно как по скорости, так и по памяти, но даже с такими утяжелителями на ногах нам удаётся идти ноздря в ноздрю с push-конкурентами на их же территории (статический граф полностью обновляющихся на каждой итерации состояний, что не очень реалистично):
Вы, возможно, скажете, что это всё экономия на спичках, и не стоит затраченных усилий. В приложениях, типа "привет мир", через которые неокрепшие умы подсаживают на раздутые фреймворки, это действительно не имеет значения.
Но, по мере роста проекта, влияние спичек становится всё значительнее, а менять коней на переправе всё сложнее. Ведь система реактивности - она как кровеносная система приложения - её нельзя просто взять и пересадить как почку.
Например, я разрабатывал WYSIWYG редактор (убийцу Google Docs) с полностью ручным рендерингом на холсте ещё до того, как это научился делать сам Google Docs. И когда загружаешь документ страниц на 200 сложной вёрстки, эти оптимизации становятся очень даже заметны, так как по чуть-чуть влияют на каждое состояние огромного приложения.
Согласитесь, разница между 250 мегабайтами и 1 гигабайтом - довольно существенна. Как и разница между 60 и 10 FPS.
Integration
Просто написать классную библиотеку - слишком мелкая цель. Построить на ней богатый фреймворк с кучей батареек - уже интересней, но всё ещё не достаточно амбициозно. Разработанный нами подход может стать lingua franca в коммуникациях между библиотеками, между состояниями браузера, и даже между удалёнными узлами. Берегите синапсы, сейчас будет настоящий киберпанк..
Reactive ReactJS
ReactJS сейчас самый популярный фреймворк, вопреки множеству архитектурных просчётов. Вот лишь некоторые из них:
Что ж, давайте вылечим больного, а заодно покажем простоту интеграции $mol_wire в совершенно инородную ему архитектуру.
Начнём издалека - напишем синхронную функцию, которая загружает JSON по ссылке. Для этого напишем асинхронную функцию и конвертируем её в синхронную:
Теперь реализуем API для GitHub, с debounce и кешированием. Поддерживаться у нас будет лишь загрузка данных issue по его номеру:
Сколько бы раз мы ни обращались за данными - результат будет возвращаться из кеша, но если нам потребуется всё же перезагрузить данные - можно передать дополнительный параметр, чтобы запустилась задача по обновлению кеша.
Теперь, наконец, мы переходим к созданию компонент. Вопреки популярному тренду, мы не будем эмулировать объекты, через грязные функции с хуками, а будем использовать классовые компоненты. А чтобы не повторять одну и ту же логику, создадим базовый класс для наших компонент:
Основная идея тут в том, чтобы каждый компонент был полностью самодостаточным, но при этом контролируемым - любое его публичное поле можно переопределить через пропсы. Все пропсы опциональны, кроме идентификатора, который мы требуем задавать извне, чтобы он был глобально уникальным и семантичным.
Важно отметить, что пропсы не отслеживаются реактивной системой - это позволяет передавать в них колбэки и это не будет вызывать ререндеров. Идея тут в том, чтобы разделить инициализацию (через проталкивание пропсов) и собственно работу (путём затягивания через колбэки, предоставленные в пропсах).
Инициализация происходит при конструировании класса, а динамическая работа - когда фреймворк вызывает
render
. ReactJS славится тем, что вызывает его слишком часто. Тут же, благодаря мемоизации, мы перехватываем у фреймворка контроль за тем, когда фактически будут происходить ререндеры. Когда поменяется любая зависимость от которой зависит результат рендеринга, реактивная система перевычислит его и уведомит фреймворк о необходимости реконцилиации, тогда фреймворк вызоветrender
и получит свежий VDOM. В остальных же случаях он будет получать VDOM из кеша и ничего дальше не делать.Проще понять принцип работы на конкретных примерах, так что давайте создадим простой компонент - поле текстового ввода:
Тут мы объявили состояние, в котором по умолчанию храним введённый текст, и экшен вызывающийся при вводе для обновления этого состояния. В конце экшена мы заставляем ReactJS немедленно подхватить наши изменения, иначе каретка улетит в конец поля ввода. В остальных случаях в этом нет необходимости. Ну а при передаче экшена в VDOM мы завернули его в обёртку, которая просто превращает синхронный метод в асинхронный.
Теперь давайте воспользуемся этим компонентом в поле ввода числа, в который и поднимем состояние поля ввода текста:
Обратите внимание, что мы переопределили у поля ввода текста свойство
value
, так что теперь оно будет хранить своё состояние не у себя, а в нашем свойствеstr
, которое на самом деле является кешированным делегатом уже к свойствуnumb
. Логика его немного замысловатая, чтобы при вводе не валидного числа, мы не теряли пользовательский ввод из-за замены его на нормализованное значение.Можно заметить, что сформированный нами VDOM не зависит ни от каких реактивных состояний, а значит он вычислится лишь один раз при первом рендере, и больше обновляться не будет. Но не смотря на это, текстовое поле будет корректно реагировать на изменения свойств
numb
и как следствиеstr
.Так же тут использованы компоненты
Button
у которых переопределены методы, вызываемые для получения названия кнопки и для выполнения действия при клике. Но о кнопках позже, а пока воспользуемся всеми нашими наработками, чтобы реализовать продвинутыйCounter
, который не просто переключает число кнопками, но и грузит данные с сервера:Как не сложно заметить, состояние текстового поля ввода мы подняли ещё выше - теперь оно оперирует номером issue. По этому номеру мы через GitHub API грузим данные и показываем их рядом, завернув в специальный компонент
Safe
, задача которого обрабатывать исключительные ситуации в переданном ему коде: при ожидании показывать соответствующий индикатор, а при ошибке - текст ошибки. Реализуется он просто - обычнымtry-catch
:Наконец, реализуем кнопку, но не простую, а умную, умеющую отображать статус выполняемой задачи:
Тут мы место того, чтобы сразу запускать действие, кладём событие в реактивное свойство
click
, от которого зависит свойствоstatus
, которое уже и занимается запуском обработчика события. А чтобы обработчик был вызван сразу, а не в следующем фрейме анимации (что важно для некоторых JS API типаclipboard
), вызываетсяforceUpdate
. Самstatus
в штатных ситуациях ничего не возвращает, но в случае ожидания или ошибки показывает соответствующие блоки благодаряSafe
.Весь код этого примера можно найти в песочнице:
Там добавлены ещё и логи, чтобы можно было понять что происходит. Например, вот так выглядит первичный рендеринг:
Тут
#counter-title-safe
рендерился 3 раза так как сперва он показывал 💤 на debounce, потом на ожидании собственно загрузки данных, а в конце уже показал загруженные данные.При нажатии
Reaload
опять же, не рендерится ничего лишнего - меняется лишь индикатор ожидания на кнопке, так как данные в итоге не поменялись:Ну а при быстром изменении номера - обновляется поле ввода текста и вывод зависящего от него заголовка:
Итого, какие проблемы мы решили:
Можете доработать этот пример и оформить в виде библиотеки, если готовы заниматься его поддержкой. Или реализовать подобную интеграцию для любого другого фреймворка. А мы пока отстыковываем первую ступень и летим ещё выше..
Reactive JSX
Не сложно заметить, что отбирая у ReactJS контроль за состоянием, мы фактически низвергаем его с пьедестала фреймворка до уровня библиотеки рендеринга DOM, которой он изначально и являлся. Но это получается очень тяжёлая библиотека рендеринга, делающая слишком много лишней работы и тратящая впустую много памяти.
Давайте возьмём голый строго типизированный JSX, и сделаем его реактивным с помощью $mol_wire, получив полную замену ReactJS, но без VirtualDOM, а с точечными обновлениями RealDOM и другими приятными плюшками.
Для этого мы сперва возьмём $mol_jsx, который так же как E4X создаёт реальные DOM узлы, а не виртуальные:
Опа, нам больше не нужен ref для получения DOM узла из JSX.
Если исполнять JSX не просто так, а в контексте документа, то вместо создания новых элементов, будут использоваться уже существующие, на основе их идентификаторов:
Опа, мы получили ещё и гидратацию, но без разделения на первичный и вторичный рендеринг. Мы просто рендерим, а существующие элементы реиспользуются, если они есть.
Опа, да мы ж получили ещё и корректные перемещения элементов, вместо их пересоздания в новом месте. Причём уже не в рамках одного родителя, а в рамках всего документа:
Обратите внимание на использование естественных для HTML атрибутов
id
иclass
вместо эфемерныхkey
иclassName
.В качестве тегов можно использовать, разумеется, и шаблоны (stateless функции), и компоненты (stateful классы). Первые просто вызываются с правильным контекстом, а значит безусловно рендерят своё содержимое. А вторые создают экземпляр объекта, делегируют ему управление рендерингом, и сохраняют ссылку на него в полученном DOM узле, чтобы использовать его снова при следующем рендеринге. В рантайме выглядит это как-то так:
Тут мы видим два компонента, которые в результате рендеринга вернули один и тот же DOM элемент. Получить экземпляры компонент из DOM элемента не сложно:
Итак, давайте создадим простейший компонент - поле ввода текста:
Почти тот же код, что и с ReactJS, но:
Для иллюстрации последних пунктов, давайте рассмотрим более сложный компонент - поле ввода числа:
По сгенерированным классам легко навешивать стили на любые элементы:
К сожалению, реализовать полноценный CSS-in-TS в JSX не представляется возможным, но даже только лишь автогенерация классов уже существенно упрощает стилизацию.
Чтобы всё это работало, надо реализовать лишь базовый класс для реактивных JSX компонент:
Весь код этого примера можно найти в песочнице. Вот так вот за 1 вечер мы реализовали свой ReactJS на $mol, добавив кучу уникальных фичей, но уменьшив объём бандла в 5 раз. По скорости же мы лишь немного отстали от оригинала:
А как насчёт обратной задачи - написать аналог фреймворка $mol на ReactJS? Вам потребуется минимум 3 миллиона долларов, команда из десятка человек и несколько лет ожидания. Но мы не будем ждать, а отстыкуем и эту ступень..
Reactive DOM
Раньше DOM был медленным и не удобным. Чтобы с этим совладать были придуманы разные шаблонизаторы и техники VirtualDOM, IncrementalDOM, ShadowDOM. Однако, фундаментальные проблемы RealDOM никуда не деваются:
Ну да ладно, давайте представим, что было бы, если бы DOM и весь остальной рантайм были реактивными. Мы могли бы безо всяких библиотек связать любые состояния через простые инварианты и браузер бы гарантировал их выполнения максимально оптимальным способом!
Я набросал небольшой пропозал, как это могло бы выглядеть. Для примера, давайте возьмём и привяжем текст параграфа к значению поля ввода:
И всё, никаких библиотек, никаких обработчиков событий, никаких DOM-манипуляций. Только наши желания в чистом виде.
А хотите попробовать ReactiveDOM в деле уже сейчас? Я опубликовал прототип полифила $mol_wire_dom. Он не очень эффективен, много чего не поддерживает, но для демонстрации сойдёт:
Тут мы применили ещё и
$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
) и вуаля:При даже чуть большей функциональности (например, поддержка цветовых тем, локализации и пр), кода на $mol получилось в 3 раза меньше, чем в варианте с JSX. А главное - уменьшилась когнитивная сложность. Но это уже совсем другая история..
Results
Итого, введя простую, но гибкую абстракцию каналов, мы проработали множество паттернов их использования для достижения самых разных целей. Единожды разобравшись в этом, мы можем создавать приложения любой сложности, и весело интегрироваться с самыми разными API.
Добавление каналам реактивной мемоизации с автоматической ревалидацией, освобождением ресурсов и поддержкой асинхронности, дало нам как радикальное упрощение прикладного кода, так и повышение его эффективности в потреблении ресурсов процессора и памяти.
А для тех, кто по каким-либо причинам ещё не готов полностью переходить на фреймворк $mol, мы подготовили несколько независимых микробиблиотек:
Хватайте их в руки и давайте зажигать вместе!
Growth
Ещё не убежили опять переписывать свой фронтенд? Ну ладно, поговорим напоследок ещё и о перспективах.
Эту статью я писал пол года, параллельно ведя битву за две жизни. Одна из них, к сожалению, уже проиграна, но вторая ещё нет, так что ещё есть время довести наши проекты до самостоятельной жизнеспособности. А их не мало..
Прежде всего это фреймворк $mol, который недавно здорово похорошел:
Ему уже почти 7 годиков, но он всё ещё технологически обгоняет даже самые современные фреймворки (привет, AngularJS, который месяц не дожил до 6 лет). У него есть все перспективы повысить эффективность отечественной разработки. Компания, которая решится стать первой - здорово обгонит конкурентов. Вот лишь несколько реальных кейсов:
Ну а пока все топчутся на месте, переизобретая очередной UI-Kit на ReactJS, мы уже пилим оупенсорс web-платформу нового поколения:
Уже есть зачатки десятка приложений:
Тем временем, мы выпустили уже четыре десятка инновационных статей и десяток хардкорных докладов.
И это мы только начали! В ближайших планах столь же детально разобрать как существующие CRDT алгоритмы, так и наш ноу-хау, который ляжет в основу распределённой реактивной базы данных. Так что следите за новостями.
Я против пейволов, рекламы, слежки и прочих бесчеловечных техник. Так что все наши материалы и разработки вы всё равно получите совершенно бесплатно. Но если вам нравится то, что мы делаем, находите это полезным, и хотите поскорее приблизить светлое будущее, то поддержите рублём.. нет, не меня, а гильдию $hyoo, где получаемый доход распределяется пропорционально вкладу каждого участника в развитие экосистемы.
А то и хватайте клавиатуру, чтобы вместе вершить эту $mol революцию!