steps to phantasien

リアルな DOM はなぜ遅いのか

これは VirtualDOM Advent Calendar 2014 に勝手に参加する記事です。

calendar

あたたかい春の昼下がりのこと、あるブラウザベンダの社内を不穏な噂が駆け巡った。 「React.js なるライブラリ、どうも仮想 DOM というやつのせいで速いらしいぞ」

もうリアルな DOM はお役御免、ブラウザも商売上がったりか・・・。雇用に不安を覚える人(私)がいる一方、 そのアイデアをとりこんでブラウザの DOM を速く出来ないかと考える人たちもいた。 仮想 DOM はなぜ速いのか。誰かのつてを辿って React.js チームにおいでいただき、速さの秘密をテックトークしてもらう。 イミュータブルなデータ構造による単純化、非同期適用による処理のバッチ化、差分アルゴリズムによる副作用の最小化…

いくつかのアイデアはブラウザからはどうにもならないが、たとえば非同期化なんかは形は違えどブラウザにも取り込めそうだ。 高速化手法のブレインストーミングをはじめる人々。 一方で DOM のチューニングを得意とする高速化のエキスパートはクビを捻っていた。 世の中のサイトでプロファイルをとってみると DOM のオーバーヘッドは 10 パーセントもない。 たしかに DOM を速くすれば Doromaeo みたいな古のマイクロベンチマークは速くなる。 でもより実践的といわれる Speedometer などには効きそうもない。 仮想 DOM って本当に速いのかしら・・・でも世の評判が嘘とも思えない・・・謎は深まるばかり。

一方、リアルな DOM はもうだめだ React.js でも勉強して仮想 DOM の冬に備えよう・・・ そう悲観した高速化の得意でないブラウザ開発者(私)は React.js をさわってみることにした。 実際に試すとなかなかよくできてる。そして速いと主張する理由もわかる。

ただ速さの工夫は仮想 DOM の表面的な売り文句から(ブラウザ開発者が)想像するものと少し違っていた。 そのギャップはブラウザ開発者とウェブ開発者の視点の違いに由来する溝だったし、 仮想 DOM という売り文句が React.js を単純化しすぎてるための溝でもあると思う。 今日はその溝を肴にリアルな DOM を眺めつつ React.js 版仮想 DOM の良さについて考えてみたい。

リアルな DOM の仕事

事実はさておき、リアルなDOMが遅いと思いたくなる理由もわかる。たしかに DOM は色々やってる。 こうした色々な仕事の大部分は fast path によってだいたいバイパスされる。現実のベンチマークを見る限り純粋な DOM の遅さからくる影響は小さい。 とはいえ API の裏でブラウザが何をしているか簡単に復習しておくと捗ることがあるかとしれない。ざっと眺めてみよう。

さて、まず 「DOM の仕事」とやらはいつ必要になるのだろう。 おおざっぱにいうと:

  • DOM ノードの作成
  • 作ったノードのツリーへの追加と削除
  • 属性(attribute)の変更
  • イベント発行

この四カ所で DOM の仕事が起こると思えばだいたい合ってる。順番に見て行こう。

DOM ノードの作成

要素名 (html, div, input …) から該当する HTML 要素のコンストラクタを引き当て、 DOM のオブジェクトを C++ のヒープに確保し、 適当にプロパティ(というかC++のメンバ変数)を初期化し、 必要なら JavaScript のラッパオブジェクトを作って返す。

オブジェクトの確保というとすごく大掛かりなイメージがあるけれど、 DOM ノードを作る作業は案外あっさりしている。作られたばかりのノードには大きな副作用がないからだ。

必要に応じ C++ と JavaScript の両方にオブジェクトを作らなければいけないぶん、 Javascript に閉じた仮想 DOM に比べてオーバーヘッドはある。数十バイトとかそんなもん。

副作用について、たとえば imginput なんかは画像データを HTTP で要求したり初期化すべきデータ構造が入り組んでいたり、 オブジェクトを作っただけでも追加のオーバーヘッドがある。 けれどこうした例外をのぞき、Document ツリーにつながれていない宙ぶらりんの DOM 要素は余計な仕事をしない。

ツリーへの追加と削除

大きな副作用がおこるのは DOM ノードがツリーに差し込まれた時だ。 このときは仕事が多い。一方で枯れ果てるほど高速化されてもいる。多くは fast path に入ってすぐ終わる。

仕事は整合性チェックからはじまる。DOMは ツリー でないといけない。 親子関係が循環してもいけないし、DocumentFragmentAttr など 一部のマイナー要素には SGML/XML 由来の奇妙な親子関係の制限がある。

