Reactユーザーの疑問!Vueはどのようにレンダリング最適化を行なっているのか?

はじめに

みなさんこんにちは!今年8月にクロスマートに入社した佐藤と申します。
普段は React をメインに書いているのですが、クロスマートに入社して初めて Vue を触る機会がありました。React ではReact.memo、useMemo、useCallbackといったAPIを使ってレンダリングの最適化を行うのが一般的ですが、Vue では明示的にそのようなコードを書かずとも最適化が行われていることに気づきどのように実現しているのか疑問でした。
慣れている方なら当たり前の内容かもしれないですが、良い機会なのでVue における再レンダリングの仕組みや最適化について調べてみることにしました。

なお、本記事はVue公式をもとにした内容なので詳しくは以下をご確認ください。

ja.vuejs.org

VueのDOM更新の流れ

まずはざっと流れを追ってみます。

1. コンパイル

vueのテンプレートはレンダー関数にコンパイルされます。このレンダー関数は仮想DOMを返します。例えば以下のテンプレートは、

// template.vue

<div>
  <div>foo</div> 
  <div>bar</div> 
  <div>{{ dynamic }}</div>
</div>

以下のレンダー関数にコンパイルされます。仮想 DOM ツリーのルートを作成する_createElementBlock()とvnodeを作成する_createElementVNode()がポイントです。

// compile.js

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _cache[0] || (_cache[0] = _createElementVNode("div", null, "foo", -1 /* HOISTED */)),
    _cache[1] || (_cache[1] = _createElementVNode("div", null, "bar", -1 /* HOISTED */)),
    _createElementVNode("div", null, _toDisplayString(_ctx.dynamic), 1 /* TEXT */)
  ]))
}
2. マウント

コンパイル時に生成されたレンダー関数が呼び出されます。レンダー関数が返す仮想DOMから実際のDOMを作成します。またこのステップでは、依存関係を自動的に追跡し、依存関係が変更されるたびに再実行するエフェクトが作成されます。

3. パッチ

マウント時に設定された依存関係が変更されると、エフェクト再実行->新しい仮想DOMが作成されます。新旧の仮想DOMを比較し更新が必要な箇所を実際のDOMに反映させます。

ここまで見ると他のフレームワークと同様に見えますが、vueではコンパイル時にいくつかの最適化が行われます

コンパイル時の最適化

以下の最適化が行われます。

静的ホイスティング

先ほどのテンプレートとレンダー関数を見てみます。foobarは静的な要素なので差分が出ません。_createElementVNode()する時にHOISTEDと書かれているように、これらの要素はレンダー関数から巻き上げ(ホイスティング)られ定数のように扱われます。これにより無駄なvnodeの再生成がスキップされます。

// template.vue
<div>
  <div>foo</div> <!-- hoisted -->
  <div>bar</div> <!-- hoisted -->
  <div>{{ dynamic }}</div>
</div>
// compile.js
export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _cache[0] || (_cache[0] = _createElementVNode("div", null, "foo", -1 /* HOISTED */)),
    _createCommentVNode(" hoisted "),
    _cache[1] || (_cache[1] = _createElementVNode("div", null, "bar", -1 /* HOISTED */)),
    _createCommentVNode(" hoisted "),
    _createElementVNode("div", null, _toDisplayString(_ctx.dynamic), 1 /* TEXT */)
  ]))
}
パッチフラグ

次に動的な要素を含むtemplate.vueと、それをコンパイルして得られるレンダー関数を見てみます。

// template.vue
<div :class="{ active }"></div>

<input :id="id" :value="value">

<div>{{ dynamic }}</div>
export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock(_Fragment, null, [
    _createElementVNode("div", {
      class: _normalizeClass({ active: _ctx.active })
    }, null, 2 /* CLASS */),
    _createElementVNode("input", {
      id: _ctx.id,
      value: _ctx.value
    }, null, 8 /* PROPS */, ["id", "value"]),
    _createElementVNode("div", null, _toDisplayString(_ctx.dynamic), 1 /* TEXT */)
  ], 64 /* STABLE_FRAGMENT */))
}

vnodeを生成する関数_createElementVNode()の第4引数に注目してください。渡されている2,8,1,64の数値がパッチフラグです。 ひとつひとつのパッチフラグに意味があり、以下のように対応しています。

github.com

例えば動的な値を含む<div :class="{ active }"></div>はパッチフラグ2が割り当てられており、レンダラーはこのパッチフラグをもとに特定の更新作業が必要かどうかを判断することができます。

if (vnode.patchFlag & PatchFlags.CLASS /* 2 */) {
  // 動的なクラスバインディングを持つ要素なので比較を行う
}

なお、パッチフラグはCLASS = 1 << 1のようにビットシフト演算で定義されているので高速にチェックできるようです。

ツリーのフラット化

仮想DOMツリーのルートは_createElementBlock()関数によって生成され、ブロックとして扱われます。そしてブロック内のパッチフラグを持つ子孫ノードを追跡します。

<div> <!-- ブロック -->
  <div>...</div>         <!-- 追跡しない -->
  <div :id="id"></div>   <!-- 追跡する -->
  <div>                  <!-- 追跡しない -->
    <div>{{ bar }}</div> <!-- 追跡する -->
  </div>
</div>

このブロックは以下のように表現され、ツリーのフラット化と呼ばれます。完全な仮想DOMツリーよりも走査する必要のあるノードの数を大幅に削減する効果があります。

div (block root)
- div with :id binding
- div with {{ bar }} binding

最後に

読んでいただきありがとうございました! 静的ホイスティングパッチフラグによって、更新作業が不要な箇所(静的な要素)と必要な箇所(動的な要素)を識別し、ツリーのフラット化によって仮想DOMツリーを単純化して走査効率を高めていることが理解できたかなと思います!

もし興味を持っていただけたら是非採用ページもご確認ください!

xorder.notion.site