讀讀 Vue 的原始碼:Virtual DOM

前面的生命週期講完後,我不禁在想:那 Vue 的 Virtual DOM 又是怎麼來的呢?這篇就是我的觀察。

另外 Vue 有針對 weex 與瀏覽器(通常放在 web 檔案那邊)給出不同的實做。不過既然是探討網站的話,我就只討論瀏覽器。

Learn Virtual DOM again

Virtual DOM 是建立一個模擬真實 DOM 結構的樹狀結構物件。它不會立刻操作 DOM,而是等待要呈現時,透過渲染函式或方法操作 DOM。雖然我們最後都要操作 DOM,但在使用框架操作龐大狀態的情況下,Virtual DOM 可以讓框架的性能快很多。

請參考以下連結:

Arriving initMixin and initRender again

要探討的話,我們該從哪裡開始呢?我們前面不是談過說 initRender 裡面的 createElement 會「透過 Virtual DOM 來建立組件的元素」嗎?所以現在就要從這裡開始了:

// src/core/instance/render.js
// bind the createElement fn to this instance
// so that we get proper render context inside it.
// args order: tag, data, children, normalizationType, alwaysNormalize
// internal version is used by render functions compiled from templates
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
// normalization is always applied for the public version, used in
// user-written render functions.
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)

簡單來說 createElement 會把 Tag 元素、資料、子元素等參數放進去。但去 src/core/vdom/create-element.js 就會發現說 createElement 其實是呼叫 _createElement(請注意底線)、然後 _createElement 的水其實相當深:裡面有很多綁定、或偵測功能之類的指令。

The lake called _createElement

我們就從 let vnode, ns 那邊開始吧:

let vnode, ns
if (typeof tag === 'string') {
    /* 省略 */
    if (config.isReservedTag(tag)) {
        /* 省略 */
        vnode = new VNode(
            config.parsePlatformTagName(tag), data, children,
            undefined, undefined, context
        )
    } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
        /* 省略 */
        vnode = createComponent(Ctor, data, context, children, tag)
    } else {
        /* 省略 */
        vnode = new VNode(
            tag, data, children,
            undefined, undefined, context
        )
    }
} else {
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children)
}

你會發現不管如何,vnode 都會建立一個稱作 VNode 的物件。就算是呼叫 createComponent 函式的,裡面也有建立 VNode

// src/core/vdom/create-component.js
/* 省略 */
const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined, undefined, undefined, context,
    { Ctor, propsData, listeners, tag, children },
    asyncFactory
)
/* 省略 */
return vnode

那就去 VNode 的原址 src/core/vdom/vnode.js 吧──但整個 VNode class 做的也不多,七十多行裡面就建立 VNode 的變數而已:

// src/core/vdom/vnode.js
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
  devtoolsMeta: ?Object; // used to store functional render context for devtools
  fnScopeId: ?string; // functional scope id support
  /* 省略 */
}

這些就是描述 Virtual DOM 所需要的東西了。但要如何改變裡面的變數呢?這就需要看其他檔案了。

Another way for VNode

正好 src/core/vdom 目錄有個有意思的檔案:patch.js。檔案裡面唯一輸出的是 createPatchFunction 這函式。另外整個算法本身也是借鑿別人的就是了。

我通常看程式,會習慣先檢查這函式會在其他檔案怎麼用、還有參數是哪些,再看看它裡面的行為。所以在這裡,我不會直接看 createPatchFunction 函式寫什麼,而是先搜尋 createPatchFunction 函式用在哪裡這樣。

嗯,不談平台的話 createPatchFunction 只在 src/platforms/web/runtime/patch.js 用到這樣。但我那時卻很疑惑:為什麼包裝的 patch 函式只有兩個參數,但呼叫的函式卻有好幾個呢?

// src/platforms/web/runtime/patch.js
export const patch: Function = createPatchFunction({ nodeOps, modules })
// src/platforms/web/runtime/index.js
Vue.prototype.__patch__ = inBrowser ? patch : noop
// src/ore/instance/lifecycle.js
if (!prevVnode) {
  // initial render
  vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
  // updates
  vm.$el = vm.__patch__(prevVnode, vnode)
}
// src/platforms/web/runtime/components/transition-group.js
this.__patch__(
  this._vnode,
  this.kept,
  false, // hydrating
  true // removeOnly (!important, avoids unnecessary moves)
)

那就來看看函式內部的行為吧。這時我才恍然大悟,原來整個檔案是回傳一個函式的:

// src/core/vdom/patch.js
return function patch (oldVnode, vnode, hydrating, removeOnly) {

oldVnodevnode 比較簡單,就是新舊的 Virtual DOM。這個解釋起來比較麻煩,所以先從 hydrating 以及 removeOnly 說吧。

如果對 Virtual DOM 以外的東西沒興趣,下一章節就不用看了:hydratingremoveOnly 與 Virtual DOM 完全無關。

hydrating and removeOnly

// src/core/vdom/patch.js
// mounting to a real element
// check if this is server-rendered content and if we can perform
// a successful hydration.

hydrating 的意思是「水合物」:但這好像沒啥用。不過我搜尋「hydration js」就找到Client Side Hydration

好吧,hydrating 是拿來做伺服器渲染用的。那 removeOnly 呢?

removeOnly 主要用在 patchVnode 那邊,而 patchVnoderemoveOnly 則會帶我們到 updateChildren 函式裡面。而 updateChildren 呢:

// src/core/vdom/patch.js
// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions

removeOnly 的用途也呼之欲出啦,就是確保 <transition-group> 元素裡面的東西,能維持正確性。

So we are approaching to the heart

到了 Virtual DOM 算法這裡,其實就已經很底層、很細部了。很多變數甚至不是很好理解:

// src/core/vdom/patch.js
function invokeDestroyHook (vnode) {
  let i, j
  const data = vnode.data
  if (isDef(data)) {
    if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode)
    for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode)
  }
  if (isDef(i = vnode.children)) {
    for (j = 0; j < vnode.children.length; ++j) {
      invokeDestroyHook(vnode.children[j])
    }
  }
}
/* 省略 */
if (isUndef(vnode)) {
  if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
  return
}

How can a Cruel Browsing System do DOM manipulation

看到前面的程式,你可能會懷疑 cbs 到底是何方神聖。好,它其實是這樣來的:

const hooks = ['create', 'activate', 'update', 'remove', 'destroy']
/* 省略 */
let i, j
const cbs = {}

const { modules, nodeOps } = backend

for (i = 0; i < hooks.length; ++i) {
  cbs[hooks[i]] = []
  for (j = 0; j < modules.length; ++j) {
    if (isDef(modules[j][hooks[i]])) {
      cbs[hooks[i]].push(modules[j][hooks[i]])
    }
  }
}

於是前面的 createPatchFunction({ nodeOps, modules }) 就派上用場了:回到 src/platforms/web/runtime/patch.js 這邊,有一個 platformModules.concat(baseModules) 的玩意。我觀察並整理了一下,裡面的的大致結構是一串陣列,裡面會放上各種 DOM 屬性;而這些屬性,裡面主要有三個函式:

destroy 函式不一定出現,但 createupdate 一定會出現。大多數時候,這三個函式會收 oldVnodevnode 這兩個參數。也就是這樣:

[
    {
        create: (oldVnode, vnode) => {},
        update: (oldVnode, vnode) => {},
        destroy: (oldVnode, vnode) => {}
    },
    // ...
]

回到 hooks 那邊,cbs 大致會變成這樣:

[
    create: [
        (oldVnode, vnode) => {},
        (oldVnode, vnode) => {},
        (oldVnode, vnode) => {},
        // ...
    ],
    activate: [
        (oldVnode, vnode) => {},
        // ...
    ],
    update: [
        (oldVnode, vnode) => {},
        // ...
    ],
    remove: [
        (oldVnode, vnode) => {},
        // ...
    ],
    destroy: [
        (oldVnode, vnode) => {},
        // ...
    ]
]

接著在啟動某個函式時,把相對應的 cbs 函式一口氣觸發。至於 cbs 函式裡面要幹什麼,這個要看平台與模塊的行為而定。像是要設定 DOM class 的話 src/platforms/web/runtime/modules/class.js 就是這樣寫的:

// src/platforms/web/runtime/modules/class.js
/* 省略 */
let cls = genClassForVnode(vnode)
/* 省略 */
// set the class
if (cls !== el._prevClass) {
  el.setAttribute('class', cls)
  el._prevClass = cls
}

也就是說轉譯 VNode 的變數後,把這些轉譯的結果,丟給 setAttribute 設定這樣。

另外 src/platforms/web/runtime/node-ops.js 的東西,最後也是用上各種原生 Web API 來操作的。

到了這裡,正文結束了。

Journey's End

終於把對 Virtual DOM 的觀察寫完了。這看似九千字、實則正文四千字的觀察肯定不夠,因為我並沒有把所有行為都講完;但這些已經夠我對資料的流動,有著一點概念了。

多看看別人的東西,可以對寫程式有點心得。而這次我觀察 Vue 原始碼後的心得,可以說是百感交集:我不太喜歡這麼複雜的架構,但也沒有其他更好的想法:Vue 的原始碼之所以架構複雜到這個樣子,是因為要支援很多功能、完成很多需求:像是測試啦、伺服器渲染啦、weex 啦等等的。

程式常常是權衡。

然後我看大多數 JavaScript style guides 感覺都蠻討厭的(笑)真想寫一個自己的 JavaScript style guides...