TypeScript最大のハマりポイント:this

TypeScriptは、全体としては整然として、素直に動作します。しかし、たまに意外な落とし穴があります(自分もはまったのですが、その時のコードが残ってませんでした。すみません)。

「なぜか型がundefinedになっている」などの問題が起きるようなら、次のTypeScript公式の文書を参考にするといいでしょう。

github.com

重要なのは「メソッドを参照だけして呼び出しが後、という形式はヤバい」ということです。それをアロー関数などで回避すれば、意味不明のトラブルに見舞われる頻度は下がると思います。

なお、参考として、以下に2016年12月1日版の原文の簡易版の和訳を示します。

(※注意)訳質が怪しい部分があります。誤りに気付いた場合、コメントなどいただければ幸いです。

TypeScriptにおける「this」

導入

thisキーワードは、JavaScript(および、その帰結としてTypeScript)では、他の多くの言語の場合とは動作が異なります。このことは、特にthisの動作について確たる直観をもつ言語の利用者からみると、とても奇妙に思えるかもしれません。

このページでは、TypeScriptにおけるthisにまつわる問題について、いかに理解、原因解明するかを解説しようと思います。また、この問題についての解決法と、それにまつわるトレードオフについても記述します。

典型的な症状とリスク要因

thisのコンテクストが失われた場合の典型的な症状には、次のものが含まれます。

  • クラス・フィールド(this.foo)が(他の何らかの値のはずが)undefinedになっている
  • thisの参照先が(何らかのクラス・インスタンスのはずが)グローバルなwindowオブジェクトである(非strictモードの場合)
  • thisの参照先が(何らかのクラス・インスタンスのはずが)undefinedである(strictモードの場合)
  • クラス・メソッド(this.doBar())の呼び出しが、次のようなメッセージのエラーになっている:"TypeError: undefined is not a function"、"Object doesn't support property or method 'doBar'"、"this.doBar is not a function"

これらの症状は、しばしば特定のコーディングパターンで発生します。

  • イベント・リスナ 例:window.addEventListener('click', myClass.doThing);
  • Promiseの解決 例:myPromise.then(myClass.theNextThing);
  • ライブラリのイベント・コールバック 例:$(document).ready(myClass.start);
  • 関数のコールバック 例:someArray.map(myClass.convert)
  • ViewModelタイプのライブラリのクラス 例:<div data-bind="click: myClass.doSomething">
  • "option bags"中の関数 例:$.ajax(url, { success: myClass.handleData })

JavaScriptthisとは?

多数の記事が、JavaScriptにおけるthisの害について書かれてきました。それについては「thisキーワード(英語)」「JavaScriptの"this"を明確に理解し、マスターしよう(英語)」「thisの全て(英語・リンク切れ)」あたりを参照してください。

JavaScriptで関数が呼び出される時、次の手順で追跡すれば、thisが何になるか決定できます(以下は優先度が高いものから並べてあります)。

  • 関数がfunction#bindの結果として呼び出される場合、thisbindの引数
  • 関数がfoo.func()といった形式で呼び出される場合、thisfoo
  • strictモードなら、thisundefined
  • さもなくば、thisはグローバル・オブジェクト(ブラウザならwindow

これらのルールの結果は、時に直観に反する挙動となります。例えば:

class Foo {
  x = 3;
  print() {
    console.log('x is ' + this.x);
  }
}

var f = new Foo();
f.print(); // 出力は 'x is 3'(想定通り)

// オブジェクト・リテラル中でクラス・メソッドを使う場合
var z = { x: 10, p: f.print };
z.p(); // 出力は 'x is 10'

var p = z.p;
p(); // 出力は 'x is undefined'

thisにまつわる危険信号

最大の危険信号として頭に入れておいてほしいのは、「クラス・メソッドを使っておいて、即座に呼び出さないこと」です。もしメソッドを参照するだけで、同一の式の中で呼び出していない場合、thisの参照先は不適切になっている可能性があります。

例。

var x = new MyObject();
x.printThing(); // 安全。メソッド呼び出しが参照箇所で行われている。

var y = x.printThing; // 危険。y()の呼び出し時、'this'は想定外の可能性あり。

window.addEventListener('click', x.printThing, 10); // 危険。参照箇所と呼び出し箇所が異なる。

window.addEventListener('click', () => x.printThing(), 10); // 安全。メソッド呼び出しは同じ式の中。

修正方法

thisのコンテキストを適切に維持する方法は、いくつかあります。

インスタンス関数を使う

プロトタイプ・メソッド(JavaScriptのメソッドの扱いのデフォルト)の代わりに、インスタンス・アロー関数をクラス・メンバの定義に利用できます。

class MyClass {
    private status = "blah";
    
    public run = () => { // <-- この文法に注意
        alert(this.status);
    }
}
var x = new MyClass();
$(document).ready(x.run); // 安全。'run'中の'this'は常に想定通り。
  • (長点または欠点)この方法は、新たなクロージャを、クラスのインスタンスのメソッドごとに生成します。通常のメソッド呼び出しにはオーバーキルな手法です。しかし、コールバックでこの方法を多用する場合、クラス・インスタンスは1回特定のthisコンテキストをキャプチャするだけで済み、呼び出しごとにクロージャを生成する必要がなくなるからです。
  • (長点)外部で呼び出す側にとっては、thisコンテキストの喪失がありえなくなります。
  • (長点)TypeScriptで使うと、タイプ・セーフなコードとなります。
  • (長点)関数にパラメータが必要な場合でも、特別な措置は不要です。
  • (欠点)派生クラスの場合、superを使っても基底クラスのメソッドが呼び出せません。
  • (欠点)厳格な意味論での「事前束縛済み(pre-bound)」メソッドと、そうでないメソッドが生じることで、タイプ・セーフでない契約関係が、クラスとconsumer間に加わってしまいます([訳注]この欠点の訳質は他より低いと思います)。

ローカル・ファット・アロー

TypeScript向け(下のコードは説明用にダミーのパラメータを含みます)。

var x = new SomeClass();
someCallback((n, m) => x.doSomething(n, m));
  • (長点または欠点)メモリとパフォーマンスのトレードオフ関係については、前項目(インスタンス・アロー関数)の逆になります。
  • (長点)TypeScriptでは、この方法は100%タイプ・セーフです。
  • (長点)ECMAScript 3でも利用できます。
  • (長点)インスタンスの名前は1回入力するだけで済みます。
  • (欠点)パラメータの名前は2回入力する必要があります。
  • (欠点)可変長引数は使えません。

Function.bind

var x = new SomeClass();
// 安全。function.bindで関数を呼び出す場合、常に'this'を維持します。
window.setTimeout(x.someMethod.bind(x), 100);
  • (長点または欠点)メモリとパフォーマンスのトレードオフ関係については、前項目(インスタンス・アロー関数)の逆になります。
  • (長点)関数にパラメータが必要な場合でも、特別な措置は不要です。
  • (欠点)TypeScriptの場合、現状では、型安全性は確保されません。
  • (欠点)ECMAScript 5以降でしか使えません。
  • (欠点)インスタンス名を2回入力する必要があります。