Здравствуйте, меня зовут Дмитрий Карловский, и я.. тот самый чел, который написал реактивную библиотеку $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"
					>
					????
				</span>
			)

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

			throw error
		}
		
	}
}

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

export class Button extends Component<Button> {
	
	title() {
		return ""
	}

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

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

	@mem status() {
		
		const event = this.click()
		if( !event ) return

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

	compose() {
		return (
			
			<button
				id={this.id}
				className="button"
				onClick={ action(this).click }
				>
				
				{ this.title() } {" "}
				
				<Safe
					id={ `${this.id}-safe` }
					task={ () => this.status() }
				/>
				
			</button>
			
		)
	}
	
}

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

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

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

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

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

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

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

render #counter-reload-safe 

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

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

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

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

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

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

  • ✅ Компонент автоматически точечно (а не как с Redux) отслеживает внешние состояния.

  • ✅ Параметры компонента обновляются без ререндера родителя.

  • ❌ Перемещением компонент по прежнему управляет ReactJS.

  • ✅ Изменение колбэка не приводит к ререндеру.

  • ✅ Наш аналог хуков можно применять в любом месте кода, даже в циклах и условиях.

  • ❌ Обработка ошибок по прежнему управляется ReactJS, поэтому требует ручной работы.

  • ✅ Для частичного обновения можно создать компонент принимающий замыкание.

  • ✅ stateful компоненты полснотью контролируемы.

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


Реактивный JSX

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

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

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

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

Опа, нам больше не нужен ref для получения DOM узла из JSX, ведь мы сразу получаем от него DOM дерево.

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

<body>
	<h1 id="title">...</h1>
</body>

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

<body>
	<h1 id="title" class="header">Wow!</h1>
</body>

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

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

<body>
	<article id="todo">
		<h1 id="task/1">Complete article about $mol_wire</h1>
	<article>
	<article id="done"></article>
</body>

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

<body>
	<article id="todo"></article>
	<article id="done">
		<h1 id="task/1">Complete article about $mol_wire</h1>
	<article>
</body>

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

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

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

const input = InputString.of( element )

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

export class InputString extends View {
	
	// statefull!
	@mem value( next = "" ) {
		return next
	}

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

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

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

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

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

  • Классы для стилизации генерируются автоматически на основе идентификаторов и имён компонент.

  • Нет необходимости руками собирать идентификаторы элементов — семантичные идентификаторы тоже формируются автоматически.

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

  • При конфликте идентификаторов кидается исключение, что гарантирует их глобальную уникальность.

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

export class InputNumber extends View {
	
	// self state
	@mem numb( next = 0 ) {
		return next
	}

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

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

	// lifted string state as delegate to number state!
	@mem str(str?: string) {
		
		const next = str?.valueOf && Number( str )
		if( Object.is( next, NaN ) ) return str ?? ""

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

		return String( res ?? "" )
	}

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

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

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

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

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

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

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

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

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

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

	/** Returns component instance for DOM node. */
	static of< This extends typeof $mol_jsx_view >( this: This, node: Element ) {
		return node[ this as any ] as InstanceType< This >
	}
	
	// Allow overriding of all fields via attributes
	attributes!: Partial< Pick< this, Exclude< keyof this, 'valueOf' > > >
	
	/** Document to reuse DOM elements by ID */
	ownerDocument!: typeof $mol_jsx_document
	
	/** Autogenerated class names */
	className = ''
	
	/** Children to render inside */
	@ $mol_wire_field
	get childNodes() {
		return [] as Array< Node | string >
	}
	
	/** Memoized render in right context */
	@ $mol_wire_solo
	valueOf() {
		
		const prefix = $mol_jsx_prefix
		const booked = $mol_jsx_booked
		const crumbs = $mol_jsx_crumbs
		const document = $mol_jsx_document
		
		try {
			
			$mol_jsx_prefix = this[ Symbol.toStringTag ]
			$mol_jsx_booked = new Set
			$mol_jsx_crumbs = this.className
			$mol_jsx_document = this.ownerDocument

			return this.render()

		} finally {

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

		}

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

}

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

export class Counter extends View {
	
	@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
	}

