🐕

JS基礎いろいろーthisキーワード

2022/04/04に公開約21,100字

全てはこの問題から始まっています。ブラウザー環境だと想定し、出力結果は何でしょうか。

// ブラウザー
var a = 1
;(function() {
  console.log(a + this.a)
  var a = '2'
  console.log(a + this.a)
})()

var name = 1
;(function() {
  console.log(name + this.name)
  var name = '2'
  console.log(name + this.name)
})()

thisの謎

thisキーワードが、JSという言語の中で非常に複雑な概念の一つ(の一つ、他にも色々とある)。経験者でも少しでも注意しないと間違ってしまうところです。thisは一体何を指しているのか、なぜこんなに混乱をもたらすか、今回は少し研究してみたいと思います。

内容について主にこちらの本を参考にしています。

詳細を見る前に、まずはJSのthisに対するいくつかよくある誤解を先に説明します。

誤解その一:インスタンスを指している

上記の例を見ると、一般的なOOP言語のように、selfとかthisとかで、インスタンスを指しているので、渡されたコンテキスト(実行対象)もインスタンスなのではないかと。

これがJSのthisに対するよくある誤解の一つです。JSは本質的にOOP言語ではありません(機能は実現できそうですが)。classなどもES6以降で追加されたシンタクスシュガー(syntax sugar)に過ぎず、結局オブジェクトとなっています。インスタンスを指しているのは、表面的にそう見えるだけです。

class Car {
  constructor(maker, model) {
    this.maker = maker
    this.model = model
  }
  describe() {
    return this.maker + ' ' + this.model
  }
}

let myCar = new Car('Honda', 'Civic')

// classが導入される前はコンストラクタ関数という方法で擬似的にクラスを作っています
function Car(maker, model) {
  this.maker = maker
  this.model = model
  // こちらは機能しますが毎回newでオブジェクトを作るときにこの関数を作り直しています
  // this.describe = function() {
  //   return this.maker + ' ' + this.model
  // }
}
// classで定義されたメソッドは実際にCarのプロトタイプに追加されています
Car.prototype.describe = function() {
  return this.maker + ' ' + this.model
}
var myCar = new Car('Honda', 'Civic')

上記の例のように、classというキーワードがJS言語設計当初からついているわけではありません。それまでに、擬似的にクラス・インスタンスを実現するには、関数オブジェクトで行われています。JSのclassは結局、関数オブジェクトに過ぎません。

そのため、thisもインスタンスを指す、というようなことは誤解です。そもそも、OOPで一般的に言われるinstanceという概念は適応できないからです。なぜインスタンスに見えるかは、後のnew演算子のところで説明します。

誤解その二:実行関数自身を指している

JSのすべての関数がオブジェクトです(もちろん、関数以外もすべてオブジェクト)。インスタンスという概念自体が適応できなければ、thisは実行する関数を指しているのではないかと、推測もできます。

これも誤解です。次の例を見ると:

function foo(num) {
  console.log('foo: ' + num)
  return this.count++
}

foo.count = 0
var i
for (i = 0; i < 5; i++) {
  foo(i)
}

console.log(foo.count) // 0

関数オブジェクトfooに、属性countを0に設定して、fooを5回実行します。そしてfoo.countを参照して、fooが実行された回数を出力します。すると、foo.countは0になります。もしthis.countfoo.countに修正すると、結果は正しくなります。つまり、thisは関数オブジェクト自身を指しているわけではありません。

誤解その三:実行関数のスコープを指している

これは結論から言うとケースバイケースですが、根本的に言えば、「スコープ」に指すことが不可能です。

例えば:

function foo() {
  var a = 2
  console.log(this.bar) // ここは一応barを呼び出すことが可能
  this.bar()
}

function bar() {
  console.log(this.a) // ここのthisを呼び出されたfooの関数のスコープへ指すことが不可能
}

foo() 
// function bar() {
// console.log(this.a)
// }
// undefined

