DOMにおける「Illegal Invocation」エラーの一例とその原因・対処法

DOMに関わるプログラムを書いていると、「Illegal Invocation」エラーに遭遇することがあります。例えば、次のようにすると`q`の呼び出しでエラーが発生します。

```js
const q = document.querySelector;
q("body"); // Uncaught TypeError: Illegal invocation
```

ちなみに、「Illegal Invocation」とはGoogle Chromeのエラーメッセージであり、Firefoxは次のようにもうちょっと親切なエラーメッセージを出力します。エラーメッセージというのは基本的に仕様で定められておらず処理系の裁量があるところですから、エラーの意味が分からなくて詰まったら別のブラウザで試してみるのもひとつの手です。

```
TypeError: 'querySelector' called on an object that does not implement interface Document.
```

Google Chromeの「Illegal Invocation」はいろいろな場合に表示されるメッセージなので、全てに通用する汎用的な原因・対処法というのはありません。この記事で扱うのは冒頭のように、「**呼び出しやすいようにメソッドを別の変数に入れた**」場合に発生するエラーです。

Twitterでこのエラーで詰まっている方がいたのですが、「Illegal Invocation」で検索しても出てくる既存の記事がどれも微妙だったのでこの記事を用意しました。

## エラーの原因

このエラーの原因をひとことで言うならば「**`this`が違う**」ということです。DOMの関数は、正しい`this`で呼び出さないといけません。

JavaScriptは、オブジェクトのメソッド内では`this`を用いて自分自身を参照することができます。

```js
const obj = {
  prop: 100,
  method() {
    console.log(this.prop);
  }
};
obj.method(); // 100 が表示される
```

この例では、`obj.method()`という呼び出しをすると`100`が表示されます。これは、`obj.method`の中で使われている`this`が`obj`を指すからです。しかし、このプログラムを次のように変えるとうまく動作しません。

```js
const obj = {
  prop: 100,
  method() {
    console.log(this.prop);
  }
};
const func = obj.method; 
func(); // undefined が表示される（strictモード内ならエラーが発生）
```

`obj.method`を別の変数`func`に入れて`func`を呼び出したら、結果が変わってしまいました。これは、`obj.method`の呼び出し方が変わったことにより`obj.method`の中の`this`が変わったからです[^note_func_this]。

