LoginSignup
90
98

JavaScriptのthisに沼って仕様書にまで沈んだ話

Last updated at Posted at 2023-05-18

JavaScriptの鬼門"this"。
物分かりの良い人にとってはなんて事のない概念なのかもしれませんが、自分は理解に戸惑い、かなり時間を溶かしてしまいました。その一方で、調べていく過程で今までの疑問に答えられる、比較的包括的な理解を得られたと思います。仕様書の解説記事にまで行きあたったところで、これまでの知識の整理をと思い、忘備録がてらまとめる次第です。

1:はじめに
2:一般的な説明
3:関数の中におけるthisの振る舞い
 3-1:グローバルスコープの関数の場合
 3-2:オブジェクトのメソッドの場合
 3-3:アロー関数の場合
 3-4:イベントハンドラーの場合
 3-5:コンストラクター関数の場合
 3-6:クラス構文の場合
4:thisに暴れてほしくないとき
5:仕様書からthisを探る
6:おわりに

1:はじめに

thisとは何でしょうか。
thisは「キーワード」であり、変数でありません(変数でない根拠は後述) 。
JavaScriptにおける「キーワード」という概念は、特別な意味を持つトークンのことで、識別子とも違うようです。例えば、関数の前につけて非同期関数であることを示すasyncなどが当たります1
そのため場合によっては、thisは「そのオブジェクト自身を指すキーワードのこと」といわれてきました。

(´・∀・`)ヘー

しかし、thisを多少なりとも触ったことのある方なら思ったかもしれませんが、頑張ってオブジェクトの中にthisを埋め込もうとしても、どうしてもthisはグローバルオブジェクトを指してしまいます。
例えば、以下の通りです。

Main.js
const objectLiteral={
    name:"amagi",
    holloWord:"Hello "+this.name
};
const objectConstructed=new Object({
    name:"yuri",
    holloWord:"Hello "+this.name
});
const objectCreated=Object.create({
    name:"Anonymous",
    holloWord:"Hello "+this.name
},Object.prototype);

console.log(objectLiteral.holloWord);
console.log(objectConstructed.holloWord);
console.log(objectCreated.holloWord);
/*
~実行結果~
Hello undefined
Hello undefined
Hello undefined
*/

オブジェクト自身を指すのであれば、どれか一つでもobject○○それ自体への参照を持っていてほしいものですが、相変わらずthisは思ったような挙動をしてくれません。

(´・ω・`)ソンナー

実は、thisはなんでもかんでもオブジェクトを指すわけではなく、条件があるのです。

2:一般的な説明

MDMのウェブドキュメントには、thisは以下のように説明されています2

strict モードでない場合は、実行コンテキスト (グローバル、関数、eval) のプロパティで、常にオブジェクトへの参照です。
strict モードではどのような値でも取り得ます。

見慣れない単語が出てきました。
コンテキストとは、コードが実行される際の状況のことで、JavaScriptには引用文の通り、一般的に三つの実行コンテキストがあるそうです3
1.グローバルコンテキスト
2.関数コンテキスト
3.evalコンテキスト

グローバルコンテキスト(すべての関数の外)ではグローバルオブジェクトへの参照を持ちます。非strictモードのとき、フロント側においてthisはwindowオブジェクトを指し、サーバー側(node.js)側においてthisはgolobalオブジェクトを指します。strictモードにおいてはundefinedになります。

厄介なのは、関数コンテキストの時です。
今までのthisに関する記事を見てみても、この関数コンテキストにおける挙動の説明が中心に行われていました。「どのような形の関数か」と、「どこでよびだされるか」によって挙動が変わるためです。ここから「場合分け」ともいえる、thisの挙動の細分化がはじまります。

3:関数の中におけるthisの振る舞い

関数のなかにあるthisはどのように関数が呼ばれたかによって決定されます。

  • グローバルスコープで関数が実行された場合
  • オブジェクトのメソッドとして関数が実行された場合
  • アロー関数で実行された場合
  • イベントハンドラーの場合。
  • コンストラクター関数の場合
  • クラス構文の場合。

