102
52

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-01-18

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の中で使われているthisobjを指すからです。しかし、このプログラムを次のように変えるとうまく動作しません。

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に入っている関数を呼び出す」という意味なのではなく、「thisfooとして、foo.barを呼び出す」とおいう意味のプログラムだったのです。

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

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

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

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

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

エラーの対処法

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

最もベーシックな方法は、常にメソッド呼び出しの記法を使うことです。document.querySelector("body")の形で呼べば、この記法は「thisdocumentとしてメソッドを呼ぶ」という意味ですから正しい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に入っているのは、「thisdocumentに固定されたdocument.querySelector」です。よって、q("body")のようにメソッド記法を使わずに呼び出した場合でも、bindの効果によってdocument.querySelectorthisdocumentの状態で呼び出されるため、エラーにはなりません。

もうひとつ別の方法としては、callメソッド(またはapplyメソッド)を用いる方法もあります。これは関数オブジェクトが持つメソッドで、「thisを明示的に指定して呼び出す」ということが可能になります。

const q = document.querySelector;

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

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

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

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

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

ということで、まずDOM仕様書をチェックしましょう。querySelectorParentNodeインターフェースの中に定義してあります。仕様にはこのように定義が書かれています。

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

なんとなく、querySelectorParentNodeインターフェースが持つメソッドであり、引数は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に実体が存在することになります。今回querySelectorParentNodeというmixinインターフェース上に定義されていますが、Document includes ParentNode;という宣言がありますから、querySelectorDocumentインターフェース上に宣言されたものであると見なされます。よって、ここで言う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:

  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.

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

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

2にはsecurity checkという文言が登場しますが、これは今回は関係ありません。ちなみに、security checkの実態はHTML仕様書にあり、これは他のオリジンのブラウジングコンテキストに対して許可されていない操作をしたらSecurityErrorが発生するという仕様を定めています。

3が一番のポイントです。ここでは、esValuetargetをimplementしないならば、TypeErrorを発生させると定義されています。esValuethisの値で、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.callthisを明示的に指定する方法もあります。

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

  1. この呼び出し方の場合、strictモードではthisundefinedになり、それ以外の場合はthisはグローバルオブジェクト(globalThis)になります。

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

102
52
0

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
102
52

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?