Атом — реализация на TypeScript

в 9:53, , рубрики: $jin.atom, FRP, javascript, TypeScript, Программирование

Здравствуйте, меня зовут Дмитрий Карловский и я… профессиональный велосипедист. За свою жизнь я перепробовал множество железных коней, но в конечном счёте остановился на самодельном. Не то чтобы мне очень нравилось работать напильником, тратя кучу свободного времени на изобретение колеса, но конечный результат, где каждая кочка не отдаётся болью в нижней половине туловища, того стоит. А теперь, когда вы знаете, что я затеял всё это не просто так, а чтобы сделать мир лучше, позвольте представить вам TypeScript/JavaScript модуль $jin.atom.

Краткое содержание предыдущей серии: простейшее приложение достигло критического уровня сложности, и, чтобы совладать с оной, была введена абстракция «атом», которая вобрала в себя всю рутину, позволив разработчику сконцентрироваться на описании инвариантов в функциональном стиле, не теряя связи с объектно ориентированной платформой. Вся теория и картинки там. Тут же будет куча практики, примеров кода и дампов консоли.

Почему именно TypeScript?

Первая реализация модуля была на чистом JavaScript, но недавно она была переписана на TypeScript. TypeScript — это практически тот же JavaScript, но с классами, выведением типов и лямбдами. Больше он практически ничего дополнительно не меняет и, как следствие, очень хорошо интегрируется с обычным JavaScript кодом. Вы можете напрямую обращаться к TypeScript модулям из JavaScript и наоборот. Разве что, для JS желательно всё же написать так называемые «декларации окружения», чтобы не терять тех преимуществ, что даёт статическая типизация. А даёт она следующие бонусы:
* Подсказки в IDE избавляют программиста от необходимости держать в памяти документацию по всем методам и свойствам всех классов.
* Поиск всех мест использования сущности — незаменимо при рефакторинге.
* Выявление несогласованности по типам между различными участками приложения на этапе редактирования/сборки.
К сожалению есть и минусы:
* Иногда приходится плясать с бубном, объясняя компилятору, что ты имеешь ввиду.

Альтернатив у TypeScript две:

JSDoc — крайне не выразительный формат статического описания динамического кода в коментариях. Зачастую объём JSDoc-комментариев (без учёта словесного описания) получается больше собственно полезного кода. Показательный пример:

	/**
	 * @callback onTitleChange_handler
	 * @param {string} next
	 * @param {string} prev
	 */

	/**
	 * @param {onTitleChange_handler} handler
	 */
	function onTitleChange( handler ){
	    // ...
	}

	onTitleChange(
	    /**
	     * @type {onTitleChange_handler}
	     */
	    function( next, prev ){
	        // ...
	    }
	)

Dart — сосем другой язык, который, однако, рассчитан на трансляцию в JavaScript. Использует совсем иные идиомы, из-за чего имеет множество ограничений при интеграции с JavaScript кодом, существенно большее потребление памяти, проблемы при отладке с использованием встроенных в браузеры средств, а генерируемый из Dart код представляет из себя довольно объёмистую лапшу. Приведённый выше пример, на нём выглядит так:

	typedef void onTitleChange_handler( String next , String prev );

	onTitleChange( onTitleChange_handler handler ){
	    // ...
	}

	void main() {
		onTitleChange( ( next, prev ) => {
			// ...
		});
	}

Уже лучше, но также требует введения слишком большого числа именованных интерфейсов/типов. Это основной недостаток номинативной типизации. В TypeScript же используется структурная:

	function onTitleChange(
		handler : ( next : string , prev : string ) => void
	){
	    // ...
	}

	onTitleChange( ( next, prev ) => {
	    // ...
	});

Но есть возможность и давать интерфейсам имена, если это необходимо:

	interface onTitleChange_handler {
		( next : string , prev : string ) : void
	}

	function onTitleChange( handler : onTitleChange_handler ){
	    // ...
	}

	onTitleChange( ( next, prev ) => {
	    // ...
	});

Итого, при переходе на TypeScript:
+ уменьшается объём кода
+ улучшается интеграция со средой разработки
+ появляются дополнительные проверки правильности по мере ввода
— добавляется необходимость трансляции в JavaScript перед исполнением

Мифы и легенды FRP