foo関数のスコープには、barが存在するため、this.barbar関数を指しています。this.bar()で実行されるとき、スコープがfoo関数となったため、this.afoo関数内のaになるはずですが、結果はundefinedとなります。

console.log(this.bar)だけを見ると、スコープじゃないかと言う錯覚がありますね。ただ実際使用中は、同じスコープにある関数を呼び出すときに、thisをつけず、関数の名前そのままで呼び出すのが正しいやり方です。

ならthisは何を指しているのか

thisの指しているものは動的に変化します。関数の定義場所と関係なく、実行方式と深く関わっており、指向は一定ではありません。関数が呼び出されるときに、一つ実行コンテキスト(execution context)が作られます。そのコンテキストには、関数実行のコールスタック、関数実行の方式(how)、関数の引数などの情報が含まれています。その中に、関数が実行された時点でthisが指すものとの情報も存在します。

thisを理解する第一歩として、関数の実行場所(call-site)が鍵となる、ということを先に念頭におきましょう。

thisの指向のケース

関数の実行場所(call-site)

関数の実行場所というのは、関数の定義(declared)された場所とは違い、呼び出される時の場所です。

実際のプログラムでは、関数の呼び出しは複雑な環境になっているので、どの関数がどの関数の中で呼び出されたのかがわかりにくいことがあります。少し専門用語的に言えば、コールスタックで実行されている関数の、一個前に実行された関数が、実行中の関数の実行場所となります。具体例を見た方がわかりやすいかもしれません:

function baz() {
  // コールスタック: `baz` 
  // コールサイト: global scope
  console.log( "baz" ); 
  bar(); // <-- `bar`のコールサイト: baz
}

function bar() {
  // コールスタック: `baz` -> `bar` 
  // コールサイト: `baz` 関数内
  console.log( "bar" ); 
  foo(); // <-- `foo`のコールサイト: bar
}

function foo() {
  // コールスタック: `baz` -> `bar` -> `foo` 
  // コールサイト: `bar` 関数内
  console.log( "foo" );
}

baz(); // <-- `baz`のコールサイト: global scope

こちらの例をvscodeのデバグモードで確認することが可能です。

最初のbaz実行時のコールスタックに、<anonymous>と書いてありますが、これはこのファイルを実行する名前のない関数となります。この匿名関数が、bazの実行場所となります。次にbazの中でbarが実行し、スタックにbazが追加されます。つまり、barの実行場所はbazです。他も同様です。

thisのデフォルト値

実行の場所をわかったら、thisの値はどうなるのかをデバグツールで確認することも可能です。

baz実行時のthisは、Objectとなっていますが、その後の関数はすべてglobalとなっています。

this:Objectの状況について、vscodeのデバグツールがnode.js環境となっていますので、ファイルで実行する場合、ファイル(モジュール)内のthisglobalではなく、{}となります。モジュールの中でグローバルオブジェクトへアクセスするには、他にglobalThisとの変数(ブラウザーとNode.js共通)があります。ブラウザー環境では、bazのところのthiswindowとなります。

上記の例を見ると、関数の中でthisを使うときは、thisglobalであることを確認することができます。

ならglobalとは何か。 MDN によると:

グローバルオブジェクトは、グローバルスコープ上に常時存在するオブジェクトです。

  • ウェブブラウザーでは、明示的にバックグランドタスクとして起動されるコードを除き、 Window がグローバルオブジェクトになります。ウェブにおける JavaScript コードのほとんどはこのケースに該当します。
  • Worker 内で実行されるコードでは WorkerGlobalScope オブジェクトがグローバルオブジェクトになります。
  • Node.js で実行されるスクリプトの場合、 global と呼ばれるオブジェクトがグローバルオブジェクトになります。

要するに、jsの実行環境の中で存在する、どこからもアクセス可能(=グローバル)な変数、関数、属性などを持っているオブジェクトです。例えば、ブラウザーだと、setTimeoutclearTimeoutsetIntervalclearIntervalconsoleなどがグローバルオブジェクトに含まれています。これらの関数は、どこからでも呼び出すことができます。vscodeのデバグ環境はNodejsなので、ここのthiswindowではなく、globalとなっています。

話に戻りますが、関数を呼び出すときに、特別な事情がなければ、関数内のthisは常にグローバルオブジェクト(具体的はjs環境による)を指しています。この状況を一番普遍的で、デフォルトのケースだと認識して良いでしょう。

strict mode

上記のデフォルトケースには、例外があります。strict modeを使うと、デフォルトのthisバイディングが禁止されるため、グローバルオブジェクトへ指すことができず、undefinedとなります。

function baz() {
  "use strict";
  console.log( this.a ); 
}
var a = 2
baz() // Uncaught TypeError TypeError: Cannot read properties of undefined (reading 'a')

ただ、注意したいのは、strict modeの制限というのは、そのスコープ内しか効きません。例えば:

function baz() {
  console.log( this.a ); 
}
var a = 2
;(function () {
  'use strict'
  console.log('this in strict mode', this) // undefined
  baz() // 2
})()

iife関数内部では、strict modeが有効になっているため、thisundefinedとなります。ただ、その外部ではstrict modeが無効になっているため、thisはグローバルオブジェクトを指しており、this.aが2となります。

通常ではstrict modeとノーマルモードの混在は避けるべきですが、サードパーティのパッケージとかを使うときに自分でコントロールできないので、ここの違いも注意する必要があります。

暗黙的なthisバイディング(implicit binding)

デフォルトのthisstrict modeの影響が分かりましたが、実際に関数そのまま呼び出すだけではなく、オブジェクトのメソッドとして呼び出すこともよくあります。もちろん、クラス・インスタンスのメソッドとして呼び出すケースも根本的に同じです。

これはまさにthisがインスタンスを指す誤解を招くケースですが、まずは次の例を:

var obj = {
  a: 2,
  b: function () {
    console.log( this.a );
  }
}
obj.b() // 2

デバグツールを使ってみてみると:

bメソッドを呼び出すときに、thisobjを指していることがわかります。これは、thisバイディングのもう一つよくみられるパターン、つまりオブジェクトの属性として呼び出されたときに、そのオブジェクトへ指すことです。

ただ、このthisは、複数階層のオブジェクトケースに、一番近い層のオブジェクトを指します:

function b () {
  console.log( this.a );
}

var obj2 = {
  a: 2,
  b: b
}

var obj1 = {
  a: 20,
  obj2: obj2,
  b: b
}

var obj3 = {
  c: 0,
  obj1: obj1
}
obj1.b() // 20 -> b()のthisがobj1を指す
obj1.obj2.b() // 2 -> b()のthisがobj2を指す
obj3.obj1.b() // 20 -> b()のthisがobj1を指す

上記の例のように、いずれもメソッド実行直前の.で繋いでいるオブジェクトがthisとなります。

暗黙的なthisバイディングの喪失(lost)

これまでの例を見ると、オブジェクトのメソッドとして呼び出すときに、その呼び出すオブジェクトを指すことから見ると、OOPのインスタンスと非常に似ています。ただ、次のケースはこの表面的な類似性を覆します。

function b () {
  console.log( this.a );
}

var obj2 = {
  a: 2,
  b: b
}

var c = obj.b // メソッドbを、cへ付与

var a = 20

c() // 20

オブジェクトはレファレンスで値を渡しているので、cはもちろん、obj.bと同じ関数オブジェクトを指しています。しかし実行するときに、出力が2ではなく、20となりました。

また、関数を引数として他の関数に渡す時も、同じく、thisのバイディングが変わることがあります:

function b () {
  console.log( this.a );
}

function c ( fn ) {
  fn() // fnのコールサイトがcとなり、ここの`this`がglobalかundefined(strict mode)になる
}

var obj = {
  a: 2,
  b: b
}

var a = 20

c(obj.b) // 20

これは、デフォルトの関数実行ケースと全く同じパターンになってしまい、cを実行するときに、thisglobalを指していることになります。つまり、オブジェクトのメソッドとして実行されるときに適応された、暗黙的なthisバイディングが失っています。

なので、setTimeoutとかのように、コールバックを渡すときに、thisが変なところに行ってしまう落とし穴があります。これを治す方法はいくつかありますが、この節の例を見ると、インスタンスを指してないことが理解できるでしょう。

明示的なthisバイディング(explicit binding)

上記の問題を治すには、要するに、thisを明示的に指定することで、デフォルトのルール(グローバルオブジェクトまたはundefined)に戻すことを防ぐことです。jsの関数オブジェクトには、共通するメソッドが存在し、その中でこの問題を解決する、callapplyメソッドを使うことで、thisを明示的に指定します。

let obj = {
  name: 'John',
  greet: function () {
    console.log(this.name + ' said hi');
  }
}

obj.greet.call({name: 'Jack'}); // Jack said hi

callapplyは基本的に一緒ですが、違いといえば、callが任意数の引数を受け入れることができ、applyは配列を受け入れることができます。上記の例では、引数として{name: 'Jack'}を渡すときに、objthisを、{name: 'Jack'}thisに紐付けました。

a.call( b ) // => aのthisをbにバインドする

ただ、callapplyのバイディングは、変えられることができません:

function b () {
  console.log( this.a );
}

var obj = {
  a: 2,
}

var a = 20

var c = function () {
  b.call( obj )
}

c() // 2
setTimeout(c, 1000) // 2
c.call(window) // 2

そのため、このようなバイディングを、ハードバイディング(hard binding)とも呼びます。

このハードバイディングを実現するためのヘルパー関数も作られます:

function bind ( fn, context ) {
  return function () {
    return fn.apply( context, arguments ) // argumentsはこの関数に渡されたすべての引数の配列
  }
}

function b (num) {
  console.log( this.a + num );
}

var obj = {
  a: 2,
}

var a = 20

var c = bind(b, obj)
var d = c(10)
console.log(d) // 12

このパターンがよく使われるため、ES5には関数オブジェクトにbindメソッドが導入されました。なので、bindを使うバイディングも一緒で、ハードバイディングとなります。使い方もヘルパー関数とほぼ一緒です。

// ...

var c = b.bind(obj)
var d = c(10) 
console.log(d) // 12

また、JS内蔵の一部の関数にも、このようなハードバイディングの対象をオプショナル引数として受け入れることができます。例えば、forEachの定義を見ると:

// Arrow function
forEach((element) => { /* ... */ } )
forEach((element, index) => { /* ... */ } )
forEach((element, index, array) => { /* ... */ } )

// Callback function
forEach(callbackFn)
forEach(callbackFn, thisArg)

// Inline callback function
forEach(function(element) { /* ... */ })
forEach(function(element, index) { /* ... */ })
forEach(function(element, index, array){ /* ... */ })
forEach(function(element, index, array) { /* ... */ }, thisArg)

この中のthisArgは、コールバック関数内のthisを指定のオブジェクトとハードバイドすることです。

function foo(el) {
  console.log(el, this.name);
}

var obj = {
  name: 'hoge'
}

[1,2,3].forEach(foo, obj) // 1 hoge 2 hoge 3 hoge

本質的には、上記のcall/applyによるハードバイディングと一緒なので、開発者の手間を少し省けることができます。

newキーワードバイディング

このnewの存在も一つの理由ですが、よくJSは一般的なOOP言語と同じだと誤解されることがあります。

function Car (maker, model) {
  this.maker = maker
  this.model = model
}
var myCar = new Car('Honda', 'Civic')

jsでは関数を呼び出す方法として、一般的に関数名の後ろに()をつけて実行しますが、newで呼び出すことも可能です。すべての関数はnewで実行することが可能ですが、通常newで実行する関数をパスカルケースで書き、コンストラクター関数と呼びます。newで関数を実行するときに、次のような処理が行われます(MDNにも参照)。

  1. 一つ新しいオブジェクトが作られる
  2. 作られたオブジェクトに、コンストラクター関数のプロトタイプとリンク付ける
  3. 新しく作られたこのオブジェクトが、thisとしてバイドされる
  4. 関数に他のリターン値が指定されていない限り、newで実行された関数は、自動で新しく作られたオブジェクトをリターンする

Carの例でいえば、newで実行するときに、一つ新しいオブジェクトを作り、そのオブジェクトをthisとしてバイドすることで、それで「インスタンスを指し」ているように見えます。

function Car1 (maker, model) {
  this.maker = maker
  this.model = model
  // リターン値がないが、`this`を返す、`this`がグローバルではなく、{maker, model}オブジェクトとなる
}

function Car2 (maker, model) {
  return {maker, model}
}

var myCar1 = new Car1('Honda', 'Civic')
var myCar2 = Car2('Nissan', 'Selena')

var myCar3 = Car1('Honda', 'Vezel')
var myCar4 = new Car2('Toyota', 'Corolla')

myCar4.model = 'Harrier'
console.log(myCar4.model)

console.log(myCar1) // {maker: 'Honda', model: 'Civic'}
console.log(myCar2) // {maker: 'Nissan', model: 'Selena'}
console.log(myCar3) // undefined => `new`演算子使用されていない、かつリターン値がないためundefinedとなる
console.log(myCar4) // {maker: 'Toyota', model: 'Corolla'}

優先順位

これまでthisのバイドについていくつかのルールをみてきました:

  • デフォルトthisバイディング:グローバルオブジェクトまたはundefined(strict mode)
  • 暗黙的なthisバイディング:メソッドとして実行され、呼び出されたオブジェクトがthisとなる
  • 明示的なthisバイディング:bindまたはapply/callで指定したオブジェクトがthisとなる
  • newバイディング:newで作られた新しいオブジェクトがthisとなる

どちらの優先順位が高いかが次の問題となります。

まずデフォルトはその名前通り、他に特に指定がなければのフォールバックとなるため、優先順位が一番低いのです。

次に暗黙的なthisバイディングと明示的なthisバイディングですが、名前でなんとなく伝わるかもしれません。一応例で見てみます:

function foo() { 
  console.log( this.a )
}

var obj1 = { a: 1, foo: foo }

var obj2 = { a: 2, foo: foo }

obj1.foo() // 1 
obj2.foo() // 2

obj1.foo.call( obj2 ) // 2 
obj2.foo.call( obj1 ) // 1

この通り、callで明示的にバイドするときは優先されます。

次はnewバイディングと、暗黙的なthisバイディングとの順位です:

function Car (maker, model) {
  this.maker = maker
  this.model = model
}

var car1 = {
  getCar: Car
}

var car2 = {}

car1.getCar('Honda', 'Civic') 
console.log(car1.model) // Civic

car1.getCar.call(car2, 'Honda', 'Vezel')
console.log(car2.model) // Vezel

var car3 = new car1.getCar('Nissan', 'Selena')
console.log(car1.model) // Civic
console.log(car3.model) // Selena

つまり、newバイディングは暗黙的なthisバイディングより優先順位が高いのです。

最後は明示的なthisバイディングと、newバイディングの順位です:

function Car (maker, model) {
  this.maker = maker
  this.model = model
}

var car1 = { }

var car2 = Car.bind(car1)

car2('Honda', 'Civic')
console.log(car1.model) // Civic

var car3 = new car2('Nissan', 'Selena')
console.log(car1.model) // Civic
console.log(car3.model) // Selena

これを見る限り、car2の時点でcar1へハードバイドしているのですが、newで呼び出すときに、thiscar1ではなく、なんと変えられました。つまり、newの優先順位は一番高いことです。

MDNの説明によると:

バインドされた関数は new 演算子でも生成されます。これを行うとターゲット関数が代わりに生成されたようになります。与えられた this の値は無視され、追加された引数はエミュレートされた関数に提供されます。

bind関数の具体的な実現について、我々が先ほど例で実装したものよりだいぶ複雑です。もしbindが先ほどの例のように簡単なものだと、確かにnewthisを変えることができません。ただ、MDNの説明のように、newを使うときは、ハードバンドされたthisが無視され、newで生成されたオブジェクトがthisとして入れ替えられます。

ちなみに、本当のbind実装は下記のようになるようですが、筆者自身もまだ理解が難しいので、今回は深入りしません。

Function.prototype.bind = function (oThis) {

  if ( typeof this !== "function") {
    throw new TypeError( "Function.prototype.bind - what is trying to be bound is not callable")
  }

  var aArgs = Array.prototype.slice.call(arguments, 1),
      fToBind = this,
      fNOP = function () {},
      fBound = function () {
        return fToBind.apply( 
          this instanceof fNOP && oThis ? this : oThis, 
          aArgs.concat(Array.prototype.slice.call(arguments))
        )
      }
  fNOP.prototype = this.prototype
  fBound.prototype = new fNOP()

  return fBound
}

一旦まとめとして、今までの4つのルールの中で、優先順位はnew演算子 > 明示的this(call/applyまたはbind) > 暗黙的this(オブジェクトのメソッドとして) > デフォルトthis(上記以外のケース)となります。このくらい分かれば、thisの謎についてもうだいぶ理解できるでしょう。

例外

残念ながら上記のルールと優先順位はすべてのケースをカバーできません。ここでいくつかの例外のケースを説明します。

無視されるthis

nullまたはundefinedcall/applyまたはbindに渡した場合、thisは無視されます。

function add(a, b) {
  return a + b
}

// ...が導入される前に、配列の要素を引数として展開するテクニック
add.apply(null, [2, 3]) // 5

// null以降の引数をadd2に渡す
var add2 = add.bind(null, 2)
console.log(add2(3)) // 5

引数の展開について、今...演算子を使うことが普通になったので、もうあまり見られないかもしれません。ただ、bindはまだ代替するものがないのでしばらくは見られる・使われるでしょう。

nullを渡し、任意数の引数をその付与された関数(add2)に渡すことが可能です。これは関数型プログロミングで言われるcurryingとの操作です。

bindは割と使われていますので、上記の例のパターンは見たことがあるかもしれません。例えば、reactのコンポーネントのクリックイベントに、引数を渡したいときにこのパターンを使うことができます:

  // ...
  const clickHandler = (nickname) => { ... }
  return (
    <ul>
      {userList.map((user) => (
        <UserCard
          key={user.id}
          clickHandler={clickHandler.bind(null, user.nickname)}
        />
      ))}
    </ul>
  )

いずれにしても、bindを使うときに、もしthisのバイディングは関係なければ、nullを入れると良いでしょう。ただ、ここで明示的なバイドではないので、デフォルトのバイドとして、thisがグローバルオブジェクトまたはundefinedとなり、万が一どれかのサードパーティのパッケージにthisが使われているとしたら、非常にデバグしにくい問題になりかねます。

この問題を防ぐために、どうせthisは誰を指すか関心を持たないなら、一つ無関係のオブジェクトを作り、それをnullの代わりに渡しておけば良いことです。jsで空のオブジェクトを作りには、Object.create(null)を使うことができます。

function add(a, b) {
  return a + b
}

var emptyObj = Object.create(null)

add.apply(emptyObj, [2, 3]) // 5

var add2 = add.bind(emptyObj, 2)
console.log(add2(3)) // 5

間接的な(indirect)レファレンス

これは特に値を与えるときにうっかりと起こりやすいのです:

function foo() {
  console.log(this.a)
}
var a = 20

var o = {a: 10, foo: foo}
var p = {a: 5}

o.foo() // 10
;(p.foo = o.foo)() // 20
p.foo(); // 5

p.foo = o.fooのリターン値は、foo関数のレファレンスで、実行する場所はpでもoでもなく、ここのthisはデフォルトのグローバルオブジェクトとなります。このような関数を呼び出す方法をできる限り避けた方が無難です。

ソフトバイディング

明示的なバイディングのときに、ハードバイディングという言葉が出ましたが、ハードがあるなら、ソフトもあるでしょう、と推測が着くかもしれません。

