こんにちは、@binarytaです。
blog.engineer.adways.net
前回に引き続き、今回もVue.jsの仮想DOMと差分レンダリングの仕組みを探っていきたいと思います。
間違いがあるかもしれませんので間違いがあったら指摘を頂けたら幸いです。
前回は差分レンダリングの実行タイミングを説明したと思います。
オプション系ライフサイクル中で差分レンダリング(patch)が実行されるタイミングは、
初回レンダリング時はbeforeMount
後
再レンダリング時はbeforeUpdate
後
ということ、そしてその実行タイミング周辺のソースを読んでいきました。
今回は差分レンダリング(patch)の仕組みに迫ります。
差分レンダリング(patch)の仕組み
前回のサンプル実装とその実DOMツリーを示した図を思い出して頂きたいので再掲します。
図に関しては前回は改行や空白によってできるText Nodeの存在を無視してしまっていたので修正しています。
サンプル実装のソース
<body> <h2>Vue.js debug</h2> <div id="main"> <section> <span class='counter'>Parent {{count}}</span> <button @click='increment()'>Increment</button> </section> <section> <my-component></my-component> </section> </div> </body> <script> Vue.component("my-component", { data: function() { return { count: 0 } }, methods: { increment: function() { this.count++; } }, template: ` <div> <span class='counter'>Child {{count}}</span> <button @click='increment()'>Increment</button> </div>` }); new Vue({ el: "#main", data: { count: 0 }, methods: { increment: function() { this.count++; } } }); </script>
図1. サンプル実装が生成する実DOMツリー
今回は初回レンダリング時に適用される差分レンダリング(patch)のみに焦点を当て説明します。
Vue.jsでは差分レンダリングは実際に内部で呼ばれる関数であるpatch
のことを指しますので、以後差分レンダリング(diff/patchの両方)をpatch
と呼ぶことにします。
前回示した上図(図1)はVue.jsが内部でいろいろと処理して最終的に構築する実DOMツリーです。
この実DOMツリーを構築するまでにnew Vue(...)
直後に何が行われどのように仮想DOMツリーを形成しているのか。
というのが今回のテーマです。
そして以下のpatchの処理が実装されたファイルを元に説明します。
https://github.com/vuejs/vue/blob/dev/src/core/vdom/patch.js
仮想DOMツリーの構造
仮想DOMツリー生成ロジックにはVNodeがどんなプロパティを持っているのか知る必要があります。
そのためまずVNodeが保持するプロパティについて以下に記します。
VNodeの実装ファイルは↓を参照してください。
https://github.com/vuejs/vue/blob/dev/src/core/vdom/vnode.js
VNodeクラス
export default class VNode { tag: string | void; data: VNodeData | void; children: ?Array<VNode>; text: string | void; elm: Node | void; ns: string | void; context: Component | void; // rendered in this component's scope key: string | number | void; componentOptions: VNodeComponentOptions | void; componentInstance: Component | void; // component instance parent: VNode | void; // component placeholder node // strictly internal raw: boolean; // contains raw HTML? (server only) isStatic: boolean; // hoisted static node isRootInsert: boolean; // necessary for enter transition check isComment: boolean; // empty comment placeholder? isCloned: boolean; // is a cloned node? isOnce: boolean; // is a v-once node? asyncFactory: Function | void; // async component factory function asyncMeta: Object | void; isAsyncPlaceholder: boolean; ssrContext: Object | void; fnContext: Component | void; // real context vm for functional nodes fnOptions: ?ComponentOptions; // for SSR caching fnScopeId: ?string; // functioanl scope id support // ................... }
英語で注釈が振ってあるので何となく理解しやすいですね。
内部実装でよく出てくるインスタンス変数だけいくつかピックアップしました。
変数名 | 役割 |
---|---|
tag | タグ名 |
data | 属性値 (classやid等) |
children | そのVNode直下の子VNodeの配列を保持している |
text | テキストノードの場合だけ使用される. 文字列を保持する |
elm | 実Node(実際の要素)を保持する. |
context | そのVNodeが所属するコンポーネントのインスタンス |
key | リストレンダリング時に使用されるVNodeを識別するためのユニークな識別子 |
parent | 親となるコンポーネントVNodeを保持する| |
VNodeツリーは最終的にコンポーネントが内部に保持することになります。
サンプル実装でいうところのRootComponent( new Vue(...)したやつ
)とMyComponentは内部にVNodeツリーを保持しています。
今回のサンプル実装のようにネストされたコンポーネントの場合、親側は子コンポーネント内のVNodeツリーまでは保持せず子コンポーネントをVNodeとして認識します。
そして子は子のスコープ内でさらにVNodeツリーを保持しています。
console.logで確認してみます。
RootComponentのmountedにconsole.logを仕込む
new Vue({ // ........ mounted: function () { console.log(this) } });
図2. console.logによるRootComponentのインスタンスを出力
図2に示したようにネストされたコンポーネントはその親となるコンポーネントの$children
プロパティに子コンポーネントとして保持されます。
そしてVNodeツリーは_vnode
プロパティが保持しています。
実際に図2のことをわかりやすく図示すると以下の図3のようになります。
図3. VNodeツリー
Vue.jsの仮想DOMはコンポーネント単位で生成され、VNodeの集合となり構築されます。
図3で示したvue-component-1-my-component
というtagを保持するVNode(MyComponentのVNode)は内部に下半分のオレンジ色の枠の仮想DOMツリーを保持しています。
初回レンダリング時におけるpatch
初回レンダリングは更新時に比べてそこまでロジックが難しくありません。
更新時はコンポーネント内でどこが変更されたのか(diff)を算出をするためです。
patchの実装を覗いてみます。
( 参照先: https://github.com/vuejs/vue/blob/dev/src/core/vdom/patch.js )
return function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) { if (isUndef(vnode)) { if (isDef(oldVnode)) invokeDestroyHook(oldVnode) return } let isInitialPatch = false const insertedVnodeQueue = [] if (isUndef(oldVnode)) { // empty mount (likely as component), create new root element isInitialPatch = true createElm(vnode, insertedVnodeQueue, parentElm, refElm) } else { const isRealElement = isDef(oldVnode.nodeType) if (!isRealElement && sameVnode(oldVnode, vnode)) { // patch existing root node patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly) } else { if (isRealElement) { // mounting to a real element // check if this is server-rendered content and if we can perform // a successful hydration. if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) { oldVnode.removeAttribute(SSR_ATTR) hydrating = true } if (isTrue(hydrating)) { if (hydrate(oldVnode, vnode, insertedVnodeQueue)) { invokeInsertHook(vnode, insertedVnodeQueue, true) return oldVnode } else if (process.env.NODE_ENV !== 'production') { warn( 'The client-side rendered virtual DOM tree is not matching ' + 'server-rendered content. This is likely caused by incorrect ' + 'HTML markup, for example nesting block-level elements inside ' + '<p>, or missing <tbody>. Bailing hydration and performing ' + 'full client-side render.' ) } } // either not server-rendered, or hydration failed. // create an empty node and replace it oldVnode = emptyNodeAt(oldVnode) } // replacing existing element const oldElm = oldVnode.elm const parentElm = nodeOps.parentNode(oldElm) // create new node createElm( vnode, insertedVnodeQueue, // extremely rare edge case: do not insert if old element is in a // leaving transition. Only happens when combining transition + // keep-alive + HOCs. (#4590) oldElm._leaveCb ? null : parentElm, nodeOps.nextSibling(oldElm) ) // update parent placeholder node element, recursively if (isDef(vnode.parent)) { let ancestor = vnode.parent const patchable = isPatchable(vnode) while (ancestor) { for (let i = 0; i < cbs.destroy.length; ++i) { cbs.destroy[i](ancestor) } ancestor.elm = vnode.elm if (patchable) { for (let i = 0; i < cbs.create.length; ++i) { cbs.create[i](emptyNode, ancestor) } // #6513 // invoke insert hooks that may have been merged by create hooks. // e.g. for directives that uses the "inserted" hook. const insert = ancestor.data.hook.insert if (insert.merged) { // start at index 1 to avoid re-invoking component mounted hook for (let i = 1; i < insert.fns.length; i++) { insert.fns[i]() } } } else { registerRef(ancestor) } ancestor = ancestor.parent } } // destroy old node if (isDef(parentElm)) { removeVnodes(parentElm, [oldVnode], 0, 0) } else if (isDef(oldVnode.tag)) { invokeDestroyHook(oldVnode) } } } invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch) return vnode.elm }
引数のoldVnode, vnodeにそれぞれ変更前, 変更後のVNodeが入ってきますが、初回実行は前の状態がないのでoldVnodeにはVNodeではないもの (undefined
や実要素(実Node)
) が入ってきます。
oldVnodeが存在しないため単に未定義かどうか、実要素が入って来ているかどうかを判断し、VNodeを生成します。
上記のpatch実装を表すフローチャートが図4になります。
いくつか処理を省いて簡単に書いています。
図4. patch関数のフローチャート
まずRootComponentにおけるpatchフローに関してです。
oldVnode
にはnew Vueによるインスタンス生成時にelオプションで指定したdiv#main
の実要素(実Node)が入ってきます。
その実要素はいったんVNodeに変換され、子のVNodeツリーを全て再帰的に生成していきます。
図5. RootComponentに対するpatch関数実行時のフローチャート
子Component (今回の場合MyComponent) に相当するVNodeが生成されるタイミングでは再帰処理は一旦終わりますが、patchはコンポーネント単位で実行されるため次はMyComponentがpatchにエントリーしてきます。
そしてMyComponentの場合はnew Vue
によるインスタンス生成ではないためoldVnode
の値は実要素ではなくundefined
となり以下の赤枠の部分に分岐していきます。
図6. MyComponentに対するpatch関数実行時のフローチャート
以降はRootComponentの時と同様にcreateElm
関数により再帰的にVNodeを生成していき、VNodeツリー(仮想DOM)が構築完了となります。
まとめ
今回はpatchの内部で何が行われているのかをフローチャート等で図示しながら探っていきました。
初回レンダリングは結構単純な実装でした!
再レンダリング時にはdiffアルゴリズムなどが関与してくるので1年目新米エンジニアの僕には読み解けるか不安があります。
ということでまた続きを更新しますのでよろしくお願いします!
次回は再レンダリング時のpatchの仕組みについて探っていきます。