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

0 件のコメント :

コメントを投稿