Vue

6.7. transition-group

前一节我们介绍了 <transiiton> 组件的实现原理,它只能针对单一元素实现过渡效果。我们做前端开发经常会遇到列表的需求,我们对列表元素进行添加和删除,有时候也希望有过渡效果,Vue.js 提供了 <transition-group> 组件,很好地帮助我们实现了列表的过渡效果。那么接下来我们就来分析一下它的实现原理。

为了更直观,我们也是通过一个示例来说明:

let vm = new Vue({
  el: '#app',
  template: '<div id="list-complete-demo" class="demo">' +
  '<button v-on:click="add">Add</button>' +
  '<button v-on:click="remove">Remove</button>' +
  '<transition-group name="list-complete" tag="p">' +
  '<span v-for="item in items" v-bind:key="item" class="list-complete-item">' +
  '{{ item }}' +
  '</span>' +
  '</transition-group>' +
  '</div>',
  data: {
    items: [1, 2, 3, 4, 5, 6, 7, 8, 9],
    nextNum: 10
  },
  methods: {
    randomIndex: function () {
      return Math.floor(Math.random() * this.items.length)
    },
    add: function () {
      this.items.splice(this.randomIndex(), 0, this.nextNum++)
    },
    remove: function () {
      this.items.splice(this.randomIndex(), 1)
    }
  }
})
 .list-complete-item {
  display: inline-block;
  margin-right: 10px;
}
.list-complete-move {
  transition: all 1s;
}
.list-complete-enter, .list-complete-leave-to {
  opacity: 0;
  transform: translateY(30px);
}
.list-complete-enter-active {
  transition: all 1s;
}
.list-complete-leave-active {
  transition: all 1s;
  position: absolute;
}

这个示例初始会展现 1-9 十个数字,当我们点击 Add 按钮时,会生成 nextNum 并随机在当前数列表中插入;当我们点击 Remove 按钮时,会随机删除掉一个数。我们会发现在数添加删除的过程中在列表中会有过渡动画,这就是 `` 组件配合我们定义的 CSS 产生的效果。

我们首先还是来分析 `` 组件的实现,它的定义在 src/platforms/web/runtime/components/transitions.js 中:

const props = extend({
  tag: String,
  moveClass: String
}, transitionProps)
delete props.mode
export default {
  props,
  beforeMount () {
    const update = this._update
    this._update = (vnode, hydrating) => {
      // force removing pass
      this.__patch__(
        this._vnode,
        this.kept,
        false, // hydrating
        true // removeOnly (!important, avoids unnecessary moves)
      )
      this._vnode = this.kept
      update.call(this, vnode, hydrating)
    }
  },
  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)
  },
  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) {
        var el: any = c.elm
        var s: any = el.style
        addTransitionClass(el, moveClass)
        s.transform = s.WebkitTransform = s.transitionDuration = ''
        el.addEventListener(transitionEndEvent, el._moveCb = function cb (e) {
          if (!e || /transform$/.test(e.propertyName)) {
            el.removeEventListener(transitionEndEvent, cb)
            el._moveCb = null
            removeTransitionClass(el, moveClass)
          }
        })
      }
    })
  },
  methods: {
    hasMove (el: any, moveClass: string): boolean {
      /* istanbul ignore if */
      if (!hasTransition) {
        return false
      }
      /* istanbul ignore if */
      if (this._hasMove) {
        return this._hasMove
      }
      // Detect whether an element with the move class applied has
      // CSS transitions. Since the element may be inside an entering
      // transition at this very moment, we make a clone of it and remove
      // all other transition classes applied to ensure only the move class
      // is applied.
      const clone: HTMLElement = el.cloneNode()
      if (el._transitionClasses) {
        el._transitionClasses.forEach((cls: string) => { removeClass(clone, cls) })
      }
      addClass(clone, moveClass)
      clone.style.display = 'none'
      this.$el.appendChild(clone)
      const info: Object = getTransitionInfo(clone)
      this.$el.removeChild(clone)
      return (this._hasMove = info.hasTransform)
    }
  }
}

render 函数

`` 组件也是由 render 函数渲染生成 vnode,接下来我们先分析 render 的实现。

定义一些变量

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)

不同于 组件, 组件非抽象组件,它会渲染成一个真实元素,默认 tagspanprevChildren 用来存储上一次的子节点;children 用来存储当前的子节点;rawChildren 表示 包裹的原始子节点;`transtionData` 是从 组件上提取出来的一些渲染数据,这点和 `` 组件的实现是一样的。

