これはChromium Browser アドベントカレンダーの五日目の記事です。本記事ではWeb IDL規格におけるcallback interfaceとcallback functionという2つのコールバック仕様を説明します。ウェブ開発者を想定読者としていますが、ウェブ開発の役には立たない内容となっていますので、トリビアとしてご活用ください。
二つのコールバックの仕組み
ウェブ開発をしている方々はなにかとコールバック関数を使う機会があると思います。一番有名なのはEventTarget
.addEventListener
でしょうか。
document.body.addEventListener('click', () => {
console.log("you've clicked");
}, false);
他にもアニメーション処理を実装する時などにはWindow
.requestAnimationFrame
を使うことでしょう。
window.requestAnimationFrame(() => {
console.log("is this ... animating?");
});
どちらも同じコールバックに見えますが、実はこの二つは異なる仕様で実装されています。EventTarget.addEventListener
はcallback interfaceという仕様で、Window.requestAnimationFrame
はcallback functionという仕様で実装されてます。次のコードを実行してみると違いを見ることができます。
obj = {
handleEvent: () => { console.log("make a difference"); },
};
document.body.addEventListener('click', obj, false);
// => undefined
// |handleEvent| will be invoked when clicked.
window.requestAnimationFrame(obj);
// => throw a TypeError: |obj| is not a function.
requestAnimationFrame
は例外を投げて動かないのに対して、addEventListener
はきちんと動きます。クリックするとコンソールにテキストが表示されます。addEventListener
はどうやらobj
のプロパティであるhandleEvent
を実行しているようです。obj
は関数ではないのになぜエラーにならないのか? handleEvent
というプロパティが実行されるのはどうしてなのか? 不思議ですね。次節で規格の詳細を少しだけ見てみましょう。
callback interfaceの仕様
addEventListener
に関する仕様を抜き出してみました。
interface EventTarget {
void addEventListener(
DOMString type,
EventListener? callback,
...<snip>...);
};
callback interface EventListener {
void handleEvent(Event event);
};
addEventListener
の第2引数はEventListener
型であることが分かります。EventListener
型はcallback interface
というキーワードで定義されており、handleEvent
という関数を持つように定義されています。これは第2引数のオブジェクトからhandleEvent
という名前のプロパティを読み出して、そのプロパティの値である関数を(Event
型の引数を渡して)実行せよ、という意味です。
-
addEventListener
は第2引数にEventListener
という名前のcallback interfaceを受け取るAPIだった。 -
EventListener
型のオブジェクトはhandleEvent
という名前のプロパティを持つこと、その値はEvent
型の引数を一つ受け取る関数であることが期待されていた。
ここでhandleEvent
というプロパティ名は各Web APIの規格で自由に命名してよく、決して固定されている訳ではありません。Web APIを策定している人たちで好きな名前を付けることができます。しかし実際にはほぼすべての規格でhandleEvent
という名前を使っています。わたしが見つけられた唯一の例外はNodeFilter
型がacceptNode
という名前を使っている例だけでした。(NodeFilter
許すまじ...)
上記の説明だけではaddEventListener
の第2引数は必ずhandleEvent
というプロパティを持つオブジェクトでなければならないかのように見えますが、実際にはただの関数を渡しても動きます。これは以下のルールがcallback interfaceの規格で定められているからです。
- 以下の条件を満たすcallback interfaceを single operation callback interface と呼ぶ。
- interfaceの継承を使っていない
- attributeを定義していない
- regular operationがoverloadを除き一つしか存在しない
- single operation callback interfaceで且つ与えられたオブジェクトが関数である場合には、その関数を呼び出す。
Web IDLの専門用語が大量に現れて難しそうですが、現実は随分と簡単です。single operation callback interfaceの条件に該当しないcallback interfaceはわたしの知る限り存在しません(Chromiumの実装の中にも見つかりません)。従って上記のルールは「関数を渡せば、その関数が呼び出される」という一言に集約されます1。尚、このルールはhandleEvent
プロパティの実行よりも優先します。
f = () => { console.log("wolf"); };
f.handleEvent = () => { console.log("ocelot"); };
document.body.addEventListener('click', f, false);
// Which of |f| or |f.handleEvent| will be invoked?
規格が正しく実装されていれば、コンソールには"ocelot"
ではなく"wolf"
と表示されるはずです。以上がcallback interfaceの基本仕様ですが、次節ではもう少しだけ細かい部分を見てみましょう。
handleEventプロパティの読み出しはいつ行われるか?
handleEvent
プロパティに保存されている関数はいつ読み出されるのでしょうか? addEventListener
を呼び出した時? それともコールバックの実行の直前でしょうか? これは後者だと規格ではっきりと決められています。コールバックを呼び出す度に毎回プロパティを読み出すべし(=読み出した関数をキャッシュしてはいけない)、と決められています。これは同時に、コールバックを登録する際(addEventListener
呼び出し時)には、プロパティが定義されている必要がないことにもつながります。
obj = {};
document.body.addEventListener('click', obj, false);
// no error at this point
document.body.click();
// nothing happens (or a TypeError on console)
obj.handleEvent = () => { console.log("cascade"); };
document.body.click();
// prints "cascade"
obj.handleEvent = () => { console.log("reverse"); };
document.body.click();
// prints "reverse"
ちなみにhandleEvent
プロパティの関数を実行する場合には、obj
をthis
として渡すことが規格で決められています。
obj.message = "mills";
obj.handleEvent = function() {
console.log(this.message);
this.message = "mess";
};
document.body.click();
// prints "mills"
console.log(obj.message);
// prints "mess"
callback interfaceの特徴を活かすには?
callback interfaceを受け取るWeb APIに関数ではないオブジェクトを渡してしまうと、handleEvent
プロパティの読み出しがコールバックの呼び出し毎に行われます。プロパティ値はキャッシュもされず、毎回読み出されるので、これは実行時ペナルティになります。素直にただの関数をコールバックとして登録するのが一番良いでしょう。
オブジェクト指向プログラミングをしている方は、レシーバオブジェクトとメソッドのbindの役割を任せられると思うかもしれませんが、わたしはお勧めできません。メソッド名がhandleEvent
に固定されてしまいますし、JITコンパイルが効かなくなる2ため遅いですし、必要に応じて自分でbindした方がよいです。
まとめ
長々と書きましたが、コールバックには常に関数を登録するとよい、というのが本記事の結論です。callback interfaceの細かい仕様を覚えてしまった方は忘れてしまって構いません。トリビア以外に使い途がありませんから。ウェブ開発者の視点からはcallback interfaceとcallback functionを区別する必要もありません。どちらも関数を渡せば動きます。
おまけ
callback functionも一応説明しておきます(忘れてた訳じゃないよ)。requestAnimationFrame
の仕様は次のようになっています。
interface Window : EventTarget {
unsigned long requestAnimationFrame(FrameRequestCallback callback);
};
callback FrameRequestCallback = void (DOMHighResTimeStamp time);
typedef double DOMHighResTimeStamp;
requestAnimationFrame
の引数はFrameRequestCallback
型であることが分かります。FrameRequestCallback
型は、callback interface
というキーワードの代わりに、callback
というキーワードで定義されており、関数型を定義しています。void
型を返し3、DOMHighResTimeStamp
型を引数に取る関数です。
FrameRequestCallback
型は関数型なので、requestAnimationFrame
に関数以外を引数に渡すとTypeError
が発生します。callback interfaceのような細かい規則もなく、渡した関数が呼び出されるだけ4です。シンプルでいいですね。NodeFilter
のような変種も存在しません。(NodeFilter
許すまじ...)
免責
この記事は私の個人的な意見に基づき書かれております。私の所属する組織、団体には一切の関係はありません。
また記事の内容の正しさは保証できません。特にウェブ規格はliving standardを採用しているため、随時更新されています。最新の情報はご自身でご確認ください。
-
将来的にsingle operation callback interfaceに該当しないcallback interfaceが追加される可能性はきわめて低いとわたしは考えています。複数のコールバックを必要とするWeb APIは複数のcallback functionを受け取るように定義するのが最近の動向です。 ↩
-
Chromiumではcallback interfaceの実装はJavaScriptエンジンではなく、レンダリングエンジンで行ってます。JavaScript言語の機能ではなく、Web APIの機能だからです。その結果JavaScriptエンジンのJITコンパイルの対象外になっています。他のブラウザ実装でも効率が良くなることはないと思われます。 ↩
-
コールバック関数はウェブ開発者がJavaScriptで書きますから、
undefined
を含め任意の値を返せます。「void
型を返す」というのは、ブラウザはコールバック関数の返り値を無視する、という意味です。 ↩ -
callback function呼び出し時の
this
オブジェクトは各Web API毎に定義されます。Web APIの規格が何も指定していない場合、デフォルトでグローバルオブジェクトがthis
として渡されます。ここが唯一callback functionで注意を要する点でしょうか。 ↩