読者です 読者をやめる 読者になる 読者になる

AngularJSで始めるリアクティブプログラミング

RxJS AngularJS

最近はAngularJSの記事をQiitaにたくさん投稿していたのですが、久しぶりにこっちのブログにも書いてみます。

AngularJSでリアクティブプログラミングをするためのフレームワーク「ReactiveProperty for AngularJS」を作ったので、その紹介記事です。

リアクティブプログラミングとは

簡単に言うと、ユーザーの入力や外部イベントなどに応じて変化した値が、自動的に他の場所に伝搬されていき、それに反応する形で処理をするようなプログラミングパラダイムのことです。

詳細についてはこちらの記事が分かりやすいです。

複数のイベントを合成したり、時間を考慮した処理というのは従来記述がとても面倒なものでしたが、リアクティブプログラミングではそれらを宣言的に記述することができるようになります。

ReactiveProperty

先日こんなツイートがありました。

.NETでは、behavior中心のdata bindingと、events中心のRx*1が相補的になっているとのこと。
確かにそうですね。
そして、その間をつなぐReactivePropertyというライブラリがあります。

ぼくはこのReactivePropertyのコンセプトに感銘を受けて、とっても大好きなライブラリの1つなんですが、なかなか使う機会がないんですよね(涙)

ReactiveProperty for AngularJS

さて、先ほどのツイートの続きです。

AngularJSのデータバインディングもリアクティブプログラミングだけど、イベントに対する記述が手続き的になってしまうとのこと。
ならば、ReactivePropertyと同じ要領で、AngularJSのデータバインディングをRxJS*2につなげればいいのではないでしょうか。

というわけで、ReactiveProperty for AngularJSを作ってみました。

以降、具体的なコードで説明していきたいと思います。

データバインディング

まずは基本的なデータバインディングの例です。
入力した文字列を大文字に変換して、3秒遅延して出力するサンプルです。

<div ng-controller="BasicsCtrl">
    <input type="text" ng-model="currentText.value">
    <br/>
    {{displayText.value}}
</div>
var app = angular.module('app', ['rxprop']);
app.controller("BasicsCtrl", ["$scope",
  function ($scope) {
    $scope.currentText = new rxprop.ReactiveProperty($scope);
    $scope.displayText = $scope.currentText
      .select(function (x) { return x.toUpperCase(); })
      .delay(3000)
      .toReactiveProperty($scope);
  }]);

通常のAngularJSのデータバインディングと比べると、JavaScriptは複雑になってしまいました。
HTML側はバインドする変数名に .value が付いていますがそれほど違いはありません。

ちなみにC#版のコードと比べるとそっくりなのが分かると思います。

インクリメンタルサーチのレスポンス改善

上記の例ではそれほどメリットを感じないと思うので、もう少し実用的な例を紹介します。

AngularJSを使っていると、ng-repeatで列挙しているリストをフィルタで絞り込むということをよくやると思います。
でもこれ、リストの要素が増えてくると検索用のテキストボックスのレスポンスが悪くなりますよね。

そこで、RxJSのthrottleを使ってみます。
throttleはイベントが発生してから一定の時間待機して、次のイベントがこなければ後続に値を流すというものです。

例えば300ミリ秒以内に"abc"と連続して入力した場合は、"a"や"ab"ではフィルタリングを行わず、"abc"のみでフィルタリングを行うことができます。
これを手続き的に書こうと思うと難しいですが、ReactivePropertyでは以下のように書くことができます。

<div ng-controller="SearchCtrl">
    <input type="text" ng-model="searchTextInput.value">
    <div ng-repeat="result in results | filter:searchTextDelay.value">
        {{result}}<br />
    </div>
</div>
var app = angular.module('app', ['rxprop']);

app.controller("SearchCtrl", ["$scope",
    function ($scope) {
        $scope.searchTextInput = new rxprop.ReactiveProperty($scope);
        $scope.searchTextDelay = $scope.searchTextInput
            .throttle(300)
            .toReactiveProperty($scope);
        $scope.results = [/* 値の列挙 */];
    }]);

ReactiveCommand

続いてReactiveCommandです。

AngularJSにはWPFのCommandに該当する機能がないので、rp-commandとrp-parameterというディレクティブを新たに作りました。
次の例では最初はボタンが押せないようになっていますが、すべてのチェックボックスにチェックを入れてテキストボックスに文字列を入力するとボタンが押せるようになります。そしてボタンを押すとアラートが表示されます。

RxJSのcombineLatestは複数のイベントを合成するもので、いずれかのイベントが発行されたとき、他のイベントが最後に発行した値と合成して後続に通知をおこないます。

