Vue源碼之:虛擬DOM及DOM-diff算法

參考文檔:vue

https://vue-js.com/learn-vue/node

https://github.com/answershuto/learnVuegit

前言

在刀耕火種的年代,咱們須要在各個事件方法中直接操做DOM來達到修改視圖的目的。可是當應用一大就會變得難以維護。github

那咱們是否是能夠把真實DOM樹抽象成一棵以JavaScript對象構成的抽象樹,在修改抽象樹數據後將抽象樹轉化成真實DOM重繪到頁面上呢?因而虛擬DOM出現了,它是真實DOM的一層抽象,用屬性描述真實DOM的各個特性。當它發生變化的時候,就會去修改視圖。web

能夠想象,最簡單粗暴的方法就是將整個DOM結構用innerHTML修改到頁面上,可是這樣進行重繪整個視圖層是至關消耗性能的,咱們是否是能夠每次只更新它的修改呢?因此Vue.js將DOM抽象成一個以JavaScript對象爲節點的虛擬DOM樹,以VNode節點模擬真實DOM,能夠對這顆抽象樹進行建立節點、刪除節點以及修改節點等操做,在這過程當中都不須要操做真實DOM,只須要操做JavaScript對象後只對差別修改,相對於整塊的innerHTML的粗暴式修改,大大提高了性能。修改之後通過diff算法得出一些須要修改的最小單位,再將這些小單位的視圖進行更新。這樣作減小了不少不須要的DOM操做,大大提升了性能。算法

Vue就使用了這樣的抽象節點VNode,它是對真實DOM的一層抽象,而不依賴某個平臺,它能夠是瀏覽器平臺,也能夠是weex,甚至是node平臺也能夠對這樣一棵抽象DOM樹進行建立刪除修改等操做,這也爲先後端同構提供了可能。後端

虛擬DOM簡介

什麼是虛擬DOM ?

所謂虛擬DOM,就是用一個JS對象來描述一個DOM節點,打個比方,好比說我如今有這麼一個VNode樹:api

<div class="test">
 <span class="demo">hello,VNode</span> </div>  {  tag: 'div'  data: {  class: 'test'  },  children: [  {  tag: 'span',  data: {  class: 'demo'  }  text: 'hello,VNode'  }  ] } 複製代碼

咱們把組成一個DOM節點的必要東西經過一個JS對象表示出來,那麼這個JS對象就能夠用來描述這個DOM節點,咱們把這個JS對象就稱爲是這個真實DOM節點的虛擬DOM節點。數組

爲何要有虛擬DOM?

上文說了,Vue屬於數據驅動視圖,數據發生變化視圖就要隨之更新,在更新視圖的時候不免操做DOM。並且操做DOM很是耗費性能。以下所示。瀏覽器

let div = document.createElement('div')
let str = '' for (const key in div) {  str += key + '' } console.log(str) 複製代碼

上圖中咱們打印一個簡單的空div標籤,就打印出這麼多東西,更不用說複雜的、深嵌套的DOM節點了。因而可知,直接操做真實DOM是很是消耗性能的。

咱們能夠用JS模擬出一個DOM節點,稱之爲虛擬DOM節點。當數據發生變化時,咱們對比變化先後的虛擬DOM節點,經過DOM-Diff算法計算出須要更新的地方,而後去更新須要更新的視圖。

這就是虛擬DOM產生的緣由以及最大的用途。

Vue中的虛擬DOM

前文咱們介紹了虛擬DOM的概念以及爲何要有虛擬DOM,那麼在Vue中虛擬DOM是怎麼實現的呢?接下來,咱們從源碼出發,深刻學習一下。

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  functionalContext: Component | void; // only for functional component root nodes  key: string | number | void;  componentOptions: VNodeComponentOptions | void;  componentInstance: Component | void; // component instance  parent: VNode | void; // component placeholder node  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?   constructor (  tag?: string,  data?: VNodeData,  children?: ?Array<VNode>,  text?: string,  elm?: Node,  context?: Component,  componentOptions?: VNodeComponentOptions  ) {  /*當前節點的標籤名*/  this.tag = tag  /*當前節點對應的對象,包含了具體的一些數據信息,是一個VNodeData類型,能夠參考VNodeData類型中的數據信息*/  this.data = data  /*當前節點的子節點,是一個數組*/  this.children = children  /*當前節點的文本*/  this.text = text  /*當前虛擬節點對應的真實dom節點*/  this.elm = elm  /*當前節點的名字空間*/  this.ns = undefined  /*當前節點的編譯做用域*/  this.context = context  /*函數化組件做用域*/  this.functionalContext = undefined  /*節點的key屬性,被看成節點的標誌,用以優化*/  this.key = data && data.key  /*組件的option選項*/  this.componentOptions = componentOptions  /*當前節點對應的組件的實例*/  this.componentInstance = undefined  /*當前節點的父節點*/  this.parent = undefined  /*簡而言之就是是否爲原生HTML或只是普通文本,innerHTML的時候爲true,textContent的時候爲false*/  this.raw = false  /*是否爲靜態節點*/  this.isStatic = false  /*是否做爲跟節點插入*/  this.isRootInsert = true  /*是否爲註釋節點*/  this.isComment = false  /*是否爲克隆節點*/  this.isCloned = false  /*是否有v-once指令*/  this.isOnce = false  }   // DEPRECATED: alias for componentInstance for backwards compat.  /* istanbul ignore next */  get child (): Component | void {  return this.componentInstance  } } 複製代碼

