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 各种浏览器下获得日期区别
Dec 22 Javascript
关于IE、Firefox、Opera页面呈现异同 写脚本很痛苦
Aug 28 Javascript
javascript高级学习笔记整理
Aug 14 Javascript
JavaScript动态创建div属性和样式示例代码
Oct 09 Javascript
js定时器怎么写?就是在特定时间执行某段程序
Oct 11 Javascript
JS实现可拖曳、可关闭的弹窗效果
Sep 26 Javascript
Javascript 基础---Ajax入门必看
Jul 06 Javascript
jQuery Easyui datagrid行内实现【添加】、【编辑】、【上移】、【下移】
Dec 19 Javascript
AngularJS实现动态添加Option的方法
May 17 Javascript
JS分页的实现(同步与异步)
Sep 16 Javascript
vue小图标favicon不显示的解决方案
Sep 19 Javascript
es6中let和const的使用方法详解
Feb 24 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
coreseek 搜索英文的问题详解
2013/06/08 PHP
PHP PDOStatement::nextRowset讲解
2019/02/01 PHP
php5.6.x到php7.0.x特性小结
2019/08/17 PHP
javascript实现 在光标处插入指定内容
2007/05/25 Javascript
js性能优化 如何更快速加载你的JavaScript页面
2012/03/17 Javascript
广泛收集的jQuery拖放插件集合
2012/04/09 Javascript
如何使用json在前后台进行数据传输实例介绍
2013/04/11 Javascript
JS写的贪吃蛇游戏(个人练习)
2013/07/08 Javascript
JQUERY 设置SELECT选中项代码
2014/02/07 Javascript
使用javascript控制cookie显示和隐藏背景图
2014/02/12 Javascript
解决jQuery动态获取手机屏幕高和宽的问题
2014/05/07 Javascript
基于jQuery实现的美观星级评论打分组件代码
2015/10/30 Javascript
数组Array的排序sort方法
2017/02/17 Javascript
微信小程序手势操作之单触摸点与多触摸点
2017/03/10 Javascript
JS简单获取当前日期和农历日期的方法
2017/04/17 Javascript
Javascript 严格模式use strict详解
2017/09/16 Javascript
为输入框加入数字js校验代码分享
2017/11/02 Javascript
JavaScript实现数组全排列、去重及求最大值算法示例
2018/07/30 Javascript
Vue中的Props(不可变状态)
2018/09/29 Javascript
Bootstrap 实现表格样式、表单布局的实例代码
2018/12/09 Javascript
每周一练 之 数据结构与算法(Stack)
2019/04/16 Javascript
elementUI Tree 树形控件的官方使用文档
2019/04/25 Javascript
微信小程序返回上一级页面的实现代码
2020/06/19 Javascript
python基础教程之Hello World!
2014/08/29 Python
python循环监控远程端口的方法
2015/03/14 Python
简单介绍Python的Django框架加载模版的方式
2015/07/20 Python
Python画柱状统计图操作示例【基于matplotlib库】
2018/07/04 Python
Python Process多进程实现过程
2019/10/22 Python
tensorflow实现在函数中用tf.Print输出中间值
2020/01/21 Python
python3.6.5基于kerberos认证的hive和hdfs连接调用方式
2020/06/06 Python
商务英语本科生的自我评价分享
2013/11/15 职场文书
法学毕业生自我鉴定
2014/01/31 职场文书
个人充满哲理的自我评价
2014/02/20 职场文书
硕士毕业论文导师评语
2014/12/31 职场文书
金榜题名主持词
2015/07/02 职场文书
Python制作动态字符画的源码
2021/08/04 Python