HabHub
HabHub copied to clipboard
Атом — реализация на TypeScript
Здравствуйте, меня зовут Дмитрий Карловский и я.. профессиональный велосипедист. За свою жизнь я перепробовал множество железных коней, но в конечном счёте остановился на самодельном. Не то чтобы мне очень нравилось работать напильником, тратя кучу свободного времени на изобретение колеса, но конечный результат, где каждая кочка не отдаётся болью в нижней половине туловища, того стоит. А теперь, когда вы знаете, что я затеял всё это не просто так, а чтобы сделать мир лучше, позвольте представить вам TypeScript/JavaScript модуль $jin.atom.
Краткое содержание предыдущей серии: простейшее приложение достигло критического уровня сложности, и, чтобы совладать с оной, была введена абстракция "атом", которая вобрала в себя всю рутину, позволив разработчику сконцентрироваться на описании инвариантов в функциональном стиле, не теряя связи с объектно ориентированной платформой. Вся теория и картинки там. Тут же будет куча практики, примеров кода и дампов консоли.
Почему именно TypeScript?
Первая реализация модуля была на чистом JavaScript, но недавно она была переписана на TypeScript. TypeScript - это практически тот же JavaScript, но с классами, выведением типов и нормальными лямбдами. Больше он практически ничего дополнительно не меняет и, как следствие, очень хорошо интегрируется с обычным JavaScript кодом. Вы можете напрямую обращаться к TypeScript модулям из JavaScript и наоборот. Разве что, для JS желательно всё же написать так называемые "декларации окружения", чтобы не терять тех преимуществ, что даёт статическая типизация. А даёт она следующие бонусы: * Подсказки в IDE избавляют программиста от необходимости держать в памяти документацию по всем методам и свойствам всех классов. * Поиск всех мест использования сущности - незаменимо при рефакторинге. * Выявление несогласованности по типам между различными участками приложения на этапе редактирования/сборки. К сожалению есть и минусы: * Иногда приходится плясать с бубном, объясняя компилятору, что ты имеешь ввиду.Альтернатив у TypeScript две:
JSDoc - крайне не выразительный формат статического описания динамического кода в коментариях. Зачастую объём JSDoc-комментариев (без учёта словесного описания) получается больше собственно полезного кода. Показательный пример:
/**
* @param {onTitleChange_handler} handler
*/
function onTitleChange( handler ){
// ...
}
onTitleChange(
/**
* @type {onTitleChange_handler}
*/
function( next, prev ){
// ...
}
)
onTitleChange( onTitleChange_handler handler ){
// ...
}
void main() {
onTitleChange( ( next, prev ) => {
// ...
});
}
onTitleChange( ( next, prev ) => {
// ...
});
function onTitleChange( handler : onTitleChange_handler ){
// ...
}
onTitleChange( ( next, prev ) => {
// ...
});
Мифы и легенды FRP
Реактивные библиотеки можно разделить на два основных типа: 1. Собственно FunctionalRP, где всё приложение описывается, как множество чистых функций. 2. ProceduralRP, которые часто путают с FRP. В них приложение описывается императивно в виде потоков событий (стримов).Во втором случае приложение описывается в виде набора процедур вида: взять данные из разных мест, последовательно применить к ним определенные преобразования, после чего записать их в другие места.
Забегая вперёд, покажу для сравнения правильный код на атомах:
Другое популярное заблуждение заключается во мнении, что реактивность нужна только на стыке модели и представления. Однако, на самом деле, реактивность - более фундаментальное понятие. Она необходима для поддержания инвариантов между состояниями. Любой кэш - это состояние. Любое персистентное хранилище - это состояние. Любая визуализация - это состояние. Состояния повсюду и они не являются независимыми даже в рамках одного слоя приложения.
Свойства
Прежде чем браться за реализацию атомов стоит разграничить два понятия: значение (RValue) и контейнер (LValue).Самый известный контейнер - это переменная. Переменная поддерживает всего три интерфейса:

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

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

В данном случае интерфейс get вызывает обработчик pull, а set - put. Такая замена сделана не спроста - в общем случае это действительно совершенно разные интерфейсы. Чтобы понять разницу достаточно ввести состояние и добавить очевидные условия:
- get вызывает pull только если значение ещё не установлено, иначе просто возвращает его - так называемая "ленивая инициализация"
- set вызывает put только если устанавливаемое значение отличается от текущего - это предотвращает исполнение put вхолостую.
Например, если мы работаем с названием документа только через наш контейнер, то можем определить его так, чтобы лишний раз не обращаться к медленному браузерному api:

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

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

И, наконец, частая ситуация - делегирование другому свойству:
var app = { get userName ( ) { return user.name } }
app.userName.get() // Anonymous app.userName.set( 'Alice' ) // Anonymous app.userName.get() // Alice

Реактивные свойства
Итак, теперь мы готовы создать свой первый атом:message.push( 'Hello' ) // записать значение
message.fail( new Error( 'Exception' ) ) // записать объект исключения

Тут всё просто - когда мы меняем значение атома, немедленно вызывается функция notify (или fail), в которой мы можем императивно отразить изменение состояния на ооп-рантайм. В норме, код FRP приложения практически не нуждается в подобных ручных синхронизациях - от большинства из них легко избавиться путём декларативного описания вёрски, по которому уже автоматически и генерируются подобные синхронизирующие атомы. Но это тема отдельной большой статьи, так что далее мы сконцентрируемся на возможностях самих атомов.
Атом является обобщением над "обещанием", так что не удивительно, что он поддерживает и thenable интерфейс:
message.push( 'Hello' ) // записать значение
message.fail( new Error( 'Exception' ) ) // пока никто не заметил, поменять значение на объект исключения

Тут важно иметь ввиду ограничения обещаний:
- обработчик вызывается отложенно
- обработчик вызывается только один раз
Метод then возвращает атом, который слушает исходный атом и когда тот принимает не undefined значение - вызывает обработчик и самоуничтожается.
А теперь, наконец, FRP в действии:
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 )

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

Типизированные атомы
Иногда при изменении значения атома требуется особая логика, отличная от базовой "новое зачение заменяет старое". Например, если в атоме хранится инстанс Date, то при вставке в атом было бы не плохо проверить. а действительно ли он указывает на другую метку времени. Делается это через переопределение интерфейса merge:lastUpdated.push( new Date( 2014 , 1 , 1 ) ) // добавит в документ 2014
lastUpdated.push( new Date( 2014 , 1 , 1 ) ) // будет проигнорировано
lastUpdated.push( new Date( 2015 , 1 , 1 ) ) // добавит в документ 2015
// обновляем данные
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" }
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)
Но мы не ограничены одними примитивами - полезно, например, иметь атомы для коллекций:
// атом для списков
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 ) - выполнить функцию, когда атом примет объект исключения