オブジェクトの中で使われた関数は「メソッド」として振る舞い、オブジェクトを新たに作るコンストラクター関数やクラス構文も関数の一種です。thisの挙動予測が難しい原因の一端は、JavaScriptにおいて関数が様々な場面で使えることにあると言えるでしょう。

3-1:グローバルスコープの関数の場合

グローバルスコープにおいて関数を呼び出した場合、その中のthisはグローバルオブジェクトを指します。
グローバルスコープでconsole.log(this)をしたときの結果と同じですね。

GlobalFunction.js
const globalFunction=function(){
    return this;
};
console.log(globalFunction());
//Window {window: Window, self: Window, document: document, …}

3-2:オブジェクトのメソッドの場合

関数がオブジェクトのメソッドとして呼ばれた場合、thisはオブジェクト自身を指します。「メソッドチェーンでつながるオブジェクトへの参照を持つ」とも言われますね。直感的に言えば、たとえばobject.method()について、.の前のオブジェクトをthisが示しているともいえます。

Main.js
const globalObject={
    name:"global",
    method:globalFunction,
};
console.log(globalObject.method());
//{name: 'global', method: ƒ, }

3-3アロー関数の場合

関数は関数でも、アロー関数はthisとの結びつけがありません。
代わりに、包含する構文上のコンテキストがもつthisの値を保持することになります。

(・・?)

まずは「thisとの結びつけがない」という部分から行きましょう。

Main.js
const arrowFunction=()=>{
    return this;
};

const globalObject={
    name:"global",
    arrowMethod:arrowFunction,
    inArrowMethod:()=>{
        return this;
    },
};
console.log(globalObject.arrowMethod());
console.log(globalObject.inArrowMethod());
//Window {window: Window, self: Window, document: document,  …} ×2

このように、アロー関数は関数式や関数宣言と違って、thisとの結びつけを行わない、つまりは事実上関数コンテキストではないと言うことができます。

では全くthisを持たないかと言われればそうではなく、アロー関数式を持っている構文上のコンテキストのthisを譲り受けることになります。具体には以下の通りです。

Main.js
const globalObject={
    name:"global",
    outerFunction:function(){
        const arrowFunction=()=>console.log(this);
        arrowFunction();
    }
};
//{name: 'global', outerFunction: ƒ}

outerFunctionはglobalObjectのメソッドであって、この関数内のthisはglobalObjectへの参照を持っています。そのため、arrowFunctionはそれを包んでいるouterFunctionの関数コンテキスト上のthisに一致することになるのです。

折角関数のネストが出てきたので、興味深い事例を一つ紹介しておきましょう。

Main.js
const globalObject={
    name:"global",
    outerFunction:function(){
        const innerFunction=function(){
            console.log(this);
        };
        innerFunction();
    }
};
//Window {window: Window, self: Window, document: document ...}

アロー関数の方とは打って変わって、今度は関数式におけるthisはグローバルオブジェクトを参照してしまっています。(非strictの場合)
これは、innerFunctionの実行コンテキスト上に、thisが参照できそうなオブジェクトがないためです。innerFunctionの実行コンテキスト上にあるのはouterFunctionであって、innerFunctionのthisとouterFunctionのthisは別である(受け継がれない)ため、ここのthisは参照すべきオブジェクトが不在であるということになります。

