CSSファイルをJSで読み込むとレンダリングを妨げない問題
Beako.jsが抱えている、3つの大きな問題の二つ目です。
一つ目と三つ目はこちら
レンダリング妨げない問題
HTMLでlink
タグを書くと、CSSファイルが読み込まれるまでレンダリングを妨げて、レイアウト崩れが発生するのを防ぎます。しかし、その挙動は初回だけで、JavaScriptで動的にlink
タグを書いてCSSファイルを読み込んでもレンダリングは妨げられず、レイアウト崩れが発生します。
再現してみる
例として、CSSフレームワークのSemantic UIをcdnjsから動的に読み込んで、併せてボタンを追加してみます。
※例としてSemantic UIを使用していますが、Semantic UIの問題ではなく、あらゆるCSSファイルで発生します。
const link = document.createElement('link')
link.setAttribute('rel', 'stylesheet')
link.setAttribute('href', 'https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css')
document.body.append(link)
const button = document.createElement('button')
button.innerText = 'Click me'
button.classList.add('ui', 'button')
document.body.append(button)
順序としては、link
要素を先に追加してからボタンを追加しているのですが、先にボタンが表示されて、後からCSSが適用されています。
innerHTML
でも発生する
次のようにinnerHTML
でまとめて追加しても変わらず発生します。
document.body.innerHTML = `
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css">
<button class="ui button">Click me</button>
`
DocumentFragment
でも発生する
淡い期待を込めてDocumentFragment
を使ってみても、やはり発生します。
const fragment = new DocumentFragment()
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = 'https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css'
fragment.append(link)
const button = document.createElement('button')
button.innerText = 'Click me'
button.classList.add('ui', 'button')
fragment.append(button)
document.body.append(fragment)
問題になるのはShadow DOMを使うとき
そもそも、Semantic UIのようなCSSフレームワークは、必ず読み込まれることを想定していますので、JavaScriptで動的に読み込むなんてことはナンセンスです。
ところが、Shadow DOMでは話は別です。Shadow DOMは自身のShadowツリー内だけに適用されるCSSをCSSファイルから動的に読み込むことを想定しています。
例えばSemantic UIとBootstrapがページ内で共存することがありうるのです。次のように。
<!DOCTYPE html>
<body>
<div id="semantic-ui"></div>
<div id="bootstrap"></div>
</body>
<script>
・・・・
</script>
const semanticUi = document.getElementById('semantic-ui').attachShadow({ mode: 'open' })
const bootstrap = document.getElementById('bootstrap').attachShadow({ mode: 'open' })
semanticUi.innerHTML = `
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css">
<button class="ui button">Click me</button>
`
bootstrap.innerHTML = `
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.6.1/css/bootstrap.min.css">
<button class="btn btn-primary">Click me</button>
`
Bootstrapのほうがサイズが小さいので先に描画されています。だからといってBootstrapのほうが良いわけではなく、1フレームでもデフォルトのスタイルが表示されてしまうのは避けなければなりません。
load
イベントで解決
この問題は確実な解決方法があります。
link
要素のload
イベントを使います。描画を遅らせたい要素の描画を行う関数をload
イベントのイベントリスナーに加えておけば、必ずCSSファイルの読み込み完了後に実行されます。
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = 'https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css'
document.body.append(link)
link.addEventListener('load', () => {
const button = document.createElement('button')
button.innerText = 'Click me'
button.classList.add('ui', 'button')
document.body.append(button)
})
解決するには解決するのですが、スマートじゃないですね。
例えば、
-
link
要素が2つ以上あるときはどうしましょう。 - 描画する要素が
link
要素かどうか分かっていないときはどうしましょう。
innerHTML
で記述することもできませんし、せっかくのShadow DOMもこれでは気軽に使うことができません。
HTMLに直接描画するときと同じようにレンダリングを妨げてくれれば、レンダリングを妨げたくないときだけlink
要素を後から追加するというだけで済むのですが・・・。
残念ではありますが、他の方法が見当たらないので、load
イベントで上手いことやっていくしかありません。
Promiseにしてみる
Promiseにしてみたら、少しは扱いやすくなるでしょうか。
function promisify(el) {
return new Promise(resolve => {
// link要素でcssを読み込むときだけloadとerrorイベントをリッスンする
if (el.tagName === 'LINK' && el.rel === 'stylesheet' && el.href) {
el.addEventListener('load', resolve)
el.addEventListener('error', resolve)
} else {
resolve()
}
})
}
これはさすが便利になります。
次のようにPromise.all()
を使えばlink
要素が複数になっても簡単に対応できます。
const link1 = document.createElement('link')
link1.rel = 'stylesheet'
link1.href = 'https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/components/reset.min.css'
document.body.append(link1)
const link2 = document.createElement('link')
link2.rel = 'stylesheet'
link2.href = 'https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/components/button.min.css'
document.body.append(link2)
const links = [link1, link2]
Promise.all(links.map(promisify)).then(() => {
const button = document.createElement('button')
button.innerText = 'Click me'
button.classList.add('ui', 'button')
document.body.append(button)
})
仮想DOMではどうするか
load
イベントをPromise化すれば、多くの場面で利用できそうです。
では私が利用したいBeako.jsの仮想DOMにはどうやって導入したら良いでしょうか。
Beako.jsの仮想DOMでは次の図のようにDOMツリーを比較しながら足りない要素補います(以下、パッチ処理と呼ぶこととします)。
ここでh3
要素がlink
要素のCSSを利用しているとします。
普通にパッチ処理を実行すると、load
イベントが発生する前にh3
要素が描画されますので、レイアウト崩れが発生します。
そのため、link
要素を挿入してからh3
要素を挿入する前にどこかでload
イベント発生まで待機しなくてはなりません。
link
要素の「変更」と「削除」
図ではlink
要素の「挿入」だけ示していますが、奨励したくない使い方として読み込むCSSファイルの「変更」とlink
要素の「削除」が発生する可能性があります。
「削除」はCSSファイルをブラウザがメモリに読み込み終わっているので瞬時に実行されます。そのたえload
イベントを待つ必要はありません。
「変更」のときは待つ必要がありますが、そもそもhref
の書き換えでload
イベントは発生するのでしょうか。
次の例は、href
の値をSemantic UIからBulmaに書き換えています。もし「変更」でload
イベント発生するのなら、ボタンが2つになるはずです。
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = 'https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css'
document.body.append(link)
link.addEventListener('load', () => {
const button = document.createElement('button')
button.innerText = 'Click me'
button.classList.add('ui', 'button')
document.body.append(button)
setTimeout(() => {
link.href = 'https://cdnjs.cloudflare.com/ajax/libs/bulma/0.9.3/css/bulma.min.css'
}, 500)
})
ボタンは2つにならず、一旦Semantic UIのスタイルになってから、Bulmaのスタイルに変わりました。
つまり、href
の書き換えではload
イベントは発生しないということになります。
ちなみにこの例、setTimeout()
を使って0.5秒待機していますが、使わずにhref
を書き換えると、興味深いことが起きます。
Semantic UIのスタイルには一度も変わらず、なぜかデフォルトのスタイルが一瞬だけ表示されます。
このことから分かることは、href
を書き換えると新しいCSSファイルの読み込みを待たずに旧CSSファイルが削除されるということです。
-
href
の書き換えではload
イベントは発生しない -
href
を書き換えると新しいCSSファイルの読み込みを待たずに旧CSSファイルが削除される
この2つから言えることは、href
は一度設定したら触らないほうがいい(今のChromeの仕様では) ということです。
そいうわけで、「変更」の際はhref
を書き換えずに、次の手順をとる必要があります。
- 新しい
link
要素を作ってhref
をセットする - 新しい
link
要素のload
イベントが発生するまで待機する -
load
イベントが発生したら元のlink
要素と入れ替える
このことも念頭に、仮想DOMのパッチ処理を見ていきましょう。
await
でパッチ処理を止める
await
を使ってlink
要素の「挿入」「変更」を見つけ次第、パッチ処理を一時停止してみます。
処理を一時停止というのは、await
が無かった時代に作るとなると骨が折れそうですが、await
を使えば簡単に実装できそうです。
デメリットとして、パッチ処理はそもそも同期関数なのですが、このためだけに非同期関数にしなくてはいけないということがあります。非同期になると、もしawait
で停止している間に新たにパッチ処理が動き出したら・・・どうなるのでしょうか。 注意深く開発しなくてはなりません。
また、link
要素が複数あった場合に並行して読み込みすることができず、一つづつload
イベントを待ちながら処理を進めなければなりません。
パッチ処理を2回実行する
パッチ処理を止めずに解決する方法として、事前にlink
要素の「挿入」「変更」だけのパッチ処理を行ってから、全体のパッチ処理を行うというように、2回パッチ処理を行うことが考えられます。
この方法は、パッチ処理自体をどうこうするのではなく、パッチ処理を使う側がひと手間追加することになります。
Beako.jsでは次のようにすることで、v0.11.1の現在でも「挿入」の場合の問題を解決できます。
import { watch, mount } from 'https://cdn.jsdelivr.net/gh/ittedev/beako@0.11.1/beako.js'
const state = watch({
loaded: false
})
const html = `
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css"
onload="loaded = true"
>
<button class="ui button" @if="loaded">Click me</button>
`
mount(document.body, html, state)
1回目のパッチ処理ではloaded
がfalse
のため、if文でbutton
がレンダリングされず、link
要素だけになります。
load
イベントが発生すると、loaded
がtrue
に書き換えられて、2回目のパッチ処理が動いて無事にbutton
がレンダリングされます。
とはいえ、CSSファイルの読み込みのためにこんな記述が毎回必要になると、使い勝手が悪いですので、Beako.jsに内蔵されるべきです。
実現するには、テンプレートから動的に変化するlink
要素の「挿入」「変更」だけの仮想DOMツリーを作らなくてはなりません。本来パッチ処理の段階で検知されるはずの「挿入」「変更」を、パッチ処理の前に検知しなけらばなりませんので、実装はちょっと難しそうですね。
どっちを採用するか
2つ思いつきましたが、文章に起こしてみて、難しいとはいえ「パッチ処理を2回実行する」しか選択肢が無いように思います。await
を使った場合の、link
要素を並列読み込みできないのは致命的です。
Beako.jsの仮想DOMは、差分を検知しながらパッチを当てていますが、virtual-domだと、diffで差分をすべて抽出してから一つづつパッチを当てているようです(なぜだろう・・・)。他の仮想DOMも同じ感じなのかもしれません。その場合は、抽出したパッチの中からlink
要素だけ先に実行するとかできるのかもしれません。
他に方法も思いつかないので、「パッチ処理を2回実行する」を実装する方向で進めていこうと思います。
[追記] 採用した手法
あれからrel="preload"
も検討してみましたが、先読みしていてもrel="stylesheet"
に変更したときに結局読み込み時間がかかることが分かりました。あまり意味無いようです。
そういうわけでパッチ処理を2回実行する方法を採用しました。
問題となるのはlink
要素の「挿入」「変更」をどうやって検知するかです。
この問題は「巻き上げ」を使って解消してみます。
- まず、仮想DOMツリーを辿って、
link
要素だけをツリーの最上位先頭に巻き上げて、ヘッダーとボディに分離します。
- 新ヘッダーと旧ヘッダーを比較して「挿入」「変更」を検出します。ここはツリーになっていないので簡単です。「挿入」「変更」が無ければ手順5に飛びます。
- 「挿入」「変更」の場合、
load
イベントを追加しておきます。 - 新ヘッダーと新ボディではなく、新ヘッダーと旧ボディで1回目のパッチ処理を行います。
- すべての
load
イベントが発生したら、新ヘッダーと新ボディで2回目のパッチ処理を行います。
以上で、CSSファイルの読み込みを待ってからレンダリングが開始されます。2回目のパッチ処理が完了するまで一時的に旧ボディに新ヘッダーのスタイルが適用されてしまうのですが、旧ヘッダーのスタイルが新ボディに適用されるのを防ぐほうが利便性が良いと考えております。
CSSファイルの読み込みを待ちたくないときだけ、link
要素には本来無いasync
属性を用意します。
ところがこれも、やはり、load
イベントで待機しますので非同期処理となりますね。
待機している間に次の仮想DOM更新が走り出したら、おかしなことになりそうです。
仮想DOM更新①と仮想DOM更新②が連なってやってきたとして、
①の1回目のパッチ処理と②の1回目のパッチ処理は、必ず順序が保証されないといけません。
では、①の2回目のパッチ処理と②の2回目のパッチ処理の場合、①の2回目のパッチ処理は実行する必要がありません。むしろ実行されないでほしい。
というわけで、Beako.jsが抱えている、三つ目の大きな問題に進みます。
Discussion