Vue.js 源码分析:transition-group 列表过渡实现

【免费下载链接】vue-analysis :thumbsup: Vue.js 源码分析 【免费下载链接】vue-analysis 项目地址: https://gitcode.com/gh_mirrors/vu/vue-analysis

在前端开发中,我们经常需要为列表元素的添加、删除和排序提供平滑的过渡动画。Vue.js 提供的 <transition-group> 组件正是解决这一需求的强大工具。与只能处理单个元素过渡的 <transition> 组件不同,<transition-group> 专为列表过渡设计,能够同时处理多个元素的进入、离开和移动动画。本文将深入分析 <transition-group> 组件的实现原理,帮助你理解其内部工作机制。

基本用法与示例

让我们先通过一个简单示例了解 <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)
    }
  }
})

配合以下 CSS 样式:

.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;
}

在这个示例中,点击 "Add" 按钮会随机插入一个新数字,点击 "Remove" 按钮会随机删除一个数字。通过 <transition-group> 组件和相应的 CSS 样式,实现了元素的进入、离开和移动过渡效果。

组件定义与基本结构

<transition-group> 组件的定义位于 src/platforms/web/runtime/components/transitions.js 文件中。它是一个非抽象组件,会渲染为一个真实的 DOM 元素,默认标签为 span,但可以通过 tag 属性自定义。

组件的基本结构如下:

export default {
  props: extend({
    tag: String,
    moveClass: String
  }, transitionProps),
  beforeMount () {
    // 重写 _update 方法
  },
  render (h: Function) {
    // 渲染逻辑
  },
  updated () {
    // 处理 move 过渡
  },
  methods: {
    hasMove (el: any, moveClass: string): boolean {
      // 判断是否有 move 过渡
    }
  }
}

<transition> 组件不同,<transition-group> 删除了 mode 属性,因为列表过渡不需要控制进入/离开的顺序。同时增加了 tagmoveClass 两个新属性,tag 用于指定渲染的元素标签,moveClass 用于自定义移动过渡的类名。

render 函数实现

<transition-group>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)

这里初始化了一些关键变量:

  • tag: 组件渲染的标签名
  • map: 用于存储当前子节点的 key 映射
  • prevChildren: 上一次渲染的子节点
  • rawChildren: 当前插槽中的原始子节点
  • children: 经过过滤后的有效子节点
  • transitionData: 从组件属性中提取的过渡相关数据

处理子节点

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

这段代码遍历原始子节点,过滤出有效的子节点并存储到 children 数组中。特别需要注意的是,<transition-group> 要求每个子节点必须有唯一的 key,否则会在开发环境下发出警告。同时,将过渡数据 transitionData 添加到每个子节点的 data.transition 属性中,以便子节点能够触发过渡效果。

处理上一次的子节点

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,将其分为两类:

  • kept: 仍然存在于当前子节点中的节点
  • removed: 已从当前子节点中移除的节点

对于每个上一次的子节点,记录其位置信息(通过 getBoundingClientRect()),并将过渡数据添加到节点中。最后,通过 h(tag, null, children) 渲染当前的子节点。

move 过渡实现

<transition-group> 最强大的功能是能够实现列表元素的移动过渡效果。这一功能主要在 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
}

首先获取 moveClass,如果未自定义,则默认为 name + '-move'v-move。然后通过 hasMove 方法判断是否定义了 move 过渡的样式。

hasMove 方法的实现:

hasMove (el: any, moveClass: string): boolean {
  if (!hasTransition) {
    return false
  }
  if (this._hasMove) {
    return this._hasMove
  }
  // 创建元素克隆
  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 方法通过创建元素克隆,添加 moveClass 并检查是否有 transform 过渡,来判断是否需要执行 move 过渡动画。

处理子节点位置变化

// 三轮循环避免 DOM 读写混合,防止布局抖动
children.forEach(callPendingCbs)
children.forEach(recordPosition)
children.forEach(applyTranslation)

// 强制重排,使所有元素就位
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)
      }
    })
  }
})

这段代码是实现 move 过渡的核心,分为四个步骤:

  1. 处理未完成的回调callPendingCbs 确保之前的过渡动画回调已执行
  2. 记录新位置recordPosition 记录每个节点更新后的位置
  3. 应用平移变换applyTranslation 计算新旧位置差异并应用 transform
  4. 触发过渡动画:清除 transform,使元素从旧位置过渡到新位置

其中,applyTranslation 函数的实现:

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'
  }
}

该函数计算节点新旧位置的差异,如果有差异,则通过 transform 将节点暂时移回旧位置,为后续的过渡动画做准备。

beforeMount 钩子函数

为了确保移除元素的过渡效果正常工作,<transition-group>beforeMount 钩子中重写了 _update 方法:

beforeMount () {
  const update = this._update
  this._update = (vnode, hydrating) => {
    // 强制执行移除过渡
    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,确保在更新子节点时不会移动节点,只会移除需要删除的节点,从而保证移除过渡的正常执行。

总结

<transition-group> 组件通过以下几个关键步骤实现了列表过渡效果:

  1. 渲染真实元素:作为非抽象组件,渲染为指定标签的真实 DOM 元素
  2. 管理子节点:在 render 函数中处理子节点,记录新旧位置信息
  3. 处理进入/离开过渡:为添加和删除的元素应用过渡效果
  4. 实现移动过渡:在 updated 钩子中通过 transform 实现元素的平滑移动

通过这些机制,<transition-group> 能够为列表提供流畅的添加、删除和排序过渡效果,极大地提升了用户体验。

官方文档中还有更多关于 <transition-group> 的使用细节和示例,可以参考 docs/v2/extend/tansition-group.md 深入学习。掌握 <transition-group> 的使用和实现原理,将帮助你构建更加生动和友好的用户界面。

【免费下载链接】vue-analysis :thumbsup: Vue.js 源码分析 【免费下载链接】vue-analysis 项目地址: https://gitcode.com/gh_mirrors/vu/vue-analysis

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