從上面的代碼中能夠看出:VNode類中包含了描述一個真實DOM節點所須要的一系列屬性,如tag表示節點的標籤名,text表示節點中包含的文本,children表示該節點包含的子節點等。經過屬性之間不一樣的搭配,就能夠描述出各類類型的真實DOM節點

VNode的類型

上一小節最後咱們說了,經過屬性之間不一樣的搭配,VNode類能夠描述出各類類型的真實DOM節點。那麼它均可以描述出哪些類型的節點呢?經過閱讀源碼,能夠發現經過不一樣屬性的搭配,能夠描述出如下幾種類型的節點。

  • 註釋節點
  • 文本節點
  • 元素節點
  • 組件節點
  • 函數式組件節點
  • 克隆節點

註釋節點 (空VNode節點)

export const createEmptyVNode = (text: string = '') => {
 const node = new VNode()  node.text = text  node.isComment = true  return node } 複製代碼

從上面代碼中能夠看到,描述一個註釋節點只需兩個屬性,分別是:textisComment。其中text屬性表示具體的註釋信息,isComment是一個標誌,用來標識一個節點是不是註釋節點。

文本節點

文本節點描述起來比註釋節點更簡單,由於它只須要一個屬性,那就是text屬性,用來表示具體的文本信息。源碼以下:

// 建立文本節點
export function createTextVNode (val: string | number) {  return new VNode(undefined, undefined, undefined, String(val)) } 複製代碼

克隆節點

克隆節點就是把一個已經存在的節點複製一份出來,它主要是爲了作模板編譯優化時使用,這個後面咱們會說到。關於克隆節點的描述,而現有節點和新克隆獲得的節點之間惟一的不一樣就是克隆獲得的節點isClonedtrue

// 建立克隆節點
export function cloneVNode (vnode: VNode): VNode {  const cloned = new VNode(  vnode.tag,  vnode.data,  vnode.children,  vnode.text,  vnode.elm,  vnode.context,  vnode.componentOptions,  vnode.asyncFactory  )  cloned.ns = vnode.ns  cloned.isStatic = vnode.isStatic  cloned.key = vnode.key  cloned.isComment = vnode.isComment  cloned.fnContext = vnode.fnContext  cloned.fnOptions = vnode.fnOptions  cloned.fnScopeId = vnode.fnScopeId  cloned.asyncMeta = vnode.asyncMeta  cloned.isCloned = true  return cloned } 複製代碼

元素節點

相比之下,元素節點更貼近於咱們一般看到的真實DOM節點,它有描述節點標籤名詞的tag屬性,描述節點屬性如classattributes等的data屬性,有描述包含的子節點信息的children屬性等。因爲元素節點所包含的狀況相比而言比較複雜,源碼中沒有像前三種節點同樣直接寫死(固然也不可能寫死),那就舉個簡單例子說明一下:

// 真實DOM節點
<div id='a'><span>難涼熱血</span></div>  // VNode節點 {  tag:'div',  data:{},  children:[  {  tag:'span',  text:'難涼熱血'  }  ] } 複製代碼

組件節點

組件節點除了有元素節點具備的屬性以外,它還有兩個特有的屬性:

  • componentOptions :組件的option選項,如組件的 props
  • componentInstance :當前組件節點對應的Vue實例

函數式組件節點

函數式組件節點相較於組件節點,它又有兩個特有的屬性:

  • fnContext:函數式組件對應的Vue實例
  • fnOptions: 組件的option選項

小結

以上就是VNode能夠描述的多種節點類型,它們本質上都是VNode類的實例,只是在實例化的時候傳入的屬性參數不一樣而已。

VNode的做用

說了這麼多,那麼VNode在Vue的整個虛擬DOM過程起了什麼做用呢?

其實VNode的做用是至關大的。咱們在視圖渲染以前,把寫好的template模板先編譯成VNode並緩存下來,等到數據發生變化頁面須要從新渲染的時候,咱們把數據發生變化後生成的VNode與前一次緩存下來的VNode進行對比,找出差別,而後有差別的VNode對應的真實DOM節點就是須要從新渲染的節點,最後根據有差別的VNode建立出真實的DOM節點再插入到視圖中,最終完成一次視圖更新。

有了數據變化先後的VNode,咱們才能進行後續的DOM-Diff找出差別,最終作到只更新有差別的視圖,從而達到儘量少的操做真實DOM的目的,以節省性能。

