溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點擊 登錄注冊 即表示同意《億速云用戶服務條款》

Vue的transition-group與Virtual Dom Diff算法的使用

發(fā)布時間:2020-09-28 05:29:16 來源:腳本之家 閱讀:162 作者:tain335 欄目:web開發(fā)

開始

這次的題目看上去好像有點奇怪:把兩個沒有什么關聯的名詞放在了一起,正如大家所知道的,transition-group就是Vue的內置組件之一主要用在列表的動畫上,但是會跟Virtual Dom Diff算法有什么特別的聯系嗎?答案明顯是有的,所以接下來就是代碼分解。

緣起

主要是最近對Vue的Virtual Dom Diff算法有點模糊了,然后順手就打開了電腦準備溫故知新;但是很快就留意到代碼:

  // 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

removeOnly是什么鬼,怎么感覺以前對這個變量沒啥印象的樣子,再看注釋:removeOnly只用在transition-group組件上,目的是為了保證移除的元素在離開的動畫過程中能夠保持正確的相對位置(請原諒我的渣渣翻譯);好吧,是我當時閱讀源碼的時候忽略了一些細節(jié)。但是這里引起我極大的好奇心,為了transition-group組件竟然要在Diff算法動手腳,這個組件有什么必要性一定要這么做尼。

深入

首先假如沒有這個removeOnly的干擾,也就是canMove為true的時候,正常的Diff算法會是怎樣的流程:

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
   if (isUndef(oldStartVnode)) {
    oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
   } else if (isUndef(oldEndVnode)) {
    oldEndVnode = oldCh[--oldEndIdx]
   } else if (sameVnode(oldStartVnode, newStartVnode)) {
    patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
    oldStartVnode = oldCh[++oldStartIdx]
    newStartVnode = newCh[++newStartIdx]
   } else if (sameVnode(oldEndVnode, newEndVnode)) {
    patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
    oldEndVnode = oldCh[--oldEndIdx]
    newEndVnode = newCh[--newEndIdx]
   } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
    patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
    canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
    oldStartVnode = oldCh[++oldStartIdx]
    newEndVnode = newCh[--newEndIdx]
   } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
    patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
    canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
    oldEndVnode = oldCh[--oldEndIdx]
    newStartVnode = newCh[++newStartIdx]
   } else {
    if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
    idxInOld = isDef(newStartVnode.key)
     ? oldKeyToIdx[newStartVnode.key]
     : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
    if (isUndef(idxInOld)) { // New element
     createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
    } else {
     vnodeToMove = oldCh[idxInOld]
     if (sameVnode(vnodeToMove, newStartVnode)) {
      patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
      oldCh[idxInOld] = undefined
      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]
   }
  }
  1. 首先會是oldStartVnode跟newStartVnode做對比,當然如果它們類型一致就會進入patch流程;
  2. 接著又嘗試oldEndVnode與newEndVnode做對比,繼續(xù)跳過;
  3. 明顯前面兩個判斷都沒有canMove的身影,因為這里patch后是不用移動元素的,都是頭跟頭,尾跟尾,但是后面就不一樣了;再繼續(xù)oldStartVnode與newEndVnode對比,canMove開始出現了,這里舊的頭節(jié)點從頭部移動到尾部了,進行patch后,oldStartElem也需要移動到oldEndElem后面;
  4. 同樣的如果跳過上一個判斷,繼續(xù)oldEndVnode與newStartVnode做對比,也會發(fā)生同樣的移動,只是這次是把oldEndElm移動到oldStartElm前面去;
  5. 如果再跳過上面的判斷,就需要在舊的Vnode節(jié)點上建立一個oldKeyToIdx的map了(很明顯并不是所有的Vnode都會有key,所以這個map上并不一定有所有舊Vnode,甚至很有可能是空的),然后如果newStartVnode上定義了key的話在個map里面嘗試去找出對應的oldVnode位置(當然不存在的話,就可以理所當然的認為這是新的元素了);又如果newStartVnode沒有定義key,它就會暴力去遍歷所有的舊Vnode節(jié)點看看能否找出一個類型一致的可以進行patch的VNode;說明定義key還是很重要的,現在Vue的模板上都會要求for循環(huán)列表的時候要定義key,可以想象如果我們直接使用下標作為key的話會怎樣尼;根據sameVnode方法:
function sameVnode (a, b) {
 return (
  a.key === b.key && (
   (
    a.tag === b.tag &&
    a.isComment === b.isComment &&
    isDef(a.data) === isDef(b.data) &&
    sameInputType(a, b)
   ) || (
    isTrue(a.isAsyncPlaceholder) &&
    a.asyncFactory === b.asyncFactory &&
    isUndef(b.asyncFactory.error)
   )
  )
 )
}

首先會判斷key是否一致,然后是tag類型還有input類型等等。

所以下標作為key的時候,很明顯key會很容易就會判斷為一致了,其次就是要看tag類型等等。

繼續(xù)如果從map里面找到了對應的舊Vnode,又會繼續(xù)把這個Vnode對應的Dom節(jié)點移動到舊的oldStartElem前面。

綜上,Diff算法的移動都是在舊Vnode上進行的,而新Vnode僅僅只是更新了elm這個屬性。

在個Diff算法的最后,可以想象一種情況,元素都會往頭尾兩邊移動,剩下都是待會要剔除的元素了,需要執(zhí)行離開動畫,但是這個效果肯定很糟糕,因為這個時候的列表是打亂了的,我們所期望的動畫明顯是元素從原有的位置執(zhí)行離開動畫了,那么也就是removeOnly存在的意義了。

transition-group的魔法

transition-group是如何利用removeOnly的尼;直接跳到transition-group的源碼上,直接就是一段注釋:

// Provides transition support for list items.
// supports move transitions using the FLIP technique.

// Because the vdom's children update algorithm is "unstable" - i.e.
// it doesn't guarantee the relative positioning of removed elements,
// we force transition-group to update its children into two passes:
// in the first pass, we remove all nodes that need to be removed,
// triggering their leaving transition; in the second pass, we insert/move
// into the final desired state. This way in the second pass removed
// nodes will remain where they should be.

大意就是:

這個組件是為了給列表提供動畫支持的,而組件提供的動畫運用了FLIP技術;

因為Diff算法是不能保證移除元素的相對位置的(正如我們上面總結的),我們讓transition-group的更新必須經過了兩個階段,第一個階段:我們先把所有要移除的元素移除以便觸發(fā)它們的離開動畫;在第二個階段:我們才把元素移動到正確的位置上。
知道了大致的邏輯了,那么transition-group具體是怎么實現的尼?

首先transition-group繼承了transiton組件相關的props,所以它們兩個真是鐵打的親兄弟。

const props = extend({
 tag: String,
 moveClass: String
}, transitionProps)

然后第一個重點來了beforeMount方法

beforeMount () {
  const update = this._update
  this._update = (vnode, hydrating) => {
   const restoreActiveInstance = setActiveInstance(this)
   // force removing pass
   this.__patch__(
    this._vnode,
    this.kept,
    false, // hydrating
    true // removeOnly (!important, avoids unnecessary moves)
   )
   this._vnode = this.kept
   restoreActiveInstance()
   update.call(this, vnode, hydrating)
  }
 }

transition-group對_update方法做了特殊處理,先強行進行一次patch,然后才執(zhí)行原本的update方法,這里也就是剛才注釋說的兩個階段的處理;

接著看this.kept,transition-group是在什么時候對VNode tree做的緩存的尼,再跟蹤代碼發(fā)現render方法也做了特殊處理:

render (h: Function) {
  const tag: string = this.tag || this.$vnode.data.tag || 'span'
  const map: Object = Object.create(null)
  const prevChildren: Array<VNode> = this.prevChildren = this.children
  const rawChildren: Array<VNode> = this.$slots.default || []
  const children: Array<VNode> = this.children = []
  const transitionData: Object = extractTransitionData(this)

  for (let i = 0; i < rawChildren.length; i++) {
   const c: VNode = rawChildren[i]
   if (c.tag) {
    if (c.key != null && String(c.key).indexOf('__vlist') !== 0) {
     children.push(c)
     map[c.key] = c
     ;(c.data || (c.data = {})).transition = transitionData
    } else if (process.env.NODE_ENV !== 'production') {
     const opts: ?VNodeComponentOptions = c.componentOptions
     const name: string = opts ? (opts.Ctor.options.name || opts.tag || '') : c.tag
     warn(`<transition-group> children must be keyed: <${name}>`)
    }
   }
  }

  if (prevChildren) {
   const kept: Array<VNode> = []
   const removed: Array<VNode> = []
   for (let i = 0; i < prevChildren.length; i++) {
    const c: VNode = prevChildren[i]
    c.data.transition = transitionData
    c.data.pos = c.elm.getBoundingClientRect()
    if (map[c.key]) {
     kept.push(c)
    } else {
     removed.push(c)
    }
   }
   this.kept = h(tag, null, kept)
   this.removed = removed
  }

  return h(tag, null, children)
 },