ハードバイディングの欠点といえば、一旦バイドされた後、newで入れ替えること以外、thisを変えることが不可能になります。

もしglobalundefined以外の値を、デフォルトバイディングの対象として指定できれば、ハードバイディングのようにthisをバイドしながら、暗黙的なバイディングとハードバイディングの両方を行うことができます。例えば:

Function.prototype.softBind = function(obj) {
  var fn = this
  var curried = Array.prototype.slice.call(arguments, 1)
  var bound = function() {
    return fn.apply(
      // thisがundefinedまたはglobal/windowの場合、引数のobjをthisにする、それ以外の場合はそのまま
      (!this || this === (window || global)) ? obj : this,
      curried.concat(curried, arguments)
    )
    bound.prototype = Object.create(fn.prototype)
    return bound
  }
}

これを試してみると:

function foo() { 
  console.log("name: " + this.name)
}

var obj = { name: "obj" },
  obj2 = { name: "obj2" },
  obj3 = { name: "obj3" };

var fooOBJ = foo.softBind( obj ); 
fooOBJ(); // name: obj 

obj2.foo = foo.softBind( obj ); 
obj2.foo(); // name: obj2 => 暗黙的なバイディング適応

fooOBJ.call( obj3 ); // name: obj3 => ハードバイディング適応

setTimeout( obj2.foo, 10 ); // name: obj => デフォルトになっていなく、ソフトバイディング適応

一点だけ注意ですが、softBindは公式のメソッドではなく、あくまでもカスタムでFunction.prototypeに追加したものです。thisバイディングの理解のためのものなので、実際のコードでは言語自身の仕様変更(FunctionsoftBindを追加)はあまりお勧めしません。

アロー関数

Es6から導入された関数定義の方法の一つ。上記の4つのルールは適応対象外となります。というのは、アロー関数のthisは、アロー関数の外層のthisを継承しているからです。

function foo() {
  return (a) => { 
    console.log(this.a)
  }
}

var obj1 = {a:2}
var obj2 = {a:20}

var bar = foo.call(obj1) // ここでbarのthisはobj1へバイド
bar.call(obj2) // 2 => 20ではありません

アロー関数のthisバイディングはハードバイディング、かつnewでは影響されません。この親からthisを継承する特性から、よくコールバックのときに使われることがあります:

function foo() {
  setTimeout(() => { 
    console.log(this.a) // ここのthisはfooのthisを継承する
  },1000)
}

var obj = {a:2}
foo.call(obj) // 2 

実際にアロー関数が導入される前に、より明示的なテクニックがすでに広く使われています:

function foo() {
  var self = this // ここでfooスコープ内のthisを保存
  setTimeout(function () { 
    console.log(self.a) // this.aの代わりに、self.aで代用
  }, 1000)
}

var obj = {a:2}
foo.call(obj) // 2 

仲介役のselfを導入することで、どのスコープ内のthisを指定するかを決めることができます。悪い実践ではありませんが、根本的にthisそのものを使わないようにしています。

また、この特性もあるから、アロー関数を使うことは、コードが少なくなるだけではないということを意識しておいた方が良いでしょう。

最初の問題に戻る

IIFE

これで最初の問題に戻りますが、一つ先に説明しておく必要のある概念があります。IIFE(Immediately Invoking Function Expression):

// ブラウザー
var a = 2
(function IIFE( global ) {
  var a = 3
  console.log(a) // 3
  console.log(global.a) // 2
})( window )
console.log(a) // 2

function foo() {...}に丸いカッコをつけることで、これを関数表現(expression)に変えています。その次に()をつけることで即時に実行することになります。上記の例では、実行時にグローバルのwindowオブジェクトを引数として渡しているので、IIFE関数内ではglobalという名前でアクセス可能になります。

ただいずれにしても、IIFEがthisバイディングへの影響がなく、判断基準はこれまでと同じです。例えば上記の例では、グローバルで実行されている関数ですから、IIFE関数内部のthisもデフォルトのグローバルオブジェクトとなります。

スコープの分離