(´゚д゚`)?

はい。
これでは(非strictモードにおいてですが)なぜ、ネスト化された関数におけるthisがグローバルオブジェクトを指すことになるのか、説明がつきません。
オブジェクトが不在だからといって、なぜ一足飛びにグローバルオブジェクトの方へ行ったのでしょうか。globalObjectはなぜ無視されたのでしょうか。
仕様といえばそれまでなのですが、詳しい説明は後述するので楽しみにしておいてください。

3-4:イベントハンドラーの場合

以下のようなhtmlのファイルがあったとします。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="this">Click Here!</div>
<script src="./this.js"></script>
</body>
</html>

JavaScriptはwindowオブジェクトのdocumentオブジェクトから非同期的に要素の取得やイベントハンドラーの設定ができますが、その場合のthisは何を指すことになるのでしょうか。

Main.js
document.addEventListener("DOMContentLoaded",function(){
    document.getElementById("this").addEventListener("click",function(){
        console.log(this);
    })
})
//<div id="this">Click Here!</div>

getElementByIdで取得したオブジェクトのメソッドとしてaddEventListenerが機能しているため、thisはイベントが発生した要素を指すことになります。

面倒なので……ゲフンゲフン、賢明なる読者諸兄にはもうアロー関数の場合の説明は不要だと思われるので出しませんが、一応言葉だけで説明しておくと、もしaddEventListenerのコールバック関数がアロー関数だったら、windowオブジェクトを返すことになります。

3-5:コンストラクター関数の場合

関数がコンストラクターとして、new演算子と一緒に使用されたとき、その this は生成される新しいオブジェクトに関連付けられます。

Main.js
const Company=function(name){
    this.name=name;
    this.getName=function(){
        return this.name;
    }
    this.getThis=function(){
        return this;
    }
}

const gameCompany=new Company("nintendo");
console.log(gameCompany.getName());
console.log(gameCompany.getThis());
/*
nintendo
Company {name: 'nintendo', getName: ƒ, getThis: ƒ}
*/

コンストラクター関数において、thisに対してドット記法で設定された変数はすべてインスタンスフィールドとして扱われるので、Comapny{...}のようにthisが指し示すオブジェクトはコンストラクター関数内で設定されたすべての変数をプロパティとして持ちます。

3-6:クラス構文の場合

classはコンストラクター関数のシンタックスシュガーである(つまり、正体は関数である)ので、そこまで結果は変わらないだろうと思った方もおられるかもしれません。が、実は、ちょっと挙動は異なります。

Main.js
class Student{
    constructor(name){
        this.name=name;
    }
    getName(){
        return this.name;
    }
    getThis(){
        return this;
    }
    getThisPrototype(){
        return Object.getPrototypeOf(this);
    }
}
const sophomore=new Student("yuri");
console.log(sophomore.getName());
console.log(sophomore.getThis());
console.log(sophomore.getThisPrototype());
/*
yuri
Student {name: 'yuri'}
{constructor: ƒ, getName: ƒ, getThis: ƒ, getThisPrototype: ƒ}
*/

クラスのコンストラクター内では、this は通常のオブジェクトです。
クラス内のすべての静的でないメソッドは this のプロトタイプに追加されます。
このため、単純にthisを返すgetThisメソッドでは、オブジェクトのプロパティしか返されず、一方でプロトタイプにアクセスするgetThisPrototypeメソッドの方では、クラスの中で定義されたインスタンスメソッドを取得することができたのです。

4:thisに暴れてほしくないとき

(×д×)「ああもう!thisの挙動、場合分け多すぎ!」

はい。
MDNのドキュメントの方には、もうちょっと関数の呼び出しパターンを詳しく記述されてありますが、もうこれ以上は面倒すぎて考慮に入れるのが難しくなります。
とかく、JavaScriptのthisは挙動が予測しにくいものです。
この課題を解決するために導入されたのが、あらゆる関数に備え付けられたbind, apply, callメソッドです。

Main.js
const character={
    firstName:"Mario",
    lastName:"Mario"
}

const getFullName=function(){
    return this.firstName+" "+this.lastName;
}

const greet=function(greeting){
    return greeting+" "+this.firstName+" "+this.lastName+"!";
}

const bindName=getFullName.bind(character);
console.log(bindName());

const applyGreet=greet.apply(character,["Hello"]);
console.log(applyGreet);

const callGreet=greet.call(character,"Hello");
console.log(callGreet);
/*
Mario Mario
Hello Mario Mario!
Hello Mario Mario!
*/

bindメソッドは、関数オブジェクトに対して呼び出され、指定したオブジェクトを関数内のthisとして固定します。上記の例では、getFullName関数にcharacterオブジェクトをバインドしてbindName変数に代入しています。バインドされた関数を呼び出すと、thisは常にpersonオブジェクトを参照します。

applyメソッドは、関数を呼び出す際、第一引数にthisの値として指定したいオブジェクトを、第二引数に関数の引数になる配列を渡します。

callメソッドは、関数を呼び出す際、第一引数にthisの値として指定したいオブジェクトを、第二引数以降に関数の引数となる値を渡します。関数の引数の数と対応することになるでしょう。

また、bindメソッドは、bindした関数が呼ばれた時点でオブジェクトのthisへのセットを行いますが、apply,callメソッドはメソッドが呼ばれたときにthisへのオブジェクトへのセットを済ませています。実行結果が○○Greet変数に代入されているとみてよいでしょう。
それが、console.log(bindName())console.log(○○Greet)の差異に繋がっています。

5:仕様書からthisを探る

ここまでの内容が、ひろく一般的にthisに関して解説されてある所だと思います。thisがキーワードで~、関数が呼ばれる場所ごとに違って~、云々。
ただ、これまでちょくちょく説明を厳密にせずに飛ばしてきたところがあります。
なぜthisは「変数」と言うことができないのでしょうか。なぜ、アロー関数はthisを持たないのでしょうか。ネスト化された関数においてthisは、なぜグローバルオブジェクトを指すのでしょうか。(非strictの場合)
いや、そもそも「thisをもつ」とはどういうことなのでしょうか。
これまで数多くの場合分けをしなくてはならなかったり、解釈をこねくり回さないといけなかったのは、ひとえにthisの仕様が特殊過ぎたからです。
「統一的で端的な、thisに対する説明」は不可能のように思えますが、その仕様を探ると、比較的鮮明にthisの姿が見えてきます。

Σ(゚Д゚)オオ!

はい。
その内容を自分で説明しきれればカッコよいことこの上ないのでしょうが、そんなこと、浅学の身には荷が重すぎました。
先達者様が居りますので、どうぞこちらから飛んでください。

JavaScriptのthisは結局何種類あるのか

これでこの長尺の記事を終えても良いのですが、それだとなかなか不親切だと思ったので、先述の記事をすこしばかりまとめたいと思います。
※以下の記述は先の記事を要約したものです。より詳細な説明は本家様を訊ねてください。
※以下、()の中身は引用記事にはない部分です。

〖thisの種類〗
大まかにまとめると三種類。厳密に分けると、なんと157種類もあります。

〖thisの定義〗
(先に、「thisは変数ではない」といったのはここを根拠にしておりました)

thisは式ですから、12章(ECMAScript Language: Expressions)を探せば見つかります。実際、12.2.2 The this Keywordにて定義されています。
なお、変数(12.1 Identifiers)とは別個に定義されていることから明らかなように、thisは変数ではありません。

thisが評価されたときに起こることを定めた定義"Runtime Semantics"から、挙動の定義を遡っていき、情報量の多い定義(GetThisEnvironment())を見てみると、以下のような記述に行き当たります。

1.Let env be the running execution context's LexicalEnvironment.
2.Repeat,
a. Let exists be env.HasThisBinding().
b. If exists is true, return env.
c. Let outer be env.[[OuterEnv]].
d. Assert: outer is not null.
e. Set env to outer.

・running execution context:プログラムの実行状態の情報を保持するもので、ここではそこからLexicalEnvironmentを取り出している。
・LextcalEnvironment:変数スコープのことで、仕様上Environment Recordと呼ばれる。
・env.[[OuterEnv]]:外側の変数スコープのこと。
この定義は、thisが使われたスコープ内でHasThisBinding()を満たすものがあるかどうかを判定し、なかったら外側のスコープへ進み、もし満たすものがあればそのスコープを返すというアルゴリズムであります。(グローバルスコープがあるので、必ずスコープは返されます)

ここまできてようやく、thisの挙動がわかります。
「thisはスコープに結びついていること」
「スコープがthisを持っているかどうかを内側から外側に探索して行って、初めに出会ったthis持ちスコープからthisの値を返すこと」
の二つです。

〖関数スコープ〗
Environment Recordはいくつかありますが、関数の中で使われるthisを考えるにあたってはfunction Environment Recordを考えればまずよいでしょう。
このスコープにおいてHasThisBinding()とGetThisBinding()は以下のように動作します。

・HasThisBinding():自身の[[ThisBindingStatus]]がlexicalならfalse、そうでなければtrueを返す。
・GetThisBinding():自身の[[ThisValue]]を返す。ただし、自身の[[ThisBindingStatus]]がuninitializedならReferenceErrorが発生する。

function Environment Recordは関数オブジェクトが呼ばれた際に実体化します。
アロー関数のスコープが作られるとき、[[ThisBindingStatus]]はlexicalで、従ってHasThisBinding()がfalseを返します。即ち、この関数スコープでは自身のthisを持たない設計になるのです。
それ以外の場合は[[ThisBindingStatus]]がuninitializedとなります。では、[[ThisBindingStatus]]を定めているものは何かというと、BindThisValueとなります。

BindThisValueが呼び出されるのは、OrdinaryCallBindThis(F, calleeContext, thisArgument)です。これが関数スコープのthisをセットする箇所であり、「非strictモードの関数においては、thisがnullまたはundefinedならば[[ThisValue]]はグローバルオブジェクトになる」という処理を行っています。この変換がthisArgumentに対して行われたあとにBindThisValueが呼び出されます。

(これが、ネスト化された関数において(非strictの場合)”一足飛びに”グローバルオブジェクトが呼び出された理由です。関数スコープにthisがなかった場合、スコープの外をたどるのではなくて、グローバルオブジェクトをthisに差し替えるということのようです)

さて、OrdinaryCallBindThisは関数オブジェクトが持つ内部メソッドである[[Construct]]および[[Call]]から依存されています。
前者は関数がコンストラクタとして呼び出された場合、後者は普通に呼び出された場合です。[[Call]]のシグネチャは[[Call]] (thisArgument, argumentsList) なので、thisの値はさらに外側から供給されることになります。関数の呼び出しにおいてthisが何になるか(呼び出しによって作られる関数スコープの[[ThisValue]]がどうなるか)が個別に定義されています。これを全て別に数えるならば、この時点で関数呼び出しにおけるthisの種類が最低でも116あることになります。

(これが、thisの挙動が予測しがたい理由になっているようです)

6:おわりに

this、かなり奥が深かったです。
本当に理解したいのなら、仕様書を読み込むまで行かないといけないのでしょうが、それはなかなか……。
仕様を理解するのは苦戦し、解説記事を参考にしてもまだ、全部をわかりきることはできませんでしたが、thisが決定するアルゴリズムのおおまかな理屈は知ることができたと思うので、それだけでも参考になったと思います。
稚拙な文章でわかりにくかったところも多々あったと思いますが、ここまで読んでくださってありがとうございました。

  1. キーワードについてのMDNウェブドキュメントを参考のこと。
    https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Lexical_grammar#%E3%82%AD%E3%83%BC%E3%83%AF%E3%83%BC%E3%83%89

  2. thisについてのMDNウェブドキュメントを参考のこと
    https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/this#%E9%96%A2%E9%80%A3%E6%83%85%E5%A0%B1

  3. 関数コンテキストについての記事。
    https://qiita.com/cotton11aq/items/091c983e9034e22c145c

90
98
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
90
98