これでできる! クロスブラウザJavaScript入門

第16回JavaScriptのthisとcall

こんにちは、太田です。前々回前回とJavaScriptにおける継承について学習しました。今回はそれに深く関わるthisについて学んでいきます。

JavaScriptのthisはややクセのある動作をするように思えるかもしれませんが、仕組みをしっかり把握すれば実に簡単です。特に重要なのは次の2点です。

  • thisが何を指すかは関数の呼び出し方で決定する
  • thisは関数スコープに存在する特殊な変数である

インスタンスとしてのthis

では、まずはコンストラクタ内でのインスタンスとしてのthisを見てみましょう。

コンストラクタとthis
function A(name){
  this.name = name;
}
A.prototype.getThis=function(){
  return this;
};
var a = new A('aaa');
console.log(a);
console.log(a === a.getThis()); // true

new演算子を使用すると、コンストラクタ関数が呼び出され、その中のthisが戻り値となります。上記コードではnew Aの結果がaに代入されます。ここでは、Aのメソッド(getThis)内のthisはa自身と一致します。つまり、ここでは「this===インスタンス」となっています。

ただし、コンストラクタが必ずthisを返すというわけではありません。

コンストラクタでthisを返さないケース
function AA(name){
  return {name:'bbb'};
}
var aa = new AA('aaa');
console.log(aa);
console.log(aa instanceof AA); // false

このように、コンストラクタ内でオブジェクトを返すと例えnew演算子を使用していたとしてもそのオブジェクト自身が戻り値になります。これでは継承ができないので、あえて戻り値にオブジェクトを指定するメリットはないでしょう。なお、オブジェクト以外、つまりプリミティブ値をreturnした場合はそのプリミティブ値は無視され、thisが返されます。new演算子の返り値は必ずオブジェクトです。いずれにしても、コンストラクタではreturnしないほうがよいという点は覚えておきましょう。

thisが指すもの

さて、thisがインスタンス以外になるケースはまだまだあります。

  • メソッドを変数に代入してから呼び出した場合
  • イベントとして呼ばれた場合
  • setTimeout、setIntervalで呼ばれた場合

具体的には下記のようなケースです。

メソッドを変数に代入するケース
function A(name){
  this.name = name;
}
A.prototype.logThis=function(){
  console.log(this);
};
var a = new A('aaa');
var a_logThis = a.logThis;
a_logThis(); // Global
setTimeout(a.logThis, 0); // Global

実はJavaScriptにおいてthisは非常に簡単なルールで決定されます。そのルールとは、⁠呼び出した関数の手前(ドットの前)のオブジェクトがthisになる。ただし、手前にドットがない場合はグローバルオブジェクトがthisになる」というものです。

つまり、thisはメソッドの定義時ではなく、それを呼び出すときに決定されます。関数を呼び出すたびにthisが変わる可能性がある、ということです。

callとapply

さて、このthisを呼び出す側からより自由にコントロールする方法があります。関数のプロトタイプがもつメソッド(すなわちすべての関数オブジェクトが持つ関数)のcallとapplyです。JavaScriptでは関数が(ファーストクラス)オブジェクトであるという説明は何度かしてきましたが、オブジェクトだから関数自身もメソッドを持つことができます。関数が関数を持っているというと少々ややこしいですが、オブジェクトがメソッドを持っているだけに過ぎません。

callによるthisの指定
function A(name){
  this.name = name;
}
A.prototype.logThis=function(){
  console.log(this);
};
var a = new A('aaa');
var a_logThis = a.logThis;
a_logThis.call(a); // {name:'aaa'}

callは第1引数にthisとなるオブジェクトを、第2引数以降は呼び出される関数の引数に渡されます。

applyは第1引数にthisとなるオブジェクトを、第2引数には配列を渡し、その配列が呼び出される関数の引数に展開されます。

callとapply
var log = function(){
  console.log(this);
  console.log(arguments);
};
log.call({id:1}, 2, 3);
// {id:1} , [2, 3]
log.apply({id:2}, [2, 3]);
// {id:2} , [2, 3]

callとapplyの違いは引数を個別に指定するか、配列でまとめて指定できるかの違いです。引数の数が可変でもよいapplyのほうが柔軟なので、applyだけでも事は足ります。

ただ、callとapplyでベンチマークを取るとcallのほうが軒並み高速です。もちろん数回程度の呼び出しでは1msほどの差にもならないので、そこまで気にするほどのことではありません。

thisとarguments

ところで、関数を呼び出すときに決定されるモノといえば、引数つまりargumentsがあります。実はこのargumentsとthisは非常によく似ています。argumentsは引数ですから、関数を呼び出すたびに値が変わるのは当然のことですが、それと一緒に「thisも関数ごとにそれぞれのthisを持っている」ということです。

クロージャについて説明した際にJavaScriptの変数は関数単位であると説明しましたが、thisやargumentsも変数と同じということです。そう考えるとJavaScript(ECMAScript)の仕様で一貫している部分が見えてくると思います。

thisとイベント

さて、最後に少し実践的な内容に触れておきましょう。ごくシンプルなツールチップを実装してみます。

span要素にdata-cbjs-tooltipというカスタム属性をつけておくと、そのspan要素にマウスオーバーした際にその属性の中身を表示するというものです。