IIFEは本来、スコープの汚染(pollution)を解決するためのテクニックです。冒頭の問題に何を影響するかというと、スコープが分けられているところです。

// ブラウザー
var a = 1 // -> ここはグローバルスコープ
;(function() {
  // -> ここはIIFE関数1のスコープ
  console.log(a + this.a) // この時点でIIFE関数1のスコープ内にはaが存在しますが、値の付与・初期化されてないので、aがundefinedとなる
  var a = '2' // IIFE関数1スコープ内のaが初期化され、'2'となる
  console.log(a + this.a) // 特に適応するルールがないのでデフォルトとして、thisはグローバルオブジェクトを指す
})()

var name = 1 // -> ここはグローバルスコープ
;(function() {
  // -> ここはIIFE関数2のスコープ
  console.log(name + this.name) // IIFE関数1と同じ??
  var name = '2'
  console.log(name + this.name) // IIFE関数1と同じ??
})()
// -> ここはグローバルスコープ

これまで明白なのは、二つのIIFE関数はグローバルスコープ内で実行されているので、その中のthisは、デフォルトのグローバルオブジェクトを指しているのです。二つのパターンが一緒なので、両方とも出力がundefined + 1 = NaN'2' + 1 = '21'となるのではないかと、思われます。

ブラウザー環境のグローバルオブジェクト

これまで半分正解です。この問題の落とし穴はもう一つあります。nameという変数名です。

最初にこの問題を見たときにおかしいと思ったのは、一つ目の変数名がaなのに、二つ目ではbではなく、なぜかnameとなっています。

nameはここで、グローバル変数として宣言されていますが、ブラウザーのwindowオブジェクトのnameプロパティと同じ名前となっています。すると、var name = 1という記述は、グローバルスコープで行われた値の付与ですので、実際はwindow.name = 1と同じ意味になります。

// ブラウザー環境
var foo = 'abc'
console.log(foo === window.foo) // true

また、MDNの記述のように、window.nameは、その付与された値をすべてtoStringメソッドで文字列に変換することになります。

Note: window.name converts all stored values to their string representations using the toString method.

つまり、name= 1で付与しても、実際はwindow.name = '1'となっています。

thisの指向は同じく、グローバルオブジェクトですので、this.namewindow.name、つまりthis.name = '1'ということです。

そのため、2つ目のIIFEでは、undefined + '1' = 'undefined1''2' + '1' = '21'となります。

これまでようやく謎が解きました。この問題の正しい解答は以下となります。

NaN
"21"
"undefined1"
"21"

ただ、この解答には、ブラウザー環境という制限があります。もしnode.jsで実行する場合、globalオブジェクトには、global.nameという属性はないため、ブラウザー環境のような謎の結果が見られません。

終わりに

冒頭の問題は、知り合いから投げられて、「これやってみ」と言われたのですが、結局予想と全く違う結果が出て、???と思って、色々と調査し始めました。

最初はthisが鍵になると思い、thisを中心に考えていましたが、どうやら他の要素も関わっており、特にブラウザー環境という大きな要因があると気づきました。結局thisだけではないことですが、今回はthisの謎解明のきっかけとして残しておきたいと思います。

thisは本当にJS言語でかなりわかりにくい概念です。JS言語開発の一人、JSON発明者のDouglas Crockford氏が著作 How JavaScript Works の16章でthisについて「問題をもたらすだけですから、thisを使わないこと」、というように完全に否定しています(もっと説明ほしかったのですがこの章だけ数ページだけでした。嫌いだったでしょうね。。)

にしても、完全に棄却する、という判断は、thisの働きが分からない、間違いが怖い、という理由ではなく、理解した上での判断だと思った方が良いでしょう。なので、使わないから知らなくて良いとの意味ではなく、JS言語を理解するために避けられないと私は思っています。

今回の内容にはいくつか軽く触れていた重要な概念、スコープ、プロトタイプとかがありますが、またの機会に詳しく書きたいと思います。

ではでは、今日は一旦これで。

Discussion

ログインするとコメントできます