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


Posted in Javascript onDecember 09, 2019

开始

这次的题目看上去好像有点奇怪:把两个没有什么关联的名词放在了一起,正如大家所知道的,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组件上,目的是为了保证移除的元素在离开的动画过程中能够保持正确的相对位置(请原谅我的渣渣翻译);好吧,是我当时阅读源码的时候忽略了一些细节。但是这里引起我极大的好奇心,为了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做对比,继续跳过;
  3. 明显前面两个判断都没有canMove的身影,因为这里patch后是不用移动元素的,都是头跟头,尾跟尾,但是后面就不一样了;再继续oldStartVnode与newEndVnode对比,canMove开始出现了,这里旧的头节点从头部移动到尾部了,进行patch后,oldStartElem也需要移动到oldEndElem后面;
  4. 同样的如果跳过上一个判断,继续oldEndVnode与newStartVnode做对比,也会发生同样的移动,只是这次是把oldEndElm移动到oldStartElm前面去;
  5. 如果再跳过上面的判断,就需要在旧的Vnode节点上建立一个oldKeyToIdx的map了(很明显并不是所有的Vnode都会有key,所以这个map上并不一定有所有旧Vnode,甚至很有可能是空的),然后如果newStartVnode上定义了key的话在个map里面尝试去找出对应的oldVnode位置(当然不存在的话,就可以理所当然的认为这是新的元素了);又如果newStartVnode没有定义key,它就会暴力去遍历所有的旧Vnode节点看看能否找出一个类型一致的可以进行patch的VNode;说明定义key还是很重要的,现在Vue的模板上都会要求for循环列表的时候要定义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类型等等。

继续如果从map里面找到了对应的旧Vnode,又会继续把这个Vnode对应的Dom节点移动到旧的oldStartElem前面。

综上,Diff算法的移动都是在旧Vnode上进行的,而新Vnode仅仅只是更新了elm这个属性。

在个Diff算法的最后,可以想象一种情况,元素都会往头尾两边移动,剩下都是待会要剔除的元素了,需要执行离开动画,但是这个效果肯定很糟糕,因为这个时候的列表是打乱了的,我们所期望的动画明显是元素从原有的位置执行离开动画了,那么也就是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的更新必须经过了两个阶段,第一个阶段:我们先把所有要移除的元素移除以便触发它们的离开动画;在第二个阶段:我们才把元素移动到正确的位置上。
知道了大致的逻辑了,那么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,然后才执行原本的update方法,这里也就是刚才注释说的两个阶段的处理;

接着看this.kept,transition-group是在什么时候对VNode tree做的缓存的尼,再跟踪代码发现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移除的时候触发对应的动画。

然后就是如果prevChildren存在的时候,也就是render第二次触发的时候遍历旧的children列表,首先会把最新的transition属性更新到旧的VNode上,然后就是很关键的去获取VNode对应的DOM节点的位置(很重要!),并且记录;然后再根据map判断哪些VNode是需要保持的(新旧列表相同的VNode),哪些是需要移除的,最后就是把this.kept指向需要保持的VNode列表;所以this.kept在第一阶段的pacth过程中,才能准确把要移除的VNode先移除,并且不会插入新的VNode,也不会移动DOM节点;在执行后面的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回调,主要是移除动画事件的监听
  2. 记录节点最新的相对位置
  3. 比较节点新旧位置,是否有变化,如果有变化就在节点上应用transform,把节点移动到旧的位置上;然后强制reflow,更新dom节点位置信息;所以我们看到的列表可能表面是没有变化的,其实是我们把节点又移动到原来的位置上了;
  4. 最后我们把位置有变化的节点,加上move class,触发移动动画;

这就是transition-group所拥有的黑魔法,确实帮我们在背后做了不少的事情。

最后

