vue虛擬DOM源碼學習-vnode的掛載和更新流程


 代碼如下:

<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" 字符串。

為了便於理解,將流程分為兩個階段:

  1. 首次渲染,生成 vnode,並將其掛載到頁面中
  2. 再次渲染,根據更新后的數據,再次生成 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 -

可以看到,大概分為這幾類處理方式:

  1. 如果 ch 是 text ,那么就對 DOM 節點直接設置新的文本;
  2. 如果 ch 為 undefined 了,那么就清空 DOM 節點的內容
  3. 如果 ch 是 children 類型,而 oldCh是 文本或者為 undefined ,那么就是在 DOM 節點內新增節點
  4. ch 和 oldCh 都是 children 類型,那么就要調用 updateChildren() 方法來更新 DOM 元素的子節點

updateChildren(elm, oldCh, ch) 方法

updateChildren() 方法是 Vnode 處理方法中最復雜也是最核心的方法,它主要做兩件事情:

  1. 遞歸調用 patchVnode 方法處理更下一級子節點
  2. 根據各種判斷條件,對頁面上的 DOM 節點進行盡可能少的添加、移動和刪除操作

下面分析方法的具體實現:

oldCh 和 ch 是代表舊和新兩個 Vnode 節點序列,oldStartIdx、newStartIdx、oldEndIdx、newEndIdx 是 4 個指針,指向 oldCh 和 ch 未處理節點序列中的的開始和結束節點,指向的節點命名為 oldStartVnode、newStartVnode、oldEndVnode、newEndVnode。指針在序列中從兩邊向中間移動,直到 oldCh 或 ch 中的某個序列中的全部節點都處理完畢,這時,如果另一個序列尚有未處理完畢的節點,會再對這些節點進行添加或刪除。

先看 while 循環,在 oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx 條件下,分為這幾種情況:

  1. isUndef(oldStartVnode) 和 isUndef(oldEndVnode) 在第一次循環時是不會觸發的,需要后續條件才可能觸發,下面會分析到
  2. sameVnode(oldStartVnode, newStartVnode) 和 sameVnode(oldEndVnode, newEndVnode) 情況下不用移動 DOM 節點,只移動指針,比如:[A, B] => [A, C]
  3. sameVnode(oldStartVnode, newEndVnode) 情況下,是要將 oldStartVnode 向右移動到 oldEndIdx 對應的節點后面,比如:[A, B] => [C, A]
  4. sameVnode(oldEndVnode, newStartVnode) 情況下,是要將 oldEndVnode 向左移動到 oldStartIdx 對應的節點前面,比如:[A, B] => [B, C]
  5. 在以上條件都不滿足的情況下,就要根據 newStartVnode 的 key 屬性來進一步處理:
    1. 如果 newStartVnode 沒有對應到 oldCh 中的某個元素,比如:[A, B] => [C],說明這個節點是新增加的,那么就調用 createElm() 新建節點及其子節點
    2. 如果 newStartVnode 對應到了 oldCh 中的某個元素,比如:[A, B, C] => [B, A, E],那么就直接移動該元素到 oldStartIdx 對應的節點前面,同時還會將 oldCh 中對應的節點置為 undefined,表示元素已經處理過了,此時,oldCh == [A, undefined, C],這樣,在后續的循環中,就可以觸發 isUndef(oldStartVnode) 或 isUndef(oldEndVnode) 條件了
    3. 另外,還可能會有重復 key 或者 key 相同但是 tag 等屬性不同的情況,比如:[A, B, C] => [B, A, A, C],對於這類情況,newStartVnode 也會被作為新元素處理

循環結束時,必然會滿足 oldStartIdx > oldEndIdx 或 newStartIdx > newEndIdx 兩種情況之一,所以對這兩種情況需要進一步處理:

  1. oldStartIdx > oldEndIdx 的情況,比如 [A] => [A, B, C],循環結束時,ch 中的 B 和 C 都還沒有添加到頁面中,這時就會調用 addVnodes() 方法將他們依次添加
  2. 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 也會被保存,供下一次視圖更新時使用,歡迎指正不對之處。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM