FRP (functional reactive programming) на Bacon.js

Часто, при создании достаточно сложных приложений на JavaScript наступает тот момент, когда становиться совершенно непонятно почему приложение перестало работать как надо, или наоборот вдруг заработало. Cвязей между элементами приложения становится так много, что уследить за ними даже с хорошими дебаггером очень трудно. И вот диллема: с одной стороны есть хорошо известная методика создания приложений на JS, столь привычная и глубоко описанная, что недостатков мы уже как бы и не замечаем. С другой стороны есть масса библиотек предлагающих нам перейти на другую сторону попробовать что-то новое. К таким библиотекам относиться и Bacon.js, предоставляя реализацию FRP на JavaScript.

Пару слов о FRP и его прикладном смысле.
Если не вдаваться в дао функционального программирования, то можно выделить несколько моментов FRP особо притягательных для веб-разработки. Это:
  • явные состояния,
  • распространение изменений,
  • работа не данными, а с источниками данных.

С состояниями, конечно, не все так прозрачно. В конце концов, это все равно JavaScript со свойственными ему проблемами. Так что где-то под капотом браузера все равно происходит все то же самое, что и происходило бы в коде без bacon.js, но вся мякотка в том, что больше это не забота разработчика. Задача разработчика сводиться к тому, чтобы думать над логикой приложения.

FRP подразумевает, что источники данных — это потоки событий. Сами данные — это состояние потока в определенное время, поэтому изменение данных влечет за собой немедленное изменение других данных, которые зависят от первых. По итогу получается структурное дерево описывающее зависимость одних данных от других, что делает систему прозрачной и легкоусвояемой.

Сейчас бы хотелось перейти к прикладной части, а именно на примере продемонстрировать преимущества FRP в целом и Bacon.js в частности перед императивным программированием на JS. В качестве примера возьму хорошо известную игру Sokoban, которую я напиcал с применением Bacon.js и без, чтобы наглядно показать различия.

Чтобы не вдаваться в излишнюю детализацию, можно посмотреть сразу на результат. Сконцентрирую внимание на функциональной части игры, той, которая отвечает за логику действий. Если у кого-то возникнет непреодолимое желание ознакомиться с сырцами, то прошу. В двух словах все работает так: мы задаем ширину и высоту игрового поля в ячейках (DHTML), рисуем уровень (JSON объект) и с помощью стрелок двигаем игрока (блок с зеленым фоном) по полю, пытаясь переместить желтые ячейки на синие, когда все желтые ячейки находятся на синих — уровень пройден.

Практическое применение
А теперь к самому главному. Обычно, мы просто слушаем события, которые генерирует браузер в ответ на действия юзера, чтобы узнать что делать дальше.

$(document).on("keydown", function(e){
	//вот здесь происходит механика игры
});

Игра управляется 4-мя клавишами. И тут же сразу возникает проблема условной проверки, что синтаксически выглядит так.

if(e.keyCode >= 37 && e.keyCode <= 40){
     //На самом деле, механика игры здесь
}


Мы ничего толком не успели начать, а уже приходится работать в рамках этого самого условия. Например для вычисления направления движения. По мере продвижения вглубь логической структуры игры мы сталкиваемся с многочисленными проверками окружающей среды, единственной целью которых служит определение текущего неявного состояния.

Что же предлагает bacon.js? В bacon.js определены потоки (EventStream) и свойства (Property), — состояния потоков в определенное время. Потоки можно обрабатывать, соединять, комбинировать. Здесь есть наглядные диаграммы методов. Таким образом описание нужных реакций сводится к описанию порядка событий в потоке и преобразованию данных. Идея в том, чтобы не следить за одиночными событиями и обрабатывать каждое из них в отдельности, а заполучить источник данных, то есть поток событий из которого можно извлекать только нужные или преобразовывать их. Например с применением фильтров:

var keyDowns = $(document).asEventStream("keydown"); //поток событий keydown на $(document)
var arrowDowns = keyDowns.filter(isArrows); //поток событий, таких, что прошли фильтр isArrows

function isArrows(e){
	//здесь asEventStream передает в эту функцию jQuery.Event 
   return e.keyCode >= 37 && e.keyCode <= 40
}

Или с применением map'ов:

var changeDirection =  $(document).asEventStream("keydown")
				.filter(isArrows)       //если filter возвращает true, то передает событие дальше, иначе не реагирует
				.map(selectDirection)   //map возвращает значение selectDirection(event)
				.onValue(function(x){   //и выполним анонимную функцию, для этих событий
     //здесь задаем направление движение игрока
});