Vue中的DOM-Diff

path

Vue中,把DOM-Diff過程叫作patch過程。patch,意爲「補丁」,即指對舊的VNode修補,打補丁從而獲得新的VNode,很是形象哈。那無論叫什麼,其本質都是把對比新舊兩份VNode的過程。咱們在下面研究patch過程的時候,必定把握住這樣一個思想:所謂舊的VNode(即oldVNode)就是數據變化以前視圖所對應的虛擬DOM節點,而新的VNode是數據變化以後將要渲染的新的視圖所對應的虛擬DOM節點

因此咱們要以生成的新的VNode爲基準,對比舊的oldVNode

  • 建立節點:若是新的VNode上有的節點而舊的oldVNode上沒有,那麼就在舊的oldVNode上加上去;

  • 刪除節點:若是新的VNode上沒有的節點而舊的oldVNode上有,那麼就在舊的oldVNode上去掉;

  • 更新節點:若是某些節點在新的VNode和舊的oldVNode上都有,那麼就以新的VNode爲準,更新舊的oldVNode,從而讓新舊VNode相同。

以新的VNode爲基準,改造舊的oldVNode使之成爲跟新的VNode同樣,這就是patch過程要乾的事。

建立節點

VNode類能夠描述6種類型的節點,而實際上只有3種類型的節點可以被建立並插入到DOM中,它們分別是:元素節點、文本節點、註釋節點。因此Vue在建立節點的時候會判斷在新的VNode中有而舊的oldVNode中沒有的這個節點是屬於哪一種類型的節點,從而調用不一樣的方法建立並插入到DOM中。