チェックがおわると、追加される子ノードを古いツリーから切り離して新しいツリーに差し込む。

切り離しや追加に際し、ブラウザは対象となる DOM ノードとそのサブツリーをトラバースし、各種状態を更新する。 ノード追加くらいでトラバースすんなよと思うでしょ。でもまあ、色々仕方ないのですよ・・・。 昔は追加と削除あわせて 4, 5 回くらいトラバースしてたけど、今は追加削除それぞれ 1 回ずつに落ち着いた。 ツリートラバースを一回減らすと 一部 DOM ヘビーなベンチマークが数パーセント良くなる、くらいのオーバーヘッド。

ツリー構造の変化は、MutationObserver API を通じて JavaScript の世界にも知らされる。 この通知作業もタダではない。ただしアプリケーションが MutationOberver API を使っていない限りオーバーヘッドはない。 (レンダラは “MutationObsever 利用中” フラグを立てて利用状況を記録している。他にもこういうヘンなフラグをいっぱい使って余計な作業をはしょってます。)

DOM 要素の中には、ツリー構造の変更に応じ副作業を起こすものがある。 そうした副作用は、多くが DOM ノードから親の Document が到達可能になったとき… ジャーゴンを使うと “in-document” フラグが立ったときとき … におこる。

この in-document フラグが立つと、たとえば script 要素なら中身のスクリプトを実行するし、外部ファイルがリンクされていればダウンロードをはじめる。 スタイルシートの追加も似たような感じ。ただこういうのは隠れたコストというより必要な仕事だ。リアルな DOM の遅さに数えるのは酷だとおもう。甘いですかね・・・。

レンダリングと遅延アタッチ

In-document フラグにともなう副作用のうちいちばん影響が大きく、よく知られているのがレンダリングだろう。

レンダリングには大きく分けて三つのフェーズがある:

  • スタイルの引き当てを含むレンダリングツリーの構築、
  • レンダリングツリー上でのレイアウト計算、
  • そしてレイアウト結果に基づく画面のペイント。

古い WebKit や Blink は、DOM ツリーを操作するたびに最初のステップ、レンダリングツリーの構築がおきていた。 追加したノードに紐づくレンダリングツリーを継ぎ足す。残りのステップであるレイアウトや描画は、少し後から必要に応じておきた。

最近の WebKit/Blink は、レンダリングツリーの更新自体もなるべく先送りする。これを 遅延アタッチ (lazy attach) という。 たとえば同じ DOM ノードを足して、引いて、また足すと、最初の足し引きに伴うレンダリングツリーの更新は無駄だ。 jQuery なんかで何も考えず DOM をさわるとそんな無駄が起こる。遅延アタッチはこうした無駄の影響を小さくする。 新しい Safari で Speedometer が速くなったのは遅延アタッチ導入が影響が大きいとのこと。

「少し後から」行われるレイアウト計算や画面描画も、その細かいタイミングには議論の余地がある。 理想的には画面を描画する直前だけ、つまり最大でも 1 秒に 60 回だけで済ませたい。 ところが従来の Blink は必要以上にこの再計算が呼び出され性能を損ねていた。 それを 1-2 年かけてちまちまと直し続け、余分な計算はほぼ駆逐された。レイアウトやペイント自体もずっと速くなった。

Uninterruptible reflow と RAF

ただし行儀の悪いアプリケーションでは遅延アタッチの効き目も限られる。 アプリケーションからレイアウト結果を問い合わせる API が使われてしまうと、先送りができなくなるからだ。 たとえば Element#offsetLeft なんてのはレイアウト結果そのもの。 このプロパティをさわった瞬間に強制レイアウトが起こり画面が固まる。

どうでもいい豆知識としては, Blink のテストの中にはわざとレイアウトを引き起こすために offsetLeftoffsetTop をさわっているコードが沢山ある。offsetLeft がだんだん forceLayout() に見えてくる。

強制レイアウトは WebKit/Blink に限らず、遅延アタッチ以前から広く知られた問題でもある。 Gecko 界隈の人がこの強制レイアウトを Uninterruptible reflow と呼び、 同じ問題を議論している記事もある。 (そしてこの名前の方がかっこいいかもしんない。ていうか Gecko は reflow を interrupt できるのね…)

過剰なレイアウト計算は長らくウェブ開発者から敵視されてきた。 レイアウトを起こす API をうっかり呼び出した時の被害を最小化するため、そして遅延アタッチ導入以前にあったオーバーヘッドを避けるため、 React や Ember など今時のフレームワークは DOM ツリーの操作タイミングをバッチ化する仕組みを持っている。 ブラウザ側もバッチ適用のタイミングを伝える requestAnimationFrame() API (略して RAF) を公開している。