這里的處理是首先用遍歷transition-group包含的VNode列表,把VNode都收集到children數組還有map上面去,并且把transition相關的屬性注入到VNode上,以便VNode移除的時候觸發(fā)對應的動畫。

然后就是如果prevChildren存在的時候,也就是render第二次觸發(fā)的時候遍歷舊的children列表,首先會把最新的transition屬性更新到舊的VNode上,然后就是很關鍵的去獲取VNode對應的DOM節(jié)點的位置(很重要?。?,并且記錄;然后再根據map判斷哪些VNode是需要保持的(新舊列表相同的VNode),哪些是需要移除的,最后就是把this.kept指向需要保持的VNode列表;所以this.kept在第一階段的pacth過程中,才能準確把要移除的VNode先移除,并且不會插入新的VNode,也不會移動DOM節(jié)點;在執(zhí)行后面的update方法才會做后面兩步。

接著看updated方法,如何去利用FLIP實現移動動畫的尼:

updated () {
  const children: Array<VNode> = this.prevChildren
  const moveClass: string = this.moveClass || ((this.name || 'v') + '-move')
  if (!children.length || !this.hasMove(children[0].elm, moveClass)) {
   return
  }

  // we divide the work into three loops to avoid mixing DOM reads and writes
  // in each iteration - which helps prevent layout thrashing.
  children.forEach(callPendingCbs)
  children.forEach(recordPosition)
  children.forEach(applyTranslation)

  // force reflow to put everything in position
  // assign to this to avoid being removed in tree-shaking
  // $flow-disable-line
  this._reflow = document.body.offsetHeight

  children.forEach((c: VNode) => {
   if (c.data.moved) {
    const el: any = c.elm
    const s: any = el.style
    addTransitionClass(el, moveClass)
    s.transform = s.WebkitTransform = s.transitionDuration = ''
    el.addEventListener(transitionEndEvent, el._moveCb = function cb (e) {
     if (e && e.target !== el) {
      return
     }
     if (!e || /transform$/.test(e.propertyName)) {
      el.removeEventListener(transitionEndEvent, cb)
      el._moveCb = null
      removeTransitionClass(el, moveClass)
     }
    })
   }
  })
 },

這里的處理首先會檢查把move class加上之后是否有transform屬性,如果有就說明有移動的動畫;再接著處理:

  1. 調起pendding回調,主要是移除動畫事件的監(jiān)聽
  2. 記錄節(jié)點最新的相對位置
  3. 比較節(jié)點新舊位置,是否有變化,如果有變化就在節(jié)點上應用transform,把節(jié)點移動到舊的位置上;然后強制reflow,更新dom節(jié)點位置信息;所以我們看到的列表可能表面是沒有變化的,其實是我們把節(jié)點又移動到原來的位置上了;
  4. 最后我們把位置有變化的節(jié)點,加上move class,觸發(fā)移動動畫;

這就是transition-group所擁有的黑魔法,確實幫我們在背后做了不少的事情。

最后

溫故而知新,在寫的過程中其實發(fā)現了以前的理解還是有很多模糊的地方,說明自己平時閱讀代碼仍然不夠細心,沒有做到不求甚解,以后必須多多注意,最后的最后,如有錯漏,希望大家能夠指正。

以上就是本文的全部內容,希望對大家的學習有所幫助,也希望大家多多支持億速云。

向AI問一下細節(jié)

免責聲明:本站發(fā)布的內容(圖片、視頻和文字)以原創(chuàng)、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。

AI