组件只能针对单一元素实现过渡效果
前端开发经常会遇到列表的需求,对列表元素进行添加和删除,有时候也希望有过渡效果,Vue.js 提供了 组件,很好地帮助实现列表的过渡效果

例子

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

初始会展现 1-9 十个数字,当点击 Add 按钮时,会生成 nextNum 并随机在当前数列表中插入;当点击 Remove 按钮时,会随机删除掉一个数。会发现在数添加删除的过程中在列表中会有过渡动画,这就是 组件配合定义的 CSS 产生的效果
组件的实现,它的定义在 src/platforms/web/runtime/components/transitions.js 中

  1. const props = extend({
  2. tag: String,
  3. moveClass: String
  4. }, transitionProps)
  5. delete props.mode
  6. export default {
  7. props,
  8. beforeMount () {
  9. const update = this._update
  10. this._update = (vnode, hydrating) => {
  11. const restoreActiveInstance = setActiveInstance(this)
  12. // force removing pass
  13. this.__patch__(
  14. this._vnode,
  15. this.kept,
  16. false, // hydrating
  17. true // removeOnly (!important, avoids unnecessary moves) 设置为true,这样在updateChildren阶段是不会移动vnode节点的
  18. )
  19. this._vnode = this.kept
  20. restoreActiveInstance()
  21. update.call(this, vnode, hydrating)
  22. }
  23. },
  24. // <transition-group> 组件也是由 render 函数渲染生成 vnode
  25. render (h: Function) {
  26. // 1.定义一些变量
  27. // 会渲染成一个真实元素,默认tag是span
  28. const tag: string = this.tag || this.$vnode.data.tag || 'span'
  29. const map: Object = Object.create(null)
  30. // 储存上一次的子节点
  31. const prevChildren: Array<VNode> = this.prevChildren = this.children
  32. // <transition-group>包裹的原始子节点
  33. const rawChildren: Array<VNode> = this.$slots.default || []
  34. // 储存当前的子节点
  35. const children: Array<VNode> = this.children = []
  36. // 从<transition-group>组件上提取出来的一些渲染数据
  37. const transitionData: Object = extractTransitionData(this)
  38. // 2.遍历rawChildren,初始化children
  39. for (let i = 0; i < rawChildren.length; i++) {
  40. // 拿到每个vnode
  41. const c: VNode = rawChildren[i]
  42. if (c.tag) {
  43. // 判断是否设置了key,这个是<transition-group>对列表元素的要求
  44. if (c.key != null && String(c.key).indexOf('__vlist') !== 0) {
  45. // 把vnode添加到children
  46. children.push(c)
  47. map[c.key] = c
  48. // 把刚刚提取的过渡数据transitionData添加到vnode.data.transition中
  49. ;(c.data || (c.data = {})).transition = transitionData
  50. } else if (process.env.NODE_ENV !== 'production') {
  51. const opts: ?VNodeComponentOptions = c.componentOptions
  52. const name: string = opts ? (opts.Ctor.options.name || opts.tag || '') : c.tag
  53. warn(`<transition-group> children must be keyed: <${name}>`)
  54. }
  55. }
  56. }
  57. // 3.处理prevChildren
  58. if (prevChildren) {
  59. const kept: Array<VNode> = []
  60. const removed: Array<VNode> = []
  61. // 遍历
  62. for (let i = 0; i < prevChildren.length; i++) {
  63. // 获取到每个vnode
  64. const c: VNode = prevChildren[i]
  65. // 把transitionData赋值到vnode.data.transition - 是为了当它在enter和leave的钩子函数中有过渡动画
  66. c.data.transition = transitionData
  67. // 调用原生DOM方法getBoundingClientRect获取到原生DOM的位置信息,记录到vnode.data.pos
  68. c.data.pos = c.elm.getBoundingClientRect()
  69. // 判断vnode.key是否在map中
  70. if (map[c.key]) {
  71. // 在则放入kept中
  72. kept.push(c)
  73. } else {
  74. // 否则表示该节点已被删除,放入removed中
  75. removed.push(c)
  76. }
  77. }
  78. // 执行h(tag, null, kept)渲染后放入this,kept中
  79. this.kept = h(tag, null, kept)
  80. // 把removed用this.removed保存
  81. this.removed = removed
  82. }
  83. // h(tag, null, children)生成渲染vnode
  84. return h(tag, null, children)
  85. },
  86. updated () {
  87. // 1.判断子元素是否定义move相关样式
  88. const children: Array<VNode> = this.prevChildren
  89. const moveClass: string = this.moveClass || ((this.name || 'v') + '-move')
  90. if (!children.length || !this.hasMove(children[0].elm, moveClass)) {
  91. return
  92. }
  93. // 2.子节点预处理
  94. // 堆children做了三轮循环
  95. // we divide the work into three loops to avoid mixing DOM reads and writes
  96. // in each iteration - which helps prevent layout thrashing.
  97. children.forEach(callPendingCbs)
  98. children.forEach(recordPosition)
  99. children.forEach(applyTranslation)
  100. // 3.遍历子元素实现move过渡
  101. // force reflow to put everything in position
  102. // assign to this to avoid being removed in tree-shaking
  103. // $flow-disable-line
  104. // 强制触发浏览器重绘
  105. this._reflow = document.body.offsetHeight
  106. // 对children遍历
  107. children.forEach((c: VNode) => {
  108. if (c.data.moved) {
  109. const el: any = c.elm
  110. const s: any = el.style
  111. // 先给子节点添加moveClass
  112. addTransitionClass(el, moveClass)
  113. // 把子节点的style.transform设置为空 - 例子中moveClass定义了transition: all 1s;缓动,由于前面把这些节点偏移到之前的旧位置,所以它就会从旧位置按照 1s 的缓动时间过渡偏移到它的当前目标位置,这样就实现了 move 的过渡动画
  114. s.transform = s.WebkitTransform = s.transitionDuration = ''
  115. // 监听transitionEndEvent过渡结束的事件,做一些清理操作
  116. el.addEventListener(transitionEndEvent, el._moveCb = function cb (e) {
  117. if (e && e.target !== el) {
  118. return
  119. }
  120. if (!e || /transform$/.test(e.propertyName)) {
  121. el.removeEventListener(transitionEndEvent, cb)
  122. el._moveCb = null
  123. removeTransitionClass(el, moveClass)
  124. }
  125. })
  126. }
  127. })
  128. },
  129. methods: {
  130. hasMove (el: any, moveClass: string): boolean {
  131. /* istanbul ignore if */
  132. if (!hasTransition) {
  133. return false
  134. }
  135. /* istanbul ignore if */
  136. if (this._hasMove) {
  137. return this._hasMove
  138. }
  139. // Detect whether an element with the move class applied has
  140. // CSS transitions. Since the element may be inside an entering
  141. // transition at this very moment, we make a clone of it and remove
  142. // all other transition classes applied to ensure only the move class
  143. // is applied.
  144. const clone: HTMLElement = el.cloneNode()
  145. if (el._transitionClasses) {
  146. el._transitionClasses.forEach((cls: string) => { removeClass(clone, cls) })
  147. }
  148. addClass(clone, moveClass)
  149. clone.style.display = 'none'
  150. this.$el.appendChild(clone)
  151. const info: Object = getTransitionInfo(clone)
  152. this.$el.removeChild(clone)
  153. return (this._hasMove = info.hasTransform)
  154. }
  155. }
  156. }