<div ng-controller="CommandCtrl">
    <input type="checkbox" ng-model="isChecked1.value">
    <input type="checkbox" ng-model="isChecked2.value">
    <input type="checkbox" ng-model="isChecked3.value">
    <input type="checkbox" ng-model="isChecked4.value">
    <input type="text" ng-model="currentText.value">
    <button rp-command="checkedCommand" rp-parameter="currentText.value">push</button>
</div>
var app = angular.module('app', ['rxprop']);
app.controller("CommandCtrl", ["$scope",
    function ($scope) {
        $scope.isChecked1 = new rxprop.ReactiveProperty($scope, {initValue: false});
        $scope.isChecked2 = new rxprop.ReactiveProperty($scope, {initValue: false});
        $scope.isChecked3 = new rxprop.ReactiveProperty($scope, {initValue: false});
        $scope.isChecked4 = new rxprop.ReactiveProperty($scope, {initValue: false});
        $scope.currentText = new rxprop.ReactiveProperty($scope, {initValue: ""});

        $scope.checkedCommand = $scope.isChecked1
            .combineLatest($scope.isChecked2, $scope.isChecked3, $scope.isChecked4, $scope.currentText, function (a, b, c, d, txt) {
                return a && b && c && d && txt;
            })
            .toReactiveCommand($scope);
        $scope.checkedCommand
            .subscribe(function (param) {
                alert("Execute! input = " + param);
            })
    }]);

さてこれ、C#版のReactivePropertyを真似てそっくりに作ってみたものの、AngularJSとの相性はあまりよくないと感じています。
なので、AngularJSに適したもっと良い方法を模索中です。

ReactiveCollection

ReactiveCollectionはイベントとして流れてきた値を、リストに追加して通知するための機能です。
例えば、WebSocketからログを受け取ってそれを表示するログビューアーみたいなものを、以下のように書くことができます。

<div ng-controller="LogCtrl">
    <div ng-repeat="log in logs.values">
        {{log.level}}: {{log.message}}<br />
    </div>
</div>
var app = angular.module('app', ['rxprop']);
app.controller("LogCtrl", ["$scope",
  function ($scope) {
    $scope.logs = webSocketAsObservable("ws://localhost:9999/")
      .selectMany(function (receiver) {
        return receiver;
      })
      .select(function (x) {
        return angular.fromJson(x.data);
      })
      .toReactiveCollection($scope);
  }]);

webSocketAsObservableは、RxJSでWebSocketクライアント - Qiitaを使っています。

AngularJSでは、WebSocketのようなAngularJSの管理外のトリガーで値を変更した場合、$scope.$apply呼んでDOMを更新する必要がありますが、ReactiveProperty for AngularJSでは自動的に$scope.$applyを呼び出すようになっています。

リストへの追加処理の改善

上記の例では、WebSocketで大量のメッセージが送信されてくると、JavaScriptの処理が間に合わずにブラウザが固まってしまいます。

RxJSには、指定した時間内に受け取ったイベントをバッファリングして、時間が経過したら後続に流すbufferWithTimeというAPIが用意されています。
また、ReactiveCollectionには、受け取った配列をフラットにしてリストに追加する flatten オプションを用意しました。

これらを利用して先ほどのコードを以下のように書き換えると、大量のメッセージを受信したとしても画面更新は1秒ごとになるので、ブラウザが固まりにくくなります。

var app = angular.module('app', ['rxprop']);
app.controller("LogCtrl", ["$scope",
  function ($scope) {
    $scope.logs = webSocketAsObservable("ws://localhost:9090/")
      .selectMany(function (receiver) {
        return receiver;
      })
      .select(function (x) {
        return angular.fromJson(x.data);
      })
      .bufferWithTime(1000)
      .toReactiveCollection($scope, {flatten: true});
  }]);

まとめ

駆け足で紹介してみましたが、いかがだったでしょうか?
ReactiveProperty for AngularJSには、他にもいくつかの機能があります。
ドキュメントはまだ用意できていないので、サンプルを参考にしてみてください。

また、ToDoMVCも用意しています。

ReactivePropertyについて詳しく知りたい場合は@neueccさんや@okazukiさんの記事が参考になります。

ReactiveProperty for AngularJSは、まだまだ開発段階ですが、自分のアプリに組み込んで育てていこうかなっと思っています。

リアクティブプログラミングは最初はとてもとっつきにくいですが、使いこなせるととても楽しいものです。
最近はいろいろなリアクティブプログラミングのライブラリが出てきているので、試してみてはいかがでしょうか。

*1:Microsoftの開発している、Reactive Extensionsというオープンソースライブラリ

*2:Reactive ExtensionsのJavaScript実装