遍历 rawChidren,初始化 children

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}>`)
    }
  }
}

其实就是对 rawChildren 遍历,拿到每个 vnode,然后会判断每个 vnode 是否设置了 key,这个是 `` 对列表元素的要求。然后把 vnode 添加到 children 中,然后把刚刚提取的过渡数据 transitionData 添加的 vnode.data.transition 中,这点很关键,只有这样才能实现列表中单个元素的过渡动画。

处理 prevChildren

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)

当有 prevChildren 的时候,我们会对它做遍历,获取到每个 vnode,然后把 transitionData 赋值到 vnode.data.transition,这个是为了当它在 enterleave 的钩子函数中有过渡动画,我们在上节介绍 transition 的实现中说过。接着又调用了原生 DOM 的 getBoundingClientRect 方法获取到原生 DOM 的位置信息,记录到 vnode.data.pos 中,然后判断一下 vnode.key 是否在 map 中,如果在则放入 kept 中,否则表示该节点已被删除,放入 removed 中,然后通过执行 h(tag, null, kept) 渲染后放入 this.kept 中,把 removedthis.removed 保存。最后整个 render 函数通过 h(tag, null, children) 生成渲染 vnode

如果 transition-group 只实现了这个 render 函数,那么每次插入和删除的元素的缓动动画是可以实现的,在我们的例子中,当新增一个元素,它的插入的过渡动画是有的,但是剩余元素平移的过渡效果是出不来的,所以接下来我们来分析 `` 组件是如何实现剩余元素平移的过渡效果的。

move 过渡实现

其实我们在实现元素的插入和删除,无非就是操作数据,控制它们的添加和删除。比如我们新增数据的时候,会添加一条数据,除了重新执行 render 函数渲染新的节点外,还要触发 updated 钩子函数,接着我们就来分析 updated 钩子函数的实现。

判断子元素是否定义 move 相关样式

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
}
hasMove (el: any, moveClass: string): boolean {
  /* istanbul ignore if */
  if (!hasTransition) {
    return false
  }
  /* istanbul ignore if */
  if (this._hasMove) {
    return this._hasMove
  }
  // Detect whether an element with the move class applied has
  // CSS transitions. Since the element may be inside an entering
  // transition at this very moment, we make a clone of it and remove
  // all other transition classes applied to ensure only the move class
  // is applied.
  const clone: HTMLElement = el.cloneNode()
  if (el._transitionClasses) {
    el._transitionClasses.forEach((cls: string) => { removeClass(clone, cls) })
  }
  addClass(clone, moveClass)
  clone.style.display = 'none'
  this.$el.appendChild(clone)
  const info: Object = getTransitionInfo(clone)
  this.$el.removeChild(clone)
  return (this._hasMove = info.hasTransform)
}

核心就是 hasMove 的判断,首先克隆一个 DOM 节点,然后为了避免影响,移除它的所有其他的过渡 Class;接着添加了 moveClass 样式,设置 displaynone,添加到组件根节点上;接下来通过 getTransitionInfo 获取它的一些缓动相关的信息,这个函数在上一节我们也介绍过,然后从组件根节点上删除这个克隆节点,并通过判断 info.hasTransform 来判断 hasMove,在我们的例子中,该值为 true

子节点预处理

children.forEach(callPendingCbs)
children.forEach(recordPosition)
children.forEach(applyTranslation)

children 做了 3 轮循环,分别做了如下一些处理:

function callPendingCbs (c: VNode) {
  if (c.elm._moveCb) {
    c.elm._moveCb()
  }
  if (c.elm._enterCb) {
    c.elm._enterCb()
  }
}
function recordPosition (c: VNode) {
  c.data.newPos = c.elm.getBoundingClientRect()
}
function applyTranslation (c: VNode) {
  const oldPos = c.data.pos
  const newPos = c.data.newPos
  const dx = oldPos.left - newPos.left
  const dy = oldPos.top - newPos.top
  if (dx || dy) {
    c.data.moved = true
    const s = c.elm.style
    s.transform = s.WebkitTransform = `translate(${dx}px,${dy}px)`
    s.transitionDuration = '0s'
  }
}

callPendingCbs 方法是在前一个过渡动画没执行完又再次执行到该方法的时候,会提前执行 _moveCb_enterCb

recordPosition 的作用是记录节点的新位置。

applyTranslation 的作用是先计算节点新位置和旧位置的差值,如果差值不为 0,则说明这些节点是需要移动的,所以记录 vnode.data.moved 为 true,并且通过设置 transform 把需要移动的节点的位置又偏移到之前的旧位置,目的是为了做 move 缓动做准备。

遍历子元素实现 move 过渡

this._reflow = document.body.offsetHeight
children.forEach((c: VNode) => {
  if (c.data.moved) {
    var el: any = c.elm
    var s: any = el.style
    addTransitionClass(el, moveClass)
    s.transform = s.WebkitTransform = s.transitionDuration = ''
    el.addEventListener(transitionEndEvent, el._moveCb = function cb (e) {
      if (!e || /transform$/.test(e.propertyName)) {
        el.removeEventListener(transitionEndEvent, cb)
        el._moveCb = null
        removeTransitionClass(el, moveClass)
      }
    })
  }
})

首先通过 document.body.offsetHeight 强制触发浏览器重绘,接着再次对 children 遍历,先给子节点添加 moveClass,在我们的例子中,moveClass 定义了 transition: all 1s; 缓动;接着把子节点的 style.transform 设置为空,由于我们前面把这些节点偏移到之前的旧位置,所以它就会从旧位置按照 1s 的缓动时间过渡偏移到它的当前目标位置,这样就实现了 move 的过渡动画。并且接下来会监听 transitionEndEvent 过渡结束的事件,做一些清理的操作。

另外,由于虚拟 DOM 的子元素更新算法是不稳定的,它不能保证被移除元素的相对位置,所以我们强制 `` 组件更新子节点通过 2 个步骤:第一步我们移除需要移除的 vnode,同时触发它们的 leaving 过渡;第二步我们需要把插入和移动的节点达到它们的最终态,同时还要保证移除的节点保留在应该的位置,而这个是通过 beforeMount 钩子函数来实现的:

beforeMount () {
  const update = this._update
  this._update = (vnode, hydrating) => {
    // force removing pass
    this.__patch__(
      this._vnode,
      this.kept,
      false, // hydrating
      true // removeOnly (!important, avoids unnecessary moves)
    )
    this._vnode = this.kept
    update.call(this, vnode, hydrating)
  }
}

通过把 patch 方法的第四个参数 removeOnly 设置为 true,这样在 updateChildren 阶段,是不会移动 vnode 节点的。

总结

那么到此,组件的实现原理就介绍完毕了,它和 组件相比,实现了列表的过渡,以及它会渲染成真实的元素。当我们去修改列表的数据的时候,如果是添加或者删除数据,则会触发相应元素本身的过渡动画,这点和 组件实现效果一样,除此之外 还实现了 move 的过渡效果,让我们的列表过渡动画更加丰富。