Реактивные библиотеки можно разделить на два основных типа:
1. Собственно FunctionalRP, где всё приложение описывается, как множество чистых функций.
2. ProceduralRP, которые часто путают с FRP. В них приложение описывается императивно в виде потоков событий (стримов).

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

	this.message = Bacon.combine( [ this.mouseTarget, this.mouseCoords ] , function( target, coords ) {
		return target + ' ' + coords
	} )
	.map( trimSpaces )
	.map( htmlEncode )
	.map( htmlParse )
	.onValue( function( messaage ){
		document.getElementById( 'log' ).appendChild( message )
	} )

Сравните с тем же самым, написанным в менее завуалированной форме:

	this.onChange( [ 'mouseCoords', 'mouseTarget' ] , function( ){
		var message = this.mouseTarget + ' ' + this.mouseCoords
		message = trimSpaces( message )
		message = htmlEncode( message )
		message = htmlParse( message )
		this.message = message
		document.getElementById( 'log' ).appendChild( message )
		this.fireChange( 'message' )
	}

Известные PRP библиотеки (Rx, Bacon) в соответствии с PRP архитектурой имеют довольно сложный API. Сложность заключается как в огромном числе методов, реализующих всевозможные операторы над стримами, так и в том, как описываются простейшие операции. Например, вот так будет выглядеть правильное условное ветвление:

	var message = config.flatMapLatest( function( config ) {
	    if( config ) {
			return mouseCoords.map( function( coords ) {
				return 'Mouse coords is ' + coords
			}
		} else {
			return mouseTarget.map( function( target ) {
				return 'Mouse target is ' + target
			}
		}
	} )

А вот так неправильное:

	var message = Bacon.combineWith( function( config, coords, target ) {
	    if( config ) {
			return 'Mouse coords is ' + coords
		} else {
			return 'Mouse target is ' + target
		}
	}, config, mouseCoords, mouseTarget )

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

Забегая вперёд, покажу для сравнения правильный код на атомах:

	var message = $jin.atom.prop( {
		pull : function( ) {
			if( config.get() ) {
				return 'Mouse coords is ' + coords.get()
			} else {
				return 'Mouse target is ' + target.get()
			}
		}
	} )

Говоря простым языком, в PRP удобно описывать зависимости, где источников данных сравнительно не много и их состав практически не меняются, а в FRP наоборот, набор источников может быть произвольным и динамичным без потери выразительности. С потребителями данных всё наоборот: в PRP одно и то же состояние может меняться множеством различных стримов, а в FRP за одно состояние отвечает ровно одна функция, по которой всегда понятно как формируется значение и от чего оно непосредственно зависит.

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

Свойства

Прежде чем браться за реализацию атомов стоит разграничить два понятия: значение (RValue) и контейнер (LValue).

Самый известный контейнер — это переменная. Переменная поддерживает всего три интерфейса:

	var count // создать контейнер если ещё не создан
	count = 2 //записать значение
	return count // вернуть значение

Другой, не менее известный контейнер — это поле объекта. Оно поддерживает все интерфейсы переменной:

	obj.count = 2 // создать контейнер (если ещё не создан) и записать в него значение
	return obj.count // вернуть значение контейнера

Но в дополнение к ним поле поддерживает ещё пару:

	delete obj.field // уничтожение контейнера
	'field' in obj // проверка на существование контейнера

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

	var count = new $jin.prop.vary({}) // создать контейнер
	count.set( 2 ) // записать значение
	count.get() // прочитать значение

image

С одной стороны мы поменяли шило на мыло: контейнер (переменная count) хранит в себе другой контейнер (инстанс класса $jin.prop.vary) который хранит собственно значение. С другой, объект-контейнер, в отличие от обычной переменной, уже является сущностью «первого класса», то есть может быть передан в качестве аргумента функции или возвращён из неё в качестве результата и тп. Это иногда полезно, но в подавляющем большинстве случаев — излишне. Куда больше пользы, если реализации интерфейсов отличаются от стандартных:

	var title = new $jin.prop.proxy({
		put : function( next ) {
			document.title = next
		},
		pull : function( ) {
			return document.title
		},
	})
	title.set( 'Hello!' ) // записать значение
	title.get() // прочитать значение

image

$jin.prop.proxy — реализация контейнера без состояния, который может быть как «обычной переменной» так и «свойством объекта»:

	var doc = {
		get title( ) {
			return new $jin.prop.proxy({
				put : function( next ) {
					document.title = next
				},
				pull : function( ) {
					return document.title
				},
			})
		}
	}
	doc.title.set( 'Hello' ) // записать значение
	doc.title.get() // прочитать значение

image

В данном случае интерфейс get вызывает обработчик pull, а set — put. Такая замена сделана не спроста — в общем случае это действительно совершенно разные интерфейсы. Чтобы понять разницу достаточно ввести состояние и добавить очевидные условия:
1) get вызывает pull только если значение ещё не установлено, иначе просто возвращает его — так называемая «ленивая инициализация»
2) set вызывает put только если устанавливаемое значение отличается от текущего — это предотвращает исполнение put вхолостую.

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

	var doc = {
		get title( ) {
			return new $jin.prop.vary({
				owner : this,
				name : '_title',
				put : function( next ) {
					document.title = next
				},
				pull : function( ) {
					return document.title
				},
			})
		}
	}
	doc.title.set( 'Hello' ) // создать контейнер и записать значение
	doc.title.get() // прочитать значение
	doc.title.update() // принудительно актуализировать значение

image

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

	var doc = {
		title : new $jin.prop.vary({
			put : function( next ) {
				document.title = next
			},
			pull : function( ) {
				return document.title
			},
		})
	}
	doc.title.set( 'Hello' )

image

Так и стоит делать для свойств, которым не нужна возможность наследования. Но если вы так объявите свойство в прототипе класса, то все инстансы будут работать с одним и тем же контейнером, что обычно совсем не то, что надо. А надо, чтобы у каждого инстанса были свои контейнеры. Для этого мы создаём контейнер через геттер и передаём ему ссылку на объект и имя поля в нём — именно в него контейнер и будет сохранять свои данные (или сохранит самого себя — зависит от реализации). Другой яркий пример использования подобного геттера — ленивый реестр, с произвольным числом ключей:

	var info = {
		item : function( key ) {
			return new $jin.prop.vary({
				owner : this,
				name : '_item:' + key,
				pull : function( ) {
					return 0
				},
			})
		}
	}
	info.item( 'foo' ).get() // 0
	info.item( 'bar' ).set( 123 )
	info.item( 'bar' ).get() // 123

image

И, наконец, частая ситуация — делегирование другому свойству:

var user = {
    get name ( ) {
        return new $jin.prop.vary({
            owner : this ,
            name : '_name' ,
            pull : function( prev ) {
                return 'Anonymous'
            }
        })
    }
}

var app = {
    get userName ( ) {
        return user.name
    }
}

app.userName.get() // Anonymous
app.userName.set( 'Alice' ) // Anonymous
app.userName.get() // Alice

image

Реактивные свойства

Итак, теперь мы готовы создать свой первый атом:

	var message = new $jin.atom.prop( {
		notify : function( next, prev ) {
			document.body.innerText = next
		},
		fail : function( error ) {
			document.body.innerText += ' ' + error.message
		},
	} )
	
	message.push( 'Hello' ) // записать значение
	message.fail( new Error( 'Exception' ) ) // записать объект исключения

image

Тут всё просто — когда мы меняем значение атома, немедленно вызывается функция notify (или fail), в которой мы можем императивно отразить изменение состояния на ооп-рантайм. В норме, код FRP приложения практически не нуждается в подобных ручных синхронизациях — от большинства из них легко избавиться путём декларативного описания вёрски, по которому уже автоматически и генерируются подобные синхронизирующие атомы. Но это тема отдельной большой статьи, так что далее мы сконцентрируемся на возможностях самих атомов.

Атом является обобщением над «обещанием», так что не удивительно, что он поддерживает и thenable интерфейс:

	var message = new $jin.atom.prop({})
	message.then( function( next ) {
		document.body.innerText = next
	}, function( error ) {
		document.body.innerText += ' ' + error.message
	} )
	
	message.push( 'Hello' ) // записать значение
	message.fail( new Error( 'Exception' ) ) // пока никто не заметил, поменять значение на объект исключения

image

Тут важно иметь ввиду ограничения обещаний:
1. обработчик вызывается отложенно
2. обработчик вызывается только один раз

Метод then возвращает атом, который слушает исходный атом и когда тот принимает не undefined значение — вызывает обработчик и самоуничтожается.

А теперь, наконец, FRP в действии:

	var user = {
		firstName : new $jin.atom.prop({ value : 'Alice' }),
		lastName : new $jin.atom.prop({ value : 'McGee' }),
		getFullName : function(){ // по хорошему тут для единообразия надо использовать fullName : new $jin.prop.proxy(...)
			return user.firstName.get() + ' ' + user.lastName.get()
		}
	}
	
	var message = new $jin.atom.prop( {
		pull : function( ) {
			return 'Hello, ' + user.getFullName()
		},
		notify : function( next , prev ) {
			document.body.innerText = next
		},
		reap : function( ) { }
	} )
	message.get()

	user.firstName.push( 'Alice' ) // установить значение
	setTimeout( function( ) {
		user.lastName.push( 'Bob' ) // обновить значение
	}, 1000 )

image

Тут в целом всё просто: message неявно объявляется как функция от свойств user.firstName и user.lastName, и, когда хотябы одно из них меняется, то меняется и message, и это отражается на документе. Особенностей тут две:
1. Атомы ленивы. Пока их кто-нибудь не дёрнет (через get или pull) — они будут неактивны.
2. Атомы склонны к суициду. Если не переопределить поведение reap, то атомы будут уничтожать себя, высвобождая память, когда не остаётся ни одного, зависящего от них атома.

Давайте реализуем атом, который будет следить за координатами указателя:

	// провайдер координат указателя
	var pointer = {
		handler : function( event ) {
			
			var point = event.changedTouches ? event.changedTouches[0] : event
			
			// координаты указателя из события сохраняем в атом
			pointer.position.push([ point.clientX , point.clientY ])
			
			event.preventDefault()
			
		},
		position : new $jin.atom.prop( {
			pull : function( prev ) {
				
				// подписываемся на все необходимые события
				document.body.addEventListener( 'mousemove' , pointer.handler , false )
				document.body.addEventListener( 'dragover' , pointer.handler , false )
				document.body.addEventListener( 'touchmove' , pointer.handler , false )
				document.body.addEventListener( 'pointermove' , pointer.handler , false )
				
				// возвращаем дефолтное значение, пока нет актуальных данных
				return [ -1, -1 ]
				
			},
			reap : function( ) { // когда никто не подписан на изменения
				
				// отписываемся от дом-событий
				document.body.removeEventListener( 'mousemove' , pointer.handler , false )
				document.body.removeEventListener( 'dragover' , pointer.handler , false )
				document.body.removeEventListener( 'touchmove' , pointer.handler , false )
				document.body.removeEventListener( 'pointermove' , pointer.handler , false )
				
				// очищаем значение, что приведёт к вызову pull при следующем запросе значения координат
				pointer.position.clear()
				
			}
		} )
	}

	// принтер координат в документ
	var title = new $jin.atom.prop( {
		pull : function( ) {
			return 'Mouse coords: ' + pointer.position.get()
		},
		notify : function( next , prev ) {
			document.body.innerText = next
		},
		reap : function( ) { }
	} )
	title.pull()
	
	// через 5 секунд перестаём обновлять коодинаты
	setTimeout( function( ) {
		title.disobeyAll()
	}, 5000 )

image

Типизированные атомы

Иногда при изменении значения атома требуется особая логика, отличная от базовой «новое зачение заменяет старое». Например, если в атоме хранится инстанс Date, то при вставке в атом было бы не плохо проверить. а действительно ли он указывает на другую метку времени. Делается это через переопределение интерфейса merge:

	var lastUpdated = new $jin.atom.prop( {
		merge : function( next , prev ) {
			if( !prev ) return next
			if( prev.getTime() === next.getTime() ) return prev
			return next
		},
		notify : function( next , prev ) {
			document.body.innerText += next.getFullYear()
		}
	} )
	
	lastUpdated.push( new Date( 2014 , 1 , 1 ) ) // добавит в документ 2014
	lastUpdated.push( new Date( 2014 , 1 , 1 ) ) // будет проигнорировано
	lastUpdated.push( new Date( 2015 , 1 , 1 ) ) // добавит в документ 2015

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

	var userInfo = new $jin.atom.prop( {
		value : {},
		merge : function( next , prev ) {
		
			// обновляем данные
			var updated = false
			for( var key in next ) {
				if( prev[ key ] === next[ key ] ) continue
				prev[ key ] = next[ key ]
				updated = true
			}
			
			// уведомляем подписчиков, что есть изменения
			if( updated ) this.notify()
			
			return prev
		}
	})
	
	userInfo.push({ firstName : 'Alice' })
	userInfo.push({ lastName : 'McGee' })
	userInfo.get() // { firstName: "Alice", lastName: "McGee" }

В главе про свойства были перечислены основные интерфейсы переменных и свойств, но есть и множество других:

	a ++ // получить значение увеличить его на 1 и записать обратно
	a += N // получить значение увеличить его на N и записать обратно
	// аналогичные интерфейсы для других математических операций

Эти интерфейсы предназначены для примитивов. Поведение их жёстко задано и не поддаётся переопределению. Но у нас то кастомные контейнеры! Давайте напишем свой контейнер для числовых значений:

	module $jin.atom {
		
		export class numb < OwnerType extends $jin.object > extends $jin.atom.prop < number , OwnerType > {
			
			summ( value ) {
				this.set( this.get() + value )
			}
	
			multiply( value ) {
				this.set( this.get() * value )
			}
	
			// и другие клёвые методы
		}
		
	}

	var count = new $jin.atom.numb({ value : 5 }) // создаём контейнер со значением
	count.summ( -1 ) // уменьшили значение на 1
	count.multiply( 2 ) // затем увеличили вдвое
	count.get() // получили текущее значение (8)

Тут в примере уже используется TypeScript так как наследование в JavaScript не слишком наглядно из-за чего в каждом фреймворке есть свой хелпер реализующий оное. Вы можете использовать и их, так как и $jin.atom.prop и $jin.atom.numb и все остальные — самые обычные яваскриптовые «функции с прототипом».

Но мы не ограничены одними примитивами — полезно, например, иметь атомы для коллекций:

	module $jin.atom {
		
		// атом для списков
		export class list<ItemType,OwnerType extends $jin.object> extends $jin.atom.prop<ItemType[],OwnerType> {
			
			// проверяем, а действительно ли новый список отличается от старого
			merge( next : ItemType[] , prev : ItemType[] ) {
				next = super.merge( next , prev )
				
				if( !next || !prev ) return next
				if( next.length !== prev.length ) return next
				
				for( var i = 0 ; i < next.length ; ++i ) {
					if( next[ i ] !== prev[ i ] ) return next
				}
				
				return prev
			}
			
			// добавляет элементы в конец списка
			append( values : ItemType[] ) {
				var value = this.get()
				value.push.apply( value, values )
				
				this.notify( null , value ) // приходится вызывать вручную так как мы поменяли внутренности объекта
			}
	
			// добавляет элементы в начало списка
			prepend( values : ItemType[] ) {
				var value = this.get()
				value.unshift.apply( value, values )
				this.notify( null , value )
			}
	
			// и другие клёвые методы
			
		}	
		
	}
	
	var list = new $jin.atom.list({ value : [ 3 ] })
	list.append([ 4 , 5 ])
	list.prepend([ 1 , 2 ])
	list.get() // [ 1 , 2 , 3 , 4 , 5 ]

Резюме

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

Собранная JS библиотека ~ 27КБ без сжатия
Исходники на TypeScript
Заготовка на JSFiddle

Основные классы:
$jin.prop.proxy — свойство без состояния
$jin.prop.vary — свойство с состоянием
$jin.atom.prop — реактивное свойство

Параметры конструктора (все опциональны):
owner — владелец атома, который должен иметь глобальный уникальный идентификатор в поле objectPath
name — имя атома, уникальное в рамках владельца
value — исходное значение
get( value: T ): T — вызывается при каждом запросе значения, по умолчанию проксирует параметр
pull( prev: T ): T — вызывается для «втягивания» значения из ведущих состояний (например, из сервера), по умолчанию возвращает текущее значение
merge( next: T, prev: T ): T — вызывается для валидации и/или слияния нового значения с текущим, по умолчанию возвращает новое значение
put( next: T, prev: T ): void — обратная к pull операция, передача нового значения в ведущие состояния (например, на сервер), по умолчанию записывает новое значение в атом
reap(): void — вызывается. когда на атом никто не подписан и его можно безболезненно удалить, что и делает по умолчанию
notify( next: T, prev: T ): void — вызывается, когда текущее значение меняется, по умолчанию ничего не делает
fail( error: Error ): void — вызывается, когда вместо текущего значения сохранен объект исключения

Основные методы атомов:
get() — получить значение
pull() — принудительно вычислить значение
update() — запланировать обновление значения
set() — предложить новое значение (которое он может не в себя записать а в ведущее состояние)
push() — принудительно записать новое значение
fail( error ) — принудительно записать объект исключения
mutate( ( prev: T ) => T ) — применить функцию трансформации
then( ( next: T1 ) => T2 ) — выполнить функцию, когда атом примет актуальное значение
catch( ( error: Error ) => T2 ) — выполнить функцию, когда атом примет объект исключения

image

Автор: vintage

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js