🦥

CSSファイルをJSで読み込むとレンダリングを妨げない問題

2022/04/11に公開

Beako.jsが抱えている、3つの大きな問題の二つ目です。

一つ目と三つ目はこちら
https://zenn.dev/itte/articles/a80bd5a358ccbf

https://zenn.dev/itte/articles/71ba4005f5fb40

レンダリング妨げない問題

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がページ内で共存することがありうるのです。次のように。

html
<!DOCTYPE html>
<body>
  <div id="semantic-ui"></div>
  <div id="bootstrap"></div>
</body>
<script>
・・・・
</script>
js
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>
`

Semantic UIとBootstrapが共存

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ツリーを比較しながら足りない要素補います(以下、パッチ処理と呼ぶこととします)。

通常の仮想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)
})

Semantic UIからBulma

ボタンは2つにならず、一旦Semantic UIのスタイルになってから、Bulmaのスタイルに変わりました。
つまり、hrefの書き換えではloadイベントは発生しないということになります。

ちなみにこの例、setTimeout()を使って0.5秒待機していますが、使わずにhrefを書き換えると、興味深いことが起きます。

急にBulma

Semantic UIのスタイルには一度も変わらず、なぜかデフォルトのスタイルが一瞬だけ表示されます。
このことから分かることは、hrefを書き換えると新しいCSSファイルの読み込みを待たずに旧CSSファイルが削除されるということです。

  • hrefの書き換えではloadイベントは発生しない
  • hrefを書き換えると新しいCSSファイルの読み込みを待たずに旧CSSファイルが削除される

この2つから言えることは、hrefは一度設定したら触らないほうがいい(今のChromeの仕様では) ということです。

そいうわけで、「変更」の際はhrefを書き換えずに、次の手順をとる必要があります。

  1. 新しいlink要素を作ってhrefをセットする
  2. 新しいlink要素のloadイベントが発生するまで待機する
  3. loadイベントが発生したら元のlink要素と入れ替える

このことも念頭に、仮想DOMのパッチ処理を見ていきましょう。

awaitでパッチ処理を止める

awaitを使ってlink要素の「挿入」「変更」を見つけ次第、パッチ処理を一時停止してみます。

パッチ処理を止める

処理を一時停止というのは、awaitが無かった時代に作るとなると骨が折れそうですが、awaitを使えば簡単に実装できそうです。

デメリットとして、パッチ処理はそもそも同期関数なのですが、このためだけに非同期関数にしなくてはいけないということがあります。非同期になると、もしawaitで停止している間に新たにパッチ処理が動き出したら・・・どうなるのでしょうか。 注意深く開発しなくてはなりません。

また、link要素が複数あった場合に並行して読み込みすることができず、一つづつloadイベントを待ちながら処理を進めなければなりません。

パッチ処理を2回実行する

パッチ処理を止めずに解決する方法として、事前にlink要素の「挿入」「変更」だけのパッチ処理を行ってから、全体のパッチ処理を行うというように、2回パッチ処理を行うことが考えられます。

パッチ処理を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回目のパッチ処理ではloadedfalseのため、if文でbuttonがレンダリングされず、link要素だけになります。
loadイベントが発生すると、loadedtrueに書き換えられて、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要素の「挿入」「変更」をどうやって検知するかです。
この問題は「巻き上げ」を使って解消してみます。

  1. まず、仮想DOMツリーを辿って、link要素だけをツリーの最上位先頭に巻き上げて、ヘッダーとボディに分離します。

巻き上げと分離

  1. 新ヘッダーと旧ヘッダーを比較して「挿入」「変更」を検出します。ここはツリーになっていないので簡単です。「挿入」「変更」が無ければ手順5に飛びます。
  2. 「挿入」「変更」の場合、loadイベントを追加しておきます。
  3. 新ヘッダーと新ボディではなく、新ヘッダーと旧ボディで1回目のパッチ処理を行います。

新ヘッダーと旧ボディ

  1. すべてのloadイベントが発生したら、新ヘッダーと新ボディで2回目のパッチ処理を行います。

新ヘッダーと新ボディ

以上で、CSSファイルの読み込みを待ってからレンダリングが開始されます。2回目のパッチ処理が完了するまで一時的に旧ボディに新ヘッダーのスタイルが適用されてしまうのですが、旧ヘッダーのスタイルが新ボディに適用されるのを防ぐほうが利便性が良いと考えております。
CSSファイルの読み込みを待ちたくないときだけ、link要素には本来無いasync属性を用意します。

ところがこれも、やはり、loadイベントで待機しますので非同期処理となりますね。
待機している間に次の仮想DOM更新が走り出したら、おかしなことになりそうです。

仮想DOM更新①と仮想DOM更新②が連なってやってきたとして、
①の1回目のパッチ処理と②の1回目のパッチ処理は、必ず順序が保証されないといけません。
では、①の2回目のパッチ処理と②の2回目のパッチ処理の場合、①の2回目のパッチ処理は実行する必要がありません。むしろ実行されないでほしい。

というわけで、Beako.jsが抱えている、三つ目の大きな問題に進みます。

https://zenn.dev/itte/articles/71ba4005f5fb40

Discussion