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

こんにちは、@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>

f:id:AdwaysEngineerBlog:20180202062438p:plain
図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)
  }
});

f:id:AdwaysEngineerBlog:20180202062531p:plain

図2. console.logによるRootComponentのインスタンスを出力

図2に示したようにネストされたコンポーネントはその親となるコンポーネントの$childrenプロパティに子コンポーネントとして保持されます。
そしてVNodeツリーは_vnodeプロパティが保持しています。

実際に図2のことをわかりやすく図示すると以下の図3のようになります。

f:id:AdwaysEngineerBlog:20180202062649p:plain

図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になります。
いくつか処理を省いて簡単に書いています。

f:id:AdwaysEngineerBlog:20180202062747p:plain

図4. patch関数のフローチャート

まずRootComponentにおけるpatchフローに関してです。
oldVnode にはnew Vueによるインスタンス生成時にelオプションで指定したdiv#mainの実要素(実Node)が入ってきます。
その実要素はいったんVNodeに変換され、子のVNodeツリーを全て再帰的に生成していきます。

f:id:AdwaysEngineerBlog:20180202062830p:plain

図5. RootComponentに対するpatch関数実行時のフローチャート

子Component (今回の場合MyComponent) に相当するVNodeが生成されるタイミングでは再帰処理は一旦終わりますが、patchはコンポーネント単位で実行されるため次はMyComponentがpatchにエントリーしてきます。
そしてMyComponentの場合はnew Vueによるインスタンス生成ではないためoldVnodeの値は実要素ではなくundefinedとなり以下の赤枠の部分に分岐していきます。

f:id:AdwaysEngineerBlog:20180202062907p:plain

図6. MyComponentに対するpatch関数実行時のフローチャート

以降はRootComponentの時と同様にcreateElm関数により再帰的にVNodeを生成していき、VNodeツリー(仮想DOM)が構築完了となります。

まとめ

今回はpatchの内部で何が行われているのかをフローチャート等で図示しながら探っていきました。
初回レンダリングは結構単純な実装でした!
再レンダリング時にはdiffアルゴリズムなどが関与してくるので1年目新米エンジニアの僕には読み解けるか不安があります。
ということでまた続きを更新しますのでよろしくお願いします!

次回は再レンダリング時のpatchの仕組みについて探っていきます。

blog.engineer.adways.net