🕳

DOMDOMタイムス#5: コンテクストをまたいだinstanceofはハマりポイント説、あるいはshadow DOM内外判定について

2023/07/18に公開

先週は熱を出してしまいおやすみしてしまったDOMDOMタイムス!果たして読んでいる人がいるのか分かりませんが、淡々と更新していきます。

さて、今日はJavaScriptの実行コンテクストについてです。DOMに関係あるの!?と思ったあなたはぜひ読んでいってくださいinstanceofを題材にしています🌝
(なお自分は「コンテキスト」ではなく「コンテクスト」派です!めちゃくちゃどっちでもいいですねえ)

実行コンテクストがDOMの文脈で重要になることある??

突然ですが、クリックした要素について、それがshadow DOM内にいるのかを見るスクリプトを作りましょう!

const isInShadowRoot = (target) => {
    const rootNode = target.getRootNode()
    if (rootNode instanceof ShadowRoot) {
        console.log('shadow DOM内にいます!')
    } else {
        console.log('shadow DOMの外だよ〜🌝')
    }
}

window.addEventListener('click', (e) => {
    // e.targetはshadow rootに入りこまないように自動で調整されるやつなので、
    // shadow rootの中までちゃんと掘り返したいときはcomposedPath()を使おう!
    isInShadowRoot(e.composedPath()[0])
})

もちろん残念ながらclosedなshadow rootには対応していませんが、こんな感じで書けます。

ただ、ここであなたは気づきました。これではiframeに対応できないじゃないかと!
というわけで改良です。window上にある全てのframeにこのイベントハンドラーを仕込みましょう🌝

const isInShadowRoot = (target) => {
    const rootNode = target.getRootNode()
    if (rootNode instanceof ShadowRoot) {
        console.log('shadow DOM内にいます!')
    } else {
        console.log('shadow DOMの外だよ〜🌝')
    }
}

window.addEventListener('click', (e) => {
    isInShadowRoot(e.composedPath()[0])
})

// ここを追加
if (window.frames.length > 0) {
    for (let i=0; i<window.frames.length; i++) {
        window.frames[i].addEventListener('click', (e) => {
            isInShadowRoot(e.composedPath()[0])
        })
    }
}

はい、これダメです😭

下のページで実際に試せるから、クリックしてみてね👇👇

お気づきでしょうか、iframeの中は軒並みshadow DOMの外だと判定されてしまいます。
どうしてこうなってしまうのでしょうか😭

instanceofの仕組みと実行コンテクスト

大前提として、instanceofは1つめの引数(左辺)として与えられたオブジェクトのプロトタイプチェーン上に、2つめの引数(右辺)として与えられた関数(constructorと呼んだ方が分かりやすいかもしれません)のprototypeが存在するかを確認してくれます。

さて、ここで私たちはframeそれぞれが持つJavaScriptの実行コンテクストは異なっているという事実を思い出す必要があります。
そして、異なる実行コンテクストを持つということは、異なるwindowを持ち、そこには異なるプロトタイプが用意されているということです。

JavaScript execution environments (windows, frames, etc.) are each in their own realm. This means that they have different built-ins (different global object, different constructors, etc.).
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/instanceof#instanceof_and_multiple_realms

実際にこんなことで確かめることができます。

frameがあるサイト、例えば「阿部寛のホームページ」などで実行してみよう
console.log(window === window.frames[0].window)
// => false
console.log(document === window.frames[0].contentDocument)
// => false
console.log(window.Array.prototype === window.frames[0].window.Array.prototype)
// => false

instanceofの仕組み、そしてframeはそれぞれ異なる実行コンテクストを持つという2つの事実から、もうわかるかと思います。先ほどの動作しないコードをもう一度見てみましょう。

const isInShadowRoot = (target) => {
    const rootNode = target.getRootNode()
    if (rootNode instanceof ShadowRoot) {
        console.log('shadow DOM内にいます!')
    } else {
        console.log('shadow DOMの外だよ〜🌝')
    }
}

window.addEventListener('click', (e) => {
    isInShadowRoot(e.composedPath()[0])
})

if (window.frames.length > 0) {
    for (let i=0; i<window.frames.length; i++) {
        window.frames[i].addEventListener('click', (e) => {
            isInShadowRoot(e.composedPath()[0])
        })
    }
}

isInShadowRoot()instanceof使用部分はトップレベルのShadowRootコンストラクタを参照しており、その計算が異なるframeに仕込まれています。これは要するに、isInShadowRoot()はあるオブジェクトについて「トップレベルのShadowRootプロトタイプにプロトタイプチェーンでつながっていますか」という判定をやっていることになります。だから、トップレベルとは異なるframeでisInShadowRoot()を実行しても結果は常にfalseです。

このようにコンテクストをまたいでinstanceofを使うことについては、MDNでも注意喚起(?)がなされています。先ほどの引用部分を後続の部分まで含めて再掲します。

