こんにちは!クロスマート開発チームのsajuhikoと申します。クロスマート・テック Advent Calendar 2024の17日目の記事になります。
クロスマートに入社して初めてvueを書きましたが確かに気にしたことがありませんでした。今までReactばかり書いていた自分にとっては大きな疑問だったので調べてみることにします。
以下の内容はvue公式の内容です。詳しくはドキュメントをご確認ください
DOM更新の流れ
まずはざっと流れを追ってみます。
1. コンパイル
vueのテンプレートはレンダー関数にコンパイルされます。このレンダー関数は仮想DOMを返します。
2. マウント
先のステップのレンダー関数が呼び出されます。返却された仮想DOMから実際のDOMを作成します。またこのステップでは、依存関係を自動的に追跡し、依存関係が変更されるたびに再実行するエフェクトが作成されます。こちらに詳しく書かれています。
3. パッチ
マウント時に設定された依存関係が変更されると、エフェクト再実行->新しい仮想DOMが作成されます。古い仮想DOMと比較し更新が必要な箇所を実際のDOMに反映させます。
ここまで見ると他のフレームワークと同様に見えますが、vueではステップ1のコンパイル時にいくつかの最適化が行われます。
コンパイル時の最適化
以下の最適化を行います。ひとつづつ見ていきます。
静的ホイスティング
以下は、静的な要素を含むテンプレートと、それをコンパイルして得られるレンダー関数です。
<div>
<div>foo</div> <!-- hoisted -->
<div>bar</div> <!-- hoisted -->
<div>{{ dynamic }}</div>
</div>
import { createElementVNode as _createElementVNode, createCommentVNode as _createCommentVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
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 */)
]))
}
foo
やbar
は静的で差分が出ないので、レンダー関数から巻き上げ(ホイスティング)を行い定数のように扱われます。これにより無駄なvnodeの再生成がスキップされます。
こう見るとReact Compilerが静的な要素をコンパイルする際も同じようにキャッシュしていますね
export default function MyApp() {
return <div>Hello World</div>;
}
function MyApp() {
const $ = _c(1);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = <div>Hello World</div>;
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
パッチフラグ
以下は、動的な要素を含むtemplate.vueと、それをコンパイルして得られるレンダー関数です。
<div :class="{ active }"></div>
<input :id="id" :value="value">
<div>{{ dynamic }}</div>
import { normalizeClass as _normalizeClass, createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
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
の数値がパッチフラグです。
ひとつひとつのパッチフラグに意味があり、以下のように対応しています。
先の静的ホイスティングの例ではパッチフラグ-1
が使われていますが、-1相当の定義は存在しないため何もしないと考えられます。
一方、<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
参照
https://ja.vuejs.org/guide/extras/rendering-mechanism
https://ja.vuejs.org/guide/extras/reactivity-in-depth.html