分析Vue列表排序过渡原理

926 阅读4分钟

有一个Vue常考面试题是:v-for为什么要加key?

官方文档告诉我们:key在触发过渡时很有效

设想一个场景:要对一个简单的列表进行重新排序,排序过程中要有动画效果。那么,要如何来实现这个效果呢?为什么必须使用key呢?

diff算法

key是在diff算法中要用到的,其实只要明白diff算法的过程,就可以理解为什么触发过渡必须要用key

当我们更改列表排序后,会生成新vnode,并使用diff算法比对新旧vnode。假如是同一个vnode,且都有子节点时,要开启while循环对子节点进行对比,这也是diff算法核心,大概过程就是依次对比:

  • 对比新旧开始节点。如果是同一节点,更新内部差异(本例中就是更新li内部的文本节点),然后继续下一轮比对
  • 对比新旧结束节点。如果是同一节点,更新内部差异,然后继续下一轮比对
  • 对比旧开始节点和新结束节点。如果是同一个节点,更新内部差异,并利用insertBefore方法更换位置,继续下一轮比对
  • 对比旧结束节点和新开始节点。如果是同一个节点,更新内部差异,并利用insertBefore方法更换位置,继续下一轮比对

判断是否为同一vnode主要就是根据标签名和key值是否相等。要是没有加key,那么每一轮对比新旧开始节点,都被判断为同一节点,只会更新li内部的文本,那么li自然就不能拥有过渡效果;如果加了key,就能追踪到节点位置,对于改变了顺序的节点,会用到insertBefore方法更换位置

使用transition-group

官方文档中有写用transition-group来实现列表排序过渡效果,接下来我就通过调试来分析一下这个内置组件的实现原理

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>compile</title>
    <style>
        .flip-list-move {
            transition: transform 10s;
        }
    </style>
</head>

<body>
    <div id="flip-list-demo" class="demo">
        <button v-on:click="shuffle">Shuffle</button>
        <transition-group name="flip-list" tag="ul">
            <li v-for="item in items" v-bind:key="item">{{ item }}</li>
        </transition-group>
    </div>
    <script src="../../dist/vue.js"></script>
    <script>
        const vm = new Vue({
            el: '#flip-list-demo',
            data: {
                items: [10,9,8,7,6,5,4,3,2,1]
            },
            methods: {
                shuffle: function () {
                    this.items.sort((a,b)=>{
                        return a - b
                    })
                }
            }
        })
    </script>
</body>

</html>

源码调试

demo中点击按钮后倒叙排列,根据diff算法的逻辑,很容易推测代码会走到什么地方,在updateChildren方法内部打如下断点:

1.png

可以看到,diff完毕后,视图上节点的位置已经更新,但并没有动画效果

2.png

继续调试,找到动画效果实现的位置,是在transition-group组件的updated方法中

3.png

4.png

既然已经找到了核心位置,那接下来具体看一下applyTranslation方法

可以看到,每个vnode.data都存储了新旧位置信息。applyTranslation方法内部,直接更改真实dom节点的transform:translate样式,使节点移动到旧位置

5.png

接着来看addTransitionClass,它的作用主要就是给li节点加class。回顾在demo中,我们给.flip-list-move设置了transition的样式

6.png

接着往下走,实现动画过渡效果

7.png

小结

  • 更改排序,diff算法会将节点放到最新的位置

  • dom更新完毕后,调用组件updated生命周期钩子,updated内部:

    • 会利用vnode.data中存储的新旧节点位置信息,更改真实dom的样式,通过transform让节点移动到旧位置

    • 给节点添加类名(demo中该类设置了transition),再清空transform样式,实现节点动效移动到新位置

问题:vnode.data什么时候被存储的新旧位置信息?

源码分析

上面调试只关注了过渡效果的核心实现,接下来从数据变更开始捋捋整个流程

sort更新数组后发生的事情

  • 调用根组件的watcher.update()

  • 异步执行flushSchedulerQueue方法,内部遍历queue队列,此时队列中只有根watcher

  • 遍历过程中,执行根组件watcher.run()重新渲染根组件

    • 在patch过程中会强制调用子组件的watcher.update

    • 调用queueWatcher,内部只做了一件事,就是给queue里加了个watcher

  • 此时queue中就多了子watcher,继续遍历,会调用子watcher.run()重新渲染子组件

    • 我们知道重新渲染核心代码就是调用vm._update(vm._render(), hydrating)

    • render重新生成vnode的过程中,给旧vnode加了位置信息

    • patch过程使用diff算法,更换了真实dom顺序

  • queue队列遍历完毕后,会调用upated生命周期钩子函数,在这里完成了动效过渡

transition-group组件的render

组件实例有一个prevChildren属性,它是一个数组,里面记录了列表节点的vnode

  // src\platforms\web\runtime\components\transition-group.js
  
  render (h: Function) {
    // tag: "ul"
    const tag: string = this.tag || this.$vnode.data.tag || 'span'
    // map: {}
    const map: Object = Object.create(null)
    // prevChildren: 重新排序前的数组,数组中的每一项都是li的vnode
    // 如果是初始渲染,这个值为undefined
    const prevChildren: Array<VNode> = this.prevChildren = this.children
    // rawChildren:重新排序后未经处理的数组,数组中的每一项都是li的vnode
    const rawChildren: Array<VNode> = this.$slots.default || []
    // 每次调用render,都将this.children赋值为空数组,后面会给它加数据
    const children: Array<VNode> = this.children = []
    // transitionData: {tag: 'ul', name: 'flip-list'}
    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}>`)
        }
      }
    }
    // 给prevChildren中的每一项添加pos位置信息
    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
        // 核心就是调用真实dom的getBoundingClientRect
        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)
  },

1.png

transition-group组件的_update

  // src\platforms\web\runtime\components\transition-group.js
  
  beforeMount () {
    // 初始渲染会将Vue的实例方法Vue.prototype._update存储到update中
    const update = this._update
    // 重写组件实例的_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()
      // 真实dom更换位置在这里发生
      update.call(this, vnode, hydrating)
    }
  },

transition-group组件的updated钩子函数

 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
        // 调试已经看过了,给节点添加class,我们在demo中已经为该class添加了transition样式
        addTransitionClass(el, moveClass)
        // 清空节点transform样式,于是节点回到该在的位置并且有过渡动效
        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)
          }
        })
      }
    })
  }
function recordPosition (c: VNode) {
  c.data.newPos = c.elm.getBoundingClientRect()
}