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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Здравствуйте, меня зовут Дмитрий Карловский, и я.. тот самый чел, который написал реактивную библиотеку $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"
					>
											
Источник: https://habr.com/ru/post/723728/


Интересные статьи

Интересные статьи

Привет! Меня зовут Саша Шутай, я тимлид в AGIMA. В прошлой статье я рассказывал, что делать, если на проекте Bitrix сожительствует с Vue.js и поисковые боты не видят контента сайта. А в этой помогу ра...
Нередко при работе с Bitrix24 REST API возникает необходимость быстро получить содержимое определенных полей всех элементов какого-то списка (например, лидов). Традиционн...
Устраивать конкурсы в инстаграме сейчас модно. И удобно. Инстаграм предоставляет достаточно обширный API, который позволяет делать практически всё, что может сделать обычный пользователь ручками.
Cтатья будет полезна тем, кто думает какую выбрать CMS для интернет-магазина, сравнивает различные движки, ищет в них плюсы и минусы важные для себя.
Одной из «киллер-фич» 12й версии Битрикса была объявлена возможность отдавать статические файлы из CDN, тем самым увеличивая скорость работы сайта. Попробуем оценить практический выигрыш от использова...