DOMに関わるプログラムを書いていると、「Illegal Invocation」エラーに遭遇することがあります。例えば、次のようにするとq
の呼び出しでエラーが発生します。
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
を用いて自分自身を参照することができます。
const obj = {
prop: 100,
method() {
console.log(this.prop);
}
};
obj.method(); // 100 が表示される
この例では、obj.method()
という呼び出しをすると100
が表示されます。これは、obj.method
の中で使われているthis
がobj
を指すからです。しかし、このプログラムを次のように変えるとうまく動作しません。
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
が変わったからです1。
このように、関数内での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")
という短いコードで呼べるようにすることでした。これを達成する方法のひとつは、次のようにすることです。
const q = (query) => document.querySelector(query);
q("body"); // エラーが出ない!
これは元のプログラムをη変換しただけのように見えますが、document.querySelector
を呼び出すときはあくまでメソッド呼び出しの記法を使われているのがポイントです。これならquerySelector
を呼び出すときのthis
がちゃんとdocument
になっているため、エラーにはなりません。
別の方法はbindを使って次のようにする方法です。
const q = document.querySelector.bind(document);
q("body"); // エラーが出ない!
bind
をこのように使う場合、「this
がすでに固定されている関数」を作ることができます。この場合変数q
に入っているのは、「this
がdocument
に固定されたdocument.querySelector
」です。よって、q("body")
のようにメソッド記法を使わずに呼び出した場合でも、bind
の効果によってdocument.querySelector
はthis
がdocument
の状態で呼び出されるため、エラーにはなりません。
もうひとつ別の方法としては、callメソッド(またはapplyメソッド)を用いる方法もあります。これは関数オブジェクトが持つメソッドで、「this
を明示的に指定して呼び出す」ということが可能になります。
const q = document.querySelector;
q.call(document, "body"); // エラーが出ない
これは長いので、短く書くという目的に使われることは無いと思いますが、call
やapply
はthis
の値も含めて関数呼び出しを詳細に制御したい場合に重宝します。
上級編:エラーを仕様で確かめる
ところで、「document.querySelector
はthis
がdocument
の状態で呼び出さないとエラーになる」というのは不親切にも思えますが、実はちゃんと仕様書に明記してあり、ブラウザはそれに従っているだけです。
そこで、この仕様がどのように定義してあるのかを確かめましょう。
ということで、まずDOM仕様書をチェックしましょう。querySelector
は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**と呼ばれるものです。WebIDLはインターフェース・メソッド等を定義するための標準化された言語であり、Web関連のさまざまな仕様書におけるインターフェース定義記法を統一するために作成されました。仕様書の書き方を定義するメタ仕様という感じです。
そして、今回目的としている挙動の根拠を見つけるためには、WebIDL仕様書を紐解かなければなりません。つまり、「Element? querySelector(DOMString selectors);
と書かれていたらどのような挙動のメソッドになるのか」という定義をWebIDL仕様書で調べることになります。
現在Webを支配しているのはJavaScript (ECMAScript) ですから、WebIDLにはECMAScript bindingという章があり、「WebIDLで定義されたメソッドはJavaScriptではどういう挙動をするのか」ということが厳密に仕様化されています。
いきなり核心に迫りますが、3.6.7 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
のことです。
// true が表示される
console.log(document.querySelector === Document.prototype.querySelector);
となると、Document.prototype.querySelector
にどのような関数が設定されるのかがポイントになります。これは、仕様書を少し読み進めると出てくる**create an operation function**アルゴリズムで定義されています。このアルゴリズムは「関数を作る」という複雑な操作を自然言語で表現しているためややこしいのですが、一部を抜粋すると、Document.prototype.querySelector
を実行した際にはまず以下のステップが実行されます。
If target is an interface, and op is not a static operation:
- 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.)
- If esValue is a platform object, then perform a security check, passing esValue, id, and "method".
- If esValue does not implement the interface target, throw a TypeError.
- 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仕様書にあり、これは他のオリジンのブラウジングコンテキストに対して許可されていない操作をしたらSecurityError
が発生するという仕様を定めています。
3が一番のポイントです。ここでは、esValueがtargetをimplementしないならば、TypeErrorを発生させると定義されています。esValueはthis
の値で、targetはDocumentインターフェースのことでしたから、ここで問題のチェックが行われていることになります。ちゃんとthis
の値をdocument
にして呼び出さなかった場合、esValueはDocumentインターフェースをimplementしていませんから、TypeErrorが発生することになります。
なお、ここで「implementする」というのがどういう意味なのかが気になるかと思います。これは3.7 Platform objects implementing interfacesで以下のように定義されています。
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によって新たに定義されているインターナルスロットです。
なお、このような方式になっているということは、オブジェクトのプロトタイプをごまかしても無意味だということを意味しています。次のような「偽Doocument」を作ってもごまかすことはできずにTypeErrorが発生します。
const fakeDocument = Object.create(Document.prototype);
fakeDocument.querySelector = document.querySelector;
fakeDocument.querySelector("body"); // TypeErrorが発生
まとめ
この記事では、DOMのメソッドを正しいthis
で呼び出さなかったことに起因する「Illegal Invocation」エラーを解説し、その対処法もあわせて説明しました。ポイントは、メソッド呼び出しのfoo.bar()
という記法はただ関数を呼び出すだけでなく、「呼び出された関数の中のthis
を指定する」という機能も併せ持っているという点です。DOMに限らず、多くのメソッドは正しくthis
を指定しないと期待した通りに動作しません2。これにより、「メソッドを別の変数に入れてそれを呼び出す」ということをした場合はメソッド呼び出し記法を使っていないため正しいthis
がセットされず、エラーとなるのでした。
対処法としては、第一に「ちゃんとメソッド呼び出し記法を使って呼び出す」が上げられます。また、Function.prototype.bind
を使ってthis
が常に固定された関数を作る方法もありました。冗長ですが、Function.prototype.call
でthis
を明示的に指定する方法もあります。
この記事の後半では上級者向けのコンテンツとして、「this
が正しくないとTypeErrorが発生する」という挙動がちゃんと仕様書において定義されていることを確かめました。
-
この呼び出し方の場合、strictモードでは
this
はundefined
になり、それ以外の場合はthis
はグローバルオブジェクト(globalThis)になります。 ↩ -
最近は
class
宣言の中でプロパティ宣言とアロー関数を使ってメソッドを宣言する流派もあり、その場合はアロー関数によってthis
が固定されるためどのように呼び出しても期待通りのthis
となります。 ↩