フロントエンドワークショップ DOMとレンダリングパフォーマンス
DOM = Document Object Model
HTMLやXMLをAPI経由で操作するためのインターフェイス
ReactのVirtual DOMはこの実際のDOMと対になる軽量なデータ構造を用いて差分などを管理することで軽量かつ高速に処理できるような仕組みになっている
DOM API自体は色々あるんですが、(暴論ではあるとは思うが、DOMを操作するAPI自体は)Reactを使う上ではそんなに知らなくてもなんとかなる
documet.querySelector
で取得とか、 element.append()
でNodeの挿入とか
一方で関連した値を取得するようなAPIはちょくちょく利用することになることがある
ウェブブラウザ上のJavaScriptには最初から必要なAPIが組み込まれている
Reactを使う時にも知っておきたいDOM API
3つの知っておきたいデータ型
document.body
で <body>
を取得
document.title
でそのページのタイトルを取得/書き込み
Window: Documentを含む「ウィンドウ(現代のブラウザだと「タブ」)」を表現するインターフェイス
window.devicePixelRatio
: ウィンドウを表示している画面のDPRを取得
window.innerWidth
: ウィンドウの内部の横幅
JavaScriptのtop level scopeでもある(ので、省略可能)
window.alert()
→ alert()
Tips: ここは意見も分かれるが、省略しない方がオススメです
ReactでもTypeScriptで型を書く際にイベントハンドラーなどではこの型を使用することが多々ある
HTMLElement
: HTML要素を表すインターフェイスの基となるインターフェイス、基本的なインターフェイスを備えていれば良い場合はこれを使う
HTMLAnchorElement
、 HTMLInputElement
、 HTMLFormElement
など……
elememt.id
: id
属性を取得
element.classList.add()
, element.classList.contains()
, element.classList.remove()
element.getClientRect
: 要素のサイズと位置を取得
Elementに関する値は基本的にはReact(とVirtual DOM)で管理されるものなので、DOM APIを用いて書き換えると酷い目にあいます
値を読み取るときも注意が必要(後述)
その他の代表的なDOM APIとキーワード
window.addEventListener(eventName, handler)
resize
画面サイズが変更された時に発火する
現代だと ResizeObserver
や window.matchMedia
などで代替出来ることも
DOMContentLoaded
HTMLのパースが全て完了し(DOMが構築され)た時
読み込みとレンダリングの終了は load
DOMとしては構築されているので、例えばReact Componentをマウントするためのrootを取ってくるなどは出来る
load
HTMLやJSやCSSや画像など必要なリソースの読み込みが全て終了した時
onload
は別のハンドラーにも GlobalEventHandlers.onload
<img>
の読み込み時
document.readyState
を見て complete
だと↑のようなのを仕掛けても発火しない(何故ならもうすでに終わっているので)
window.alert()
window.confirm()
ユーザーインタラクションを発生させる
window.scrollTo()
画面のスクロール
JavaScriptでDOMを変更した際のレンダリングサイクルについて
ReactではVirtual DOMを操作するが、裏側ではもちろんDOMの操作が行われるので、こういうサイクルがReactのライフサイクルとは別で存在しているということを抑えておく
図です
Style: CSSがどの要素に当たるかをセレクターなどから計算する
Layout: そのHTML要素自身の持っているルールやCSSなどからどういう場所にどういうスペースが必要であるかなどを計算する(ブラウザによってはreflowとも)
ここで1つの要素のレイアウトの計算結果が、その周辺の要素などにも影響を与えることがあることに注意
つまり影響を与える範囲が大きいとブラウザでの処理が複雑になる
Paint: Layoutを元にウェブブラウザの画面上に表示するためのピクセルを実際に描画する。
レイヤーの概念を利用して複数の面を重ねたりして表現をする
Composite: レイヤーの重なりなどが正しくなるように順序を考慮して最終的な成果物を生成する
LayoutとPaintが起きるとレンダリング更新のコストが高まることがある
→つまりFPSが低下する
レンダリングが起きるパターンを検討する
すべてが起きる場合
Layoutを変更することになる left
や width
などの変更
レイアウトの変更が起きない場合
background
や color
などの変更
Layoutはスキップ出来るがPaintは発生する
Compositeだけが起きる場合
transform
を用いた変更や opacity
の変更
Paintなども既存のものが利用され、Compositeだけが発生するので非常に軽量
width
や left
を変更してアニメーション表現をする場合は、 transform
などで代用が出来るとレンダリングを軽量にすることが可能
ChromeのDevToolを使って様子を確認してみよう
まずはPerformanceを使って素朴に様子を見る
それぞれのフェイズに掛かっている時間が分かる
数を増やして、Optimize / Un-Optimizeで様子が変わることを確認してみよう
RenderingタブのFrame Rendaring Statsを有効にするとFPSメーターなどが表示できる
ペイントが更新された領域を確認する
Rendaring の Paint Flashingを有効にすると、Paintが更新された領域が緑色になる
意図しない要素の再ペイントが走ってないかなどを確認できる
Layer bordersを有効にするとどのようにレイヤーが構成されているかを見ることが出来る
レイヤーに関する情報を確認する
レイヤーの様子を詳細に見ることが出来る
そのレイヤーがPaintされた回数
そのレイヤーがCompositeされる理由
ドラッグで角度を変えたり、スクロールでズームしたりできる
Paint Profilerを見るとPaintにどういう時間が掛かったかや内部で発生したペイントコールの詳細を確認することが出来る(が、ここの情報を使って最適化するということはあまり無いかも)
ペイントの変更のされ方によってウェブブラウザがどのようにペイントを実施するかを確認できる
レイヤーを分ける方法
CSSの will-change
プロパティを付与すると新しいレイヤーが生成される
※ will-change
はその要素の transform
や opacity
が変更されることを明示し、ウェブブラウザにその要素のレイヤーを分離させて変更のための準備を行い、変更を最適化させるためのCSSプロパティ
レイヤーはメモリを消費し、レイヤーが増えるとCompositeのコストが増大することに注意
Profilerを注意深く確認し本当に必要なときのみ行う(基本的にはブラウザにまかせておいて問題ない)
css* {
will-change: transform;
}
とするとすべての要素をレイヤーに分割できる
Forced Synchronous Layout (強制同期レイアウト)の回避
Styleの変更をしなくてもLayoutをJavaScriptの実行中に同期的に発生させるAPIがあることに注意
例えばJavaScriptを用いて要素の高さを記録する
jsfunction loggingBoxHeight (box) {
console.log(box.clientHeight) // このときbox.clientHeightを取得するために暗黙的にLayoutが走る
}
上記のような素朴なケースの場合は既知のLayoutから値を取得できるが、同時にStyleの変更が行われる場合には再計算が必要になる
jsfunction loggingBoxHeight (box) {
box.classList.add('large')
console.log(box.clientHeight)
}
何が起きるのか
このとき、 box.clientHeight
の算出に必要なためのLayoutの処理が重い場合に、そのLayout計算が終了しないと値を取得できないためにJavaScriptの実行がその間停止する
Chrome DevtoolでPerformanceを確認すると、JavaScriptの実行中(オレンジ)にLayout(紫)が発生しており、それらは強制同期レイアウトにより発生していることを知ることが出来る
Try function update()
をDevtoolのconsoleで上書き宣言すると修正できる
Style変更が呼び出しまでの間に必ず行われないなら呼び出しても問題ない
そのような場合は値をそもそもキャッシュできるはずなので、値を別の方法でキャッシュしておいたりすることを検討するべき
素朴に悲惨なことになる例
js function resizeAllParagraphsToMatchBlockWidth() {
for (var i = 0; i < paragraphs.length; i++) {
paragraphs[i].style.width = box.offsetWidth + 'px';
}
}
キャッシュしておく
jsfunction resizeAllParagraphsToMatchBlockWidth() {
let boxWidth = box.offsetWidth;
for (var i = 0; i < paragraphs.length; i++) {
paragraphs[i].style.width = boxWidth + 'px';
}
}
この例は素朴なDOM APIで書かれていてループの中にあるので発見しやすいが、ReactComponentの中でこのようなコードを書いていると、Componentのレンダリング時に呼び出されて簡単に同じような状況が起き得る
要素自身の高さや幅などは必要な際に取得するようにする
一度取得した要素の大きさはキャッシュして再利用可能にしておく
この際、要素サイズが変更された際にキャッシュを更新する必要があるので、 ResizeObserver
などを組み合わせて利用する
そもそも取得せずにCSSの width: fit-content
などを用いて代用できる表現ではないかなどを検討する
その他 element.getClientRect()
や element.offsetLeft
なども呼び出すとLayout計算が走る
Reactとウェブブラウザペインティング
ReactでVirtual DOMが変更されたときの影響範囲は最終的にはDOMが変更され、PaintによりLayerが生成され、それらがLayoutされCompositeされたものが表示されます
Reactで変更した結果がその変更差分によってはすぐさまにウェブブラウザ上のレンダリングに反映されない可能性があることに注意
Styleを変更したアニメーションをReactで素朴に書く際にもどのように変更が発生するかを考慮しておこう
このページの参考文献
Frontend Web Performance: The Essentials [0] | by Matthew Costello | Dec, 2021 | Medium