Проектируем идеальную систему реактивности
Здравствуйте, меня зовут Дмитрий Карловский и я… крайне плох в построение социальных связей, но чуть менее плох в построении программных. Недавно я подытожил свой восьмилетний опыт реактивного программирования, проведя обстоятельный анализ различных подходов к решению типичных детских болячек:
Main Aspects of Reactivity
Я очень рекомендую прочитать сперва ту статью, чтобы лучше понимать дальнейшее повествование, где мы с нуля разработаем совершенно новую TypeScript реализацию, вобравшую в себя все самые крутые идеи, позволяющие достигнуть беспрецедентной выразительности, компактности, скорости, надёжности, простоты, гибкости, экономности…
Статья разбита на главы, перелинкованные с соответствующими аспектами из упомянутого выше анализа. Так что если вдруг потеряетесь — сможете быстро восстановить контекст.
Повествование будет долгим, но если вы продержитесь до конца, то сможете смело идти к начальнику за повышением. Даже если вы сам себе начальник.
К сожалению, статья получилась слишком длинной для Хабра, поэтому полную версию вы найдёте среди остальных статей. Далее же я подготовил для вас краткое оглавление, чтобы вы понимали сколько всего вас там ждёт.
- Рассматриваются разные абстракции работы состоянием: поля, хуки, и вводится новый тип — каналы, позволяющие через одну функцию и проталкивать значения и затягивать, полностью контролируя оба процесса.
- Приводятся примеры работы через канал с локальной переменной, обработки событий, делегирование работы другому каналу с образованием цепочек, пересекающих разные слои абстракции.
let _title = ''
const title = ( text = _title )=> _title = text
title() // ''
title( 'Buy some milk' ) // 'Buy some milk'
title() // 'Buy some milk'
- Рассматривается использование каналов в качестве методов объектов.
- Вводится декоратор
$mol_wire_solo
, мемоизирующий их работу для экономии вычислений и обеспечения идемпотентности.
class Task extends Object {
@ $mol_wire_solo
title( title = '' ) {
return title
}
details( details?: string ) {
return this.title( details )
}
}
- Расcматривается композиция нескольких простых каналов в один составной.
- И наоборот — работа с составным каналом через несколько простых.
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 title?: string
readonly dur?: number
} ) {
return {
title: this.title( data?.title ),
dur: this.duration( data?.dur ),
} as const
}
}
- Рассматриваются каналы, мультиплексированные в одном методе, принимающем первым аргументом идентификатор канала.
- Вводится новый декоратор
$mol_wire_plex
для таких каналов. - Демонстрируется подход с выносом копипасты из нескольких сольных каналов в один мультиплексированный в базовом классе без изменения API.
- Демонстрируется вынос хранения состояний множества объектов в локальное хранилище через мультиплексированный синглтон с получением автоматической синхронизации вкладок.
class Task_persist extends Task {
@ $mol_wire_solo
data( data?: {
readonly title: string
readonly 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'
- Реализуется библиотека выдающая уникальный строковый ключ для эквивалентных сложных структур.
- Объясняется универсальный принцип поддержки и пользовательских типов данных.
- Демонстрируется её применение для идентификации мультиплексированных каналов.
@ $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 )
}
- Вводится понятие реактивного фабричного метода, управляющего жизненным циклом создаваемого объекта.
- Рассматривается ленивое создание цепочки объектов с последующим автоматическим её разрушением.
- Объясняется принцип захвата владения объектом и предсказуемость момента его разрушения.
- Подчёркивается важность ленивого создания объектов для скорости компонентного тестирования.
class Account extends Entity {
@ $mol_wire_plex
project( id: number ) {
return new Project( id )
}
}
class User extends Entity {
@ $mol_wire_solo
account() {
return new Account
}
}
- Рассматривается техника настройки объекта путём переопределения его каналов.
- Демонстрируется поднятие стейта используя хакинг.
- Подчёркиваются преимущества хакинга для связывания объектов ничего не знающих друг про друга.
- Связывание объектов классифицируются по направлению: одностороннее и двустороннее.
- А так же по методу: делегирование и хакинг.
- Подчёркивается недостатки связывания методом синхронизации.
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
}
// 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 )
}
}
- Раскрываются возможности фабрик по формированию глобально уникальных семантичных идентификаторов объектов.
- Демонстрируется отображение индентификаторов в отладчике и стектрейсах.
- Демонстрируется использование custom formatters для ещё большей информативности объектов в отладчике.
- Демонстрируется логирование изменений состояний с отображением их идентификаторов.
- Вводится понятие волокна — приостанавливаемой функции.
- Оценивается потребление памяти наивной реализацией волокна на хеш-таблицах.
- Предлагается наиболее экономная реализация на обычном массиве.
- Раскрывается техника двусторонних связей с накладными расходами всего в 16 байт и константной алгоритмической сложностью операций.
- Обосновывается ограниченность разрастания занимаемой массивом памяти при динамической перестройке графа.
- Вводится понятия издателя, как минимального наблюдаемого объекта.
- Оценивается потребление памяти издателем.
- Демонстрируется применение издателя для реактивизации обычной переменной и адреса страницы.
- Предлагается к использованию микро библиотека, предоставляющая минимального издателя для встраивания в другие библиотеки.
- Демонстрируется создание реактивного множества из нативного.
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
}
- Разбирается структурное сравнение произвольных объектов.
- Вводится эвристика для поддержки пользовательских типов данных.
- Обосновывается важность кеширования и разъясняется как избежать утечек памяти при этом.
- Раскрывается применение кеширования для корректного сравнения циклических ссылок.
- Предлагается к использованию независимая микро библиотека.
- Приводятся результаты сравнения производительности разных библиотек глубокого сравнения объектов.
- Вводится понятие подписчика, как наблюдателя способного автоматически подписываться на издателей и отписываться от них.
- Оценивается потребление памяти подписчиком и подписчиком совмещённым с издателем.
- Раскрывается алгоритм автоматической подписки на издателей.
- Разбирается ручная низкоуровневая работа с подписчиком.
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()
}
- Вводится понятие задачи, как одноразового волокна, которое финализируется при завершении, освобождая ресурсы.
- Сравниваются основные виды задач: от нативных генераторов и асинхронных функций, до NodeJS расширения и SuspenseAPI с перезапусками функции.
- Вводится декоратор
$mol_wire_task
автоматически заворачивающий метод в задачу. - Разъясняется как бороться с неидемпотентностью при использовании задач.
- Раскрывается механизм обеспечения надёжности при перезапусках функции с динамически меняющимся потоком исполнения.
// 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
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
left( next = false ) {
return next
}
@ $mol_wire_solo
right( next = false ) {
return next
}
@ $mol_wire_solo
res( next?: boolean ) {
return this.left( next ) && this.right()
}
- Приводятся 5 состояний в которых может находиться волокно: вычисляется, устаревшее, сомнительное, актуальное, финализировано.
- Раскрывается назначение курсора для представления состояния жизненного цикла волокна.
- Иллюстрируются переходы состояний узлов реактивного графа при изменениях значений и при обращении к ним.
- Обосновывается перманентная актуальность значения, получаемого от атома.
- Раскрывается механизм автоматического обновления от точки входа, гарантирующего корректный порядок вычислений.
- Обосновывается отложенный пересчёт инвариантов именно при следующем фрейме анимации, что экономит ресурсы без видимых артефактов.
- Рассматриваются основные сценарии работы с атомами, которые могут зависеть от глубины зависимостей.
- Разбираются два основных подхода к реализации этих сценариев: цикл и рекурсия.
- Обосновывается выбор именно рекурсивного подхода не смотря на его ограничение глубины зависимостей.
- Приводится пример анализа стектрейса и подчёркивается важность его информативности.
- Разъясняется прозрачное поведение реактивной системы для всплывающих исключений.
- Классифицируются возможные значения волокна: обещание, ошибка, корректный результат.
- Классифицируются возможные способы передачи нового значения волокну:
return
,throw
,put
. - Обосновывается нормализация поведения волокна независимо от способа передачи ему значения.
- Рассматриваются особенности работы с асинхронными и синхронными интерфейсами.
- Разъясняется механизм работы SuspenseAPI, основанный на всплытии обещаний.
- Разбираются возможности отслеживания зависимостей в синхронных функциях, асинхронных и генераторах.
- Приводятся результаты замера скорости работы разных подходов.
- Подчёркивается проблема разноцветных функций и необходимость их обесцвечивания.
- Обосновывается выбор именно синхронного подхода.
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
}
}
- Вводятся прокси
$mol_wire_sync
и$mol_wire_async
позволяющие трансформировать асинхронный код в синхронный и обратно. - Приводится пример синхронной, но не блокирующей загрузки данных с сервера.
function getData( uri: string ): { lucky: number } {
const request = $mol_wire_sync( fetch )
const response = $mol_wire_sync( request( uri ) )
return response.json().data
}
- Разбирается сценарий, когда одно и то же действие запускается до завершения предыдущего запуска.
- Раскрывается особенность
$mol_wire_async
позволяющая управлять будет ли предыдущая задача отменена автоматически. - Приводится пример использования этой особенности для реализации debounce.
button.onclick = $mol_wire_async( function() {
$mol_wait_timeout( 1000 )
// no last-second calls if we're here
counter.sendIncrement()
} )
- Рассматриваются существующие в JS механизмы отмены асинхронных задач.
- Объясняется как использовать механизм контроля времени жизни в том числе и для обещаний.
- Приводится пример простейшего 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 } )
} )
- Разбирается наивная реализация конвертера температур с циклической зависимостью.
- Реализуется корректный конвертер температур без циклических зависимостей путём выноса источника истины в отдельный атом.
- Раскрывается техника снижения алгоритмической сложности через реактивную мемоизацию на примере вычисления чисел Фибоначчи.
- Рассматриваются проблемы транзакционности работы со внешними состояниями, не поддерживающими изоляцию, на примере личных заметок и локального хранилища.
- Подчёркивается важность не только внутренней согласованности, но и согласованности со внешними состояниями.
- Раскрываются проблемы обмана пользователя, которые только усугубляют ситуацию с которой призваны бороться.
- Обосновывается бесперспективность отката уже принятых изменений и неизбежность несогласованности внешнего состояния.
- Принимается решение не морочить прикладному программисту голову, а сконцентрироваться на том, чтобы он лучше понимал, что происходит.
- Предлагается писать прикладную логику, нормализующую неконсистентность исходных данных.
- Приводятся результаты замеров скорости и потребления памяти
$mol_wire
в сравнении с ближайшим конкурентомMobX
. - Раскрываются решающие факторы позволяющие
$mol_wire
показывать более чем двукратное преимущество по всем параметрам не смотря на фору из-за улучшенного debug experience. - Приводятся замеры показывающие конкурентоспособность
$mol_wire
даже на чужом поле, где возможности частичного пересчёта состояний не задействуются. - Обосновывается важность максимальной оптимизации и экономности реактивной системы.
- Приводятся основные архитектурные проблемы ReactJS.
- Вводятся такие архитектурные улучшения из $mol как controlled but stateful, update without recomposition, lazy pull, auto props и другие.
- Большая часть проблем решается путём реализации базового ReactJS компонента с прикрученным
$mol_wire
. - Реализуется компонент автоматически отображающий статус асинхронных процессов внутри себя.
- Реализуется реактивное GitHub API, не зависящее от ReactJS.
- Реализуется кнопка с индикацией статуса выполнения действия.
- Реализуется поле ввода текста и использующее его поле ввода числа.
- Реализуется приложение позволяющее вводить номер статьи и загружающее с GitHub её название.
- Демонстрируется частичное поднятие стейта компонента.
- Приводятся логи работы в различных сценариях, показывающие отсутствие лишних ререндеров.
- Обосновывается отсутствие пользы от ReactJS в реактивной среде.
- Привносится библиотека
mol_jsx_lib
осуществляющая рендер JSX напрямую в реальный DOM. - Обнаруживаются улучшения в гидратации, перемещении компонент без ререндера, доступа к DOM узлам, именовании атрибутов и тд.
- Демонстрируются возможности каскадной стилизации по автоматически генерируемым именам классов.
- Приводятся замеры показывающие уменьшение бандла в 5 раз при сопоставимой скорости работы.
- Приводятся основные архитектурные проблемы DOM.
- Предлагается proposal по добавлению реактивности в JS Runtime.
- Привносится библиотека
mol_wire_dom
позволяющая попробовать реактивный DOM уже сейчас.
- Обосновывается необходимость ленивого построения DOM для заполнения лишь видимой области страницы.
- Подчёркивается сложность виртуализации рендеринга DOM как на уровне фреймворка, так и на прикладном уровне.
- Предлагается стратегии по продвижению реактивности в стандарты.
- Уменьшается объём кода приложения в несколько раз путём отказа от JSX в пользу всех возможностей $mol.
- Отмечается также и расширение функциональности приложения без дополнительных движений.
Итого, введя простую, но гибкую абстракцию каналов, мы проработали множество паттернов их использования для достижения самых разных целей. Единожды разобравшись в этом, мы можем создавать приложения любой сложности, и весело интегрироваться с самыми разными API.
Добавление каналам реактивной мемоизации с автоматической ревалидацией, освобождением ресурсов и поддержкой асинхронности, дало нам как радикальное упрощение прикладного кода, так и повышение его эффективности в потреблении ресурсов процессора и памяти.
А для тех, кто по каким-либо причинам ещё не готов полностью переходить на фреймворк $mol, мы подготовили несколько независимых микробиблиотек:
- $mol_key (1 KB) — уникальный ключ для структур
- $mol_compare_deep (1 KB) — быстрое глубокое сравнение объектов
- $mol_wire_pub (1.5 KB) — минимальный издатель для интеграции в реактивный рантайм
- $mol_wire_lib (7 KB) — полный набор инструментов для реактивного программирования
- $mol_wire_dom (7.5 KB) — магия превращения обычного DOM в ReactiveDOM.
- $mol_jsx_view (8 KB) — по настоящему реактивный React.
Хватайте их в руки и давайте зажигать вместе!
- Приводятся реальные кейсы, где $mol хорошо себя показал в скорости обучения, разработки, запуска, отзывчивости и даже в уменьшении размера команды с сохранением конкурентоспособности.
- Раскрываются основные достоинства разрабатываемой нами на его основе оупенсорс веб-платформы нового поколения.
- Освещаются радужные перспективы по импортозамещению множества веб-сервисов на новом уровне качества.
- Подробно разбираются уже начатые нами проекты, написанные наукоёмкие статьи и записанные хардкорные доклады.
- Предлагается дать денег на продолжение этого банкета или самим начать готовить закуски.