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 アプリを作っている時に、値の変更をトリガーにして処理を実行しようとしていたんですが、以下の事象にぶち当たりました。
- Custom Elements で two-way bind している変数の値を書き換える
- 書き換えた直後の処理で、呼び出し元のテンプレートから渡していた callback 関数を呼び出す
- 呼び出し元の callback 関数で、 two-way bind している変数の値を参照したら、なんと書き換える前の値だった!
上記 3 の段階で変更後の値が取れなくて非常に困ってしまったので、それについて調べてみました。
実測してみる
とにもかくにも実測してみました。
画面としては以下。
ユーザをドロップダウンメニューでも切り替えられるし、左右のボタンでも順繰り変更できるやつ。
実測サンプル用に書き換えてありますが、ほとんど社内向けの Web アプリで使うやつと一緒です。
ソースは以下。
まずは呼び出し元となる親テンプレート。
<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>
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);
}
}
次に呼び出し先となる子テンプレート。
<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>
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箇所。
-
Index::userChanged()
… 親テンプレートでuser
の変更を検知した時に呼ばれる -
Index::parentCallback()
… 子テンプレートから callback される -
UserSelector::currentUserChanged()
… 子テンプレートでcurrentUser
の変更を検知した時に呼ばれる
実測の結果は以下の通り。
呼ばれる順序は、必ず child changed
, parent callback
, parent changed
の順。
child changed
は変更後の値が出力されているが、 parent callback
は変更前の値が出力されて、 parent changed
でようやく変更後の値になっていました。
これでは確かにうまく動くはずがありません。
でも、何でこんな結果になってしまったのでしょうか。
どうしてこうなるの?
早速、結論。
イベント発火後の callback は、現在実行中の関数の処理が終わってから実行されるためだからでした。
JavaScript がシングルスレッドで動作していることを知らなくて全然分からなかったんですが、以下の記事を見つけてようやく納得しました。
JavaScriptの非同期処理を並列処理と勘違いしていませんか?
今回の例で言えば、以下のように Index::parentCallback()
の処理が割り込んでしまったのでしょう。
-
UserSelector.currentUser
の値が変更される - 変更イベントを検知して
UserSelector::currentUserChanged()
の処理が実行される - そのコンテキストで
Index::parentCallback()
が呼ばれ、処理が実行される -
UserSelector.currentUser
の変更イベントを検知してIndex.user
の値が変更される - 変更イベントを検知して
Index::userChanged()
の処理が実行される
これじゃあ確かに Index::parentCallback()
で変更前の値が参照されちゃいますね。
じゃあどうしよう?
単純に Index::userChanged()
で処理してあげれば OK です。
値の変更をトリガーにしたいのなら、無理やり callback を渡してあげるなんてことをしないで、 @observable
で変更を検知してあげましょう。