温故而知新,在写的过程中其实发现了以前的理解还是有很多模糊的地方,说明自己平时阅读代码仍然不够细心,没有做到不求甚解,以后必须多多注意,最后的最后,如有错漏,希望大家能够指正。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
Prototype使用指南之array.js
Jan 10 Javascript
一个简单的js渐显(fadeIn)渐隐(fadeOut)类
Jun 19 Javascript
js对象数组按属性快速排序
Jan 31 Javascript
contains和compareDocumentPosition 方法来确定是否HTML节点间的关系
Sep 13 Javascript
js控制网页背景音乐播放与停止的方法
Feb 06 Javascript
Js与Jq 获取页面元素值的方法和差异对比
Apr 30 Javascript
解决iView中时间控件选择的时间总是少一天的问题
Mar 15 Javascript
js使用ajax传值给后台,后台返回字符串处理方法
Aug 08 Javascript
详解Vue+Element的动态表单,动态表格(后端发送配置,前端动态生成)
Apr 20 Javascript
3分钟了解vue数据劫持的原理实现
May 01 Javascript
利用JavaScript将Excel转换为JSON示例代码
Jun 14 Javascript
Python机器学习之决策树和随机森林
Jul 15 Javascript
Vue实现渲染数据后控制滚动条位置(推荐)
Dec 09 #Javascript
element-ui如何防止重复提交的方法步骤
Dec 09 #Javascript
js刷新页面location.reload()用法详解
Dec 09 #Javascript
vue 实现websocket发送消息并实时接收消息
Dec 09 #Javascript
Vue extend的基本用法(实例详解)
Dec 09 #Javascript
vue基于v-charts封装双向条形图的实现代码
Dec 09 #Javascript
微信小程序图片加载失败时替换为默认图片的方法
Dec 09 #Javascript
You might like
PHP学习笔记之数组篇
2011/06/28 PHP
dhtmlxTree目录树增加右键菜单以及拖拽排序的实现方法
2013/04/26 PHP
PHP基于数组实现的分页函数实例
2014/08/20 PHP
php 参数过滤、数据过滤详解
2015/10/26 PHP
php 防止表单重复提交两种实现方法
2016/11/03 PHP
老生常谈PHP面向对象之注册表模式
2017/05/26 PHP
PHP使用 Pear 进行安装和卸载包的方法详解
2019/07/08 PHP
js中top/parent/frame概述及案例应用
2013/02/06 Javascript
form表单中去掉默认的enter键提交并绑定js方法实现代码
2013/04/01 Javascript
使用jQuery重置(reset)表单的方法
2014/05/05 Javascript
JQuery.validationEngine表单验证插件(推荐)
2016/12/10 Javascript
详解axios在vue中的简单配置与使用
2017/05/10 Javascript
ionic 自定义弹框效果
2017/06/27 Javascript
详解让sublime text3支持Vue语法高亮显示的示例
2017/09/29 Javascript
详解vue的diff算法原理
2018/05/20 Javascript
解决Vue-cli npm run build生产环境打包,本地不能打开的问题
2018/09/20 Javascript
JointJS流程图的绘制方法
2018/12/03 Javascript
小程序实现抽奖动画
2020/04/16 Javascript
Python 错误和异常小结
2013/10/09 Python
pyside写ui界面入门示例
2014/01/22 Python
Python入门篇之字典
2014/10/17 Python
Python中getattr函数和hasattr函数作用详解
2016/06/14 Python
Python和C/C++交互的几种方法总结
2017/05/11 Python
python中kmeans聚类实现代码
2018/02/23 Python
使用python Telnet远程登录执行程序的方法
2019/01/26 Python
python3读取autocad图形文件.py实例
2020/06/05 Python
波兰快递服务:Globkurier.pl
2019/11/08 全球购物
奥地利时尚、美容、玩具和家居之家:Kastner & Öhler
2020/04/26 全球购物
c++工程师面试问题
2013/08/04 面试题
C#如何调用Word并打开一个Word文档
2013/05/08 面试题
Java的类与C++的类有什么不同
2014/01/18 面试题
关于责任的演讲稿
2014/05/20 职场文书
中学学校门卫岗位职责
2014/08/15 职场文书
领导参观欢迎词
2015/01/26 职场文书
2016年春节慰问信息
2015/03/25 职场文书
大学生就业意向书
2015/05/11 职场文书