🎤

DOMDOMタイムス#6: 鏡の国のDOMラッパー、あるいはブラウザ内部のDOMとJSのDOMの結びつきについての覚書

2023/07/26に公開

DOMDOMタイムス、今日はDOMの内部実装の話。話を単純にするため、V8の実装を念頭において進めていくよ
ふだんJavaScriptで操作しているDOMのオブジェクトは、ブラウザ内部で管理されているDOMのオブジェクトとは別のものだよという話。
これだけ聞くと当たり前かもしれないけど、周辺をふかぼりして考えてみるとけっこう面白かったわけです👶

DOMの実体、そしてDOM wrapper

さっそくだけどDOMの実体はブラウザ内部、例えばchromiumならC++の世界に存在していることに思いを馳せてほしいのです。
考えてみればchromiumの場合、ページを構築するのはBlinkというレンダリングエンジンなわけで、C++で実装されたBlinkがHTMLやらCSSやら場合によってはscriptまで含めた材料からDOMを構成してくれています。

BlinkがC++の世界、というかブラウザ内部の世界に作ったDOMの実体、誤解を恐れずに言えば「ホンモノのDOMオブジェクトたち」に私たちが直接触ることはできません。C++の世界でメモリ上に構成されたDOMの実体を、何も介さず、それこそC++でBlinkが操作しているような方法で直接操作する手段はブラウザのインターフェースに実装されていないわけです

それでも私たちはDOMを触っている気分にはなります。JSを通じてDOMに関する操作や計算がたくさんできるからです。
でもこれは冷静になってみると、C++の世界にあるDOMオブジェクトに対応するモノがJSの世界に存在しないとできないはずのことだと分かります。

この「C++世界のDOMオブジェクトの、JS世界における対応物」こそが"DOM wrapper"と呼ばれるシロモノです
私たちがJSを駆使してDOMを操作しているとき、それらは全てラッパーなのです。

const div = document.createElement('div')
div // これはラッパー。「div要素の実体」はブラウザ内部のC++の世界にある
document // そもそもこれだってラッパー。
div.dispatchEvent(new Event('click')) // これで生じるeventもラッパー。「eventの実体」はブラウザ内部のC++の世界にある

DOMラッパーは越境できない

ところでV8にはworldという概念があります。これはDOMラッパーに深く関わる概念なので、ここで少しおさらいしてみます👶

少なくともV8においてはweb経由でダウンロードされたスクリプトが実行される「世界」と、ブラウザ拡張それぞれが実行される「世界」が区切られています。
これら「世界」をV8では"world"と呼びます。web経由でダウンロードされたスクリプトが実行される「世界」はmain worldと呼ばれ、拡張たちのそれはisolated worldと呼ばれます。

A main world is a world where a normal JavaScript downloaded from the web is executed. An isolated world is a world where a content script of a Chrome extension is executed. An isolate of the main thread has 1 main world and N isolated worlds.
https://chromium.googlesource.com/chromium/src.git/+/62.0.3178.1/third_party/WebKit/Source/bindings/core/v8/V8BindingDesign.md

ご存知の通り、各worldの実行contextは互いに異なります。

Also each world has its own context. This means that each world has its own global variable scope and prototype chains.
https://chromium.googlesource.com/chromium/src.git/+/62.0.3178.1/third_party/WebKit/Source/bindings/core/v8/V8BindingDesign.md

余談ですが、isolated worldつまり拡張の世界からmain worldつまりウェブページのスクリプトの世界へ何かスクリプトを注入するとき、scriptタグを作ってDOMに入れるのではうまくいきません。セキュリティなどの都合からそのようなスクリプトは実行されないようになっています。
だからこそ拡張の開発においてはWeb Accessible Resourcesが取りざたされるのです(isolated worldからmain worldへのスクリプト注入はWeb Accessible Resourcesを通じて行うのがセオリーであるはずです)。

さて、このように実行contextを互いに異にするworldたちは、下記の2点のルールを守りながらそれぞれがDOMとインタラクションします。

  • DOMオブジェクトの実体(C++の世界のDOM)は、すべてのworldが共有する
  • DOMラッパー(JSの世界のDOM)は、それぞれのworldで持つ

