JavaScriptでのDOM操作は重いのかという話とForced Synchronous Layoutについて

2015年にもなるのにJavaScriptでのDOM操作のパフォーマンスについて書く。ウェブページにインタラクションを持たせたい時に、JavaScriptでDOM操作を行うことがよくある。このDOM操作のパフォーマンスについて、よく聞く意見を大別すると次の2つがある。

  • JavaScriptによるDOM操作は重たい
  • レンダリングが重いだけで、DOM操作そのものはそれほど重たくない

JavaScriptでオブジェクトのプロパティを操作したりする単体の処理は通常1ミリ秒もかからないが、DOM操作をするとレンダリングが完了するまでに数十ミリ秒程度かかったりする場合がある。1番目のDOM操作が重たいと言っている人は経験則的にそう言っていることが多い。

レンダリングの仕組みを知っている人は2番目の意見を言うが、重箱の隅をつつくような話をするとこれも必ずしも正しいわけではない。DOM操作するコードによっては、JavaScriptの実行そのものをブロックするケースもあるからだ。この記事ではその辺りの事情についておさらい的に説明する。

DOM操作するとそもそも何が起こるのか

現在見ているウェブページのドキュメントのDOMツリーに属するDOM要素を操作をすると、JavaScriptの実行が終わってからブラウザのレンダリング処理が行われる。具体的にはWebkit系のレンダリングエンジンだと次の図のようになる。

f:id:anatoo:20151013210539p:plain

ScriptingではJavaScriptの実行が行われる。DOM操作の処理もここに含まれる。

Calculate Styleでは、更新されたDOM要素ごとのスタイルが再計算される。例えば、DOM要素を生成してDOMツリーに追加すると、その影響で当たるスタイルを更新しないといけない要素が出てくるので、それらのDOM要素に当たるスタイルがCSSセレクタのマッチング処理とともに再計算される。

Layoutでは、計算されたDOM要素のスタイルを元に要素のレイアウトの計算を行う。いわゆるリフローと呼ばれるものはこれ。Firefoxだとリフローと呼ばれる。

Paintでは要素をレイヤごとにラスタライズする。Composite Layersではラスタライズした各レイヤを1枚に合成して最終的なレンダリング結果を生成する。

HTML5アプリでのインタラクションやアニメーションのレンダリングのパフォーマンス改善というのは、このDOM操作した後のこの一連の実行パスをどのようにして最適化するかという話である。

例えば、iOSやAndroidなどのモバイル端末で60FPSのアニメーションを達成しようとすると、LayoutやPaintなどの処理をやってたら速度的にちょっときつくなるのでなるべく最後のComposite Layersだけが実行されるようなコードを記述したりする。

DOM操作に伴うレンダリングの処理は、JavaScriptの実行そのものを遅くするわけではないので、そういう意味では後続するレンダリングが重いだけでDOM操作そのものは大して重くない、というのはおおむね正解である。実際ドキュメントのDOMツリーに属していないDOM要素を操作してもこれらのレンダリング処理は行われない。

ただし、JavaScriptのコードの書き方によってはForced Synchronous Layoutを引き起こして、JavaScriptの実行そのものをブロックする場合がある。

Forced Synchronous Layout is 何

Forced Synchronous Layoutとは、JavaScriptと同期する形で実行されるレイアウト計算処理のことである。通常このレイアウト計算は、JavaScriptの実行が終わってから初めて行われるので、JavaScriptの実行を遅くしたりするわけではない。しかしForced Synchronous LayoutではJavaScriptの実行中に同期的に処理されるので、JavaScriptの実行パフォーマンスを劣化させることがある。

DOM APIにはレイアウト情報を参照するメソッドやプロパティがいくつかある。具体的には、offsetHeightプロパティやoffsetWidthプロパティやgetBoundingClientRect()メソッド等がある。Forced Synchronous Layoutは、ドキュメントのDOMツリー内でDOM操作を行った後、そのDOM要素のレイアウト情報を参照するコードを書くと起こる。

例えば、DOM要素を生成してbody要素に追加する次のようなコードを見てみよう。

var div = document.createElement('div');
div.innerHTML = 'hogehoge';

document.body.appendChild(div);

このコードでは、JavaScriptの実行が終わってから初めてレイアウト計算が行われる。対して、次のコードではForced Synchronous Layoutが発生する。

var div = document.createElement('div');
div.innerHTML = 'hogehoge';

document.body.appendChild(div);

// ここでForced Synchronous Layoutが起こる 
console.log(div.offsetHeight);

Webkitなどのレンダリングエンジンでは、JavaScriptによるDOM操作が行われてもすぐにレイアウト計算を行うのではなくJavaScriptの実行が一旦終わってから行うが、DOM操作を行った後にgetBoundingClientRect()やoffsetHeightなどのプロパティを参照すると、正しいレイアウト情報を返すためにレンダリングエンジンはその場でレイアウト計算を行う。

Forced Synchronous Layoutが起こっているかどうかは、ChromeのウェブインスペクタのTimelineでプロファイルを取ればわかる。

f:id:anatoo:20151013222902p:plain
https://developers.google.com/web/tools/profile-performance/rendering-tools/forced-synchronous-layouts

Forced Synchronous Layoutが発生したからと言って即パフォーマンス上の問題が出るわけではないが、ループ内などでForced Synchronous Layoutを引き起こすと、JavaScript実行中にレイアウト計算が何度も繰り返し起こるのでJavaScriptのパフォーマンスが大幅に劣化する事がある。

まとめ

JavaScriptでのDOM操作自体は大して重くない。重く見えるのはDOM操作に伴うレンダリング処理がJavaScriptの実行後に行われるからである。ただし、DOM操作するコードがForced Synchronous Layoutを引き起こす場合にはJavaScriptの実行パフォーマンスを劣化させる原因になる。