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

Реактивный React
ReactJS сейчас самый популярный фреймворк, вопреки множеству архитектурных просчётов. Вот лишь некоторые из них:
Компонент не отслеживает внешние состояния, от которых он зависит, - обновляется он только при изменении локального. Это требует аккуратных подписок/отписок и своевременных уведомлений об изменениях.
Единственный способ изменить один параметр компонента - это полностью ререндерить внешний компонент, заново сформировав все параметры как для него, так и для соседей. То же касается и добавления/удаления/перемещения компонента.
При перемещении компонента между контейнерами происходит его полное пересоздание. И наоборот, разные экземпляры одного компонента могут быть неуместно реиспользованы.
Создаваемые на лету колбэки приводят к лишним ререндерам, поэтому требуют аккуратной их мемоизации с точным указанием используемых внутри них переменных.
Хуки нельзя применять в условиях, циклах и других местах динамичности потока исполнения, иначе всё сломается.
Ошибки и индикация ожидания происходят вне компонента. Компонент получается не самодостаточным и фатально влияющим и на внешний компонент и, как следствие, на соседей.
Компонент невозможно обновить частично - только полный ререндер. Чтобы это побороть либо обмазываются мемоизацией, либо излишне увеличивают гранулярность компонент.
Отсутствие контроля stateful компонента часто приводит к необходимости разбивать каждый компонент на два: контролируемый stateless и неконтролируемая stateful обёртка над ним. Частичный контроль при этом сопряжён с трудностями и копипастой.
Что ж, давайте вылечим больного, а заодно покажем простоту интеграции реактивной библиотеки $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 {
// cache
@mems static issue( value: number, reload?: "reload" ) {
sleep(500) // debounce
const uri = `https://api.github.com/repos/nin-jin/HabHub/issues/${value}`
return getJSON( uri ) as {
title: string
html_url: string
}
}
}
Сколько бы раз мы ни обращались за данными - результат будет возвращаться из кеша, но если нам потребуется всё же перезагрузить данные - можно передать дополнительный параметр, чтобы запустилась задача по обновлению кеша. В любом случае запрос пойдёт не сразу, а с задержкой в пол секунды.
Теперь, наконец, мы переходим к созданию компонент. Вопреки популярному тренду, мы не будем эмулировать объекты, через грязные функции с хуками, а будем использовать классовые компоненты. А чтобы не повторять одну и ту же логику, создадим базовый класс для наших компонент:
export abstract class Component<
Props = { id: string },
State = {},
SnapShot = any
> extends React.Component<
Partial<Props> & { id: string },
State,
SnapShot
> {
// every component should have guid
id!: string;
// show id in debugger
[Symbol.toStringTag] = this.props.id
// override fields by props to configure
constructor(props: Props & { id: string }) {
super(props)
Object.assign(this, props)
}
// composes inner components as vdom
abstract compose(): any
// memoized render which notify react on recalc
@mem render() {
log("render", "#" + this.id)
Promise.resolve().then(() => this.forceUpdate())
return this.compose()
}
}
Основная идея тут в том, чтобы каждый компонент был полностью самодостаточным, но при этом контролируемым - любое его публичное поле можно переопределить через пропсы. Все пропсы опциональны, кроме идентификатора, который мы требуем задавать извне, чтобы он был глобально уникальным и семантичным.
Важно отметить, что пропсы не отслеживаются реактивной системой - это позволяет передавать в них колбэки и это не будет вызывать ререндеров. Идея тут в том, чтобы разделить инициализацию (через проталкивание пропсов) и собственно работу (путём затягивания через колбэки, предоставленные в пропсах).
Инициализация происходит при конструировании класса, а динамическая работа - когда фреймворк вызывает render. ReactJS славится тем, что вызывает его слишком часто. Тут же, благодаря мемоизации, мы перехватываем у фреймворка контроль за тем, когда фактически будут происходить ререндеры. Когда поменяется любая зависимость от которой зависит результат рендеринга, реактивная система перевычислит его и уведомит фреймворк о необходимости реконцилиации, тогда фреймворк вызовет render и получит свежий VDOM. В остальных же случаях он будет получать VDOM из кеша и ничего дальше не делать.
Такая схема работы уже не позволит использовать в своей логике хуки, но с $mol_wire, хуки - как собаке пятая нога.
Проще понять принцип работы на конкретных примерах, так что давайте создадим простой компонент - поле текстового ввода:
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"
>