JavaScript execution environments (windows, frames, etc.) are each in their own realm. This means that they have different built-ins (different global object, different constructors, etc.). This may result in unexpected results. For instance, [] instanceof window.frames[0].Array will return false, because Array.prototype !== window.frames[0].Array.prototype and arrays in the current realm inherit from the former.
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/instanceof#instanceof_and_multiple_realms

じゃあいったいどうしろっていうの!?

このstackoverflowをみてください🌝

ここにあるアイデアをざっと眺めると下記のようなやり方が一番よさそうです。

const isInShadowRoot = (target) => {
    const rootNode = target.getRootNode()
    if (Object.prototype.toString.call(rootNode) === "[object ShadowRoot]") {
        console.log('shadow DOM内にいます!')
    } else {
        console.log('shadow DOMの外だよ〜🌝')
    }
}

// ここから下は変わっていません //
window.addEventListener('click', (e) => {
    isInShadowRoot(e.composedPath()[0])
})

if (window.frames.length > 0) {
    for (let i=0; i<window.frames.length; i++) {
        window.frames[i].addEventListener('click', (e) => {
            isInShadowRoot(e.composedPath()[0])
        })
    }
}

要するに判定のロジックをtoString()頼りにしています。
(rootNode.toString()としていないのは、いちおうrootNodetoString()が書き換えられている可能性を考慮しています)

実際に試してみよう👇

うまくいっていることが分かると思います、よかったねえ🌝

もう1つのやり方について

他にも「判定コールバックについて、それが呼ばれたコンテクスト上のShadowRootコンストラクタを参照してinstanceofするようにする」というやり方がありそうです。ただ、これはある状況下において正しい判定ができません

まず思い浮かぶのは「frameのattachShadow()が、トップレベルのattachShadow()に上書きされていたら、frameの中でつくられるshadow rootがもしかしてトップレベルのものを参照したりしませんか」というポイントです。上書きというのはこんな風に。

// frameの中でshadow rootのhostとなる要素について、attachShadowを上書きする
const host = window.frames[0].document.getElementById("host");
host.attachShadow = function _attachShadow(option) {
  console.log("上書きぃ!");
  return document.body.attachShadow.call(this, option);
};

実は、これについては心配要りません
というのも大前提として、トップレベルおよび各frameのattachShadow()はいわゆるnative codeである、つまり実装がブラウザ側でなされているシロモノですが、attachShadow()は仕様によれば「呼び出しコンテクストとなる要素(Element)が属するdocumentに属するshadow rootを作成してアタッチする」ように実装されています。whatwgのDOM Living Standardを見てみましょう。

Let shadow be a new shadow root whose node document is this’s node document, host is this, and mode is init["mode"].
https://dom.spec.whatwg.org/#dom-element-attachshadow

結果として、あるframeの中の要素のattachShadow()が、トップレベルのattachShadow()で上書きされていても、作られるshadow rootのプロトタイプはちゃんとそのframeの実行コンテクスト上に存在するわけです。

ひまな人は試してみよう!👇

では、別の心配事として「異なるframe上に存在するshadow rootのプロトタイプ参照先(__proto__)が、トップレベルのshadow rootプロトタイプに書き換えられていたら?」ということについてはどうでしょう??こんな風にです👇

const host = window.frames[0].document.getElementById("host");
const shadow = host.attachShadow({ mode: "open" });
shadow.__proto__ = window.ShadowRoot.prototype;

はい、残念ながらこれをされると終わりです
(そんな書き換えやる人いる!?という感じもしますが、トップレベルのshadow rootプロトタイプを魔改造したうえで各frameにもそれを伝播させたいがために書き換えるという場合が……いや、やっぱりそんな人いないか)

ひまな人は試してみよう!実際に判定がうまくいかなくなってしまっていることが分かるかと思います👇👇

というわけで、万全を期すならtoString()の方が良いのかもしれませんね👶

おわりに

さて、今回はinstanceofを題材にしつつ実行コンテクストとやらを取り上げました。
実は記事を書いていて改めて一番楽しかったのは、プロトタイプチェーンのおさらいだったりします。記事には書かなかったことがたくさんあり、プロトタイプベースは面白いなとあらためて感じられました✌

また、実はcodesandboxでつくった例ではdeclarative shadow DOMを使ってみました。これはめちゃくちゃ便利ですね!?

ところでframeたちは異なるコンテクストと言いつつ、先ほど見たようにプロトタイプを共有することができちゃいます。また、異なるコンテクストではありますが同じDOMツリーにアクセスもしています。このあたりは精確なメモリの状況が少し気になったりならなかったりします。DOMツリー上の様々な参照構造とあわせるなどしつつ、いつか記事にしてみたいものです👶

ではでは今日はここまでであります。

GitHubで編集を提案

Discussion