こんにちは、@binarytaです。
何回かに分けて記事を公開していこうかなと思っております。
前提
Vue.js version : 2.5.13
対象読者
- JSフレームワークの知見がある
- 仮想DOMの仕組みを知りたい
はじめに
なぜVue.jsか?という質問が飛んできそうなので一応述べさせて頂きます。
そもそもですが僕がコミットしているチームではVue.jsを使っています。
なぜVue.jsを選択したかというと、流行りのフロントエンドフレームワークだということももちろんありましたが、Vue.jsのhtmlの記述がReactのようなJSX記法ではなく限りなくhtmlに近いDSLを持っていることが一番大きいです。
それに加え、既存のプロジェクトに適用しやすいようなメリットが多かったのも選択した理由になりますが、その辺に関しては以下のVue.js公式ページを参照してみてください。
https://jp.vuejs.org/v2/guide/comparison.html
とまぁ結論としては僕が使っているフレームワークの内部実装はどうなってるのか?
という疑問と興味を持ったためです。
Vue.jsの仮想DOMと差分レンダリングの仕組み
仮想DOMの差分を適用する処理はよくdiff/patchなどと呼ばれます。
特にVue.jsではその処理を patch
という関数で実装しています。↓
https://github.com/vuejs/vue/blob/dev/src/core/vdom/patch.js
早速ですが、本題の仮想DOMと差分レンダリングの深層に迫っていきましょう。
仮想DOMがどのように生成され、
生成された仮想DOMの変更差分はどのように適用されるのかというところに焦点を当てて説明しますが、
ざっと3つに分けて以下のような順序で処理を追っていきます。
で、今回はライフサイクルと差分レンダリング(patch)の実行
を探っていきます。
- ライフサイクルと差分レンダリング(patch)の実行
- 差分レンダリング(patch)の仕組み
- 再レンダリング
サンプルとして簡易的な実装を用意しました。
このサンプル実装を元に説明します。
<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>
このソースがブラウザに実際にレンダリングされた場合、下図のような実DOMツリーを生成します。
もちろん仮想DOMから実DOMが生成され、その内容が実DOMツリーとなって生成されています。
実DOMはhtmlのNodeの集合でありツリー構造です。
そして次回以降の記事で解説しますが、仮想DOMももちろん仮想Node(VNode)で形成されるツリー構造です。
DOMとは何か
https://developer.mozilla.org/ja/docs/DOM/DOM_Reference/Introduction
仮想DOMは最終的にjavascriptにより実DOMを生成します。
なので当然ですが実DOM生成の内部実装には、javascriptを知る人なら誰もがわかる以下のように記述が存在します。
document.createElement(tag)
↓こちらの内部実装を参照してみるとすぐわかります。
https://github.com/vuejs/vue/blob/dev/src/platforms/web/runtime/node-ops.js
仮想DOMについて長々と述べると趣旨がずれそうなので、早速DOMがレンダリングされる際のフローを追っていきます。
1. ライフサイクルと差分レンダリング(patch)の実行
先ほど述べた通り、Vue.jsでは diff / patch の処理を以下のファイルのpatch関数内に実装しています。
https://github.com/vuejs/vue/blob/dev/src/core/vdom/patch.js
ReactなどのJSフレームワーク同様、Vue.jsにもライフサイクル関数がいくつか提供されています。
- beforeCreate
- created
- beforeMount
- mounted
- beforeUpdate
- updated
- beforeDestroy
- destroyed
公式ページに描かれている図がわかりやすいです。
差分レンダリングはどのライフサイクルの後に実行されるのでしょうか?
このファイルに記述されています。
https://github.com/vuejs/vue/blob/dev/src/core/instance/lifecycle.js
結論からいうと、初回レンダリング時はbeforeMountのあとに実行されます。
再レンダリング時の実行タイミングはまた違う流れになるのでまた後で見ていきます。
ざっと見た感じこれらの関数が差分レンダリング処理に直接的に関与しています
- lifecycleMixin
- mountComponent
- callHook
lifecycleMixin
/* src/core/instance/lifecycle.js */ export function lifecycleMixin (Vue: Class<Component>) { Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) { const vm: Component = this if (vm._isMounted) { callHook(vm, 'beforeUpdate') } const prevEl = vm.$el const prevVnode = vm._vnode const prevActiveInstance = activeInstance activeInstance = vm vm._vnode = vnode // Vue.prototype.__patch__ is injected in entry points // based on the rendering backend used. if (!prevVnode) { // initial render vm.$el = vm.__patch__( vm.$el, vnode, hydrating, false /* removeOnly */, vm.$options._parentElm, vm.$options._refElm ) // no need for the ref nodes after initial patch // this prevents keeping a detached DOM tree in memory (#5851) vm.$options._parentElm = vm.$options._refElm = null } else { // updates vm.$el = vm.__patch__(prevVnode, vnode) } /* ... 略 ... */ } Vue.prototype.$forceUpdate = function () { /* ... 略 ... */ } Vue.prototype.$destroy = function () { /* ... 略 ... */ } }
llifecycleMixin関数
はmounted以降のライフサイクルメソッド群をVueのプロトタイプチェーンに定義するために以下のAPIの初期化処理と、状態更新の処理を内包した関数(_update関数
)の初期化を担う関数です。
_update
$forceUpdate
$destroy
_update関数
以外はインスタンスメソッドとしてのライフサイクルなので実際にVue.jsをimportしたら使用できるこれらのAPIになります。
mounted後( 仮想DOMが実DOMになった後 )のライフサイクルは実行タイミングが不定なため別のデータフローが走ります。
そのためmounted前のこれら↓ライフサイクルは一回のみ実行されます。
- beforeCreate
- created
- beforeMount
- mounted
状態更新を行う関数(_update関数
)をVueのプロトタイプチェーンに定義されています。
実際に差分レンダリング(patch関数)を実行する関数もプロトタイプチェーンでpatch関数という名で定義されていて、patch実行処理はその_update関数
の内部から呼び出されます。
以下の部分です。
/* 初回レンダリングの時 */ if (!prevVnode) { // initial render vm.$el = vm.__patch__( vm.$el, vnode, hydrating, false /* removeOnly */, vm.$options._parentElm, vm.$options._refElm ) // no need for the ref nodes after initial patch // this prevents keeping a detached DOM tree in memory (#5851) vm.$options._parentElm = vm.$options._refElm = null /* 再レンダリング */ } else { // updates vm.$el = vm.__patch__(prevVnode, vnode) }
mountComponent
上述したlifecycleMixin関数
内部の_update関数
ですが、実際にこの関数内から呼ばれます。
export function mountComponent ( vm: Component, el: ?Element, hydrating?: boolean ): Component { vm.$el = el /* ... 略 ... */ callHook(vm, 'beforeMount') let updateComponent /* istanbul ignore if */ if (process.env.NODE_ENV !== 'production' && config.performance && mark) { updateComponent = () => { /* ... 略 ... */ vm._update(vnode, hydrating) mark(endTag) measure(`vue ${name} patch`, startTag, endTag) } } else { updateComponent = () => { vm._update(vm._render(), hydrating) } } // we set this to vm._watcher inside the watcher's constructor // since the watcher's initial patch may call $forceUpdate (e.g. inside child // component's mounted hook), which relies on vm._watcher being already defined new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */) hydrating = false // manually mounted instance, call mounted on self // mounted is called for render-created child components in its inserted hook if (vm.$vnode == null) { vm._isMounted = true callHook(vm, 'mounted') } return vm }
このmountComponent関数
は初回レンダリング時にのみ作用します。
再レンダリング時はこのWatcherというのがよしなに監視して、_update関数
を間接的に呼ぶことで状態更新を行います。
new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */)
この関数(mountComponent関数
)が呼び出されるタイミングと
前述した_update関数
が走るタイミングを実際にconsoleで確認していきます。
mountComponent関数
の開始時とreturn前にconsole.group
_update関数
の開始時にconsole.log
を仕込んでみました。
export function lifecycleMixin (Vue: Class<Component>) { Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) { console.log(vnode); /* .... ... 略 ... */ } } /* .... ... 略 ... */ export function mountComponent ( vm: Component, el: ?Element, hydrating?: boolean ): Component { console.group('mountComponent'); /* .... ... vm._update()が実行される ... */ console.groupEnd('mountComponent'); return vm }
こちらが初回レンダリング時
こちらは再レンダリング時 (counterがインクリメントされた後)
この関数は初回レンダリング時にのみ作用します. 再レンダリング時はこのWatcherがよしなに監視して、
_update関数
を間接的に呼ぶことで状態更新を行います。
前述したことが何となく確認できましたね。
初回レンダリング時にmountComponentが2回呼ばれるのは今回のサンプル実装で子コンポーネント(MyComponent)を作ってるからですね。
再レンダリング時にはmountComponent関数
が呼ばれずに_update関数
が実行されています。
callHook
export function callHook (vm: Component, hook: string) { const handlers = vm.$options[hook] if (handlers) { for (let i = 0, j = handlers.length; i < j; i++) { try { handlers[i].call(vm) } catch (e) { handleError(e, vm, `${hook} hook`) } } } if (vm._hasHookEvent) { vm.$emit('hook:' + hook) } }
オプション系ライフサイクルメソッドを呼び出す関数です。
前述のmountComponent関数内やinitLifecycle関数内でもcallHookが使用されています。
Vue.jsにはオプション系のライフサイクルとインスタンスメソッドとしてのライフサイクルの 2つが存在しますが、 このcallHook関数で呼び出されるライフサイクルはオプション系です。
まとめ
今回は差分レンダリング(patch)の適用タイミングを追っていきました。
次回は差分レンダリング(patch)の仕組みを共有できたらと思います。