シンプルなTooltipの実装
var addEvent = document.addEventListener ? 
  function (node, type, listener){
    node.addEventListener(type, listener, false);
  } :
  function (node, type, listener){
    node.attachEvent('on' + type, listener);
  };
var TEXT = 'textContent' in document.documentElement ?
           'textContent' : 'innerText';

function Tooltip(element, text){
  this.element = element;
  this.text = text;
  // thisを変数に代入
  var that = this;
  addEvent(element, 'mouseover', function(evt){
   //ここはfunctionの中なので、thisが変わっている

   // clientXで表示領域内でのマウス位置を取得
    var x = evt.clientX;
    var y = evt.clientY;
    // クロージャのthatからshowを呼び出す
    that.show(x, y);
  });
  addEvent(element, 'mouseout', function(evt){
   //ここはfunctionの中なので、thisが変わっている

    // クロージャのthatからhideを呼び出す
    that.hide();
  });
}

Tooltip.prototype = {
  show: function(x, y){
    Tooltip.tip[TEXT] = this.text;
    Tooltip.tip.style.left = x + 10 + 'px';
    Tooltip.tip.style.top = y + 10 + 'px';
    Tooltip.tip.style.display = 'block';
  },
  hide: function(){
    Tooltip.tip.style.display = 'none';
  }
};
addEvent(window, 'load', function(){
  // 入れ物となる要素を作る
  Tooltip.tip = document.createElement('div');
  Tooltip.tip.id = 'cbjs-tooltip-element';
  document.body.appendChild(Tooltip.tip);

  var spans = document.getElementsByTagName('span');
  for (var i = 0, len = spans.length;i < len; i++){
    var span = spans[i];
    // data-cbjs-tooltip属性の中身を取り出す
    var tip = span.getAttribute('data-cbjs-tooltip');
    if (tip) {
      new Tooltip(span, tip);
    }
  }
});

ポイントはTooltipの中の次の部分です。

イベントとthis
function Tooltip(element, text){
  // thisを変数に代入
  var that = this;
  addEvent(element, 'mouseout', function(evt){
   //ここはfunctionの中なので、thisが変わっている

    // クロージャのthatからhideを呼び出す
    that.hide();
  });
}

addEventの引数に渡す関数の中ではthisもargumentsも違うものに置き換わっているので、インスタンスが行方不明になってしまいます。そこで、thatという変数にthisを代入しておき、そのthatをインスタンスとして用います。もちろん、変数の名前は何でもよいので、thatのほかselfなどが用いられる傾向があります。

ここはthisとクロージャの両方が関わってきているので、少々難しく感じるかもしれません。しかし、thisはthis、クロージャはクロージャで分けて捉えれば、決して難しいことをしているわけではないことに気がつくと思います。

さて、実際にツールチップを表示するためのCSSも見ていきましょう。

Tooltip用CSS
#cbjs-tooltip-element{
  position:fixed;
  display:none;
  background:#fff;
  color:#000;
  border:1px solid #0080FF;
  padding:10px;
  margin:0px;
  text-align:left;
  line-height:1.2;
}

こちらもシンプルですね。ただ、position:fixed;はIE 6が対応していません。よってIE 6対応が必要になります。

CSSとJavaScript両方が関係するので、今回は条件付きコメントを使用してみます。条件付きコメントとは、 <!--[if IE]> IE用コード <![endif]--> のような書式のコメントをHTML中に書くとIEだけでコメント内のHTMLを解釈してくれるというテクニックです。

if の部分には簡単な条件を指定することができ、if IE 6とすればIE 6だけに解釈させることもできます。

IE 6用のCSSとJavaScript
<!--[if IE 6]>
<style>
#cbjs-tooltip-element{
  position:absolute;
}
</style>
<script>
(function(proto){
  var root = document.compatMode === 'BackCompat' ?
             document.body : document.documentElement;
  var show  = proto.show;
  proto.show = function(x, y){
    x += root.scrollLeft;
    y += root.scrollTop;
    show.call(this, x, y);
  };
})(Tooltip.prototype)
</script>
<![endif]-->

IE 6の場合はpositionをabsoluteにして、showメソッドをオーバーライドしてスクロール位置を加味してから本来のshowメソッドを呼び出すようにしています。callを使って、thisの中身が変わらないように配慮している点がポイントです。

なお、スクロール位置の取得には互換モードなら document.body.scrollTop を、標準モードでは document.documentElement.scrollTop を使用しています。WebKit系ブラウザ(SafariやChrome)以外ではこの方法でスクロール量を取得できます。WebKitではモードに関係なくdocument.documentElement.scrollTopは常に0になり、document.body.scrollTop からスクロール量を取得できます。

なお、スクロール量の取得方法としてはほかに window.pageXOffset, window.pageYOffset などがあります。こちらはIE 6~8以外のブラウザでスクロール量が取得できます。スクロール量はややこしいですね。今回はここまでで、改めてまとめたいと思います。

  • ツールチップ デモ: A JS

まとめ

3回に分けてプロトタイプベースの継承とthisについて学びました。継承はクロージャとも関わってくるため、初級から中級へステップアップできるかどうかの最難関かもしれません。逆にここをしっかり抑えることができれば、JavaScriptの全体像が見えてくるはずです。

次回はアニメーションの基礎とCSSに関係した部分について解説します。

おすすめ記事

記事・ニュース一覧