function selectDirection(e){
      return { 
            x : e.keyCode % 2 ? e.keyCode - 38 : 0, 
            y : !(e.keyCode % 2) ? e.keyCode - 39 : 0 
      } 
}

Или как-нибудь еще. Методов для работы с потоками и свойствами много. Суть даже не в том какой функционал предоставляет Bacon.js, а в том, что потом можно делать с этими потоками событий. Если общий алгоритм игры без bacon.js представляет собой хитросплетенный лабиринт условий и состояний, то с помощью FRP мы достигаем вполне себе декларативного описания состояний программы и источников данных.
Мы привыкли изменять состояния системы, когда происходят нужные события, будь то ввод с клавиатуры или клик по какому-нибудь объекту. Чтобы понять, что события нужные приходится вешать хендлеры и пристально следить за развитием событий. Приходится много планировать и предугадывать как вообще поведет себя юзер, чтобы на всякое состояние были свои действия. Но в действительности меня как разработчика мало интересует чего такого сделал юзер, если это логически не влияет на состояние системы. При таком подходе декларативное описание событий очень сильно облегчает жизнь так как не требует проверок в стиле «а правда, что юзер ткнул именно эту кнопку на клавиатуре, а не какую-то другую».

	
var playerMove = $(document).asEventStream("keydown")  //события keydown на $(document)
									.filter(isArrows)  //которые являются нажатиями на стрелки
									.map(player)       //для игрока
									.map(nextCell);    //ячейка, куда движется игрок.  

//поток событий, таких, что следующая ячейка куда двигается игрок пуста
var playerNextEmpty =  playerMove.filter(isEmpty).onValue(function(nov){
	//меняем координаты игрока.
});

//поток событий, таких, что следующая ячейка куда двигается игрок - подвижный блок
var goalMove = playerMove.map(isGoal).filter(function(x){return x});

//поток событий, таких, что следующая ячейка за той, где стоит подвижный блок - пуста
var goalNextEmpty = goalMove.map(nextCell).filter(isEmpty).onValue(function(x){
	//меняем координаты блока
});

Как видно, синтаксис сам подталкивает к тому, чтобы писать очевидно. На самом деле все можно сделать еще проще, если использовать методы scan() и combine(), но я хотел показать самые простые методы map() и filter().

Тот же функционал без bacon.js целиком приводить не буду, но callback, который обрабатывает механику игры при событии keydown заканчивается вот этим:


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

	
//поток событий, таких, что следующая ячейка, куда двигается игрок - мина
var playerNextMine = playerMove.filter(isMine); 
//поток событий, таких, что следующая ячейка, куда будет перемещен блок - мина
var goalNextMine = goalMove.map(nextCell).filter(isMine);
//совмещенный поток событий playerNextMine и goalNextMine.
var mineAlert = goalNextMine.merge(playerNextMine).onValue(function(x){
	//игра проиграна
});


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

Плюсы и минусы
  • + Больше не нужно следить за состоянием системы, что не может не радовать тех, кто пишет глубоко асинхронный код, состоящий преимущественно из одних только ajax-запросов.
  • + Не нужны сверх-замороченные описания управляющего механизма приложения, что не может не радовать, тех, кто пишет сложные логические приложения (например анкету с очень хитрой спецификацией заполнения). Сам синтаксис велит писать очевидно.
  • + Легко поддерживать, дополнять, изменять приложение не опасаясь, что все накроется медным сосудом.


  • — Все равно, все чего мы пытаемся избежать прибегая к FRP в JavaScript так или иначе делается, но уже скрыто от нас в недрах Bacon.js.
  • — Производительность. Тщательное профилирование показало, что делая все в лоб выходит быстрее. Однако, проект развивается, и правильное его использование окупается
  • — Из моих субъективных ощущений, — это скорее эмуляция FRP. ClojureScript, например, предоставляет тот же функционал в более привычной форме. Я гораздо дольше вникал в прикладную разницу между свойствами и потоками соответствующими map'у, чем в работу тех же ячеек (cell) в Javelin.