	render() {
		return (
			<div>
				
				<InputNumber
					id="numb"
					numb={ next => this.numb(next) } // hack to lift state up
				/>

				<Safe
					id="titleSafe"
					task={ ()=> (
						<a id="title" href={ this.link() }>
							{ this.title() }
						</a>
					) }
				/>

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

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

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


Реактивный DOM

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

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

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

3. Тернистость. На самом деле DOM нам и не нужен. Нам нужен способ сказать браузеру как и когда рендерить наши компоненты.

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

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

<input id="input" />
<p id="output"></p>
<script>
	
	const input = document.getElementById('input')
	const output = document.getElementById('output')
	
	Object.defineProperty( output, 'innerText', {
		get: ()=> 'Hello ' + input.value
	} )
	
</script>

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

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

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

import { $mol_wire_dom, $mol_wire_patch } from "mol_wire_dom";

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

// Make globals reactive
$mol_wire_patch(globalThis);

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

// Setup invariants

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

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

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

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


Ленивый DOM

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

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

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

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

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

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

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

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

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

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

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

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

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


Фреймворк на основе $mol_wire

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

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

$my_counter $mol_list
	Issue $mol_github_issue
		title => title
		web_uri => link
		json? => data?
	sub /
		<= Numb $mol_number
			value? <=> numb? 48
		<= Title $mol_link
			title <= title
			uri <= link
		<= Reload $mol_button_minor
			title @ \Reload
			click? <=> reload?

export class $my_counter extends $.$my_counter {
	
	Issue() {
		const endpoint = `https://api.github.com/repos`
		const uri = `${ endpoint }/nin-jin/HabHub/issues/${ this.numb() }`
		return this.$.$mol_github_issue.item( uri )
	}
	
	reload() {
		this.data( null )
	}
	
}

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

Пока же, приглашаю вас попробовать $mol_wire в своих проектах. А если у вас возникнут сложности, не стесняйтесь задавать вопросы в теме про $mol на форуме Hyper Dev.


Актуальный оригинал на $hyoo_page

Комментарии (31)


  1. ionicman
    00.00.0000 00:00
    +6

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

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

    За притаскивание такого в проект я бы лично обрывал руки - почему написал выше.

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

    Естественно это мое и только мое мнение - ничего никому не навязываю.


    1. Ahuromazdie
      00.00.0000 00:00
      +3

      Слышал звон да не знаю где он? $mol ничего к Реакту не приделывает. $mol модульный, и его модули можно использовать даже с Реактом...


    1. nin-jin Автор
      00.00.0000 00:00

      Не стоит преувеличивать, комьюнити хоть и небольшое, но есть, и активно участвует в улучшении фреймворка. Ну а про поливание гном, отвечу словами ВВП: "Я ещё даже не начинал".


  1. VladimirFarshatov
    00.00.0000 00:00
    +1

    Не увидел в табличке сравнения как с чистым JS, так и с низкоуровневой (будем считать так) библиотекой JQuery. Возможно такой пример был бы и сложнее и даже существенно, но в качестве сравнительной колонки, кмк, просто обязан присутствовать. А так .. в JS можно писать любые "обфускаторы" и надстройки.. дорого всё это, что наглядно видно на страницах сайтов, как только спускаешься с небес на землю. Достаточно попасть в какой-то уголок, где штатная скорость 20-50кБод и фсё .. можно покурить, налить и выпить кофе пока прогрузятся страницы с очередным модным шаблонизатором - улучшателем ДОМ .. :(

    В общем, табличку стоит таки дополнить.


    1. Ahuromazdie
      00.00.0000 00:00
      -2

      1. VladimirFarshatov
        00.00.0000 00:00
        +1

        Думаю там про этот фреймворк http://vanilla-js.com/

        Иначе как-то слабо верится что обфускатор, генерирующий в конечном итоге JS-код, работает быстрее оригинального js. "так не бывает".. ;)


    1. nin-jin Автор
      00.00.0000 00:00

      Бандл всего приведённого в статье приложения на $mol весит меньше одной только голой библиотеки jQuery без плагинов, стилей и прикладного кода.


      1. VladimirFarshatov
        00.00.0000 00:00
        +1

        Ну не фронтендщик, бэк в основном. На своих хромоногих фронтах дальше JQuery не видел надобности применять что-либо. Из фронтовой работы, имею опыт по выпиливанию Ангуляра с заменой на чистый JS. По результатам этой работы могу сказать что страница сайта полегчала примерно с 2 Мб, до 150кб и ускорилась от 20 до 200 раз.

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


        1. nin-jin Автор
          00.00.0000 00:00

          Тут объясняется, что не так с подходом "на jQuery". техническая часть там, правда, устарела, но мотивационная актуальна.


  1. ht-pro
    00.00.0000 00:00

    Это Владимир Дмитрий Карловский.


  1. bromzh
    00.00.0000 00:00

    Как я уже писал - не взлетит, как минимум, потому что snake_case. Лапшу из разного формата кодирования никто в здравом уме не хочет видеть у себя на проектах. И дело даже не в том, что хуже или лучше, это дело вкуса. Если вы не уважаете сообщество, используя не то форматирование, то зачем сообществу в ответ пытаться использовать ваше творение?


    1. nin-jin Автор
      00.00.0000 00:00

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


      1. yroman
        00.00.0000 00:00
        +2

        >> ради которой я буду за ним бегать и уговаривать взяться за ум


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


        1. nin-jin Автор
          00.00.0000 00:00

          Крабам этого не понять
          Крабам этого не понять


        1. VladimirFarshatov
          00.00.0000 00:00
          +6

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


      1. bromzh
        00.00.0000 00:00
        -1

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


        1. nin-jin Автор
          00.00.0000 00:00

          Рационально как раз не смешивать стили. А от линтера больше вреда, чем пользы.


          1. bromzh
            00.00.0000 00:00
            +1

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

            А от линтера больше вреда, чем пользы.

            От подсветки синтаксиса, кстати, тоже. Роб Пайк подтвердит.

            В целом, линтер вреден, да. Например, внедрение sort-imports на всех моих проектах уменьшило количество merge-conflicts радикально. И после этого, вместо просиживания штанов за разрешением конфликтов приходилось код писать, вот отстой.


            1. nin-jin Автор
              00.00.0000 00:00
              -1

              Очевидно потому, что я отвечаю лишь за свой код, но не сторонние апи, которые могут быть любой кривизны. Яркие примеры: XMLHttpRequest, onsecuritypolicyviolation.


              1. bromzh
                00.00.0000 00:00
                -1

                Как тогда писать проект, где будет ваша либа и от другого автора? Как заимплементить 2 интерфейса в классе, чтобы не было some_method и anotherMethod?


                1. nin-jin Автор
                  00.00.0000 00:00

                  В статье есть много примеров кода и даже ссылки на песочницу.


  1. jodaka
    00.00.0000 00:00
    +2

    Круто! На самом деле круто.
    (вот уж не думал, что напишу такое под статьёй Карловского)


  1. bO_oblik
    00.00.0000 00:00
    +1

    Спасибо! У вас очень интересный контент, я требую продолжить гнуть свою линию и не оглядываться на "унылых" ребят.


  1. flancer
    00.00.0000 00:00
    +1

    Интересно, чем $mall так плох, что требует настолько агрессивный маркетинг? Ведь это же просто "реактивная библиотека", почему именно ей "пугают детей перед сном"? Если эта библиотека настолько хороша (да даже не настолько, а просто хороша), то почему бы на ней не делать проекты и не дать ей жить своей жизнью? Прорастёт со временем. Если, конечно, у неё есть реальные преимущества перед другими. Вон, @VladimirFarshatov чуть выше заменил в каком-то проекте ангуляр на чистый JS и получил выигрыш на порядки (!). Может вместо статей на хабре заменять что-угодно на $small в тех проектах, где $mall может показать себя с наилучшей стороны? А уже потом писать статьи на хабре? Со ссылкой на эти проекты и на $small. Так-то оно не настолько пугающе будет.

    P.S.

    Ссылки на проекты, построенные на $small изначально - то такое себе, малоубедительное. Для соответствующего впечатления нужно, как у @VladimirFarshatov: было то-то, поставили $small - стало в XX раз быстрее. Только с цифрами до и после. И со ссылками на владельцев проекта. Тогда уже бизнес будет заинтересован в этой технологии. Если у неё действительно есть преимущества.


    1. nin-jin Автор
      00.00.0000 00:00

      Вы тоже статью до конца не дочитали? Там есть яркий пример до и после.


      1. flancer
        00.00.0000 00:00
        +1

        Да, я тоже до конца статью не дочитал. Если вы хотите, чтобы у меня в голове ярко отложилось до и после, то разжуйте, положите мне в рот и спросите: "Ну как? Здорово ведь, да? Может ещё?" Я спецом сбегал ещё раз до конца статьи и не увидел ничего такого, ради чего стоит опять вчитываться в буквы, которые там написаны. Или вы всерьёз считаете, что вот этот виджет - это проект, который может реально заинтриговать бизнес?

        Боюсь, вы просто не понимаете, что я имел в виду под словом "проект" в своём комменте выше. Но я и не заинтересован в том, чтобы вы это понимали. Я использую Vue у себя. Я инвестировал своё время в изучение этой технологии и мне не выгодно, чтобы её вытеснила какая-либо другая.


        1. nin-jin Автор
          00.00.0000 00:00
          -2

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


          1. flancer
            00.00.0000 00:00
            +1

            А что вы можете мне дать? У меня full stack - file system, RDBMS, SSE, Web Sockets, IndexedDB, Service Workers, интеграция с внешними API как с фронта, так и с бэка (STT, OCR). Ваша реактивность лишь малая часть общего функционала в проектах, с которыми я имею дело. Мне не нужна "глубина погружения", мне нужно оставаться в текущем тренде, "на гребне волны". А для этого пока что хватает Vue. Если куда и переключаться, то на Svelte:


            1. nin-jin Автор
              00.00.0000 00:00
              -3

              Для начала я могу дать вам почитать свои статьи, но не забесплатно, конечно.


              1. flancer
                00.00.0000 00:00

                Для чего? Для чего я буду читать ваши статьи? Чтобы что? Ознакомиться с ещё одной технологией/библиотекой/платформой? Я знаю, для чего нужен ./autoexec.bat в MS-DOS. И config.sys тоже знаю, для чего. Я знаю, как устанавливать и конфигурить OS/2 Warp и Solaris. Но кто бы мне объяснил, зачем мне сейчас все эти знания? У меня в голове полно ненужных знаний. Зачем мне ещё одно. К тому же незабесплатно.

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


                1. nin-jin Автор
                  00.00.0000 00:00
                  -4

                  Какой-то вы не современный, вон, уже даже ChatGPT научился в $mol, а вы до сих пор мусор про DOS на антресолях храните.