代碼如下:
<div id="app"> {{someVar}} </div> <script type="text/javascript"> new Vue({ el: '#app', data: { someVar: 'init' }, mounted(){ setTimeout(() => this.someVar = 'changed', 3000) } }) </script>
頁面初始會顯示 "init" 字符串,3秒鍾之后,會更新為 "changed" 字符串。
為了便於理解,將流程分為兩個階段:
- 首次渲染,生成 vnode,並將其掛載到頁面中
- 再次渲染,根據更新后的數據,再次生成 vnode,並將其更新到頁面中
第一階段
流程
vm.$mount(vm.$el) => render = compileToFunctions(template).render => updateComponent() => vnode = render() => vm._update(vnode) => patch(vm.$el, vnode)
說明
由 render() 方法生成 vnode,然后由 patch() 方法掛載到頁面中。
render() 方法
render() 方法根據當前 vm 的數據生成 vnode。
該方法可以是新建 Vue 實例時傳入的 render() 方法,也可以由 Vue 的 compiler 模塊根據傳入的 template 自動生成。
本例中該方法是由 el 屬性對應的 template 生成的,代碼如下:
(function() { with (this) { return _c('div', { attrs: { "id": "app" } }, [_v("\n" + _s(someVar) + "\n")]) }
實例化 Vue 時傳入這樣的參數可以達到相似的效果(區別在於變量兩邊的空格):
new Vue({ data: { someVar: 'init' }, render: function(createElement){ return createElement( 'div', { attrs: { "id": "app" } }, [ this.someVar ] ) }, mounted(){ setTimeout(() => this.someVar = 'changed', 3000) } }).$mount('#app')
Vnode() 類
Vnode 是虛擬 DOM 節點類,其實例 vnode 是一個包含着渲染 DOM 節點所需要的一切信息的普通對象。
上述的 render() 方法調用后會生成 vnode 對象,這是第一次生成,將其稱為 initVnode,結構如下(選取部分屬性):
{ children: [ { children: undefined, data: undefined, elm: undefined, tag: undefined, text: 'init' } ], data: { attrs: { id: 'app' } }, elm: undefined, tag: 'div', text: undefined }
簡要介紹其屬性:
children 是當前 vnode 的子節點(VNodes)數組,當前只有一個文本子節點 data 是當前 vnode 代表的節點的各種屬性,是 createElement() 方法的第二個參數 elm 是根據 vnode 生成 HTML 元素掛載到頁面中后對應的 DOM 節點,此時還沒有掛載,所以為空 tag 是當前 vnode 對應的 html 標簽 text 是當前 vnode 對應的文本或者注釋
children 和 text 是互斥的,不會同時存在。
生成了 vnode 之后,就要根據其屬性生成 DOM 元素並掛載到頁面中了,這是 patch() 方法要做的事情,下面看其內部的流程:
patch(vm.$el, vnode) => createElm(vnode, [], parentElm, nodeOps.nextSibling(oldElm)) => removeVnodes(parentElm, [oldVnode], 0, 0)
patch(oldVnode, vnode) 方法
根據參數的不同,該方法的處理方式也不同,oldVnode 有這幾種可能的取值:undefined、ELEMENT_NODE、VNode,vnode 有這幾種可能的取值:undefined、VNode,所以組合起來一共是 3 * 2 = 6 種處理方式:
oldVnode | vnode | 操作 |
---|---|---|
undefined | undefined | - |
ELEMENT_NODE | undefined | invokeDestroyHook(oldVnode) |
Vnode | undefined | invokeDestroyHook(oldVnode) |
undefined | Vnode | createElm(vnode, [], parentElm, refElm) |
ELEMENT_NODE | Vnode | createElm(vnode, [], parentElm, refElm) |
Vnode | Vnode | patchVnode(oldVnode, vnode) |
可以看到,處理方式可以分為3種情況:
如果 vnode 為 undefined,就要刪除節點 如果 oldVnode 是 undefined 或者是 DOM 節點,vnode 是 VNode 實例的話,表示是第一次渲染 vnode,調用 createElm() 方法創建新節點 如果 oldVnode 和 vnode 都是 VNode 類型的話,就要調用 patchVnode() 方法來對 oldVnode 和 vnode 做進一步處理了,第二階段流程會介紹這種情況
本階段流程是首次渲染,符合第 2 種情況,下面看 createElm() 方法的實現:
createElm(vnode, [], parentElm, refElm) 方法
該方法根據 vnode 的屬性創建組件或者普通 DOM 元素,有如下幾種處理方式:
調用 createComponent() 方法對 component 做處理,這里就不再展開討論。 vnode.tag 存在: 調用 nodeOps.createElement(tag, vnode) 創建 DOM 元素, 調用 createChildren() 方法遞歸創建子節點。 調用 invokeCreateHooks() 方法調用生命周期相關的 create 鈎子處理 vnode.data 數據 vnode 是文本類型,調用 nodeOps.createTextNode(vnode.text) 創建文本元素
對於2,3 這兩種情況,最后都會調用 insert() 方法將生成的 DOM 元素掛載到頁面中。此時,頁面的 DOM 結構如下:
<body> <div id="app"> {{someVar}} </div> <div id="app"> init </div>
可以看到,原始的 DOM 元素還保留在頁面中,所以在createElm() 方法調用之后,還會調用 removeVnodes() 方法,將原始的 DOM 元素刪除掉。
這樣,就完成了首次視圖的渲染。在這個過程中,Vue 還會做一些額外的操作:
將 vnode 保存到 vm._vnode 屬性上,供再次渲染視圖時與新 vnode 做比較
{ children: [ { children: undefined, data: undefined, elm: Text, // text tag: undefined, text: 'init' } ], data: { attrs: { id: 'app' } }, elm: HTMLDivElement, // div#app tag: 'div', text: undefined
可以看到,vnode 及其子節點的 elm 屬性更新為了頁面中對應的 DOM 節點,不再是 undefined,也是為了再次渲染時使用。
第二階段
流程
updateComponent() => vnode = render() => vm._update(vnode) => patch(oldVnode, vnode)
第二階段渲染時,會根據更新后的 vm 數據,再次生成 vnode 節點,稱之為 updateVnode,結構如下:
{ children: [ { children: undefined, data: undefined, elm: undefined, tag: undefined, text: 'changed' } ], data: { attrs: { id: 'app' } }, elm: undefined, tag: 'div', text: undefined }
可以看到, updateVnode 與 最初生成的 initVnode 的區別就是子節點的 text 屬性由 init 變為了 changed,正是符合我們預期的變化。
生成新的 vnode 之后,還是要調用 patch 方法對 vnode 做處理,不過這次參數發生了變化,第一個參數不再是要掛載的DOM節點,而是 initVnode,本次 patch() 方法調用的流程如下:
patch(oldVnode, vnode) => patchVnode(oldVnode, vnode) => updateChildren(elm, oldCh, ch) => patchVnode(oldCh, ch) => nodeOps.setTextContent(elm, vnode.text)
其中 oldVnode 就是第一階段保存的 vm._vnode,elm 就是第一階段更新的 elm 屬性。
根據上面對 patch() 方法的分析,此時 oldVnode 和 vnode 都是 VNode 類型,所以調用 patchVnode() 方法做進一步處理。
patchVnode(oldVnode, vnode) 方法
該方法包含兩個主要流程:
更新自身屬性,調用 Vue 內置的組件生命周期 update 階段的鈎子方法更新節點自身的屬性,類似之前的 invokeCreateHooks() 方法,這里不再展開說明 更新子節點,根據子節點的不同類型調用不同的方法
根據 vnode 的 children 和 text 屬性的取值,子節點有 3 種可能:
children 不為空,text 為空 children 為空,text 不為空 children 和 text 都為空
由於 oldVnode 和 vnode 的子節點都有 3 種可能:undefined、children 或 text,所以一共有 3 * 3 = 9 種操作:
oldCh | ch | 操作 |
---|---|---|
children | text | nodeOps.setTextContent(elm, vnode.text) |
text | text | nodeOps.setTextContent(elm, vnode.text) |
undefined | text | nodeOps.setTextContent(elm, vnode.text) |
children | children | updateChildren(elm, oldCh, ch) |
text | children | setTextContent(elm, ''); addVnodes(elm, null, ch, 0, ch.length - 1) |
undefined | children | addVnodes(elm, null, ch, 0, ch.length - 1) |
children | undefined | removeVnodes(elm, oldCh, 0, oldCh.length - 1) |
text | undefined | nodeOps.setTextContent(elm, '') |
undefined | undefined | - |
可以看到,大概分為這幾類處理方式:
- 如果 ch 是 text ,那么就對 DOM 節點直接設置新的文本;
- 如果 ch 為 undefined 了,那么就清空 DOM 節點的內容
- 如果 ch 是 children 類型,而 oldCh是 文本或者為 undefined ,那么就是在 DOM 節點內新增節點
- ch 和 oldCh 都是 children 類型,那么就要調用 updateChildren() 方法來更新 DOM 元素的子節點
updateChildren(elm, oldCh, ch) 方法
updateChildren() 方法是 Vnode 處理方法中最復雜也是最核心的方法,它主要做兩件事情:
- 遞歸調用 patchVnode 方法處理更下一級子節點
- 根據各種判斷條件,對頁面上的 DOM 節點進行盡可能少的添加、移動和刪除操作
下面分析方法的具體實現:
oldCh 和 ch 是代表舊和新兩個 Vnode 節點序列,oldStartIdx、newStartIdx、oldEndIdx、newEndIdx 是 4 個指針,指向 oldCh 和 ch 未處理節點序列中的的開始和結束節點,指向的節點命名為 oldStartVnode、newStartVnode、oldEndVnode、newEndVnode。指針在序列中從兩邊向中間移動,直到 oldCh 或 ch 中的某個序列中的全部節點都處理完畢,這時,如果另一個序列尚有未處理完畢的節點,會再對這些節點進行添加或刪除。
先看 while 循環,在 oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx 條件下,分為這幾種情況:
- isUndef(oldStartVnode) 和 isUndef(oldEndVnode) 在第一次循環時是不會觸發的,需要后續條件才可能觸發,下面會分析到
- sameVnode(oldStartVnode, newStartVnode) 和 sameVnode(oldEndVnode, newEndVnode) 情況下不用移動 DOM 節點,只移動指針,比如:[A, B] => [A, C]
- sameVnode(oldStartVnode, newEndVnode) 情況下,是要將 oldStartVnode 向右移動到 oldEndIdx 對應的節點后面,比如:[A, B] => [C, A]
- sameVnode(oldEndVnode, newStartVnode) 情況下,是要將 oldEndVnode 向左移動到 oldStartIdx 對應的節點前面,比如:[A, B] => [B, C]
- 在以上條件都不滿足的情況下,就要根據 newStartVnode 的 key 屬性來進一步處理:
- 如果 newStartVnode 沒有對應到 oldCh 中的某個元素,比如:[A, B] => [C],說明這個節點是新增加的,那么就調用 createElm() 新建節點及其子節點
- 如果 newStartVnode 對應到了 oldCh 中的某個元素,比如:[A, B, C] => [B, A, E],那么就直接移動該元素到 oldStartIdx 對應的節點前面,同時還會將 oldCh 中對應的節點置為 undefined,表示元素已經處理過了,此時,oldCh == [A, undefined, C],這樣,在后續的循環中,就可以觸發 isUndef(oldStartVnode) 或 isUndef(oldEndVnode) 條件了
- 另外,還可能會有重復 key 或者 key 相同但是 tag 等屬性不同的情況,比如:[A, B, C] => [B, A, A, C],對於這類情況,newStartVnode 也會被作為新元素處理
循環結束時,必然會滿足 oldStartIdx > oldEndIdx 或 newStartIdx > newEndIdx 兩種情況之一,所以對這兩種情況需要進一步處理:
- oldStartIdx > oldEndIdx 的情況,比如 [A] => [A, B, C],循環結束時,ch 中的 B 和 C 都還沒有添加到頁面中,這時就會調用 addVnodes() 方法將他們依次添加
- newStartIdx > newEndIdx 的情況,比如 [A, B, C] => [D],循環結束時,A, B, C 都還保留在頁面中,這時需要調用 removeVnodes() 將他們從頁面中移除
如果循環結束時,新舊序列中的節點全部都處理完畢了,如:[A, B] => [B, A],那么,雖然也會觸發這兩種邏輯之一,但是並不會對 DOM 產生實際的影響。
下面通過一些例子來展示該方法對 DOM 節點的操作流程:
[A, B] => [A, C]
序號 | 說明 | oldStartIdx | oldEndIdx | newStartIdx | newEndIdx | DOM |
---|---|---|---|---|---|---|
0 | 初始狀態 | 0 | 1 | 0 | 1 | A, B |
1 | 第一次循環,滿足 sameVnode(oldStartVnode, newStartVnode), 無 DOM 操作 | 1 | 1 | 1 | 1 | A, B |
2 | 第二次循環,滿足 isUndef(idxInOld) 條件,新增 C 到 B 之前 | 1 | 1 | 2 | 1 | A, C, B |
2 | 循環結束,滿足 newStartIdx > newEndIdx,將 B 移除 | 1 | 1 | 2 | 1 | A, C |
[A, B] => [C, A]
序號 | 說明 | oldStartIdx | oldEndIdx | newStartIdx | newEndIdx | DOM |
---|---|---|---|---|---|---|
0 | 初始狀態 | 0 | 1 | 0 | 1 | A, B |
1 | 第一次循環,滿足 sameVnode(oldStartVnode, newEndVnode) ,移動 A 到 B 之后 | 1 | 1 | 0 | 0 | B, A |
2 | 第二次循環,滿足 isUndef(idxInOld) 條件,新增 C 到 B 之前 | 1 | 1 | 1 | 0 | C, B, A |
2 | 循環結束,滿足 newStartIdx > newEndIdx,將 B 移除 | 1 | 1 | 1 | 0 | C, A |
[A, B, C] => [B, A, E]
序號 | 說明 | oldCh | oldStartIdx | oldEndIdx | ch | newStartIdx | newEndIdx | DOM |
---|---|---|---|---|---|---|---|---|
0 | 初始狀態 | [A, B, C] | 0 | 2 | [B, A, E] | 0 | 2 | A, B, C |
1 | 第一次循環,滿足 sameVnode(elmToMove, newStartVnode),移動 B 到 A 之前 | [A, undefined, C] | 0 | 2 | [B, A, E] | 1 | 2 | B, A, C |
2 | 第二次循環,滿足 sameVnode(oldStartVnode, newStartVnode),無 DOM 操作 | [A, undefined, C] | 1 | 2 | [B, A, E] | 2 | 2 | B, A, C |
3 | 第三次循環,滿足 isUndef(oldStartVnode),無 DOM 操作 | [A, undefined, C] | 2 | 2 | [B, A, E] | 2 | 2 | B, A, C |
4 | 第四次循環,滿足 isUndef(idxInOld),新增 E 到 C 之前 | [A, undefined, C] | 2 | 2 | [B, A, E] | 3 | 2 | B, A, E, C |
5 | 循環結束,滿足 newStartIdx > newEndIdx,將 C 移除 | [A, undefined, C] | 2 | 2 | [B, A, E] | 3 | 2 | B, A, E |
[A] => [B, A]
序號 | 說明 | oldStartIdx | oldEndIdx | newStartIdx | newEndIdx | DOM |
---|---|---|---|---|---|---|
0 | 初始狀態 | 0 | 0 | 0 | 1 | A |
1 | 第一次循環,滿足 sameVnode(oldStartVnode, newEndVnode),無 DOM 操作 | 1 | 0 | 0 | 0 | A |
2 | 循環結束,滿足 oldStartIdx > oldEndIdx ,新增 B 到 A 之前 | 1 | 0 | 0 | 1 | B, A |
[A, B] => [B, A]
序號 | 說明 | oldStartIdx | oldEndIdx | newStartIdx | newEndIdx | DOM |
---|---|---|---|---|---|---|
0 | 初始狀態 | 0 | 1 | 0 | 1 | A, B |
1 | 第一次循環,滿足 sameVnode(oldStartVnode, newEndVnode),移動 A 到 B 之后 | 1 | 1 | 0 | 0 | B, A |
2 | 第二次循環,滿足 sameVnode(oldStartVnode, newStartVnode) 條件,無 DOM 操作 | 2 | 1 | 1 | 0 | B, A |
3 | 循環結束,滿足 oldStartIdx > oldEndIdx ,無 DOM 操作 | 2 | 1 | 1 | 0 | B, A |
通過以上流程,視圖再次得到了更新。同時,新的 vnode 和 elm 也會被保存,供下一次視圖更新時使用,歡迎指正不對之處。