これはChromium Browser アドベントカレンダーの五日目の記事です。本記事ではWeb IDL規格におけるcallback interfacecallback 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に関する仕様を抜き出してみました。

EventTarget.addEventListener
interface EventTarget {
  void addEventListener(
      DOMString type,
      EventListener? callback,
      ...<snip>...);
};
EventListener
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の規格で定められているからです。

  1. 以下の条件を満たすcallback interfaceを single operation callback interface と呼ぶ。
    1. interfaceの継承を使っていない
    2. attributeを定義していない
    3. regular operationがoverloadを除き一つしか存在しない
  2. 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プロパティの関数を実行する場合には、objthisとして渡すことが規格で決められています。

continued
obj.message = "mills";
obj.handleEvent = () => {
    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の仕様は次のようになっています。

Window.requestAnimationFrame
interface Window : EventTarget {
  unsigned long requestAnimationFrame(FrameRequestCallback callback);
};
FrameRequestCallback
callback FrameRequestCallback = void (DOMHighResTimeStamp time);
DOMHighResTimeStamp
typedef double DOMHighResTimeStamp;

requestAnimationFrameの引数はFrameRequestCallback型であることが分かります。FrameRequestCallback型は、callback interfaceというキーワードの代わりに、callbackというキーワードで定義されており、関数型を定義しています。void型を返し3DOMHighResTimeStamp型を引数に取る関数です。

FrameRequestCallback型は関数型なので、requestAnimationFrameに関数以外を引数に渡すとTypeErrorが発生します。callback interfaceのような細かい規則もなく、渡した関数が呼び出されるだけ4です。シンプルでいいですね。NodeFilterのような変種も存在しません。(NodeFilter許すまじ...)

免責

この記事は私の個人的な意見に基づき書かれております。私の所属する組織、団体には一切の関係はありません。

また記事の内容の正しさは保証できません。特にウェブ規格はliving standardを採用しているため、随時更新されています。最新の情報はご自身でご確認ください。


  1. 将来的にsingle operation callback interfaceに該当しないcallback interfaceが追加される可能性はきわめて低いとわたしは考えています。複数のコールバックを必要とするWeb APIは複数のcallback functionを受け取るように定義するのが最近の動向です。 

  2. Chromiumではcallback interfaceの実装はJavaScriptエンジンではなく、レンダリングエンジンで行ってます。JavaScript言語の機能ではなく、Web APIの機能だからです。その結果JavaScriptエンジンのJITコンパイルの対象外になっています。他のブラウザ実装でも効率が良くなることはないと思われます。 

  3. コールバック関数はウェブ開発者がJavaScriptで書きますから、undefinedを含め任意の値を返せます。「void型を返す」というのは、ブラウザはコールバック関数の返り値を無視する、という意味です。 

  4. callback function呼び出し時のthisオブジェクトは各Web API毎に定義されます。Web APIの規格が何も指定していない場合、デフォルトでグローバルオブジェクトがthisとして渡されます。ここが唯一callback functionで注意を要する点でしょうか。