All worlds in one isolate share underlying C++ DOM objects, but each world has its own DOM wrappers. That way the worlds in one isolate can operate on the same C++ DOM object without sharing any DOM wrapper among the worlds.
https://chromium.googlesource.com/chromium/src.git/+/62.0.3178.1/third_party/WebKit/Source/bindings/core/v8/V8BindingDesign.md

要するに、こんなふうになっているわけです。C++をオリジナルとした鏡の国がたくさんあるようなイメージです!

ブラウザはDOMラッパーの取り扱いをけっこうがんばっている

実はこの鏡の国構造を、DOMに関する整合性を保ったまま実装するのはけっこう難しいようで。

例えばこのような例がV8のデザインドキュメントに載っています。
https://chromium.googlesource.com/chromium/src.git/+/62.0.3178.1/third_party/WebKit/Source/bindings/core/v8/V8BindingDesign.md

var div1 = document.createElement('div')
div1.hoge = 'hoge'

var div2 = document.createElement('div')
div2.appendChild(div1)

div1 = null

// ここでガベージコレクションを実行してみる
// (例えばchromeならdev toolsのmemoryタブのゴミ箱マークからできる🗑)

console.log(div2.firstChild.hoge)
// => 'hoge'

ここで注意しなければならないのは、上記div1へのhogeプロパティ追加は――hogeプロパティは単純にプロパティアクセスしても要素の属性として扱われないので――C++の世界のオブジェクトには影響を与えず、あくまでもDOMラッパーへの変更、つまりJSの世界での変更にとどまるということです。
だから、例えばJSの世界でDOM要素へのアクセスがあるたびに、C++の世界のDOMの情報を参照してDOMラッパーを新規作成していてはダメなのです。そんなことをしたらhogeプロパティは一瞬で失われてしまうでしょう。DOMラッパーのプロパティ操作はC++の世界が知り得ない情報なのだから、それはJSの世界で保持し続けなければならないのです。

もちろん、DOMラッパーのプロパティ操作も含めて何から何までC++の世界に持ち込んで管理するという方法もありえるでしょう。hogeの値をC++の世界でも記録しようという発想です。
しかしそれはそれですごく不便になりそうです。先ほど引用した"each world has its own DOM wrappers"という言葉を思い出してください。あるworldで行われたDOMラッパーへの操作でJSの世界にとどまる類のもの(例: DOM要素の属性などではない単なるプロパティを追加する)は他のworldでシェアされてはならないのです
実際、いまあなたがこの記事をchromeで読んでいて、そのchromeに拡張が入っているなら、このことをすぐに確かめられます。下記をやってみてください🍉。

// main worldでbodyに属性として扱われないような単なるプロパティを付与してみよう
// つまりコンソールを開き、実行コンテクストが"top"になっていることを確認して、下記を実行する
document.body.example = "this is NOT shared with other worlds"
document.body.example
// => "this is NOT shared with other worlds"

// そのあとisolated worldで、いま追加したプロパティにアクセスしてみる
// つまりコンソールを開き、実行コンテクストを適当な拡張のコンテクストに変更して、下記を実行する
document.body.example
// => undefined

こんな事情があるため、DOMラッパーの属性ではないプロパティまでC++で管理しようとすると、それがどのworldからなら「見える」ものなのかも管理するはめになります。もっといえば各worldはframeの数だけコンテクストを持つわけですから、どのコンテクストから「見える」かを考えなければならないのです。
やろうと思えばできるのかもしれませんが、そんなことをするくらいなら、せっかくJSが実行コンテクストという便利な隔離壁を持っているのだからそこに任せた方が良さそうです

そんなこんなで、それぞれのworldはDOMラッパーをC++世界のDOMオブジェクトに正しく結び付けなければならないため、そのマッピングを保持しています

