Help us understand the problem. What is going on with this article?

Aurelia の two-way bind で反映されるタイミングはちょっと遅い

More than 1 year has passed since last update.

Aurelia には Custom Attributes/Elements の機能があります。
これには双方向で変数の値を bind する two-way bind の機能があって、とても便利。
けど、その two-way bind が呼び出し元に反映されるまで、ちょっと遅いことが分かりました。

環境

  • OS X El Capitan 10.11.6
  • npm v8.7.0
  • aurelia-skeleton-navigation-webpack 1.1.2
  • Bootstrap v3.3.7 (上記 skeleton に入っていたやつ)

two-way bind って?

ここに書いてあるやつです。
http://aurelia.io/docs/fundamentals/cheat-sheet#databinding

.two-way - Flows data both ways: from view-model to view and from view to view-model.

view で変更された内容を view-model に反映するし、逆も然り。
双方向 bind のことですね。

ちょっと遅れる?

どうやらこの two-way bind 、呼び出し元の変数に反映されるのは、変更処理を行ったコンテキストが終わってからになるらしいです。
社内向けの Web アプリを作っている時に、値の変更をトリガーにして処理を実行しようとしていたんですが、以下の事象にぶち当たりました。

  1. Custom Elements で two-way bind している変数の値を書き換える
  2. 書き換えた直後の処理で、呼び出し元のテンプレートから渡していた callback 関数を呼び出す
  3. 呼び出し元の callback 関数で、 two-way bind している変数の値を参照したら、なんと書き換える前の値だった!

上記 3 の段階で変更後の値が取れなくて非常に困ってしまったので、それについて調べてみました。

実測してみる

とにもかくにも実測してみました。

画面としては以下。
ユーザをドロップダウンメニューでも切り替えられるし、左右のボタンでも順繰り変更できるやつ。
実測サンプル用に書き換えてありますが、ほとんど社内向けの Web アプリで使うやつと一緒です。

20171107_173508.png

ソースは以下。
まずは呼び出し元となる親テンプレート。

src/sample-element/index.html
<template>
  <require from="./user-selector"></require>
  <section>
    <h2>${heading}</h2>
    <user-selector current-user.bind="user" users.bind="users" callback.call="parentCallback()"></user-selector>
  </section>
</template>
src/sample-element/index.js
import {observable} from 'aurelia-framework';

export class Index {
  heading = 'Sample Element';
  @observable user;

  constructor() {
    this.users = [
      {id: '1', name: '佐藤 太郎'},
      {id: '2', name: '山田 次郎'},
      {id: '3', name: '鈴木 花子'},
      {id: '4', name: '田中 三郎'},
      {id: '5', name: '後藤 うめ'}
    ];
    this.user = this.users[0];
  }

  userChanged() {
    console.log('parent changed', this.user.id);
  }

  parentCallback() {
    console.log('parent callback', this.user.id);
  }
}

次に呼び出し先となる子テンプレート。

src/sample-element/user-selector.html
<template>
  <div class="btn-group">
    <button type="button" class="btn btn-default" disabled.bind="isFirstIndex" click.delegate="shiftCurrentUser(-1)">
      <i class="fa fa-caret-left"></i>
    </button>

    <div class="btn-group" role="group">
      <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
        ${currentUser.name}
        <i class="fa fa-caret-down" style="margin-left: 1rem;"></i>
      </button>
      <ul class="dropdown-menu" role="menu">
        <li repeat.for="user of users">
          <a href="#" click.delegate="changeCurrentUser(user.id)">${user.name}</a>
        </li>
      </ul>
    </div>

    <button type="button" class="btn btn-default" disabled.bind="isLastIndex" click.delegate="shiftCurrentUser(1)">
      <i class="fa fa-caret-right"></i>
    </button>
  </div>
</template>
src/sample-element/user-selector.js
import {bindable, bindingMode} from 'aurelia-framework';
import _ from 'lodash';

export class UserSelector {
  @bindable({ defaultBindingMode: bindingMode.twoWay }) currentUser;
  @bindable({ defaultBindingMode: bindingMode.oneWay }) users;
  @bindable callback;

  shiftCurrentUser(diffValue) {
    if (diffValue === -1 && this.isFirstIndex) return false;
    if (diffValue ===  1 && this.isLastIndex) return false;

    this.currentUser = this.users[this.currentIndex + diffValue];
  }

  changeCurrentUser(userId) {
    this.currentUser = _.find(this.users, (user) => {
      return user.id === userId;
    });
  }

  currentUserChanged() {
    console.log('child changed', this.currentUser.id);

    if (this.callback) this.callback();
  }

  get currentIndex() {
    return _.findIndex(this.users, (user) => {
      return user.id === this.currentUser.id;
    });
  }

  get isFirstIndex() {
    return this.currentIndex === 0;
  }

  get isLastIndex() {
    return this.currentIndex === (this.users.length - 1);
  }
}

ログ出力を仕掛けたのは、以下の3箇所。

  1. Index::userChanged() … 親テンプレートで user の変更を検知した時に呼ばれる
  2. Index::parentCallback() … 子テンプレートから callback される
  3. UserSelector::currentUserChanged() … 子テンプレートで currentUser の変更を検知した時に呼ばれる

実測の結果は以下の通り。

 2017-11-07 17.55.37.png

呼ばれる順序は、必ず child changed, parent callback, parent changed の順。
child changed は変更後の値が出力されているが、 parent callback変更前の値が出力されて、 parent changed でようやく変更後の値になっていました。

これでは確かにうまく動くはずがありません。
でも、何でこんな結果になってしまったのでしょうか。

どうしてこうなるの?

早速、結論。
イベント発火後の callback は、現在実行中の関数の処理が終わってから実行されるためだからでした。

JavaScript がシングルスレッドで動作していることを知らなくて全然分からなかったんですが、以下の記事を見つけてようやく納得しました。
JavaScriptの非同期処理を並列処理と勘違いしていませんか?

今回の例で言えば、以下のように Index::parentCallback() の処理が割り込んでしまったのでしょう。

  1. UserSelector.currentUser の値が変更される
  2. 変更イベントを検知して UserSelector::currentUserChanged() の処理が実行される
  3. そのコンテキストで Index::parentCallback() が呼ばれ、処理が実行される
  4. UserSelector.currentUser の変更イベントを検知して Index.user の値が変更される
  5. 変更イベントを検知して Index::userChanged() の処理が実行される

これじゃあ確かに Index::parentCallback() で変更前の値が参照されちゃいますね。

じゃあどうしよう?

単純に Index::userChanged() で処理してあげれば OK です。
値の変更をトリガーにしたいのなら、無理やり callback を渡してあげるなんてことをしないで、 @observable で変更を検知してあげましょう。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした