Как стать автором
Обновить

Трансплантация реактивности

Уровень сложности Средний
Время на прочтение 19 мин
Количество просмотров 2.3K
Разработка веб-сайтов *JavaScript *Проектирование и рефакторинг *ReactJS *$mol *
Туториал

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

Теги:
Хабы:
Всего голосов 25: ↑20 и ↓5 +15
Комментарии 31
Комментарии Комментарии 31

Публикации

Истории

Работа