/*建立一個節點*/
 function createElm (vnode, insertedVnodeQueue, parentElm, refElm, nested) {  /*insertedVnodeQueue爲空數組[]的時候isRootInsert標誌爲true*/  vnode.isRootInsert = !nested // for transition enter check  /*建立一個組件節點*/  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {  return  }   const data = vnode.data  const children = vnode.children  const tag = vnode.tag  if (isDef(tag)) {  ....  ....  vnode.elm = vnode.ns  ? nodeOps.createElementNS(vnode.ns, tag)  : nodeOps.createElement(tag, vnode)  ....  ....  createChildren(vnode, children, insertedVnodeQueue)  ....  insert(parentElm, vnode.elm, refElm)  ....  }  } else if (isTrue(vnode.isComment)) {  vnode.elm = nodeOps.createComment(vnode.text)  insert(parentElm, vnode.elm, refElm)  } else {  vnode.elm = nodeOps.createTextNode(vnode.text)  insert(parentElm, vnode.elm, refElm)  }  } 複製代碼

從上面代碼中,咱們能夠看出:

判斷是否爲元素節點只需判斷該VNode節點是否有tag標籤便可。若是有tag屬性即認爲是元素節點,則調用createElement方法建立元素節點,一般元素節點還會有子節點,那就遞歸遍歷建立全部子節點,將全部子節點建立好以後insert插入到當前元素節點裏面,最後把當前元素節點插入到DOM中。

判斷是否爲註釋節點,只需判斷VNodeisComment屬性是否爲true便可,若爲true則爲註釋節點,則調用createComment方法建立註釋節點,再插入到DOM中。

若是既不是元素節點,也不是註釋節點,那就認爲是文本節點,則調用createTextNode方法建立文本節點,再插入到DOM中。

刪除節點

若是某些節點再新的VNode中沒有而在舊的oldVNode中有,那麼就須要把這些節點從舊的oldVNode中刪除。刪除節點很是簡單,只需在要刪除節點的父元素上調用removeChild方法便可。源碼以下:

function removeNode (el) {
 const parent = nodeOps.parentNode(el) // 獲取父節點  if (isDef(parent)) {  nodeOps.removeChild(parent, el) // 調用父節點的removeChild方法  }  } 複製代碼

更新節點

建立節點和刪除節點都比較簡單,而更新節點就相對較爲複雜一點了,其實也不算多複雜,只要理清邏輯就能理解了。

更新節點就是當某些節點在新的VNode和舊的oldVNode中都有時,咱們就須要細緻比較一下,找出不同的地方進行更新。

介紹更新節點以前,咱們先介紹一個小的概念,就是什麼是靜態節點?咱們看個例子:

<p>我是不會變化的文字</p>
複製代碼

OK,有了這個概念之後,咱們開始更新節點。更新節點的時候咱們須要對如下3種狀況進行判斷並分別處理:

  • 若是VNode和oldVNode均爲靜態節點

咱們說了,靜態節點不管數據發生任何變化都與它無關,因此都爲靜態節點的話則直接跳過,無需處理。

  • 若是VNode是文本節點

若是VNode是文本節點即表示這個節點內只包含純文本,那麼只需看oldVNode是否也是文本節點,若是是,那就比較兩個文本是否不一樣,若是不一樣則把oldVNode裏的文本改爲跟VNode的文本同樣。若是oldVNode不是文本節點,那麼不論它是什麼,直接調用setTextNode方法把它改爲文本節點,而且文本內容跟VNode相同。

  • 若是VNode是元素節點

若是VNode是元素節點,則又細分如下兩種狀況: 該節點包含子節點

若是新的節點內包含了子節點,那麼此時要看舊的節點是否包含子節點,若是舊的節點裏也包含了子節點,那就須要遞歸對比更新子節點;若是舊的節點裏不包含子節點,那麼這個舊節點有多是空節點或者是文本節點,若是舊的節點是空節點就把新的節點裏的子節點建立一份而後插入到舊的節點裏面,若是舊的節點是文本節點,則把文本清空,而後把新的節點裏的子節點建立一份而後插入到舊的節點裏面。

該節點不包含子節點

若是該節點不包含子節點,同時它又不是文本節點,那就說明該節點是個空節點,那就好辦了,無論舊節點以前裏面都有啥,直接清空便可。

OK,處理完以上3種狀況,更新節點就算基本完成了,接下來咱們看下源碼中具體是怎麼實現的,源碼以下:

 /*patch VNode節點*/  function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {  /*兩個VNode節點相同則直接返回*/  if (oldVnode === vnode) {  return  }  // reuse element for static trees.  // note we only do this if the vnode is cloned -  // if the new node is not cloned it means the render functions have been  // reset by the hot-reload-api and we need to do a proper re-render.  /*  若是新舊VNode都是靜態的,同時它們的key相同(表明同一節點),  而且新的VNode是clone或者是標記了once(標記v-once屬性,只渲染一次),  那麼只須要替換elm以及componentInstance便可。  */  if (isTrue(vnode.isStatic) &&  isTrue(oldVnode.isStatic) &&  vnode.key === oldVnode.key &&  (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))) {  vnode.elm = oldVnode.elm  vnode.componentInstance = oldVnode.componentInstance  return  }  let i  const data = vnode.data  if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {  /*i = data.hook.prepatch,若是存在的話,見"./create-component componentVNodeHooks"。*/  i(oldVnode, vnode)  }  const elm = vnode.elm = oldVnode.elm  const oldCh = oldVnode.children  const ch = vnode.children  if (isDef(data) && isPatchable(vnode)) {  /*調用update回調以及update鉤子*/  for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)  if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)  }  /*若是這個VNode節點沒有text文本時*/  if (isUndef(vnode.text)) {  if (isDef(oldCh) && isDef(ch)) {  /*新老節點均有children子節點,則對子節點進行diff操做,調用updateChildren*/  if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)  } else if (isDef(ch)) {  /*若是老節點沒有子節點而新節點存在子節點,先清空elm的文本內容,而後爲當前節點加入子節點*/  if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')  addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)  } else if (isDef(oldCh)) {  /*當新節點沒有子節點而老節點有子節點的時候,則移除全部ele的子節點*/  removeVnodes(elm, oldCh, 0, oldCh.length - 1)  } else if (isDef(oldVnode.text)) {  /*當新老節點都無子節點的時候,只是文本的替換,由於這個邏輯中新節點text不存在,因此直接去除ele的文本*/  nodeOps.setTextContent(elm, '')  }  } else if (oldVnode.text !== vnode.text) {  /*當新老節點text不同時,直接替換這段文本*/  nodeOps.setTextContent(elm, vnode.text)  }  /*調用postpatch鉤子*/  if (isDef(data)) {  if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)  }  } 複製代碼

上面代碼裏註釋已經寫得很清晰了,接下來咱們畫流程圖來梳理一下整個過程,流程圖以下:

另外,你可能注意到了,若是新舊VNode裏都包含了子節點,那麼對於子節點的更新在代碼裏調用了updateChildren方法

更細子節點

更新子節點

當新的VNode與舊的oldVNode都是元素節點而且都包含子節點時,那麼這兩個節點的VNode實例上的children屬性就是所包含的子節點數組。咱們把新的VNode上的子節點數組記爲newChildren,把舊的oldVNode上的子節點數組記爲oldChildren,咱們把newChildren裏面的元素與oldChildren裏的元素一一進行對比,對比兩個子節點數組確定是要經過循環,外層循環newChildren數組,內層循環oldChildren數組,每循環外層newChildren數組裏的一個子節點,就去內層oldChildren數組裏找看有沒有與之相同的子節點,僞代碼以下:

for (let i = 0; i < newChildren.length; i++) {
 const newChild = newChildren[i];  for (let j = 0; j < oldChildren.length; j++) {  const oldChild = oldChildren[j];  if (newChild === oldChild) {  // ...  }  } }  複製代碼

那麼以上這個過程將會存在如下四種狀況:

  • 建立子節點

若是newChildren裏面的某個子節點在oldChildren裏找不到與之相同的子節點,那麼說明newChildren裏面的這個子節點是以前沒有的,是須要這次新增的節點,那麼就建立子節點。

  • 刪除子節點

若是把newChildren裏面的每個子節點都循環完畢後,發現在oldChildren還有未處理的子節點,那就說明這些未處理的子節點是須要被廢棄的,那麼就將這些節點刪除。

  • 移動子節點

若是newChildren裏面的某個子節點在oldChildren裏找到了與之相同的子節點,可是所處的位置不一樣,這說明這次變化須要調整該子節點的位置,那就以newChildren裏子節點的位置爲基準,調整oldChildren裏該節點的位置,使之與在newChildren裏的位置相同。

  • 更新節點

若是newChildren裏面的某個子節點在oldChildren裏找到了與之相同的子節點,而且所處的位置也相同,那麼就更新oldChildren裏該節點,使之與newChildren裏的該節點相同。

OK,到這裏,邏輯就相對清晰了,接下來咱們只需分門別類的處理這四種狀況就行了。

建立子節點

若是newChildren裏面的某個子節點在oldChildren裏找不到與之相同的子節點,那麼說明newChildren裏面的這個子節點是以前沒有的,是須要這次新增的節點,那麼咱們就建立這個節點,建立好以後再把它插入到DOM中合適的位置。

那麼建立好以後如何插入到DOM中的合適的位置呢?顯然,把節點插入到DOM中是很容易的,找到合適的位置是關鍵。接下來咱們分析一下如何找這個合適的位置。咱們看下面這個圖:

上圖中左邊是新的VNode,右邊是舊的oldVNode,同時也是真實的DOM。這個圖意思是當咱們循環newChildren數組裏面的子節點,前兩個子節點都在oldChildren裏找到了與之對應的子節點,那麼咱們將其處理,處理事後把它們標誌爲已處理,當循環到newChildren數組裏第三個子節點時,發如今oldChildren裏找不到與之對應的子節點,那麼咱們就須要建立這個節點,建立好以後咱們發現這個節點本是newChildren數組裏左起第三個子節點,那麼咱們就把建立好的節點插入到真實DOM裏的第三個節點位置,也就是全部已處理節點以後,OK,此時咱們拍手稱快,全部已處理節點以後就是咱們要找的合適的位置,可是真的是這樣嗎?咱們再來看下面這個圖:

假如咱們按照上面的方法把第三個節點插入到全部已處理節點以後,此時若是第四個節點也在oldChildren裏找不到與之對應的節點,也是須要建立的節點,那麼當咱們把第四個節點也按照上面的說的插入到已處理節點以後,發現怎麼插入到第三個位置了,可明明這個節點在newChildren數組裏是第四個啊

這就是問題所在,其實,咱們應該把新建立的節點插入到全部未處理節點以前,這樣以來邏輯才正確。後面無論有多少個新增的節點,每個都插入到全部未處理節點以前,位置纔不會錯。

因此,合適的位置是全部未處理節點以前,而並不是全部已處理節點以後

刪除子節點

若是把newChildren裏面的每個子節點都循環一遍,能在oldChildren數組裏找到的就處理它,找不到的就新增,直到把newChildren裏面全部子節點都過一遍後,發如今oldChildren還存在未處理的子節點,那就說明這些未處理的子節點是須要被廢棄的,那麼就將這些節點刪除。

更新子節點

若是newChildren裏面的某個子節點在oldChildren裏找到了與之相同的子節點,而且所處的位置也相同,那麼就更新oldChildren裏該節點,使之與newChildren裏的該節點相同。

移動子節點

若是newChildren裏面的某個子節點在oldChildren裏找到了與之相同的子節點,可是所處的位置不一樣,這說明這次變化須要調整該子節點的位置,那就以newChildren裏子節點的位置爲基準,調整oldChildren裏該節點的位置,使之與在newChildren裏的位置相同。

一樣,移動一個節點不難,關鍵在於該移動到哪,或者說關鍵在於移動到哪一個位置,這個位置纔是關鍵。咱們看下圖:

在上圖中,綠色的兩個節點是相同節點可是所處位置不一樣,即newChildren裏面的第三個子節點與真實DOM即oldChildren裏面的第四個子節點相同可是所處位置不一樣,按照上面所說的,咱們應該以newChildren裏子節點的位置爲基準,調整oldChildren裏該節點的位置,因此咱們應該把真實DOM即oldChildren裏面的第四個節點移動到第三個節點的位置,通過上圖中的標註咱們不難發現,全部未處理節點以前就是咱們要移動的目的位置。若是此時你說那可不能夠移動到全部已處理節點以後呢?那就又回到了更新節點時所遇到的那個問題了:

回到源碼

// 源碼位置: /src/core/vdom/patch.js
 if (isUndef(idxInOld)) { // 若是在oldChildren裏找不到當前循環的newChildren裏的子節點  // 新增節點並插入到合適位置  createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } else {  // 若是在oldChildren裏找到了當前循環的newChildren裏的子節點  vnodeToMove = oldCh[idxInOld]  // 若是兩個節點相同  if (sameVnode(elmToMove, newStartVnode)) {  /*若是新VNode與獲得的有相同key的節點是同一個VNode則進行patchVnode*/  patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)  /*由於已經patchVnode進去了,因此將這個老節點賦值undefined,以後若是還有新節點與該節點key相同能夠檢測出來提示已有重複的key*/  oldCh[idxInOld] = undefined  /*當有標識位canMove實能夠直接插入oldStartVnode對應的真實Dom節點前面*/  canMove && nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm)  newStartVnode = newCh[++newStartIdx]  } else {  // same key but different element. treat as new element  /*當新的VNode與找到的一樣key的VNode不是sameVNode的時候(好比說tag不同或者是有不同type的input標籤),建立一個新的節點*/  createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)  newStartVnode = newCh[++newStartIdx]  } } 複製代碼

以上代碼中,首先判斷在oldChildren裏可否找到當前循環的newChildren裏的子節點,若是找不到,那就是新增節點並插入到合適位置;若是找到了,先對比兩個節點是否相同,若相同則先調用patchVnode更新節點,更新完以後再看是否須要移動節點,注意,源碼裏在判斷是否須要移動子節點時用了簡寫的方式

優化更新子節點

上節說的新的VNode 與 舊的oldVNode都是元素節點且有子節點時候 先外層循環newChildren數組,再內層循環oldChildren數組,每循環外層newChildren數組裏的一個子節點,就去內層oldChildren數組裏找看有沒有與之相同的子節點,最後根據不一樣的狀況做出不一樣的操做。

優化策略介紹

假如咱們現有一份新的newChildren數組和舊的oldChildren數組,以下所示:

newChildren = ['新子節點1','新子節點2','新子節點3','新子節點4']
oldChildren = ['舊子節點1','舊子節點2','舊子節點3','舊子節點4'] 複製代碼

那麼咱們該怎麼優化呢?其實咱們能夠這樣想,咱們不要按順序去循環newChildrenoldChildren這兩個數組,能夠先比較這兩個數組裏特殊位置的子節點,好比:

  • 先把newChildren數組裏的全部未處理子節點的第一個子節點和oldChildren數組裏全部未處理子節點的第一個子節點作比對,若是相同,那就直接進入更新節點的操做;

  • 若是不一樣,再把newChildren數組裏全部未處理子節點的最後一個子節點和oldChildren數組裏全部未處理子節點的最後一個子節點作比對,若是相同,那就直接進入更新節點的操做;

  • 若是不一樣,再把newChildren數組裏全部未處理子節點的最後一個子節點和oldChildren數組裏全部未處理子節點的第一個子節點作比對,若是相同,那就直接進入更新節點的操做,更新完後再將oldChildren數組裏的該節點移動到與newChildren數組裏節點相同的位置;

  • 若是不一樣,再把newChildren數組裏全部未處理子節點的第一個子節點和oldChildren數組裏全部未處理子節點的最後一個子節點作比對,若是相同,那就直接進入更新節點的操做,更新完後再將oldChildren數組裏的該節點移動到與newChildren數組裏節點相同的位置;

  • 最後四種狀況都試完若是還不一樣,那就按照以前循環的方式來查找節點。

其過程以下

在上圖中,咱們把:

  • newChildren數組裏的全部未處理子節點的第一個子節點稱爲:新前;

  • newChildren數組裏的全部未處理子節點的最後一個子節點稱爲:新後;

  • oldChildren數組裏的全部未處理子節點的第一個子節點稱爲:舊前;

  • oldChildren數組裏的全部未處理子節點的最後一個子節點稱爲:舊後; OK,有了以上概念之後,下面咱們就來看看其具體是如何實施的。

新前與舊前

newChildren數組裏的全部未處理子節點的第一個子節點和oldChildren數組裏全部未處理子節點的第一個子節點作比對,若是相同,那好極了,直接進入以前文章中說的更新節點的操做而且因爲新前與舊前兩個節點的位置也相同,無需進行節點移動操做;若是不一樣,不要緊,再嘗試後面三種狀況。

新後與舊後

newChildren數組裏全部未處理子節點的最後一個子節點和oldChildren數組裏全部未處理子節點的最後一個子節點作比對,若是相同,那就直接進入更新節點的操做而且因爲新後與舊後兩個節點的位置也相同,無需進行節點移動操做;若是不一樣,繼續日後嘗試。

新後與舊前

newChildren數組裏全部未處理子節點的最後一個子節點和oldChildren數組裏全部未處理子節點的第一個子節點作比對,若是相同,那就直接進入更新節點的操做,更新完後再將oldChildren數組裏的該節點移動到與newChildren數組裏節點相同的位置;

此時,出現了移動節點的操做,移動節點最關鍵的地方在於找準要移動的位置。咱們一再強調,更新節點要以新VNode爲基準,而後操做舊的oldVNode,使之最後舊的oldVNode與新的VNode相同。那麼如今的狀況是:newChildren數組裏的最後一個子節點與oldChildren數組裏的第一個子節點相同,那麼咱們就應該在oldChildren數組裏把第一個子節點移動到最後一個子節點的位置,以下圖:

從圖中不難看出,咱們要把oldChildren數組裏把第一個子節點移動到數組中全部未處理節點以後。

若是對比以後發現這兩個節點仍不是同一個節點,那就繼續嘗試最後一種狀況。

新前與舊後

newChildren數組裏全部未處理子節點的第一個子節點和oldChildren數組裏全部未處理子節點的最後一個子節點作比對,若是相同,那就直接進入更新節點的操做,更新完後再將oldChildren數組裏的該節點移動到與newChildren數組裏節點相同的位置;

一樣,這種狀況的節點移動位置邏輯與「新後與舊前」的邏輯相似,那就是newChildren數組裏的第一個子節點與oldChildren數組裏的最後一個子節點相同,那麼咱們就應該在oldChildren數組裏把最後一個子節點移動到第一個子節點的位置,以下圖:

從圖中不難看出,咱們要把oldChildren數組裏把最後一個子節點移動到數組中全部未處理節點以前。

OK,以上就是子節點對比更新優化策略種的4種狀況,若是以上4種狀況逐個試遍以後要是還沒找到相同的節點,那就再經過以前的循環方式查找。

回到源碼

// 循環更新子節點
 function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {  let oldStartIdx = 0 // oldChildren開始索引  let oldEndIdx = oldCh.length - 1 // oldChildren結束索引  let oldStartVnode = oldCh[0] // oldChildren中全部未處理節點中的第一個  let oldEndVnode = oldCh[oldEndIdx] // oldChildren中全部未處理節點中的最後一個   let newStartIdx = 0 // newChildren開始索引  let newEndIdx = newCh.length - 1 // newChildren結束索引  let newStartVnode = newCh[0] // newChildren中全部未處理節點中的第一個  let newEndVnode = newCh[newEndIdx] // newChildren中全部未處理節點中的最後一個   let oldKeyToIdx, idxInOld, vnodeToMove, refElm   // removeOnly is a special flag used only by <transition-group>  // to ensure removed elements stay in correct relative positions  // during leaving transitions  const canMove = !removeOnly   if (process.env.NODE_ENV !== 'production') {  checkDuplicateKeys(newCh)  }   // 以"新前""新後""舊前""舊後"的方式開始比對節點  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {  if (isUndef(oldStartVnode)) {  oldStartVnode = oldCh[++oldStartIdx] // 若是oldStartVnode不存在,則直接跳過,比對下一個  } else if (isUndef(oldEndVnode)) {  oldEndVnode = oldCh[--oldEndIdx]  } else if (sameVnode(oldStartVnode, newStartVnode)) {  // 若是新前與舊前節點相同,就把兩個節點進行patch更新  patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)  oldStartVnode = oldCh[++oldStartIdx]  newStartVnode = newCh[++newStartIdx]  } else if (sameVnode(oldEndVnode, newEndVnode)) {  // 若是新後與舊後節點相同,就把兩個節點進行patch更新  patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)  oldEndVnode = oldCh[--oldEndIdx]  newEndVnode = newCh[--newEndIdx]  } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right  // 若是新後與舊前節點相同,先把兩個節點進行patch更新,而後把舊前節點移動到oldChilren中全部未處理節點以後  patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)  canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))  oldStartVnode = oldCh[++oldStartIdx]  newEndVnode = newCh[--newEndIdx]  } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left  // 若是新前與舊後節點相同,先把兩個節點進行patch更新,而後把舊後節點移動到oldChilren中全部未處理節點以前  patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)  canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)  oldEndVnode = oldCh[--oldEndIdx]  newStartVnode = newCh[++newStartIdx]  } else {  // 若是不屬於以上四種狀況,就進行常規的循環比對patch  if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)  idxInOld = isDef(newStartVnode.key)  ? oldKeyToIdx[newStartVnode.key]  : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)  // 若是在oldChildren裏找不到當前循環的newChildren裏的子節點  if (isUndef(idxInOld)) { // New element  // 新增節點並插入到合適位置  createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)  } else {  // 若是在oldChildren裏找到了當前循環的newChildren裏的子節點  vnodeToMove = oldCh[idxInOld]  // 若是兩個節點相同  if (sameVnode(vnodeToMove, newStartVnode)) {  // 調用patchVnode更新節點  patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)  oldCh[idxInOld] = undefined  // canmove表示是否須要移動節點,若是爲true表示須要移動,則移動節點,若是爲false則不用移動  canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)  } else {  // same key but different element. treat as new element  createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)  }  }  newStartVnode = newCh[++newStartIdx]  }  }  if (oldStartIdx > oldEndIdx) {  /**  * 若是oldChildren比newChildren先循環完畢,  * 那麼newChildren裏面剩餘的節點都是須要新增的節點,  * 把[newStartIdx, newEndIdx]之間的全部節點都插入到DOM中  */  refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm  addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)  } else if (newStartIdx > newEndIdx) {  /**  * 若是newChildren比oldChildren先循環完畢,  * 那麼oldChildren裏面剩餘的節點都是須要刪除的節點,  * 把[oldStartIdx, oldEndIdx]之間的全部節點都刪除  */  removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)  }  }  複製代碼

讀源碼以前,咱們先有這樣一個概念:那就是在咱們前面所說的優化策略中,節點有多是從前面對比,也有多是從後面對比,對比成功就會進行更新處理,也就是說咱們有可能處理第一個,也有可能處理最後一個,那麼咱們在循環的時候就不能簡單從前日後或從後往前循環,而是要從兩邊向中間循環。

那麼該如何從兩邊向中間循環呢?請看下圖:

首先,咱們先準備4個變量:

  • newStartIdx:newChildren數組裏開始位置的下標;

  • newEndIdx:newChildren數組裏結束位置的下標;

  • oldStartIdx:oldChildren數組裏開始位置的下標;

  • oldEndIdx:oldChildren數組裏結束位置的下標;

在循環的時候,每處理一個節點,就將下標向圖中箭頭所指的方向移動一個位置,開始位置所表示的節點被處理後,就向後移動一個位置;結束位置所表示的節點被處理後,就向前移動一個位置;因爲咱們的優化策略都是新舊節點兩兩更新的,因此一次更新將會移動兩個節點。說的再直白一點就是:newStartIdxoldStartIdx只能日後移動(只會加),newEndIdxoldEndIdx只能往前移動(只會減)。

當開始位置大於結束位置時,表示全部節點都已經遍歷過了。

OK,有了這個概念後,咱們開始讀源碼:

1.若是oldStartVnode不存在,則直接跳過,將oldStartIdx加1,比對下一個

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
 if (isUndef(oldStartVnode)) {  oldStartVnode = oldCh[++oldStartIdx]  } } 複製代碼

2.若是oldEndVnode不存在,則直接跳過,將oldEndIdx減1,比對前一個

else if (isUndef(oldEndVnode)) {
 oldEndVnode = oldCh[--oldEndIdx] }  複製代碼

3.若是新前與舊前節點相同,就把兩個節點進行patch更新,同時oldStartIdxnewStartIdx都加1,後移一個位置

patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
 oldStartVnode = oldCh[++oldStartIdx]  newStartVnode = newCh[++newStartIdx] } 複製代碼

4.若是新後與舊後節點相同,就把兩個節點進行patch更新,同時oldEndIdxnewEndIdx都減1,前移一個位置

else if (sameVnode(oldEndVnode, newEndVnode)) {
 patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)  oldEndVnode = oldCh[--oldEndIdx]  newEndVnode = newCh[--newEndIdx] } 複製代碼

5.若是新後與舊前節點相同,先把兩個節點進行patch更新,而後把舊前節點移動到oldChilren中全部未處理節點以後,最後把oldStartIdx加1,後移一個位置,newEndIdx減1,前移一個位置

else if (sameVnode(oldStartVnode, newEndVnode)) {
 patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)  canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))  oldStartVnode = oldCh[++oldStartIdx]  newEndVnode = newCh[--newEndIdx] }  複製代碼

6.若是新前與舊後節點相同,先把兩個節點進行patch更新,而後把舊後節點移動到oldChilren中全部未處理節點以前,最後把newStartIdx加1,後移一個位置,oldEndIdx減1,前移一個位置

else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
 patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)  canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)  oldEndVnode = oldCh[--oldEndIdx]  newStartVnode = newCh[++newStartIdx] } 複製代碼

7.若是不屬於以上四種狀況,就進行常規的循環比對patch 8.若是在循環中,oldStartIdx大於oldEndIdx了,那就表示oldChildrennewChildren先循環完畢,那麼newChildren裏面剩餘的節點都是須要新增的節點,把[newStartIdx, newEndIdx]之間的全部節點都插入到DOM中

if (oldStartIdx > oldEndIdx) {
 refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm  addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) }  複製代碼

9.若是在循環中,newStartIdx大於newEndIdx了,那就表示newChildrenoldChildren先循環完畢,那麼oldChildren裏面剩餘的節點都是須要刪除的節點,把[oldStartIdx, oldEndIdx]之間的全部節點都刪除

else if (newStartIdx > newEndIdx) {
 removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx) } 複製代碼

以上就是Vue中的patch過程,即DOM-Diff算法全部內容了,到這裏相信你再讀這部分源碼的時候就有比較清晰的思路了。

本文使用 mdnice 排版

相關文章
相關標籤/搜索