React は標準では RAF を使わない。 おそらく JavaScript でなにかをアニメーションするユースケースを重く見ていないのだろう。 リンク先の Stackoverflow 記事では RAF を使うプラグインが紹介されている。React で 60fps したい人は使うといいかもしんない。

「DOM の」オーバーヘッド?

ところでブラウザ開発者はレンダリングの時間を DOM の遅さに数えない。なぜならレンダリングはレンダリングモジュールの仕事だから。 プロファイル結果に DOM のオーバーヘッドが見つからず困っていた開発者は純粋な DOM の遅さについて気にかけていた。 でもウェブ開発者が文句をいう DOM の遅さは大半がレンダリング由来だったりする。

そんなすれ違いを防ぐため、遅いと苦情をうけた近隣のブラウザ開発者はまず「Tracing のデータをよこせ」と答えるようになった。 Tracing は詳しくてわかりにくい Chrome 組み込みのプロファイラ。 そのダンプを睨むとボトルネックがわかることが多い。実際に動かせるサイトを触らせろと言うより敷居が低いせいか、対話の助けになっている模様。

属性の変更

DOM のオーバーヘッドに話を戻そう。

DOM 要素の属性を書き換えるのも、ツリーのノード操作と似たオーバーヘッドがある。 img や の src 属性や linkhref 属性を書き換えれば新しいリソースのロードが始まるし、スタイルの再計算も起こる。

素朴に考えるとあらゆる属性の変更は潜在的にスタイルの再計算を起こしうる。 WebKit/Blink はスタイルシート側で使われているセレクタの種類(クラス名や属性名など)をトラックし、 再計算を最小限に抑えようとしている。ただ少なくとも「再計算が必要ないことをチェックするコスト」は支払わないといけない。 ハッシュ表をちょこっと検索するだけの大したことない処理だけど。

そのほか id 属性を変えると getElementById() で使う索引を無効化しないといけないとか、 style 属性を変更するとスタイルの再計算をトリガーするとか、細かい話は色々ある。でもそれほど意外性はないので割愛。

イベントの発行

DOM のイベント処理も、それなりに重い処理としてよく引き合いに出される。 イベントの伝播するパスを事前に計算し、順番にトラバースしてイベントハンドラを呼び出していく。 遅そう・・・だけれど、現実にはアプリケーションの登録したイベントハンドラの処理時間が全て。 ツリー操作や属性の変更よりも頻度が少ないため、イベント配信でブラウザの遅さが問題になった話は聞いたことが無い。

ネイティブコール

DOM の API 呼び出しに際し JavaScript -> C++ 境界をまたぐコストもオーバーヘッドの一つに数えることは出来るだろう。 このオーバーヘッドはブラウザによって異なる。古い Blink では JavaScript から C++ に入るとき毎回スレッドローカル変数へのアクセスがおき、 そのせいで他の処理系よりやや遅かった。もうだいたいなおっているはず。

そのほかの影響。ネイティブコードが JavaScript 処理系の最適化処理を邪魔するのは今も昔もかわらない。 たとえばネイティブコードの中身が副作用フリー(pure)かどうか、処理系にはわからない。 そのせいでできない高速化がある。理論上できないものの他に、実装が大変なせいで高速化が後回しにされているケースもある。 高速化しそこねるオーバーヘッドがどのくらいなのか、簡単なプロファイリングではわかりにくい。小さいながら厄介な遅さではある。

リアル DOM の遅さ = 仮想 DOM の単純さ

こうしてみると、深く考えるまでもなくリアル DOM に対する仮想 DOM の優位は明らかだ。 仮想 DOM は、すくなくとも React に限って言えば単なる JS オブジェクト。リアルな DOM とちがって構造の整合性のチェックなんていらないし、 構造の変更に応じたイベントのバブルアップもリソースローディングもレンダリングもない。JS/C++ 境界もない。Null を渡してブラウザが落ちることも無い。 そもそも React の仮想 DOM はツリー構造を変更できない・・・というと語弊があるけれど、 少なくとも React 版仮想 DOM の一部 ReactElement のツリー構造はイミュータブルだ。いろいろラクすぎる。

React.js の仮想 DOM

そうした所与の単純さのみならず、React.js は更に軽量化の工夫をしている

