讀讀 Vue 的原始碼:Virtual DOM
前面的生命週期講完後,我不禁在想:那 Vue 的 Virtual DOM 又是怎麼來的呢?這篇就是我的觀察。
另外 Vue 有針對 weex 與瀏覽器(通常放在 web 檔案那邊)給出不同的實做。不過既然是探討網站的話,我就只討論瀏覽器。
Learn Virtual DOM again
Virtual DOM 是建立一個模擬真實 DOM 結構的樹狀結構物件。它不會立刻操作 DOM,而是等待要呈現時,透過渲染函式或方法操作 DOM。雖然我們最後都要操作 DOM,但在使用框架操作龐大狀態的情況下,Virtual DOM 可以讓框架的性能快很多。
請參考以下連結:
- What is Virtual DOM?
- React: The Virtual DOM on Codecademy
- Virtual DOM 概述
- 從頭打造一個簡單的 Virtual DOM
- Virtual DOM and Internals
- 如何理解虚拟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) {
oldVnode
與 vnode
比較簡單,就是新舊的 Virtual DOM。這個解釋起來比較麻煩,所以先從 hydrating
以及 removeOnly
說吧。
如果對 Virtual DOM 以外的東西沒興趣,下一章節就不用看了:hydrating
及 removeOnly
與 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
那邊,而 patchVnode
的 removeOnly
則會帶我們到 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 屬性;而這些屬性,裡面主要有三個函式:
create
update
destroy
destroy
函式不一定出現,但 create
與 update
一定會出現。大多數時候,這三個函式會收 oldVnode
與 vnode
這兩個參數。也就是這樣:
[
{
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...