Uncategorized

Knockout.js の依存の更新タイミングと rateLimit

こんにちは。

Knockout.js では ViewModel の更新 (update) がおこなわれると、関連する subscribable (obervable, computed, あるいは custom function など) に更新を伝搬します。この更新について、KnockoutJS ハンズオンで触れた rateLimit がどのように使えるか、こちらのブログでコードも含めて紹介しておきます。

 

Knockout.js の依存トラックと更新の頻度

Knockout.js における computed などの更新の特徴を理解してもらうため、今回は、以下の簡単なサンプルを使ってみましょう。

<!DOCTYPE html><html><head>  <title></title>  <link href="Content/themes/base/jquery.ui.all.css" rel="stylesheet" />  <script src="Scripts/jquery-2.1.1.min.js"></script>  <script src="Scripts/jquery-ui-1.10.4.min.js"></script>  <script src="Scripts/knockout-3.1.0.js"></script>  <script type="text/javascript">    $(document).ready(function () {      // ViewModel      function Order() {        this.name = ko.observable();        this.price = ko.observable();        this.payment = ko.computed(function () {          return this.price() * (1 + expenseViewModel.tax()) * expenseViewModel.rate();        }, this);      };      function ExpenseViewModel() {        var self = this;        self.orders = ko.observableArray();        self.rate = ko.observable(1);        self.tax = ko.observable(0);      };      // Model Initialize      var expenseViewModel = new ExpenseViewModel();      expenseViewModel.orders.push(new Order()        .name('test1')        .price(500)        );      expenseViewModel.orders.push(new Order()        .name('test2')        .price(20)        );      expenseViewModel.orders.push(new Order()        .name('test3')        .price(150)        );      ko.applyBindings(expenseViewModel);      // jquery event      $('#finDialog').dialog({        autoOpen: false,        modal: true,        width: 600,        buttons: {          'Update': function () {            $(this).dialog('close');            expenseViewModel.tax(parseFloat($('#newtax').val()));            expenseViewModel.rate(parseFloat($('#newrate').val()));          },          'Cancel': function () {            $(this).dialog('close');          }        }      });      $('#finchange').click(function () {        $('#finDialog').dialog('open');      });    });  </script></head><body>  <div class="header">    <span>Orders</span>  </div>  <div id="finDialog" title="Finance Info">    <table>      <tr>        <td>New Tax</td>        <td><input type="text" id="newtax" /></td>      </tr>      <tr>        <td>New Rate</td>        <td><input type="text" id="newrate" /></td>      </tr>    </table>  </div>  <div class="ordersPanel">    <table border="1">      <thead>        <tr>          <th>Name</th>          <th>Price</th>          <th>Payment</th>        </tr>      </thead>      <tbody data-bind="foreach: orders">        <tr>          <td data-bind="text: name"></td>          <td data-bind="text: price"></td>          <td data-bind="text: payment"></td>        </tr>      </tbody>    </table>  </div>  <div class="fininfo">    Tax: <span data-bind="text: tax"></span><br />    Rate: <span data-bind="text: rate"></span><br />    <input type="button" id="finchange" value="Change Base" />  </div></body></html>

このサンプルでは Order の一覧を表示しますが、各 Order の payment は、下記の通り computed を使って通貨レート (rate) と税率 (tax) に基づいて計算されています。
この tax と rate は jquery-ui dialog を使って変更可能で、変更すると computed によって Order の表も更新されます。

. . .this.payment = ko.computed(function () {  return this.price() * (1 + expenseViewModel.tax()) * expenseViewModel.rate();}, this);. . .

Knockout.js では、どのオブジェクトが利用 (subscribe) しているか といった Dependency の管理 (Track) をおこなっています。(ko.dependencyDetection が仲介しています。) これにより、computed 内で参照されているオブジェクトが変更された際に関数 (computed) が呼び出されます。上記の場合、computed では tax(), rate() と各 Order ごとの price() が参照されているので、これらの値が変更されるたびに computed が呼び出されます。

例えば、以下のように、computed 内に debug 目的などで name を参照すると、これだけで name も payment に依存した要素として管理されるため、余計なオーバーヘッド (再描画) が発生することになるので注意してください。

this.payment = ko.computed(function () {  console.log(this.name());  return this.price() * (1 + expenseViewModel.tax()) * expenseViewModel.rate();}, this);

前述の通り、今回のサンプル (上記) では、jquery-ui dialog で tax や rate を編集可能であり、編集完了すると下記の通り tax, rate の値の変更をおこないます。Order が 3 つあり、それぞれについて tax と rate の 2 回の変更が発生するので、tax と rate を変更すると computed は合計 6 回呼ばれる結果になります。本来なら、各 Order ごとに 1 回ずつ、合計 3 回で充分であることは言うまでもありません。

expenseViewModel.tax($('#newtax').val());expenseViewModel.rate($('#newrate').val());

このように、上記のサンプル程度であれば大きな問題とはなりませんが、さまざまな依存関係があって値が複数更新される場合は、ケースによって画面のちらつきの原因となります。また、あまり良いことではありませんが、もし computed や custom function (subscribable) の内部でオーバーヘッドの高い処理をおこなっている場合にはパフォーマンス面も無視できないでしょう。
さらに注意していただきたいのは、computed はネストが可能です。例えば、下記の通り total 金額を計算するような場合は、total には 3 つの Order オブジェクトの payment が依存し、各 payment は price, tax, rate に依存するので、tax と rate を変えると合計 6 回呼ばれることになります。この場合は、本来なら 1 回の呼び出しで充分ですね。
つまり、大きな開発になった場合には各オブジェクトが複雑に依存することになりますので、function が非常にたくさん呼び出される結果になるでしょう。

<!DOCTYPE html><html><head>  . . .  <script type="text/javascript">    . . .    function ExpenseViewModel() {      var self = this;      self.orders = ko.observableArray();      self.rate = ko.observable(1);      self.tax = ko.observable(0);      self.total = ko.computed(function () {        var t = 0;        $.each(self.orders(), function (idx, elem) {          t = t + elem.payment();        });        return t;      }, this);    . . .  </script></head><body>  <div class="header">    <span data-bind="text: total"></span>    <span>JPY</span>  </div>  . . .</body></html>

そこで、rateLimit の活用

前置きが長くなりましたが、勉強会で軽く紹介した rateLimit を使うと、こうした無駄な呼び出しの繰り返しを抑制できます。(Knockout.js 3.1 が必要です。)
既定の動作では、変更が発生してから rateLimit で指定した時間が経過した後に 1 度だけ更新がおこなわれるように動作します。(その間に複数回更新が入ったとしても、最終的に 1 回の呼び出しのみになります。) なお、そのあとに依存する要素が変更された場合は、再度、rateLimit で指定した時間の経過後に更新されます。

例えば、以下の通り記述すると 500 ms (ミリ秒) 後に更新されるため、上記で tax と rate の変更時に 6 回呼ばれていた computed の呼び出しは、概ね 3 回 (各 Order ごとに 1 回ずつ) に抑制されるでしょう。(ただし、何かのトラブルで、もし更新処理が 500 ms を超えた場合には、つぎの 500 ms 後にも再度更新されるため、1 つの Order につき 2 回更新がおこなわれることになります。)

. . .this.payment = ko.computed(function () {  return this.price() * (1 + expenseViewModel.tax()) * expenseViewModel.rate();}, this).extend({  timeout: 500});. . .

上述の total のサンプルの場合にはさらに効率的です。上記で 6 回呼ばれていた computed は、1 回に抑制されるでしょう。

. . .function ExpenseViewModel() {  var self = this;  self.orders = ko.observableArray();  self.rate = ko.observable(1);  self.tax = ko.observable(0);  self.total = ko.computed(function () {    var t = 0;    $.each(self.orders(), function (idx, elem) {      t = t + elem.payment();    });    return t;  }, this).extend({    timeout: 500  });};. . .

ちょっと今回のサンプルの場合、500 ms 後に更新されるので何となくもたついた感じの動作になり、良いサンプル (例) ではありませんが、例えば、プログレスバーのように継続的に推移を表示するようなケースでは、この rateLimit を使用することで無駄な描画を避け、一定時間に 1 度だけ状態が更新されるように動作させることができます。このサンプルは、作者の方が以下で紹介されていますので是非参考にしてみてください。

Knock Me Out : Knockout.js 3.1 Released
http://www.knockmeout.net/2014/03/knockout-3-1-released.html

 

Categories: Uncategorized

1 reply»

Leave a Reply