…なおここからは主に React.js を使う気のないブラウザ開発者向けの React 入門です。 同 Advent Calendar のエントリ ”React.jsのVirtualDOMについて” と重複しておりますので 詳しい人のちゃんとした説明が欲しい人はリンク先を読んでくださいね。

ReactComponent

さて React.js における仮想 DOM の実体は ReactComponent とよばれるオブジェクトだ。

ReactComponent はざっくりいうと Backbone や Ember の View みたいなもの。状態と(仮想 DOM)サブツリーをカプセル化する。 開発者は自分のコンポーネントを定義し、それを組み合わせて使う。 コンポーネントはネストしてツリー構造を作る。

コンポーネントはユーザ定義のものだけでない。 そのほかに単一の DOM 要素をあらわす特別なコンポーネント ReactDOMComponent がある。 ReactDOMComponent とユーザ定義の ReactComponent はまぜて使える。そういう意味で ReactComponent は仮想 DOM における Custom Elements と言えなくもない。

React はこのコンポーネントツリーをトラバースしてリアルな DOM ツリーを作る。 ユーザ定義の ReactComponent は最終的な DOM ツリーに姿を見せない。ReactDOMComponent だけがリアル DOM にマップされる。仮想っぽい。

ReactElementrender()

開発者はどうやってコンポーネントを組み合わせるのか。 よくあるフレームワークと違い、React では各コンポーネントが自らネストしたコンポーネント・サブツリーを作ることはしない。 かわりに render() というメソッドを定義し、その戻り値を通じ React のフレームワークに 「こんなサブツリーを作ってくれ」とたのむ。 React はサブツリーが必要なタイミングでコンポーネントの render() を呼び出し、必要なサブツリーの形を調べる。

使う気無い人むけなんで雰囲気用にサンプルをコピペしときますね。

1
2
3
4
5
6
7
8
9
10
11
12
13
var CommentBox = React.createClass({
  ...

  render: function() {
    return (
      <div className="commentBox">
        <h1>Comments</h1>
        <CommentList data={this.state.data} />
        <CommentForm />
      </div>
    );
  }
});

render() なんてどのフレームワークにもあるじゃん。そう思うかもしれないけれど、React の render() は少し意味合いが違う。 各 render()ReactComponent のかわりに ReactElement と呼ばれるオブジェクトのツリーを返す。 ReactElement はコンポーネントのコンストラクタと引数を詰め合わせたファクトリオブジェクト。 RenderElement のツリー = エレメントツリーは、子となるコンポーネントたちのツリー構造をあらわすテンプレートだ。

雑多な状態を抱え込む ReactComponnet と違い、ReactElement はコンポーネントのコンストラクタ呼び出しに必要な最低限の情報しかもっていない。だから軽い。 そしてコンストラクタの引数が等しい ReactElement のインスタンスは・・・たとえばサンプルの JSX にある <CommentForm /> みたいに子要素も変数プロパティも持たないものは・・・ インスタンスを共有、再利用できる。いわゆる Flyweight というやつ。

このように、 React.js の仮想 DOM 実装は宣伝から想像するモデルと少し違う: まず長寿でステートを持ちそれなりに重い ReactComponentコンポーネントツリー がある。 そしてコンポーネントツリーの変更が必要になるたびにフレームワークがコンポーネントに問い合わせ、軽量な ReactElementエレメントツリー を手に入れる。

差分計算とリアル DOM の状態更新

render() が返した新鮮なエレメントツリーと既存のコンポーネントツリーを比較しながら、フレームワークはコンポーネントの状態を更新する。 必要なら新しいコンポーネントインスタンスをつくり、余って要らないものは消す。既存のコンポーネントが使い回せるなら属性を上書きして済ます。 この更新トラバーサルの中で色々呼ばれるフックを介し、 ReactDOMComponent はリアル DOM の状態を更新する。これが噂の差分計算。

差分計算という名前から新旧のツリーは同じ種類のものと思っていたかもしれない。 でも実際は重くて永続するコンポーネントツリーと軽い一過性のエレメントツリーを比較する。 ReactElement ってなんだよそれ!仮想 DOM ですらなくね?リアル DOM シンパとしては苦情を申し立てたくなる。 実際のところ、古いバージョンの React は render() がコンポーネントツリーをつくっていた。ReactComponentReactElement は、後になって高速化のため切り離された。

リアル DOM 派(私)からの苦情を脇に置けば、全てを RenderComponent で表現するより RenderElement を分離する方が速いのは確かだ。 それにこの実装…ステートフルなコンポーネントツリーにエレメントツリーというテンプレート経由で状態をセットする…をそのままメンタルモデルとして受け入れる方がわかりやすいと、個人的には思う。 「イミュータブルな仮想 DOM でデルタ計算が・・・」とか言われると未来的すぎて中年はつらい。ステートおじさんといわれても仕方ない・・・。