To meet the requirements, we make each world hold a DOM wrapper storage that stores a mapping from the C++ DOM objects to the DOM wrappers in that world.
As a result, we have multiple DOM wrapper storages in one isolate. The mapping of the main world is written in ScriptWrappable. If ScriptWrappable::main_world_wrapper_ has a non-empty value, it is a DOM wrapper of the C++ DOM object of the main world. The mapping of other worlds are written in DOMWrapperMap.
https://chromium.googlesource.com/chromium/src.git/+/62.0.3178.1/third_party/WebKit/Source/bindings/core/v8/V8BindingDesign.md

なお、あるworldでDOM要素の属性を変更する(例: hogeプロパティとかではなくidプロパティを設定する / setAttribute()を使う)場合、それをC++のDOMオブジェクトにも反映させる必要があります。属性はDOMの実体に関わる話であり、それは各worldで共有されるべき変更だからです
この点について特に興味深いのは、idのようにDOMラッパーオブジェクトでプロパティアクセスをすると、それがちゃんと属性として扱われるようなプロパティです。

// これを実行しても属性は何も設定されない
document.body.example = 'example'
// これを実行するとid属性が設定される
document.body.id = 'id'

bodyのDOMラッパーオブジェクトにおいて、exampleプロパティは単なるJSオブジェクトのプロパティどまりであるのに対して、idプロパティはC++の世界にも影響を及ぼすシロモノです。
ゆえにidにはきちんとsetterが定義されています。このsetterは見てみるとnative codeであり、おそらくC++の世界のDOMオブジェクトに変更を波及させる機構が実装されているのだと思います。

なお、こういった「直接アクセスできるようにgetter/setterを用意しておいてね系の属性」はきちんとDOM standardに明記されており、classNameといった名前が挙げられています🌝
https://dom.spec.whatwg.org/#ref-for-dom-element-id①

実際こんなふうに__lookupSetter__すると、これらの属性についてはsetterが定義されていることがわかります。

worldをまたいだイベントのやりとり

もう1つの興味深いトピックとして、イベントはすべてのworldで共有されるという話があります。
ネット上では言及されているのをあまり目にしませんが、これを使えばイベントのtargetを介してworld間で要素(というか要素の参照というか)を受け渡すことが可能です。これは拡張開発の文脈で役立つことがありそうですネ👶
例えば下記のようにすることができます。

// main worldにて、windowにEventListenerを仕掛ける
window.addEventListener('passElement', (e) => {
    callback(e.target)
})

// isolated worldから、渡したい要素をtargetとしてeventをdispatchする
elementToBePassed.dispatchEvent(new Event('passElement', {bubbles: true, composed: true}))
// => main worldでcallbackがelementToBePassedを引数として実行される!!

この流れは図にしてみると少し面白い気がします。

Window.postMessage()なんかも要素を運ぶことはできないけど、ちょっとだけ手品めいた雰囲気が出てきます。

// main worldにて、topレベルのwindowでmessageを待ち構える
window.addEventListener('message', (e) => {
    callback(e.data)
})

// isolated worldにて、topレベルから1つ下のiframeから、topレベルのwindowへmessageを送る
window.frameElement.ownerDocument.defaultView.postMessage('some data', '*')
// => main worldでcallbackが'some data'を引数として実行される!!

拡張のworldのframeから、main worldのtop level windowに手紙が届くのがちょっと不思議じゃないですか!?不思議ではないか。でも面白いですよね!👶

おわりに

DOMの内部実装やその周辺の話はめちゃ面白いなと思いました。
world, frame contextのあたりの帳尻のあわせ方だったり、DOMラッパーの微妙な挙動だったりは調べて実際に動かしてみて楽しかったです。
なお、ガベージコレクションについての下記の記事は今回の話のふかぼりとしてめちゃよさそうでした。なんだかすごくディープな感じがして、そのうち読んでみようと思います🌝
https://blog.dodgson.org/b/2012/12/19/dom-and-gc-or-what-happend-at-eden/

というわけで今回はここまで!月曜日に更新できなかったけど、なんとか週のうちに更新できてよかったねえ〜

GitHubで編集を提案

Discussion