Здравствуйте, меня зовут Дмитрий Карловский и я… состоятельный человек. У меня есть состояние на сервере, есть состояния в локальных хранилищах, есть состояние окна браузера, есть состояние доменной модели, есть состояние интерфейса. И всё это многообразие состояний нужно поддерживать синхронизированным. Если одно состояние как-то изменяется, то остальные связанные с ним состояния должны как можно скорее обновиться. Особую пикантность ситуации придаёт то, что синхронизация с сервером может занимать секунды, а блокировать пользовательский интерфейс можно лишь на доли секунд.
Далее вы узнаете: как реактивность побеждает асинхронность, как императивная реактивность уживается с функциональной, как простые абстракции позволяют писать надёжный и быстрый код, а также как я однажды перешёл на идемпотентную сторону силы и всё заверте
Разбираемся в сортах реактивности
Прежде всего стоит определиться с понятием "реактивность" (reactivity). Это — явление, когда изменение одного состояния приводит ко каскадному изменению других состояний. Реактивное программирование (reactive programming) использует этот принцип для описания правил изменения одних состояний при изменений других. В дальнейшем единицу реактивного состояния для простоты мы будем называть "атомом".
[ State1 ]----/ Rule1-2 /---->[ State2 ]----/ Rule2-3 /---->[ State3 ]
Реактивные правила могут описываться в трёх парадигмах:
Перезапуски (restarts). Правила хранятся отдельно от состояния и представляют собой процедуру меняющую одни состояния на основе других состояний. Когда какое-либо состояние, задействованное этой процедурой, изменяется, процедура автоматически перезапускается. Проблема этого подхода в стационарности правил синхронизации, больших накладных расходах и риске уйти в бесконечный цикл.
[ State ]<-------->[ Rule1-2( State ) ]
[ State ]<-------->[ Rule2-3( State ) ]
Примеры реализаций: AngularJS@1, MeteorJS.
Проталкивание (push). Внутри атома хранятся правила формирования состояний других атомов. Обычно правила задаются при инициализации приложения и в дальнейшем не меняются, а если и меняются, то вручную. API реализующих этот подход библиотек очень сильно раздут — десятки и сотни методов решают его многочисленные проблемы: немедленное исполнение правил, зависимость от одного единственного атома, реагирование даже если новое значение равно старому и прочие.
[ State1 => Rule2( State1 ) ]-------->[ State2 => Rule3( State2 ) ]-------->[ State3 ]
Примеры реализаций: BaconJS, KefirJS, RxJS.
Затягивание (pull). Внутри атома хранится правило формирования его состояния в виде функции от состояний других атомов. Состояние формируется лениво, в момент обращения к нему. Если от состояния атома перестаёт кто-либо зависеть, то он может "засыпать" теряя зависимость от других атомов. В "ленивости", динамической автоматической активации и деактивации правил основное преимущество данного подхода, поэтому именно о нём и пойдёт дальнейшее повествование.
[ State1 ]-------->[ State2 = Rule2( State1 ) ]-------->[ State3 = Rule3( State2 ) ]
Примеры реализаций: KnockOutJS, MobXJS, CellX и собственно $mol_atom.
Стоит отметить, что приведённое разделение принципов условно и отражает основной принцип работы соответствующих библиотек.
Нет времени объяснять
Создаём пару изменяемых атомов с вычисляемым значением по умолчанию:
const userName = new $mol_atom( 'userName' , next => next || 'Anonymous' )
const showName = new $mol_atom( 'showName ' , next => next || false )
Создаём вычисляемый атом, не допускающий прямое изменение своего значения:
const greeting = new $mol_atom( 'greeting' , next => {
if( !showName.value() ) return 'Hello!'
return `Hello, ${ userName.value() }!`
} )
Создаём презентационный атом, выводящий приветствие в консоль всякий раз, когда что-либо в данных меняется:
const presenting = new $mol_atom( 'presenting' , next => {
console.log( greeting.value() )
} )
Принудительно активируем презентационный атом:
presenting.value()
//Hello!
Меняем сразу 2 атома, но вывод будет только один:
showName.value( true )
userName.value( 'John' )
//Hello, John!
Принудительно презентуем после каждого изменения данных:
userName.value( 'Jin' )
presenting.value()
// Hello, Jin!
showName.value( false )
presenting.value()
// Hello!
Пытаемся изменить приветствие напрямую — ничего не выходит:
greeting.value( 'Hi!' )
Принудительно устанавливаем приветствие в обход правил:
greeting.value( 'Hi!' , $mol_atom_force )
//Hi!
Меняем исходные данные — ноль реакции:
showName.value( false )
Принудительно обновляем значение приветствия по правилам:
greeting.value( void null , $mol_atom_force )
//Hello!
Включаем логирование всех атомов и меняем один из них:
$mol_log.filter( '' )
showName.value( true )
//21:44:11 showName.value() ["push", true, false]
//21:44:11 greeting.value() ["obsolete"]
//21:44:11 $mol_atom.sync []
//21:44:11 userName.value() ["push", "Anonymous", undefined]
//21:44:11 greeting.value() ["push", "Hello, Anonymous!", "Hello!"]
//21:44:11 presenting.value() ["obsolete"]
//Hello, Anonymous!
Выключаем логгирование:
$mol_log.filter( null )
Попробовать онлайн. Ещё пример.
А внутре у ней что?
Казалось бы, что может быть проще: при изменении одного состояния, вызывать пересчёт зависимых состояний. Однако..
Прежде всего нужно определиться что от чего зависит.
Допустим, вы делаете блог и на странице какого-нибудь поста нужно выводить в заголовке окна название поста, а на странице постов отфильтрованных по тегу — название тега. Получается, состояние заголовка окна динамически меняет свои зависимости. То оно зависит от названия поста, то от имени тега, но в обоих случаях оно зависит от некоторого условия, определяющего от чего ему нужно зависеть.
Уже в этом простом примере видно, как важна поддержка динамических зависимостей. Поэтому нам не подходит такая абстракция как "stream", вокруг которой построены популярные "push" библиотеки.
Типичная "pull" реализация работает следующим образом:
-
Когда атом вычисляет своё состояние, он первым делом помещает себя в некоторую глобальную переменную, что позволяет другим атомам понять, какой атом сейчас вычисляется.
-
Далее он создаёт пустой список своих зависимостей, в котором будут зарегистрированы все атомы, к которым так или иначе будет произведено обращение.
-
Только теперь происходит исполнение формулы — обычной функции, возвращающей некоторый результат.
-
В процессе исполнения формулы, может быть обращение к каким угодно другим функциям, объектам и браузерным интерфейсам.
-
Если в процессе исполнения формулы произойдёт обращение к значению другого атома, то тот, взяв из глобальной переменной зависимый атом, слинкует их таким образом, что оба будут знать, что значение одного зависит от значения другого.
-
После того, как формула вычислена, полученное значение запоминается в атоме, чтобы в дальнейшем можно было возвращать его сразу, без относительно долгого вычисления формулы.
-
И наконец, происходит сравнение нового списка зависимостей и старого, чтобы "разъединить" более не зависящие друг от друга атомы.
- Если значение атома меняется, то происходит уведомление завясящих от него атомов, что их значения устарели и им тоже требуется актуализация.
Таким образом мы имеем всегда актуальную сеть из атомов, которая динамически перестраивается, отражая реальные зависимости в данный момент времени.
Стоит отметить, что к формулам (и, как следствие, ко всем так или иначе вызываемым функциям) предъявляется требование быть идемпотентными. То есть, если ни одна зависимость не изменилось, то и результат работы функции должен остаться неизменным. Под результатом тут понимается не только возвращаемое формулой значение, но и производимые в процессе вычисления побочные действия.
Именно благодаря идемпонентности, появляется возможность кешировать результат на неопределённый срок и сбрасывать кеш лишь когда он либо больше не нужен, либо больше не актуален.
Но что если при вычислении значения возникнет исключение? Если его не перехватить, то текущий "вычисляемый атом", помещённый в глобальную переменную, так там и останется, что в дальнейшей работе приложения может привести к появлению весьма странных зависимостей, что довольно не просто отдебажить. Кроме того, каждый раз при обращении к атому, будет происходить вычисление формулы и всплытие исключения, что не только засорит консоль на больших объёмах данных, но и вызовет неслабые тормоза. И вишенкой на торте проблем будет то, что часть атомов так и останется в неактуальном состоянии.
Говоря простым языком, состояние приложение перестанет быть консистентным и начнёт работать нестабильно. Чтобы этого не допустить, каждый атом должен перехватить исключение и сохранить в себе вместо значения и позволить ему всплыть дальше. Таким образом, при следующем обращении к атому, это исключение будет брошено вновь. Если исключение было следствием некорректных данных, то как только данные станут корректными, атом пересчитает своё значение и вместо кидания исключения будет уже возвращать актуальное значение.
Но в какой момент зависимые атомы должны пересчитывать свои значения? Если делать это сразу при получении уведомления об устаревании, атом может впустую по многу раз исполнять формулу, когда последовательно меняются несколько его зависимостей. Поэтому, в хороших реализациях атом немедленно лишь помечается устарешим, а вот актуализируется он уже отложенно.
Вообще говоря, проблема лишних вычислений не так безобидна, как может показаться. Она может приводить ко следующим неприятным последствиям:
- Снижение производительности вплоть до невозможности пользоваться приложением.
- Возникновение исключений в неожиданных местах. Типичная ситуация — обращение к уже удалённому объекту.
- Лишние запросы к серверу, нотификации и тому подобные не "схлопывающиеся" повторения.
Существует несколько стратегий отложенной актуализации атомов:
-
В порядке устаревания. В момент устаревания, атом добавляется в конец очереди на пересчёт. Самая простая стратегия. Однако, она оставляет довольно большое число лишних пересчётов.
-
В порядке создания. Каждому атому задаётся числовой идентификатор. Позднее созданные атомы имеют и большее значение идентификатора. Пересчитываются атомы, начиная с атома с наименьшим идентификатором. Получается, что зависимые атомы пересчитываются раньше, чем те, от которых они зависят, так как они зачастую создаются в процессе вычисления зависящих. Это всё опять же приводит к лишним вычислениям.
-
В порядке увеличения глубины. Атомы сортируются по максимальной глубине зависимостей. Сначала пересчитываются атомы без зависимостей, потом зависящие от атомов без зависимостей, потом от атомов, которые зависят от атомов без зависимостей и так далее. При такой схеме лишних пересчётов практически не происходит. Однако, иногда возникают казусы, когда атом имеет небольшую глубину, но его существование определяется атомом с большей глубиной. Таким образом он пересчитывает своё значение первым, а позже удаляется, так как в нём больше нет необходимости.
- В правильном порядке. Зависимости актуализируются в том же порядке, в котором к ним было произведено обращение при вычислении зависимого атома.
Чтобы обеспечить правильный порядок актуализации, каждый атом может находиться в одном из 4 состояний:
-
Устаревший (obsolete). При следующем обращении, его значение будет вычислено по формуле. Когда атом переходит в это состояние, он уведомляет зависимые атомы, что они "возможно устарели".
-
Возможно устаревший (checking). При следующем обращении, он сначала убедится, что все его зависимости в актуальном состоянии. Как только одна из них изменит своё значение, то атом станет "устаревшим" со всеми вытекающими. Иначе — станет "актуальным" без перевычисления значения. Когда атом переходит в состояние "возможно устаревший", то тут же уведомляет зависимые атомы, что они тоже "возможно устарели". Таким образом этот состояние каскадно распространяется на всё зависимое поддерево. Если от этого атома никто не зависит, то он добавляет себя в очередь на отложенную актуализацию.
-
Актуальный (actual). При обращении, возвращает запомненное значение. Если при переходе в актуальное состояние, его значение изменилось, то он уведомляет зависимые атомы, что они "устарели".
- Вычисляется (pulling). Когда атом начинает перевычисляться, то переходит в это состояние. Обращение к атому в этом состоянии приводит к возникновению исключения, так как свидетельствует о циклической зависимости. После вычисления, даже если оно закончилось ошибкой, атом запоминает результат, переходя в "актуальное" состояние.
Такая логика работы может показаться слишком сложной и избыточной, однако но она позволяет гарантировать, что:
- Приложение не зависнет из-за циклических зависимостей.
- Пересчёт атома будет происходить не раньше, чем его зависимости примут актуальное значение.
- Пересчёт атома не будет произведён, если актуальные значения его зависимостей не поменялись.
- При обращении к атому, мы гарантированно получаем актуальное значение (остальные схемы этого не гарантируют, так как не исключают возможной необходимости его повторного вычисления).
Нужно больше мемов
Работать с атомами напрямую не очень удобно. Им нужно давать уникальные имена, чтобы в логах выводилось что-то осмысленное, а не просто абстрактные числа. Нужно их где-то хранить и не создавать лишний раз. Чтобы избавиться от этой рутины, введём понятие "свойства", как полиморфного метода, который в зависимости от числа параметров "возвращает" или "устанавливает и тут же возвращает" некоторое значение.
Обычное свойство имеет следующий интерфейс:
{
< Value >() : Value
< Vlaue >( nextValue? : Value ) : Value
}
Например:
class App {
title( next? : string ) {
if( next !== void null ) document.title = next
return document.title
}
}
Его можно сделать реактивным (кешируемым с автоматической инвалидацией кеша), просто добавив декоратор $mol_mem()
:
class App {
@ $mol_mem()
title( next? : string ) {
if( next !== void null ) document.title = next
return document.title
}
}
Оформим код приветствующего приложения в виде класса:
class App extends $mol_object {
@ $mol_mem()
userName( next? : string ) { return next || 'Anonymous' }
@ $mol_mem()
showName( next? : boolean ) { return next || false }
greeting() {
if( !this.showName() ) return 'Hello!'
return `Hello, ${ this.userName() }!`
}
@ $mol_mem()
presenting() {
console.log( this.greeting() )
}
}
Как можно заметить свойство greeting
не реактивное, так что этот метод будет вызываться каждый раз при обращении к нему. А вот showName
— реактивное, так что метод будет вызываться лишь при первом чтении значения по умолчанию и при передаче ему нового значения.
Мы могли бы объявлять свойства в духе MobX, но для этого пришлось бы писать более громоздкий код с дублированием имени свойства:
class App {
@observable
get userName() { return 'Anonymous' }
set userName( next : string ) { return next }
@observable
get showName() { return false }
set showName( next : boolean ) { return next }
get greeting() {
if( !this.showName ) return 'Hello!'
return `Hello, ${ this.userName }!`
}
@computed
get presenting {
console.log( this.greeting )
}
}
Кроме того, нельзя было бы также легко и просто перегружать свойство целиком извне, как в следующем примере:
class My extends $mol_object {
@ $mol_mem()
static instance() {
return new this
}
name(){ return `Jin #${ Date.now() }` }
@ $mol_mem()
showName( next ) {
return ( next === void null ) ? true : next
}
@ $mol_mem()
app() {
const app = new App
app.userName = ()=> this.name()
app.showName = ( next )=> this.showName( next )
return app
}
}
My.instance().app().presenting()
//Hello, Jin #1481383086982!
Тут мы использовали перегрузку свойств для элегантного создания одностороннего биндинга для свойства userName
и двустороннего для свойства showName
. Это очень мощная техника, позволяющая детально настроить поведение любого объекта, сразу после его создания, без риска его поломать.
Стоит обратить внимание на исключительно понятные логи, по которым чётко видно какие состояния как изменялись:
$mol_log.filter( '' )
My.instance().app().showName( false )
$mol_log.filter( null )
//11:27:58 My.instance().showName() ["push", false, true]
//11:27:58 My.instance().app().presenting() ["obsolete"]
//Hello!
Часто требуется работать не с одним значением, а с целым семейством значений, различаемых некоторым ключом, но по одной и той же логике. Для этих случаев используется следующий интерфейс:
{
< Key , Value >( key : Kay ) : Value
< Key , Vlaue >( key : Key , nextValue? : Value ) : Value
}
Для примера, создадим простейший класс, позволяющий использовать REST ресурсы:
class Rest extends $mol_object {
@ $mol_mem_key()
static resource( uri : string , next? : any , force : $mol_atom_force ) {
const request = new XMLHttpRequest
const method = ( next === void null ) ? 'get' : 'put'
request.onload = ( event : Event )=> {
this.resource( uri , request.responseText , $mol_atom_force )
}
request.onerror = ( event : ErrorEvent )=> {
this.resource( uri , event.error || new Error( 'Unknown HTTP error' ) , $mol_atom_force )
}
request.open( method , uri )
request.send( next )
throw new $mol_atom_wait( `${ method } ${ uri }` )
}
}
Для разных uri будут создаваться отдельные атомы. Но при обращении к одному и тому же uri — будет использован один и тот же атом. Как можно заметить, логика получения данных и установки значения полностью совпадает. Разница лишь в том, что при запросе данных будет использован http-метод "get", а при передаче данных — "put".
Так как атомы умеют адекватно работать с исключительными ситуациями, то тут мы использовали это их свойство для абстрагирования кода приложения от асинхронности. Так как данные ещё не загружены, то мы не можем сразу же их вернуть. Вместо этого кидается специальное исключение и вычисление зависимых от этого свойства свойств прерывается в ожидании каких-либо изменений. Когда приходит ответ от сервера, он устанавливается в качестве значения свойства и зависимые атомы "оживают", но на этот раз их вычисление не прерывается.
Давайте реализуем приложение, рисующее все эмодзи, которые поддерживает гитхаб:
class App extends $mol_object {
@ $mol_mem()
static presenting() {
const emojis = JSON.parse( Rest.resource( 'https://api.github.com/emojis' ) )
document.body.innerHTML = ''
for( let id in emojis ) {
const image = document.createElement( 'img' )
image.src = emojis[ id ]
document.body.appendChild( image )
}
}
}
App.presenting()
Как видно, нам не пришлось плясать с колбэками, обещаниями, генераторами, стримами, асинхронными функциями и прочими адскими созданиями. Вместо этого наш код остался простым и понятным.
Но я ведь только внедрил..
RxJS
Облегчённая версия — всего лишь 250КБ.
Всё это только для того, чтобы вместо последовательного кода писать комбинаторы комбинаторов кучи мелких замыканий. Мейнтейнеры AngularJS@2 ведь не могут ошибаться. Спикер с *JsConfTalksMeetUpDays убедительно размахивал об этом руками. Именно так нужно писать код в 2k16:
const greeting = showName
.select( showName => {
if( showName ) return userName.map( userName => `Hello, ${ userName }!` )
return Rx.Observable.from([ 'Hello!' ])
} )
.switch()
А за такое устаревшее поделие, нужно руки отрывать:
greeting() {
if( this.showName() ) return `Hello, ${ this.userName() }!`
else return 'Hello!'
}
Promises
Встроенные уже почти в каждый браузер, они позволяют вам писать… цепочки из цепочек мелких замыканий. Ведь именно так выглядит код настоящего синьора:
let _config
const getConfig = ()=> {
if( _config ) return _config
return _config = $.get( 'config.json' ).then( JSON.parse )
}
let _profile
const getProfile = ()=> {
if( _profile ) return _profile
return _profile = $.get( 'profile.json' ).then( JSON.parse )
}
const getGreeting = ()=> getConfig()
.then( config => {
if( !config.showName ) return 'Hello!'
return getProfile()
.then( profile => `Hello, ${profile.userName}!` )
} )
Этот же код просто невозможно поддерживать:
@ $mol_mem()
config() {
return JSON.parse( Rest.resource( 'config.json' ) )
}
@ $mol_mem()
profile() {
return JSON.parse( Rest.resource( 'profile.json' ) )
}
@ $mol_mem()
greeting() {
if( !this.config().showName ) return 'Hello!'
return `Hello, ${ this.profile().userName }!`
}
Async functions
Вы на самом острие технологий. На столь остром, что многие браузеры не понимают то, что вы пишете:
let _config
const getConfig = async ()=> {
if( _config ) return _config
return _config = JSON.parse( await $.get( 'config.json' ) )
}
let _profile
const getProfile = async ()=> {
if( _profile ) return _profile
return _profile = JSON.parse( await $.get( 'profile.json' ) )
}
const getGreeting = async ()=> {
if( !( await getConfig() ).showName ) return 'Hello!'
return `Hello, ${ ( await getProfile() ).userName }!`
}
Поэтому вы используете webpack и babel, которые во мгновение ока преобразуют ваш код с потрясающе удобной явной асинхронностью в код для нижнего интернета:
'use strict';
function _asyncToGenerator(fn) { return function () { var gen = fn.apply(this, arguments); return new Promise(function (resolve, reject) { function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { return Promise.resolve(value).then(function (value) { step("next", value); }, function (err) { step("throw", err); }); } } return step("next"); }); }; }
var _config = void 0;
var getConfig = function () {
var _ref = _asyncToGenerator(regeneratorRuntime.mark(function _callee() {
return regeneratorRuntime.wrap(function _callee$(_context) {
while (1) {
switch (_context.prev = _context.next) {
case 0:
if (!_config) {
_context.next = 2;
break;
}
return _context.abrupt('return', _config);
case 2:
_context.t0 = JSON;
_context.next = 5;
return $.get('config.json');
case 5:
_context.t1 = _context.sent;
return _context.abrupt('return', _config = _context.t0.parse.call(_context.t0, _context.t1));
case 7:
case 'end':
return _context.stop();
}
}
}, _callee, undefined);
}));
return function getConfig() {
return _ref.apply(this, arguments);
};
}();
var _profile = void 0;
var getProfile = function () {
var _ref2 = _asyncToGenerator(regeneratorRuntime.mark(function _callee2() {
return regeneratorRuntime.wrap(function _callee2$(_context2) {
while (1) {
switch (_context2.prev = _context2.next) {
case 0:
if (!_profile) {
_context2.next = 2;
break;
}
return _context2.abrupt('return', _profile);
case 2:
_context2.t0 = JSON;
_context2.next = 5;
return $.get('profile.json');
case 5:
_context2.t1 = _context2.sent;
return _context2.abrupt('return', _profile = _context2.t0.parse.call(_context2.t0, _context2.t1));
case 7:
case 'end':
return _context2.stop();
}
}
}, _callee2, undefined);
}));
return function getProfile() {
return _ref2.apply(this, arguments);
};
}();
var getGreeting = function () {
var _ref3 = _asyncToGenerator(regeneratorRuntime.mark(function _callee3() {
return regeneratorRuntime.wrap(function _callee3$(_context3) {
while (1) {
switch (_context3.prev = _context3.next) {
case 0:
_context3.next = 2;
return getConfig();
case 2:
if (_context3.sent.showName) {
_context3.next = 4;
break;
}
return _context3.abrupt('return', 'Hello!');
case 4:
_context3.next = 6;
return getProfile();
case 6:
_context3.t0 = _context3.sent.userName;
_context3.t1 = 'Hello, ' + _context3.t0;
return _context3.abrupt('return', _context3.t1 + '!');
case 9:
case 'end':
return _context3.stop();
}
}
}, _callee3, undefined);
}));
return function getGreeting() {
return _ref3.apply(this, arguments);
};
}();
И нет, этот код всё-равно никуда не годится:
@ $mol_mem()
config() {
return JSON.parse( Rest.resource( 'config.json' ) )
}
@ $mol_mem()
profile() {
return JSON.parse( Rest.resource( 'profile.json' ) )
}
@ $mol_mem()
greeting() {
if( !this.config().showName ) return 'Hello!'
return `Hello, ${ this.profile().userName }!`
}
Тут ведь не понятно, асинхронный метод config
или нет, а ведь это очень важно знать!
Но если всё же..
$mol_atom является основным строительным кирпичиком фреймворка $mol. Он обеспечивает надёжную и гибкую динамическую взаимосвязь между всеми компонентами, позволяя описывать их предельно простым синхронным кодом. Асинхронность не выпячивается наружу, а инкапсулируется внутри асинхронных модулей, делая работу с ней простой и приятной. Ошибки не рушат всё приложение, а корректно обрабатываются. А дебаг на редкость удобен, благодаря человекопонятным идентификаторам, синхронному коду и быстрому доступу из консоли к любому состоянию. Независимая сборка $mol_atom+$mol_mem весит всего 25KB и может быть использована с любым другим фреймворком.
Автор: vintage