Имеет ли все это смысл?
Из всего написанного выше можно сделать вывод. Существует гипотетическая кривая зависимости необходимости использования bacon.js (и вообще FRP) от сложности приложения. Понятно, что для наведения красотулек на сайт или для одного только sign form никакого смысла нет использовать bacon.js. Библиотека не тяжелая, но лучше сразу увидеть, где можно ее применить, а где просто не нужно. Bacon.js разумно использовать там, где будет сложно ориентироваться без декларативного программирования. Даже если система большая и включает в себя огромное множество элементов, bacon использовать имеет смысл только тогда, когда эти элементы зависят друг от друга, изменение одних данных должно повлечь за собой изменение других данных и так далее. Сложность в этом смысле не означает размер приложения или совокупную сложность алгоритмов, скорее размер логической структуры приложения.

Альтернативы
Из JS библиотек есть Майкрософтовский RxJS. Есть такая штука, как Elm. Есть еще такая интересная вещь, как ClojureScript. Посвятив немного времени изучению этого вопроса, можно удобный для себя вариант.

Материалы:
GitHub: Bacon.js, web-site
Несколько хороших материалов о FRP: один, два, три.
Поделиться публикацией
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 17

    0
    ClojureScript сам по себе ничего не даёт для FRP, на нём просто элегантно все можно реализовать.
      0
      Виноват. ClojureScript с Javelin.
      +1
      Лучшее, что я видел по FRP, это вот этот доклад: habrahabr.ru/post/193950/
      0
      Очень похоже на promises, но с многократным resolve
        0
        Скорее promises — кастрированная версия FRP-потоков
        0
        Для наведения красотулек бекон тоже очень помогает, там тоже бывает много связей.
          0
          А насколько сильно страдает производительность? Единицы, десятки процентов?
          Пробовал подобное (observable — переменные) навешивать на таблицу данных. Если 10-100 значений — нормально, если в боевом режиме (там до 1000 может доходить) — всё, браузер складывается…
          Подозреваю что 10-100 asEventStream не особо напрягают систему?
            0
            В официальном FAQ пишут что производительность толком не тестировали, но с другой стороны никто не жаловался.

            Там есть какие-то оптимизации. Например, если я правильно понял, если никто не слушает поток то, сам поток не будет слушать свой источник событий; или если поток получен с помощью map (someStream.map(someFunction)), но у него нет подписчиков, то и someFunction выполняться не будет. Как-то так.
              0
              Опять же от подхода зависит. Я профилировал в Firebug, и есть три основные ситуации, где важно учитывать потери в производительности:
              1) Много разнородных объектов, создающих много разнородных эвентов, которые между собой могут быть связанны, а могут быть и нет. В таком случае приходится часто пользоваться методами scan(), combine() и filter(), которые могут по факту обрубать обработку событий потока, где-то в конце цепочки, и тем самым уменьшать производительность тем, что в stream.map(b).map(c).filter(someFunction).toProperty(d).scan(e,anotherFunction) до scan могут доходить редкие события, а все функции b,c,someFunction выполнятся все равно будут.
              2) Большое количество однородных объектов, создающих разнородные события, с предсказуемой реакцией, например те же таблицы данных (например, если эмулировать некоторый функционал Excel в браузере), то тут потери небольшие т.к. почти все эвенты надо слушать и на них надо реагировать.
              3) Большое количество однородных объектов, создающих одни и те же эвенты. Так как события одни и те же, условия одни и те же, выходит, что слушать надо все события, а реагировать в зависимости от объекта т.к их много, следовательно вычисления нужного поведения занимают много времени, а когда еще и событий много, то тут могут быть серьезные потери в скорости исполнения.

              Самая годная практика заранее знать, что и как лучше слушать. Можно приводить какие-то потоки к property и слушать их, когда в этом возникает необходимость. Это, конечно, немного мутировавший вариант FRP, и с декларативным стилем не сочетается, но если действительно нужна оптимизация, то лучше уж так, наверноею
                0
                Чёта фразу прочитал как: «Самая годная практика заранее знать, что и как лучше сделать.»
                И это пять! :)
                0
                а таблица у вас виртуальная была?
                  0
                  Пробовал разные варианты. И виртуальная, и частично отрисованная, и полностью.
                  Но бекон попробую. Мало ли, вдруг в моём случае хорошо всё будет :)
                0
                просто интересно
                1. сколько человек переписало по свойму код из примера?
                2. что использовали при этом?

                может сделать что-то типа todomvc.com/ только для сокобана :)
                0
                fix
                  0
                  Хорошая статья, яркий пример! Но возник вопрос: все эти функции isEmpty, isGoal, nextCell, isMine — они не чистые, они читают глобальный state игры, получается как-то не в функциональном стиле. Это нормально или лучше сделать как-то, чтобы state игры (состояние карты, координаты игрока) хранились внутри потока?

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое