2015/04/02

【TypeScript】thisの使い方にハマった!thisを保持する3つの方法

photo by Brendio

C#erが、それほど勉強せずにTypeScriptに足を踏み入れたせいで、thisの使い方にハマってしまった。

JavaScriptはちょっと勉強していたので、「thisは今の親オブジェクトを参照する」ということだけは知っていた。しかし、それだけでは認識が甘かったようだ。



サンプル用HTML


<!DOCTYPE html>
  
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <title>TypeScript HTML App</title>
  <script src="app.js"></script>
</head>
<body>
  <h1>TypeScript HTML App</h1>

  <div id="content"></div>
  <button type="button" id="count-up">カウントアップ</button>
  <button type="button" id="count-down">カウントダウン</button>
</body>
</html>

カウントアップ、ダウンのボタンを押すと、カウントが表示されるHTMLを元にTypeScript、JavaScriptのソースコードをまとめていく。


エラーになるケース


一見、どこもおかしくないように見える。
しかし、Counterクラスで使っている「this.count」が実行時に未定義になってしまう。

TypeScriptの場合
class Counter {
  private count: number;

  constructor() {
    this.count = 0;
  }

  countUp() {
    this.count++;
    alert(this.count);
  }

  countDown() {
    this.count--;
    alert(this.count);
  }
}

window.onload = (event) => {
  var counter = new Counter()

  // counterのthisが「button#count-up」になる -> NaN
  document.getElementById('count-up').onclick = counter.countUp;
  // counterのthisが「button#count-down」になる -> NaN
  document.getElementById('count-down').onclick = counter.countDown;
};

コンパイルで生成されたJavaScript
var CountClass = (function () {
  function CountClass() {
    this.count = 0;
  }
  CountClass.prototype.countUp = function () {
    this.count++;
    alert(this.count);
  };
  CountClass.prototype.countDown = function () {
    this.count--;
    alert(this.count);
  };
  return CountClass;
})();
window.onload = function (event) {
  var counter = new CountClass();
  document.getElementById('count-up').onclick = counter.countUp;
  document.getElementById('count-down').onclick = counter.countDown;
};
//# sourceMappingURL=app.js.map

このように、クラスのメソッドをイベントから呼び出したり、コールバックとして使うと、元の「this」の値が失われてしまう。

具体的には、上記の例でいうとcounter.countUpを呼び出したときに、thisの値が「button#count-up」に変わってしまう。
もちろん「button#count-up」なんかに「count」という変数は定義されていないので、未定義エラーになる。



対策1:アロー関数


メソッドをプロパティに置き換え、アロー関数を使って初期化する方法。


TypeScriptの場合
// arrow.ts
class Counter {
  private count: number;

  constructor() {
    this.count = 0;
  }

  countUp = () => {
    this.count++;
    alert(this.count);
  }

  countDown = () => {
    this.count--;
    alert(this.count);
  }
}

window.onload = (event) => {
  var counter = new Counter()

  // counterのthisが「Counterクラス」になる -> this.countが表示される
  document.getElementById('count-up').onclick = counter.countUp;
  document.getElementById('count-down').onclick = counter.countDown;
};

コンパイルで生成されたJavaScript
// arrow.js
var Counter = (function () {
  function Counter() {
    var _this = this;
    this.countUp = function () {
      _this.count++;
      alert(_this.count);
    };
    this.countDown = function () {
      _this.count--;
      alert(_this.count);
    };
    this.count = 0;
  }
  return Counter;
})();
window.onload = function (event) {
  var counter = new Counter();
  document.getElementById('count-up').onclick = counter.countUp;
  document.getElementById('count-down').onclick = counter.countDown;
};
//# sourceMappingURL=app.js.map

コンパイルされたJavaScriptを見ればすぐわかると思うが、thisを「_this」に退避している。
これにより、イベントやコールバックで使われても、Counter.countを参照することができる。


注意点


メソッドをプロパティとして保持するため、インスタンスごとに複製されてしまう。
もしインスタンスが大量に作成される場合は、パフォーマンスに要注意。


対策2:クロージャ


インスタンスメソッドをfunctionで囲み、クロージャにする方法。

TypeScriptの場合
// closure.ts
class Counter {
  private count: number;

  constructor() {
    this.count = 0;
  }

  countUp() {
    this.count++;
    alert(this.count);
  }

  countDown() {
    this.count--;
    alert(this.count);
  }
}

window.onload = (event) => {
  var counter = new Counter()

  // counterのthisが「Counterクラス」になる -> 数値が表示される
  document.getElementById('count-up').onclick = function () { counter.countUp(); }
  document.getElementById('count-down').onclick = function () { counter.countDown(); }
};

コンパイルで生成されたJavaScript
// closure.js
var Counter = (function () {
  function Counter() {
    this.count = 0;
  }
  Counter.prototype.countUp = function () {
    this.count++;
    alert(this.count);
  };
  Counter.prototype.countDown = function () {
    this.count--;
    alert(this.count);
  };
  return Counter;
})();
window.onload = function (event) {
  var counter = new Counter();
  document.getElementById('count-up').onclick = function () {
    counter.countUp();
  };
  document.getElementById('count-down').onclick = function () {
    counter.countDown();
  };
};
//# sourceMappingURL=app.js.map

ネットで調べてこの方法にたどり付いたけど、ウチが知ってるクロージャと違う!
まだ勉強不足なので、functionで囲むと、なぜthisの値が失われないかよくわかっていない。



追記:2015/04/02 21:30
わからないまま放置するのは危険なので調べてみた。

すべての関数に渡されるthisの値は、関数が実行時に呼び出される際のコンテクスト(状況)に依存します。
開眼!JavaScript p.86

イベントに登録されたメソッドのthisは、イベントの発生源のオブジェクト(ここでは「button#count-up」など)を参照してしまう。

ただ、thisがイベントの発生源を参照するのは、イベントハンドラ内(ここでは「onclick」)だけ。

言い換えると、イベントハンドラに直接メソッドを指定しなければ、thisは保持されるということ。

だから以下のように、「インスタンスメソッドを呼ぶ用のメソッド」を用意し、それをイベントハンドラに登録すれば、このthis問題が解決される。
var counter = new Counter();

function onclick_CountUp() {
    counter.CountUp();
}

document.getElementById('count-up').onclick = onclick_CountUp;

JavaScriptには便利な機能があって、もっと簡単に「インスタンスメソッドを呼ぶ用のメソッド」を簡単につくることができる。
それがクロージャ。(→ この対策2となる。)



対策3:bind関数


JavaScriptのbind関数を使う方法。

TypeScriptの場合
// bind.ts
class Counter {
  private count: number;

  constructor() {
    this.count = 0;
  }

  countUp() {
    this.count++;
    alert(this.count);
  }

  countDown() {
    this.count--;
    alert(this.count);
  }
}

window.onload = (event) => {
  var counter = new Counter();

  var countUpHandler = counter.countUp.bind(counter);
  var countDownHandler = counter.countDown.bind(counter);

  // counterのthisが「Counterクラス」になる -> 数値が表示される
  document.getElementById('count-up').onclick = countUpHandler;
  document.getElementById('count-down').onclick = countDownHandler;
};

コンパイルで生成されたJavaScript
// bind.js
var Counter = (function () {
  function Counter() {
    this.count = 0;
  }
  Counter.prototype.countUp = function () {
    this.count++;
    alert(this.count);
  };
  Counter.prototype.countDown = function () {
    this.count--;
    alert(this.count);
  };
  return Counter;
})();
window.onload = function (event) {
    var counter = new Counter();
    var countUpHandler = counter.countUp.bind(counter);
    var countDownHandler = counter.countDown.bind(counter);
    document.getElementById('count-up').onclick = countUpHandler;
    document.getElementById('count-down').onclick = countDownHandler;
};
//# sourceMappingURL=app.js.map

bind関数は、引数をthisキーワードに設定した新しい関数(束縛された関数)を生成する。
それにより、thisの値を束縛(保持)できる。

ただ、ちょっと長くなるのが難点かな。

注意点


bind関数はECMAScript5からサポートされているので、IE8などの古いブラウザでは動作しないので注意。



さいごに


stackoverflowとかいろいろ検索してみたが、ほとんどの場合「対策1:アロー関数」を使っている。
複数インスタンスを作らなければ、一番簡単だからかもしれない。

まだJavaScript・TypeScriptともに未熟なので、正直どれがよいのかわからない。
参考書を読んでも、どれを推奨しているというわけではない。
状況によって使い分ける必要がありそうだ。

もし「こうした方が良いよ」とかありましたら、コメントなりTwitterなりで教えて下さい。


以上

written by @bc_rikko

2 件のコメント :

  1. デコレータを使ってthisを束縛するのが今のところ最もスマートです。

    返信削除
  2. typescriptならブラウザの互換性をあまり気にせずでクラスが使えるというのでJavaやphpの感覚でメソッドを書いたら
    クリックイベントでthisが使えずハマりました。
    stackoverflow見てアロー関数にしたところ動くことは動きましたが理由がわからず混乱していました。
    bc_rikkoさんの記事がわかりやすくてやっとちゃんと理解できました。
    ありがとうございました。

    返信削除