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 相关文章推荐
jquery实现可拖动DIV自定义保存到数据的实例
Nov 20 Javascript
详解JavaScript的while循环的使用
Jun 03 Javascript
Node.js中路径处理模块path详解
Nov 14 Javascript
Bootstrap 网格系统布局详解
Mar 19 Javascript
基于JavaScript定位当前的地理位置
Apr 11 Javascript
360doc网站不登录就无法复制内容的解决方法
Jan 27 Javascript
原生JS实现的双色球功能示例
Feb 02 Javascript
vue input 输入校验字母数字组合且长度小于30的实现代码
May 16 Javascript
vue-router懒加载速度缓慢问题及解决方法
Nov 25 Javascript
javascript中call,apply,callee,caller用法实例分析
Jul 24 Javascript
vue ssr服务端渲染(小白解惑)
Nov 10 Javascript
JS实现移动端双指缩放和旋转方法
Dec 13 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配置心得包含MYSQL5乱码解决
2006/11/20 PHP
php面向对象全攻略 (十四) php5接口技术
2009/09/30 PHP
php获取从百度、谷歌等搜索引擎进入网站关键词的方法
2015/07/08 PHP
微信公众号判断用户是否已关注php代码解析
2016/06/24 PHP
实例讲解YII2中多表关联的使用方法
2017/07/21 PHP
你可能不再需要JQUERY
2021/03/09 Javascript
JavaScript Event学习第七章 事件属性
2010/02/07 Javascript
js不能获取隐藏的div的宽度只能先显示后获取
2014/09/04 Javascript
javascript解析json实例详解
2014/11/05 Javascript
js实现模拟银行卡账号输入显示效果
2015/11/18 Javascript
js实现倒计时及时间对象
2016/11/15 Javascript
使用angular-cli webpack创建多个包的方法
2018/10/16 Javascript
详解微信小程序缓存--缓存时效性
2019/05/02 Javascript
JavaScript图像放大镜效果实现方法详解
2020/06/28 Javascript
Python实现豆瓣图片下载的方法
2015/05/25 Python
Python生成随机密码的方法
2017/06/16 Python
pyspark.sql.DataFrame与pandas.DataFrame之间的相互转换实例
2018/08/02 Python
pycharm+PyQt5+python最新开发环境配置(踩坑)
2019/02/11 Python
对Django 转发和重定向的实例详解
2019/08/06 Python
Pycharm小白级简单使用教程
2020/01/08 Python
python对数组进行排序,并输出排序后对应的索引值方式
2020/02/28 Python
Python实现对adb命令封装
2020/03/06 Python
Python 如何创建一个线程池
2020/07/28 Python
css3动画过渡实现鼠标跟随导航效果
2018/02/08 HTML / CSS
CSS3 按钮边框动画的实现
2020/11/12 HTML / CSS
阿迪达斯奥地利官方商城:adidas.at
2016/10/16 全球购物
Scholastic父母商店:儿童书籍
2017/01/01 全球购物
中国最大的潮流商品购物网站:YOHO!BUY有货
2017/01/07 全球购物
光电信息专业应届生求职信
2013/10/07 职场文书
4s店总经理岗位职责
2013/12/31 职场文书
人事科岗位职责范本
2014/03/02 职场文书
支部书记四风对照材料
2014/08/28 职场文书
如何写早恋检讨书
2014/09/10 职场文书
支行行长岗位职责
2015/02/15 职场文书
法制主题班会教案
2015/08/13 职场文书
幼儿园教师辞职信
2019/06/21 职场文书