Vue.jsの仮想DOMと差分レンダリングの仕組み①

こんにちは、成田です。

何回かに分けて記事を公開していこうかなと思っております。

前提

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)の実行を探っていきます。

  1. ライフサイクルと差分レンダリング(patch)の実行
  2. 差分レンダリング(patch)の仕組み
  3. 再レンダリング

サンプルとして簡易的な実装を用意しました。
このサンプル実装を元に説明します。

<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

f:id:AdwaysEngineerBlog:20180119173118p:plain

仮想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://jp.vuejs.org/v2/guide/instance.html

差分レンダリングはどのライフサイクルの後に実行されるのでしょうか?

このファイルに記述されています。
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
}

こちらが初回レンダリング時
f:id:AdwaysEngineerBlog:20180119173153p:plain



こちらは再レンダリング時 (counterがインクリメントされた後) f:id:AdwaysEngineerBlog:20180119173206p:plain

この関数は初回レンダリング時にのみ作用します. 再レンダリング時はこの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)の仕組みを共有できたらと思います。