遅延ツリー

React.js の差分計算には、面白い高速化の工夫がもう一つある。コンポーネントが定義する別のフック shouldComponentUpdate() だ。

React の差分計算はコンポーネントツリーを親から子に DFS する。一般にこうした探索ではなるべく余計な子への訪問を減らす (prune する) のが定石。 React は各訪問先でコンポーネントの shouldComponentUpdate() を呼び、false を返したコンポーネント、およびその子の処理は prune する。

React は処理を省いたコンポーネントの render() を呼びださない。 つまりサブコンポーネントをあらわす RenderElement は必要になる時まで作られない。これがちょっと面白い。

たとえば先のサンプルでは CommentBox のサブコンポーネントに CommentForm があった。 ここで開発者が CommentForm#shouldComponentUpdate()false を返しておけば、 CommentForm#render() は初回のコンポーネントツリー構築時に一回呼ばれるだけですむ。

要するに状態の影響する単位で HTML をコンポーネントに切り出して必要なフックを足せば、 そのコンポーネントのサブツリーは必要になるまで比較されない。比較相手のエレメントツリーも作られない。おトクだ。

別の見方をするとエレメントツリーは遅延リストならぬ遅延ツリーだと言える。 枝狩りという目的からすれば当たり前の結論だけれど、状態更新のたびにツリーを丸ごと比較すると思いこんでいた外野の私は驚いた。 それツリー比べてなくね?リアル DOM 派として虚偽広告を非難したくもなる。でもまあ、全体のデザインとしては自然。正しい。 遅延ツリーによって、変更が及ばない部分のツリーは生成自体をまるごとスキップできる。これなら性能上の主張も腑におちる。

件のコールバック shouldComponentUpdate() は単に状態 (states と、場合によっては props) を比較すればいいだけ。実装は難しくない。ただ JavaScript で入り組んだ値を比べるのはちょっとめんどい。 Facebook はこの比較を堅牢にする不変コレクションライブラリ Immutable.js なんてのまでつくっている。 まじファンクショナルだな・・・とおもいきやライブラリ公開時点だと実践投入はまだとのこと。

仮想 DOM の速さ = React.js のがんばり

失業への恐れからはじまった私の React 入門は、こうしてまあまあ実りのある調べものとなった。

リアルな DOM と比べ仮想 DOM はなぜ速いのか? リアルな DOM が持つ各種オーバーヘッドを最小化するバッチ化や差分適用はたしかに性能を助けているだろう。 でもそれを足場に積み上げたフレームワークとしての細かい工夫は無視できない。 不変性を駆使して軽量化された ReactElement, ツリーの遅延評価、コンポーネント側の力を借りたトラバースの pruning. こうした工夫の組み合わせで DOM のオーバーヘッドだけでなく JavaScript 側の計算量をも小さく留めている。

ブラウザもリアル DOM のオーバーヘッドを減らす努力をしており、アプリ側のバッチ化なんかはやがて過去のものとなるだろう。 ただリアル DOM のオーバーヘッドが小さくなったとしても、JS 側の計算量を小さく抑えつつステートレスの幻想を与えてくれる React.js の戦略は意味を持ち続ける気がする。

仕事柄 Web Components と仮想 DOM の関係も気になる。ステートフルな ReactComponent とステートレスな ReactElement の分離は興味深い。 ReactComponent も Custom Elements も ES6 のクラスになろうとしているReactComponent のようなステートフルなコンポーネントが Custom Elements になり、 その Shadow DOM を更新するのに ReactElement 相当の軽量構造化テンプレートを使う・・・とかなんとかやればいいのかねえ。わからん。

React.js が積極的に Web Components を取り込むとは思えない。 react-future の Web Components セクションには TBD ファイルがおかれているだけ。 でも Web 標準好きの Ember や Polymer がそういう方向に進むと面白いのになあ。期待しつつ眺めている。 Ember.js は 2.0 で仮想 DOM を使うと言っている。それがどんな姿をとるのかは、気が向いたときにでも調べたい。

そう言いつつも仮想 DOM があまり幅を利かせるのもなんとなくむかつくので、 しばらくはステートフルおじさんとして「仮想 DOM なんて所詮インクリメンタルアップデートに対応した構造化テンプレートでしょ」とプロパガンダを流しつつ リアル DOM を応援していきたい所存です。あらあらかしこ。