環境

  • TypeScript - 2.0.6
  • Knockout.js - 3.4.0

目次

問題

TypeScriptのクラスでKnockout.jsのViewModelを書くとき、foreachの中でのclickバインディングを使おうとしたらうまく動かなかった。
knockoutjs.com - Creating view models with observables
TypeScript Handbook - Classes

下のようなHTMLを書いて、stringの配列を表示するテーブルを作る。
各行にはDeleteボタンをつけて、クリックで行を削除できるようにする。

html

<!DOCTYPE html>
<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
        <script src="knockout.js"></script>
        <title>Hello Knockout.js</title>
    </head>
    <body>
        <button data-bind="click: reset">Reset</button>
        <table>
            <thead><th>Item</th><th>Delete</th></thead>
            <tbody data-bind="foreach: items">
                <td data-bind="text: $data"></td>
                <td><button data-bind="click: $root.delete">Delete</button></td>
            </tbody>
        </table>
        <script src="js/view1.js"></script>
    </body>
</html>

Knockout.jsのViewModelはTypeScriptのクラスで定義する。

view1.ts

class ViewModel1 {
    public items = ko.observableArray(new Array<string>());

    public reset() {
        this.items.removeAll();
        this.items.push("AAA", "BBB", "CCC", "DDD");
    }

    public delete(item: string) {
        this.items.remove(item);
    }

    constructor() {
        this.reset();
    }
}
ko.applyBindings(new ViewModel1());

これを実行してみて、Deleteボタンをクリックしてもうまく動作しない。
deleteメソッドの中で参照しているthis.itemsがundefinedになっているため、スクリプトエラーになる。
thisはViewModelのオブジェクトでなく、クリックした行のオブジェクト(stringの配列から取り出された要素)となっている。

普通にJavaScriptでKnockout.jsを書くときは、変数selfに一旦thisを保存してから、thisを参照したいところではすべてselfを参照するようにするので、この問題は発生しない。
問題が起きないようにJavaScriptで書くとこんな感じ。
公式ドキュメントのサンプルもだいたいそうなっている。
knockoutjs.com - The "click" binding

function ViewModel1() {
    var self = this;
    self.items = ko.observableArray(new Array());
    self.reset = function () {
        self.items.removeAll();
        self.items.push("AAA", "BBB", "CCC", "DDD");
    };
    self.delete = function (item) {
        self.items.remove(item);
    };

    self.reset();
}
ko.applyBindings(new ViewModel1());

TypeScriptのクラスでメソッドを定義すると自動的にprototypeに追加されてしまうので、同じようなやり方ができない。

いろいろ調べたら、Stack Overflowでこの問題とその解決方法見つかったので、ちょっと試してみた。
TypeScript and Knockout binding to 'this' issue - lambda function needed?

解決方法1:コンストラクタでメソッドにFunctionオブジェクトを代入する

以下のようにコンストラクタの中でdeleteメソッドにFunctionを代入する。こうするとdeleteメソッドの中で参照しているthisは、コンストラクタ実行時のthisになる。
コンパイル後のJavaScriptではthisは「_this」のような変数に保存されてから使われているので、通常のKnockout.jsの書き方と同じような内容になる。

view1.ts

class ViewModel1 {
    public items = ko.observableArray(new Array<string>());

    public reset() {
        this.items.removeAll();
        this.items.push("AAA", "BBB", "CCC", "DDD");
    }

    public delete: (item: string) => void;

    constructor() {
        this.reset();

        this.delete = (item: string) => {
            this.items.remove(item);
        };
    }
}
ko.applyBindings(new ViewModel1());

これが一番基本的なやりかたらしい。
TypeScript的に、クラスの中に存在しないプロパティには代入できないので、代入先のdeleteメソッドはFunction型として定義だけしておく必要がある。
TypeScript Handbook - Function Types

解決方法2:clickバインディングでメソッドの引数に$rootを渡す。

HTML上のforeachの内部では $rootで元のViewModelを参照できる。列挙されるデータは$dataで参照できる。
これをclickバインディングするときに明示的にメソッドに渡すようにする。

HTMLの一部

<tbody data-bind="foreach: items">
    <td data-bind="text: $data"></td>
    <td><button data-bind="click: function(){$root.delete($root,$data)}">Delete</button></td>
</tbody>

deleteメソッド自体も、引数を変えたので変更が必要。
また、HTML側のclickバインディングに値として指定するのはイベントハンドラとなるFunctionオブジェクトなので、上のようにfunction(){}でくくるようにする必要もある。

view1.ts

class ViewModel1 {
    public items = ko.observableArray(new Array<string>());
    
    public reset() {
        this.items.removeAll();
        this.items.push("AAA", "BBB", "CCC", "DDD");
    }

    public delete(self: ViewModel1, item: string) {
        self.items.remove(item);
    }

    constructor() {
        this.reset();
    }
}
ko.applyBindings(new ViewModel1());

解決方法3:Function.prototype.bind()でthisの内容を変更する。

これは初めて知ったけど、Functionオブジェクトにはbindというメソッドがあり、これでFunctionの中で参照されるthisを変えるということができるらしい。
bindメソッドの戻り値は変更されたFunctionオブジェクト。
MDN - Function.prototype.bind()

HTML側のclickバインディングのコードを変更し、deleteメソッドに対してbindメソッドを呼び出して、thisを$rootにする。

HTMLの一部

<tbody data-bind="foreach: items">
    <td data-bind="text: $data"></td>
    <td><button data-bind="click: $root.delete.bind($root)">Delete</button></td>
</tbody>

これでdeleteメソッドのthisはViewModelのオブジェクトになるので、deleteメソッドも最初の書き方でよくなる。

view1.ts

class ViewModel1 {
    public items = ko.observableArray(new Array<string>());
    
    public reset() {
        this.items.removeAll();
        this.items.push("AAA", "BBB", "CCC", "DDD");
    }

    public delete(item: string) {
        this.items.remove(item);
    }

    constructor() {
        this.reset();
    }
}
ko.applyBindings(new ViewModel1());