TypeScriptでknockout.jsを使う・その4~コールバック関数内のthisの罠~
前回のclick/eventバインディングで、無事イベント発生時のメソッド呼び出しができるようになりました。
めでたしめでたし!!・・・・・とは行きませんorz
thisの罠
前回のclickバインディングのサンプルを少し修正して、以下のようにforeachでリスト表示をしています。
そして、リストの子要素ではbuttonクリック時に親クラスのaddCountメソッドにバインドしてみます。
こんな画面ですね。
具体的なコードは以下のような感じです。
index.html
/// <reference path="scripts/typings/knockout/knockout.d.ts" /> // リストに格納される型の定義 class Person { constructor(public name: string) { } } class AppViewModel { count: KnockoutObservable<number> = ko.observable<number>(0); list: KnockoutObservableArray<Person>; constructor() { this.list = ko.observableArray<Person>(); this.list.push(new Person("taro")); this.list.push(new Person("yamada")); this.list.push(new Person("hoge")); } addCount() { var current = this.count(); // 現在のカウント数を取得 this.count(current + 1); // カウント数を更新 } } window.onload = () => { var viewModel = new AppViewModel(); ko.applyBindings(viewModel); };
app.ts
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <title>TypeScript HTML App</title> <link rel="stylesheet" href="app.css" type="text/css" /> <script src="Scripts/knockout-3.2.0.js"></script> <script src="app.js"></script> </head> <body> <h2>ボタン押下時のイベント処理</h2> <span data-bind="text: count"></span>回クリックされました。<br /> <ul data-bind="foreach: list"> <li> <strong data-bind="text: name"></strong>さん <!-- foreachバインディングで生成された子要素内で、親のVMのメソッドにバインドしてみる。 --> <button data-bind="click: $parent.addCount">Click</button> </li> </ul> </body> </html>
これを実行してbuttonをクリックすると、以下のように例外が発生します。
countプロパティが存在しない??
デバッガで確認してみると、以下のようになってます。
なんかnameプロパティ持ってるし、、、
イミディエイトウィンドウで、thisの型を見てみると、
AppViewModelではなく、Personクラスのインスタンスとなっています。
JavaScriptのthisの扱い方のせいですね・・・・
(この動作は、これはこれで便利なときもあるようですが。。。)
clickバインドなどをした時のthisの扱い
knockout.jsでは、clickバインディングなどのコールバック関数が呼び出されたときは、呼び出し元のDOM要素に対応するViewModelがthisに割り当てられた状態で実行されます。
なので、コールバック関数の中で「this.○○」とかやると、呼び出し元のコンテキストで○○というプロパティを探すこととなってしまいます。(この場合では、buttonのクリックイベントが発生したDOM要素に対応するプロパティとして、Personクラスのインスタンスを対象に探すことになります。)
その結果、先ほどデバッガで見たように、プロパティがundefinedとなって見えていたのです。
この問題については、Knockout.js日本語ドキュメントの以下のページでも触れられています。
以下のページでは、コールバックで関数が呼ばれたときのthisを束縛する、という方法で対処してます。
http://kojs.sukobuto.com/tips/withTypeScript
ただし、この方法はES5仕様のFunction.bindメソッドを利用して、関数呼び出し時のthis束縛をしています。なので、IE8などの古いブラウザには対応できないので注意が必要です。
TypeScriptならではの回避法
ということで、ここはTypeScriptならではの、アロー関数を使ってこの問題を回避したいと思います。
クラス内のaddCountメソッドを以下のようにアロー関数で定義してみます。
public addCount = () => { var current = this.count(); // 現在のカウント数を取得 this.count(current + 1); // カウント数を更新 }
これで実行してみると、無事addCountメソッド内のthisがAppViewModelクラス自身のインスタンスを指すようになり、無事正しく動作するようになります。
コンパイル結果の確認
VSの定番の拡張機能「Web Essentials」をインストールしておくと、TypeScriptのコードとコンパイル結果のJavaScritpを並べて表示してくれます。
「なぜか意図した動作をしない・・」、という時には、こういう機能を使って、コンパイル結果のJavaScriptも少し見てみると原因発見の助けとなるかと思います。
ここで、問題のaddCountメソッドのそれぞれの定義方法によるコンパイル結果の違いを確認してみましょう
通常の関数定義
普通のfunctionキーワードを使った関数定義をした場合は、以下のようにprototypeに対して関数定義をしていることが分かります。
また、addCountメソッド内では、そのままthisキーワードが出てくるため、コールバック呼び出し時などに、thisが指す物が変わる原因となっていることが分かります。
アロー関数でとしてメンバ関数を定義した時の問題点
このアロー関数を使ったメンバ関数の定義では、以下の2点で問題があります。
- prototypeでの関数定義ではなく、インスタンスのメンバが関数オブジェクトを持つ
⇒各インスタンスが、関数オブジェクトとしてメンバを持つことになるので、大量のインスタンスを作るような場合はメモリ効率が悪そうです。
- 派生クラスでオーバーライドしたときに、基底クラスのメソッドを呼べない。
以下のコードのように、アロー関数で定義したメソッドを派生クラスでオーバーライドしたとき、基底クラスのメソッドをsuperキーワードで呼ぼうとしても、エラーとなってしまいます。
この2点に気を付ければ、ES5の機能を使わずに、TypeScriptの仕様を利用して、コールバック関数内でうまいことthisを扱えるようになりますね。
うまいことprototypeを使って関数定義をしつつ、クラスのインスタンス自身を指すthisを保持するようにコンパイルする方法ないかなぁ、、、