Kefir.js — новая библиотека для функционального реактивного программирования (FRP) в JavaScript

    Наверняка многие уже слышали о подходе FRP для организации асинхронного кода. На хабре уже писали об FRP (Реактивное программирование в Haskell, FRP на Bacon.js) и есть хорошие доклады на эту тему (Программировние UI с помощью FRP и Bacon.js, Functional Reactive Programming & ClojureScript, О Bacon.js от Juha Paananen — автора бекона)

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

    Вот что это дает по сравнению с обратными вызовами:

    1) Поток событий (Event stream) и значение меняющаяся во времени (Property / Behavior) становятся объектами первого класса. Это значит что их можно передавать в функции и возвращать из функций.

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

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

    К примеру можно написать функцию, возвращающую поток перетаскиваний (drag). В качестве параметров она будет принимать 3 потока — начало перетаскивания, движение, конец перетаскивания. Дальше можно передать в эту функцию: либо потоки для соответствующих событий мыши (mousedown, mousemove, mouseup), либо для touch событий (touchstart, touchmove, touchend). Сама же функция не будет ничего знать об источниках событий, а будет работать только с абстрактными потоками. Пример реализации на Bacon.

    2) Явный state

    Второе большое преимущество FRP это явное управление состоянием. Как известно, state — один из самых главных источников сложности программ, поэтому грамотное управление им позволяет писать более надежные и простые в поддержке программы. Отличный доклад от Рича Хикки о сложности (complexity) «Simple Made Easy».

    FRP позволяет писать бОльшую часть кода на «чистых функциях» и управлять потоком данных (dataflow) явно (с помощью потоков событий), а состояния хранить тоже явно в Property.



    Kefir.js



    Сейчас есть две основные FRP библиотеки для JavaScript, это Bacon.js и RxJS. Как мне кажется, Bacon более близок духу функционального программирования, а RxJS это что-то из мира ООП. У Rx очень тяжелая для восприятия документация — её во-первых много, а во-вторых она написана в очень формальном стиле (как автогенерируемая документация из исходного кода). Т.е. Rx труднее изучать и труднее им пользоваться. Но Rx более быстрый и потребляет меньше памяти.

    Последнее обстоятельство иногда бывает ахиллесовой пятой Bacon. Впервые я заметил проблему, когда попытался написать аналог scrollMonitor на Bacon. Получился очень хороший API со всей мощью FRP, но когда я запустил этот стресс тест, всё просто зависло. Как оказалось, Bacon потребляет кучу памяти и частые сборки мусора вызывают фризы. Это может быть актуально при большом количестве потоков или на мобильных устройствах. Я считаю что в библиотеке должен быть больший запас производительности, чтобы меньше думать об этом при написании кода приложения!

    Kefir.js — новая FRP билиотека, над которой я работаю последние несколько месяцев. API Kefir очень похож на API Bacon, но в Kefir я уделяю много внимания производительности и потреблению памяти. Сейчас Kefir примерно в 5-10 раз быстрее Bacon, и в 1-2 раза быстрее Rx, примерно тоже и с памятью.

    Сравнение производительности Kefir и Bacon в живом тесте. Также есть результаты синтетических тестов памяти. Еще есть синтетические тесты производительности, вот результаты некоторых из них:

    stream.map(id)
    ----------------------------------------------------------------
    Kefir x 7,692,055 ops/sec ±1.62% (33 runs sampled)
    Bacon x 703,734 ops/sec ±1.63% (34 runs sampled)
    RxJS x 2,303,480 ops/sec ±1.70% (34 runs sampled)
    -----------------------
    Kefir 1.00   Bacon 0.09   RxJS 0.30
    
    
    stream.map(id) with multiple listeners
    ----------------------------------------------------------------
    Kefir x 4,185,280 ops/sec ±0.89% (34 runs sampled)
    Bacon x 421,695 ops/sec ±0.79% (33 runs sampled)
    RxJS x 604,156 ops/sec ±1.21% (31 runs sampled)
    -----------------------
    Kefir 1.00   Bacon 0.10   RxJS 0.14
    
    
    stream.flatMap (x) -> Lib.once(x)
    ----------------------------------------------------------------
    Kefir x 1,073,871 ops/sec ±1.14% (32 runs sampled)
    Bacon x 57,474 ops/sec ±4.45% (28 runs sampled)
    -----------------------
    Kefir 1.00   Bacon 0.05
    
    
    stream.combine(Lib.constant(1), fn)
    ----------------------------------------------------------------
    Kefir x 2,413,356 ops/sec ±1.14% (34 runs sampled)
    Bacon x 220,898 ops/sec ±1.41% (34 runs sampled)
    -----------------------
    Kefir 1.00   Bacon 0.09
    
    
    stream.skipDuplicates()
    ----------------------------------------------------------------
    Kefir x 7,009,320 ops/sec ±1.49% (33 runs sampled)
    Bacon x 684,319 ops/sec ±1.55% (34 runs sampled)
    RxJS x 401,798 ops/sec ±1.48% (31 runs sampled)
    -----------------------
    Kefir 1.00   Bacon 0.10   RxJS 0.06
    


    Также я стараюсь делать Kefir максимально простым для изучения, примерно как Underscore или LoDash. Поэтому и документация очень похожа на документацию Underscore. Цель — сделать документацию лучше чем и в Rx и в Bacon.

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

    Текущее состояние


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

    По сравнению с Bacon сейчас в Kefir не хватает:

    • Ошибок как событий, есть только значения
    • Части методов / комбинаторов: zip, combineTemplate, when, update, различных методов для буферов, и некоторых других
    • Атомарных событий (в Rx, кстати, тоже нет)


    Вот и всё что я хотел пока рассказать про Kefir. Я не описывал подробно саму библиотеку, т.к. Kefir очень похож на Bacon, и если вы знакомы с последним, то без труда освоите первый. А если нет, то можно изучать Kefir по туториалам Bacon, поглядывая в документацию кефира :-)

    github.com/pozadi/kefir — проект на GitHub
    pozadi.github.io/kefir — документация

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

    Слышали ли вы раньше об FRP?

    Если пользовались RxJS или Bacon, то что вам больше нравится?

    Поделиться публикацией

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

      –7
      Оспади, когда уже JS-библиотекам начнут давать нормальные названия O_o

      Ну а если по делу — навскидку выглядит круто, надо будет обязательно попробовать. Вообще, движение в этом направлении очень радует
        +3
        Также я стараюсь делать Kefir максимально простым для изучения, примерно как Underscore или LoDash.
        Это здорово, API Бекона и Rx какие-то неэлегантные. Было бы круто иметь FRP с API в духе Underscore/LoDash.
          +1
          Надежда на то что FRP можно будет применять в боевых условиях, демки впечатляют, спасибо!
          Вопрос, насколько можно будет сочетать потоки Kefir'a с методами LoDash? В API Bacon'a функциональщины все-таки не хватает…
            0
            Или LazyJS который идиоматичен, резок и ленив. Хотя и менее популярен.
            Хотя Lodash тоже планирует лень.

            Упс, пардон, невнимательно прочитал вопрос.
              0
              Kefir, кстати, по определению ленив и работает похоже на LazyJS. Так как он обрабатывает поток значений, а не массив значений, и не может создавать временные массивы с результатами при всём желании.
                0
                В LazyJS тоже можно DOM события фильтровать и т.д. посмотрите в описание Event sequences
              0
              Даже не знаю. LoDash можно например активно использовать внутри кефировских .map, .filter и пр. Еще можно его использовать во внешнем коде который, что-то делает с потоками. А так чтобы LoDash прям как-то взаимодействовал с Kefir — ничего особо в голову и не приходит.

              Вот если бы LoDash умел создавать трансдьюсеры, и Kefir поддерживал трансдьюсеры, тогда они могли бы прям клёво взаимодействовать. Если трансдьюсеры станут модными в JavaScript, тогда может это будет…
              +2
              Можно еще Elm упомянуть.
                +1
                Во всех подобных библиотеках меня смущает один момент. Допустим у меня есть одно редко меняющееся свойство (конфиг) и часто меняющееся (координата мыши), и нужно, в зависимости от состояния, показывать координаты, например, в заголовке окна. Реализация в лоб будет примерно такой:

                config = Kefir.constant( false )
                coords = Kefir.constant( [0,0] )
                printer = Kefir.merge([ config, coords ]).scan( 0, functon( config, coords ){
                    return 'Hello' + ( config ? coords : '' ) 
                })
                


                Когда config содержит true — всё ок, но когда он содержит false, то будет куча пустых вычислений при движении мыши. Как эта проблема решается при использовании таких библиотек?
                  0
                  Вы что-то странное написали, оно не будет работать :)
                  В .scan первый аргумент — предыдущее значение, а второй новое значение из источника, и вы должны вернуть значение которое выйдет из printer и станет «предыдущим значением» на следующем шаге. А .merge просто берет два потока и все события из них отправляет в новый поток. Ну т.е. вы никак не получите config и coords как параметры в .scan.

                  Реализация в лоб скорее будет такая:

                  printer = Kefir.combine([config, coords], function(config, coords) {
                    return 'Hello' + ( config ? coords : '' )
                  });
                  


                  Но как вы верно заметили будут ненужные вычисления. Чтобы их избежать можно использовать .filterBy:

                  printer = coords.filterBy(config).map(function(coords) {
                    return 'Hello' + coords
                  });
                  


                  .filterBy он как .filter, только вместо функции принимает проперти или поток.
                    0
                    То есть filterBy будет отписывать стрим от coords, до тех пор пока config не станет true?
                    А что если нужно не просто фильтровать, а выбирать один поток из двух?

                    printer = Kefir.combine([ config, mouseCoords, mouseTarget ], function( config, coords, target ) {
                      return 'Hello ' + ( config ? coords : target )
                    });
                    
                      0
                      Тогда можно так:

                      printer = config.flatMapLatest(function(config) {
                        // возвращаем поток. Т.е. как бы переключаемся с потока на поток в зависимости от config
                        return config ? mouseCoords : mouseTarget;
                      }).map(function (coordsOrTarget) {
                        // тут уже обрабатываем конкретные значения из mouseCoords или mouseTarget
                        return  'Hello ' + coordsOrTarget;
                      });
                      


                      Я еще не успел написать документацию для .flatMapLatest, можно почитать в доках бекона пока: github.com/baconjs/bacon.js#observable-flatmaplatest
                        0
                        То есть в общем случае будет нечто-типа:

                        printer = 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
                        		}
                        	}
                        } )
                        
                        
                0
                А еще примеры будут?

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