[^note_func_this]: この呼び出し方の場合、strictモードでは`this`は`undefined`になり、それ以外の場合は`this`はグローバルオブジェクト（[globalThis](https://qiita.com/uhyo/items/f3b6feef9444e86bef94)）になります。

このように、関数内での`this`がどうなっているかは、その関数をどのように呼び出したかによって決まります。`foo.bar()`というメソッド呼び出しの記法を用いて呼んだ場合は、関数`bar`内での`this`の値は`foo`です。つまり、`foo.bar()`というメソッド呼び出しの記法は、ただ単に「`foo.bar`に入っている関数を呼び出す」という意味なのではなく、「`this`を`foo`として、`foo.bar`を呼び出す」とおいう意味のプログラムだったのです。

冒頭の`document.querySelector`についても同じことが言えます。この`querySelector`メソッドは、`this`が`document`の状態で呼び出さないといけません。よって、「`this`を`document`として呼び出す」というプログラムを書かないと正しく呼び出すことができません。

`const q = document.querySelector`としたあとに`q()`を呼び出した場合は、`q`はたしかに`document.querySelector`と同じであるものの、`q()`として呼び出しても`this`が`document`になりません。これが「Illegal Invocation」エラーの原因でした。

ここで、Firefoxのエラーメッセージを振り返ってみましょう。

```
TypeError: 'querySelector' called on an object that does not implement interface Document.
```

日本語にすると「`querySelector`が`Document`インターフェースを実装していないオブジェクト上で呼ばれました」となります。「～上で」という表現がわかりにくいですが、これは`this`が何かということを意味しています。つまり、これは「`this`が`Document`インターフェースを実装したオブジェクトではない」ということに文句を言っているのです。まさに上で解説した通りのことが起きていることになります。個人的には、エラーメッセージに「`this`」というワードを含めてくれればもっと親切になるのにと思わないでもありませんが。

## エラーの対処法

以上で、エラーの原因は分かりました。次は、これに対する対処法を説明します。基本的には、問題が「`this`が正しくない」ことだったのですから、対処法は「正しい`this`で呼ぶ」しかありません。その方法は色々あります。

最もベーシックな方法は、常にメソッド呼び出しの記法を使うことです。`document.querySelector("body")`の形で呼べば、この記法は「`this`を`document`としてメソッドを呼ぶ」という意味ですから正しい`this`で呼ぶことができます。

しかし、これでは冒頭のコードの目的を達成したとは言えません。冒頭のコードの目的は、これを`q("body")`という短いコードで呼べるようにすることでした。これを達成する方法のひとつは、次のようにすることです。

```js
const q = (query) => document.querySelector(query);

q("body"); // エラーが出ない！
```

これは元のプログラムを[η変換](https://ja.wikipedia.org/wiki/%E3%83%A9%E3%83%A0%E3%83%80%E8%A8%88%E7%AE%97#%CE%B7-%E5%A4%89%E6%8F%9B)しただけのように見えますが、`document.querySelector`を呼び出すときはあくまでメソッド呼び出しの記法を使われているのがポイントです。これなら`querySelector`を呼び出すときの`this`がちゃんと`document`になっているため、エラーにはなりません。

別の方法は[bind](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind)を使って次のようにする方法です。

```js
const q = document.querySelector.bind(document);

q("body"); // エラーが出ない！
```

`bind`をこのように使う場合、「`this`がすでに固定されている関数」を作ることができます。この場合変数`q`に入っているのは、「`this`が`document`に固定された`document.querySelector`」です。よって、`q("body")`のようにメソッド記法を使わずに呼び出した場合でも、`bind`の効果によって`document.querySelector`は`this`が`document`の状態で呼び出されるため、エラーにはなりません。

もうひとつ別の方法としては、[call](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/call)メソッド（または[apply](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/apply)メソッド）を用いる方法もあります。これは関数オブジェクトが持つメソッドで、「`this`を明示的に指定して呼び出す」ということが可能になります。

```js
const q = document.querySelector;

q.call(document, "body"); // エラーが出ない
```

これは長いので、短く書くという目的に使われることは無いと思いますが、`call`や`apply`は`this`の値も含めて関数呼び出しを詳細に制御したい場合に重宝します。

## 上級編：エラーを仕様で確かめる

ところで、「`document.querySelector`は`this`が`document`の状態で呼び出さないとエラーになる」というのは不親切にも思えますが、実はちゃんと仕様書に明記してあり、ブラウザはそれに従っているだけです。

そこで、この仕様がどのように定義してあるのかを確かめましょう。

ということで、まず[DOM仕様書](https://dom.spec.whatwg.org/)をチェックしましょう。`querySelector`は[ParentNodeインターフェース](https://dom.spec.whatwg.org/#parentnode)の中に定義してあります。仕様にはこのように定義が書かれています。

```
interface mixin ParentNode {
  // （省略）
  Element? querySelector(DOMString selectors);
  [NewObject] NodeList querySelectorAll(DOMString selectors);
};
Document includes ParentNode;
```

なんとなく、`querySelector`は`ParentNode`インターフェースが持つメソッドであり、引数は`DOMString`型（JavaScriptでいうただの文字列です）の引数`selectors`ひとつであり、返り値は`Element?`型（`Element`または`null`を意味します）であることが分かります。

実は、この記法は**[WebIDL](https://heycam.github.io/webidl/)**と呼ばれるものです。WebIDLはインターフェース・メソッド等を定義するための標準化された言語であり、Web関連のさまざまな仕様書におけるインターフェース定義記法を統一するために作成されました。仕様書の書き方を定義するメタ仕様という感じです。

そして、今回目的としている挙動の根拠を見つけるためには、WebIDL仕様書を紐解かなければなりません。つまり、「`Element? querySelector(DOMString selectors);`と書かれていたらどのような挙動のメソッドになるのか」という定義をWebIDL仕様書で調べることになります。

現在Webを支配しているのはJavaScript (ECMAScript) ですから、WebIDLには[ECMAScript binding](https://heycam.github.io/webidl/#ecmascript-binding)という章があり、「WebIDLで定義されたメソッドはJavaScriptではどういう挙動をするのか」ということが厳密に仕様化されています。

いきなり核心に迫りますが、[3.6.7 Operations](https://heycam.github.io/webidl/#es-operations)という節でこの構文に対する仕様が定義されています。最初の段落を引用します（強調は筆者）。

> For each unique identifier of an exposed operation defined on the interface, there exist a corresponding property. Static operations are exposed of the interface object. **Regular operations are exposed on the interface prototype object**, unless the operation is unforgeable or the interface was declared with the [Global] extended attribute, in which case they are exposed on every object that implements the interface.

強調されている点が今回関係のある部分です。上記の記法によれば、実は`querySelector`はregular operationに分類されます。よって、`querySelector`はinterface prototype objectに実体が存在することになります。今回`querySelector`は`ParentNode`というmixinインターフェース上に定義されていますが、`Document includes ParentNode;`という宣言がありますから、`querySelector`は`Document`インターフェース上に宣言されたものであると見なされます。よって、ここで言うinterface prototype objectとは`Document.prototype`のことです。

```js
// true が表示される
console.log(document.querySelector === Document.prototype.querySelector);
```

となると、`Document.prototype.querySelector`にどのような関数が設定されるのかがポイントになります。これは、仕様書を少し読み進めると出てくる**[create an operation function](https://heycam.github.io/webidl/#dfn-create-operation-function)**アルゴリズムで定義されています。このアルゴリズムは「関数を作る」という複雑な操作を自然言語で表現しているためややこしいのですが、一部を抜粋すると、`Document.prototype.querySelector`を実行した際にはまず以下のステップが実行されます。

> If *target* is an interface, and *op* is not a static operation:
>
1. Let *esValue* be the **this** value, if it is not **null** or **undefined**, or *realm*’s global object otherwise. (This will subsequently cause a TypeError in a few steps, if the global object does not implement *target* and [LenientThis] is not specified.)
2. If *esValue* is a platform object, then perform a security check, passing *esValue*, *id*, and "method".
3. If *esValue* does not implement the *interface* target, throw a TypeError.
4. Set *idlObject* to the IDL interface type value that represents a reference to *esValue*.

ここでは*target*と*op*という2つの変数が存在しており、それぞれinterfaceとoperationです。これらはJavaScriptの値ではなく、WebIDLにおけるインターフェース・オペレーションといった概念です。言うなればこれらはWebIDLのASTノードのことであると見なせます。ここでは`target`は`querySelector`の実装先である`Document`インターフェースを表すものであり、`op`は``Element? querySelector(DOMString selectors);`という定義そのものであると考えられます。

実際にこのステップを実行すると、まず1により、`this`の値が取得され*esValue*に入ります。`document.querySelector("body")`のように呼び出した場合は*esValue*に入るのは`document`であり、正しくない方法で呼び出した場合は*esValue*にグローバルオブジェクトが入ります。

2にはsecurity checkという文言が登場しますが、これは今回は関係ありません。ちなみに、security checkの実態は[HTML仕様書](https://html.spec.whatwg.org/multipage/browsers.html#integration-with-idl)にあり、これは他のオリジンのブラウジングコンテキストに対して許可されていない操作をしたら`SecurityError`が発生するという仕様を定めています。

3が一番のポイントです。ここでは、*esValue*が*target*をimplementしないならば、TypeErrorを発生させると定義されています。*esValue*は`this`の値で、*target*はDocumentインターフェースのことでしたから、ここで問題のチェックが行われていることになります。ちゃんと`this`の値を`document`にして呼び出さなかった場合、*esValue*はDocumentインターフェースをimplementしていませんから、TypeErrorが発生することになります。

なお、ここで「implementする」というのがどういう意味なのかが気になるかと思います。これは[3.7 Platform objects implementing interfaces](https://heycam.github.io/webidl/#es-platform-objects)で以下のように定義されています。

> An ECMAScript value *value* **implements** an interface *interface* if *value* is a platform object and the inclusive inherited interfaces of *value*.[[PrimaryInterface]] contains *interface*.

つまり、*value*.[[PrimaryInterface]]にそのインターフェース（もしくはそれを継承したインターフェース）が入っているかどうかでチェックしています。[[PrimaryInterface]]というのは**インターナルスロット**です。これも仕様書用語ですが、インターナルスロットについては筆者が最近書いた別の記事で詳しく解説しています。これはWebIDLによって新たに定義されているインターナルスロットです。

- [JavaScriptの「継承」はどう定義されるのか？　仕様書を読んで理解する](https://qiita.com/uhyo/items/b63ac11e8ec54d2c3a2b)

なお、このような方式になっているということは、オブジェクトのプロトタイプをごまかしても無意味だということを意味しています。次のような「偽Doocument」を作ってもごまかすことはできずにTypeErrorが発生します。

```js
const fakeDocument = Object.create(Document.prototype);
fakeDocument.querySelector = document.querySelector;

fakeDocument.querySelector("body"); // TypeErrorが発生
```

## まとめ

この記事では、DOMのメソッドを正しい`this`で呼び出さなかったことに起因する「Illegal Invocation」エラーを解説し、その対処法もあわせて説明しました。ポイントは、メソッド呼び出しの`foo.bar()`という記法はただ関数を呼び出すだけでなく、「呼び出された関数の中の`this`を指定する」という機能も併せ持っているという点です。DOMに限らず、多くのメソッドは正しく`this`を指定しないと期待した通りに動作しません[^note_class_property]。これにより、「メソッドを別の変数に入れてそれを呼び出す」ということをした場合はメソッド呼び出し記法を使っていないため正しい`this`がセットされず、エラーとなるのでした。

対処法としては、第一に「ちゃんとメソッド呼び出し記法を使って呼び出す」が上げられます。また、`Function.prototype.bind`を使って`this`が常に固定された関数を作る方法もありました。冗長ですが、`Function.prototype.call`で`this`を明示的に指定する方法もあります。

この記事の後半では上級者向けのコンテンツとして、「`this`が正しくないとTypeErrorが発生する」という挙動がちゃんと仕様書において定義されていることを確かめました。

[^note_class_property]: 最近は`class`宣言の中でプロパティ宣言とアロー関数を使ってメソッドを宣言する流派もあり、その場合はアロー関数によって`this`が固定されるためどのように呼び出しても期待通りの`this`となります。