render 函数

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

move过渡实现

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

  1. hasMove (el: any, moveClass: string): boolean {
  2. /* istanbul ignore if */
  3. if (!hasTransition) {
  4. return false
  5. }
  6. /* istanbul ignore if */
  7. if (this._hasMove) {
  8. return this._hasMove
  9. }
  10. // Detect whether an element with the move class applied has
  11. // CSS transitions. Since the element may be inside an entering
  12. // transition at this very moment, we make a clone of it and remove
  13. // all other transition classes applied to ensure only the move class
  14. // is applied.
  15. // 克隆一个DOM节点
  16. const clone: HTMLElement = el.cloneNode()
  17. // 移除它的所有其它的过渡Class
  18. if (el._transitionClasses) {
  19. el._transitionClasses.forEach((cls: string) => { removeClass(clone, cls) })
  20. }
  21. // 添加moveClass样式
  22. addClass(clone, moveClass)
  23. // 设置display为none
  24. clone.style.display = 'none'
  25. // 添加到根节点上
  26. this.$el.appendChild(clone)
  27. // 通过getTransitionInfo获取它的一些缓动相关的信息
  28. const info: Object = getTransitionInfo(clone)
  29. // 从组件根节点上删除这个克隆节点
  30. this.$el.removeChild(clone)
  31. // 通过判断info.hasTransform来判断hasMove
  32. return (this._hasMove = info.hasTransform)
  33. }

callPendingCbs、recordPosition 、applyTranslation

  1. // 在前一个过渡动画没执行完又再次执行到该方法时会提前执行_moveCb和_enterCb
  2. function callPendingCbs (c: VNode) {
  3. /* istanbul ignore if */
  4. if (c.elm._moveCb) {
  5. c.elm._moveCb()
  6. }
  7. /* istanbul ignore if */
  8. if (c.elm._enterCb) {
  9. c.elm._enterCb()
  10. }
  11. }
  12. // 记录节点的新位置
  13. function recordPosition (c: VNode) {
  14. c.data.newPos = c.elm.getBoundingClientRect()
  15. }
  16. // 先计算节点新位置和旧位置的差值
  17. // 如果差值不为 0,则说明这些节点是需要移动的,所以记录 vnode.data.moved 为 true,并且通过设置 transform 把需要移动的节点的位置又偏移到之前的旧位置,目的是为了做 move 缓动做准备
  18. function applyTranslation (c: VNode) {
  19. const oldPos = c.data.pos
  20. const newPos = c.data.newPos
  21. const dx = oldPos.left - newPos.left
  22. const dy = oldPos.top - newPos.top
  23. if (dx || dy) {
  24. c.data.moved = true
  25. const s = c.elm.style
  26. s.transform = s.WebkitTransform = `translate(${dx}px,${dy}px)`
  27. s.transitionDuration = '0s'
  28. }
  29. }

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

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