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 相关文章推荐
Ext 表单布局实例代码
Apr 30 Javascript
获取css样式表内样式的js函数currentStyle(IE),defaultView(FF)
Feb 14 Javascript
JS target与currentTarget区别说明
Aug 28 Javascript
js工具方法弹出蒙版
May 08 Javascript
分享JavaScript获取网页关闭与取消关闭的事件
Dec 13 Javascript
jQuery实现checkbox全选的方法
Jun 10 Javascript
浅谈Sticky组件的改进实现
Mar 22 Javascript
微信小程序  简单实例(阅读器)的实例开发
Sep 29 Javascript
javascript事件捕获机制【深入分析IE和DOM中的事件模型】
Dec 15 Javascript
解决在vue项目中,发版之后,背景图片报错,路径不对的问题
Mar 06 Javascript
JavaScript对象的特性与实践应用深入详解
Dec 30 Javascript
详解实现vue的数据响应式原理
Jan 20 Vue.js
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版自动生成文章摘要
2008/07/23 PHP
PHP获取windows登录用户名的方法
2014/06/24 PHP
高性能web开发 如何加载JS,JS应该放在什么位置?
2010/05/14 Javascript
修复IE9&amp;safari 的sort方法
2011/10/21 Javascript
js 用CreateElement动态创建标签示例
2013/11/20 Javascript
JS判断客户端是手机还是PC的2个代码
2014/04/12 Javascript
jquery操作checkbox示例分享
2014/07/21 Javascript
JavaScript实现按Ctrl键打开新页面
2014/09/04 Javascript
Node.js中使用mongoskin操作mongoDB实例
2014/09/28 Javascript
简介JavaScript中substring()方法的使用
2015/06/06 Javascript
JavaScript生成二维码图片小结
2015/12/27 Javascript
jquery实用技巧之输入框提示语句
2016/07/28 Javascript
JavaScript实现窗口抖动效果
2016/10/19 Javascript
打造通用的匀速运动框架(实例讲解)
2017/10/17 Javascript
nodeJs爬虫的技术点总结
2018/05/13 NodeJs
js打开word文档预览操作示例【不是下载】
2019/05/23 Javascript
Vue.js项目实战之多语种网站的功能实现(租车)
2019/08/07 Javascript
Nodejs 识别图片类型的方法
2019/08/15 NodeJs
JS删除对象中某一属性案例详解
2020/09/08 Javascript
[01:05]DOTA2完美大师赛趣味视频之选手教你打职业
2017/11/23 DOTA
在Python中使用元类的教程
2015/04/28 Python
在Python中使用PIL模块处理图像的教程
2015/04/29 Python
python 写入csv乱码问题解决方法
2016/10/23 Python
python使用pycharm环境调用opencv库
2018/02/11 Python
selenium3+python3环境搭建教程图解
2018/12/07 Python
Django中FilePathField字段的用法
2020/05/21 Python
Python HTMLTestRunner库安装过程解析
2020/05/25 Python
高三自我鉴定怎么写
2013/10/19 职场文书
化工专业推荐信范文
2013/11/28 职场文书
洗发露广告词
2014/03/14 职场文书
2014各大专业毕业生自我评价
2014/09/17 职场文书
护士个人年度总结范文
2015/02/13 职场文书
办公室主任岗位职责范本
2015/03/31 职场文书
民事上诉状范文
2015/05/22 职场文书
Golang数据类型和相互转换
2022/04/12 Golang
Android开发EditText禁止输入监听及InputFilter字符过滤
2022/06/10 Java/Android