フロントエンドワークショップ 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のライフサイクルとは別で存在しているということを抑えておく
図です
https://gyazo.com/e008370cfa874b103a86e30d6d58027e
Style: CSSがどの要素に当たるかをセレクターなどから計算する
Layout: そのHTML要素自身の持っているルールやCSSなどからどういう場所にどういうスペースが必要であるかなどを計算する(ブラウザによってはreflowとも)
ここで1つの要素のレイアウトの計算結果が、その周辺の要素などにも影響を与えることがあることに注意
つまり影響を与える範囲が大きいとブラウザでの処理が複雑になる
Paint: Layoutを元にウェブブラウザの画面上に表示するためのピクセルを実際に描画する。
レイヤーの概念を利用して複数の面を重ねたりして表現をする
Composite: レイヤーの重なりなどが正しくなるように順序を考慮して最終的な成果物を生成する
LayoutとPaintが起きるとレンダリング更新のコストが高まることがある
→つまりFPSが低下する
レンダリングが起きるパターンを検討する
すべてが起きる場合
Layoutを変更することになる leftやwidthなどの変更
https://gyazo.com/e008370cfa874b103a86e30d6d58027e
レイアウトの変更が起きない場合
backgroundやcolorなどの変更
https://gyazo.com/53c56e45c2a8b62e07502f5dee0432c2
Layoutはスキップ出来るがPaintは発生する
Compositeだけが起きる場合
transformを用いた変更やopacityの変更
https://gyazo.com/c91ff089a7a0512aeffd63e0c53dfa97
Paintなども既存のものが利用され、Compositeだけが発生するので非常に軽量
widthやleftを変更してアニメーション表現をする場合は、transformなどで代用が出来るとレンダリングを軽量にすることが可能
ChromeのDevToolを使って様子を確認してみよう
まずはPerformanceを使って素朴に様子を見る
https://gyazo.com/5b29180abe9160dc848d4c68f96893c0
https://gyazo.com/31c6514f0d635725cf25dedb59177e07
それぞれのフェイズに掛かっている時間が分かる
数を増やして、Optimize / Un-Optimizeで様子が変わることを確認してみよう
RenderingタブのFrame Rendaring Statsを有効にするとFPSメーターなどが表示できる
https://gyazo.com/4edcde0588e67c0e8b54139a94cbabe5
ペイントが更新された領域を確認する
Rendaring の Paint Flashingを有効にすると、Paintが更新された領域が緑色になる
https://gyazo.com/f299e5164b9d0b3b674b1476b7d3d3bd
意図しない要素の再ペイントが走ってないかなどを確認できる
Layer bordersを有効にするとどのようにレイヤーが構成されているかを見ることが出来る
レイヤーに関する情報を確認する
https://gyazo.com/7d40dc3aca386069b6339094587abd7e
レイヤーの様子を詳細に見ることが出来る
そのレイヤーがPaintされた回数
そのレイヤーがCompositeされる理由
https://gyazo.com/99e7190211ff3e83f771faee7b982076
ドラッグで角度を変えたり、スクロールでズームしたりできる
Paint Profilerを見るとPaintにどういう時間が掛かったかや内部で発生したペイントコールの詳細を確認することが出来る(が、ここの情報を使って最適化するということはあまり無いかも)
ペイントの変更のされ方によってウェブブラウザがどのようにペイントを実施するかを確認できる
レイヤーを分ける方法
CSSのwill-changeプロパティを付与すると新しいレイヤーが生成される
※ will-changeはその要素のtransformやopacityが変更されることを明示し、ウェブブラウザにその要素のレイヤーを分離させて変更のための準備を行い、変更を最適化させるためのCSSプロパティ
レイヤーはメモリを消費し、レイヤーが増えるとCompositeのコストが増大することに注意
Profilerを注意深く確認し本当に必要なときのみ行う(基本的にはブラウザにまかせておいて問題ない)
code: css
* {
will-change: transform;
}
とするとすべての要素をレイヤーに分割できる
https://gyazo.com/fa9b1702e2a4a6dd90513037483f4cb6
Forced Synchronous Layout (強制同期レイアウト)の回避
Styleの変更をしなくてもLayoutをJavaScriptの実行中に同期的に発生させるAPIがあることに注意
例えばJavaScriptを用いて要素の高さを記録する
code: js
function loggingBoxHeight (box) {
console.log(box.clientHeight) // このときbox.clientHeightを取得するために暗黙的にLayoutが走る
}
上記のような素朴なケースの場合は既知のLayoutから値を取得できるが、同時にStyleの変更が行われる場合には再計算が必要になる
code: js
function loggingBoxHeight (box) {
box.classList.add('large')
console.log(box.clientHeight)
}
何が起きるのか
このとき、box.clientHeightの算出に必要なためのLayoutの処理が重い場合に、そのLayout計算が終了しないと値を取得できないためにJavaScriptの実行がその間停止する
Chrome DevtoolでPerformanceを確認すると、JavaScriptの実行中(オレンジ)にLayout(紫)が発生しており、それらは強制同期レイアウトにより発生していることを知ることが出来る
https://gyazo.com/f4038cd7303601a21e153f6d16d83a4e
Tryfunction update()をDevtoolのconsoleで上書き宣言すると修正できる
Style変更が呼び出しまでの間に必ず行われないなら呼び出しても問題ない
そのような場合は値をそもそもキャッシュできるはずなので、値を別の方法でキャッシュしておいたりすることを検討するべき
素朴に悲惨なことになる例
code: js
function resizeAllParagraphsToMatchBlockWidth() {
for (var i = 0; i < paragraphs.length; i++) {
paragraphsi.style.width = box.offsetWidth + 'px'; }
}
キャッシュしておく
code: js
function resizeAllParagraphsToMatchBlockWidth() {
let boxWidth = box.offsetWidth;
for (var i = 0; i < paragraphs.length; i++) {
paragraphsi.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