浅谈 Vue Transition 源码

从 Vue 1.0 时代开始,Transition 功能已成为 Vue 的核心模块之一。它能让开发者通过编写简单的 CSS 来定义过渡状态,从而实现组件之间切换的动效改变,是顺滑交互体验开发中必不可少的利器。结合 FLIP 原理,Vue Transition 更能以高性能的方法实现复杂的多组件交互效效果。本篇文章就来介绍一下 Transition 背后的原理和实现。

Patch Hooks 与 Vue Modules 概览

在了解 Vue Transition 的核心实现前,我们需要了解一下 Vue 在 Patch 新旧 vdom 的过程中涉及生命周期函数的调用(以下简称 Patch Hooks)。与组件本身的生命周期钩子如 beforeCreate 不同,共有五种 Patch Hooks:

  • create 从 vnode 创建组件或真实 DOM 元素(非文本或注释节点)后调用
  • activate <keep-alive> 组件被重新激活时调用
  • update 同一节点被 patch 时调用
  • remove 节点在 patch 过程被移除时调用
  • destroy 在 patch remove 调用后直接调用 patch destroy

Vue 中很多自带的核心功能如指令、transition 以及对 class 或者 DOM props 的处理都是以 modules 的形式,注入到createPatchFunction 高阶函数中创建 patch 方法,以保证能在 patch 的各个阶段执行。在 src/platforms/web/runtime/patch.js 中引入了上述 modules:

1
2
3
4
5
6
7
import * as nodeOps from 'web/runtime/node-ops'
import { createPatchFunction } from 'core/vdom/patch'
import baseModules from 'core/vdom/modules/index'
import platformModules from 'web/runtime/modules/index'

const modules = platformModules.concat(baseModules)
export const patch: Function = createPatchFunction({ nodeOps, modules })

patch 就会收集各个 modules 的钩子函数,储存在全局变量 cbs 中,调用时直接遍历 cbs[<hook_name>] 即可:

1
2
3
4
5
6
7
8
9
10
// src/core/vdom/patch.js createPatchFunction
const { modules, nodeOps } = backend
for (i = 0; i < hooks.length; ++i) {
cbs[hooks[i]] = []
for (j = 0; j < modules.length; ++j) {
if (isDef(modules[j][hooks[i]])) {
cbs[hooks[i]].push(modules[j][hooks[i]])
}
}
}

Transition 核心介绍

Vue 的 transition 功能主要由两大部分组成:1)框架自带 <transition> 组件的定义和 2)用于执行动画主要逻辑 的 module。

<transition> 组件的定义

在 web 版本的 Vue 中,transition 作为框架自带的组件,在 Vue 被引入项目时即挂载到 Vue.options.components 中,以供所有自定义组件使用。

Vue.options 会在子组件的类继承 Vue 时合并到子组件的 options 上(如 Sub.options),进而在子组件实例化的过程中可直接被 vm.$options 访问到,因此让子组件也能获取到注册在全局 Vue 类上的组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* src/platforms/web/runtime/components/index.js
* 在 runtime/components 中引入 <transition> 和 <transition-group>
*/
import Transition from './transition'
import TransitionGroup from './transition-group'

export default {
Transition,
TransitionGroup
}

/**
* src/platforms/web/runtime/index.js
* 在 runtime 入口引用组件
*/
import platformComponents from './components/index'
extend(Vue.options.components, platformComponents)

数据预处理

该组件是个 abstract 组件,主要用于直接渲染子节点、预处理一些赋值在 <transition> 上的属性,以及判断 mode 字段以添加不同的动画阶段钩子。

由于当前 render 函数是用于创建过渡组件的新 vnode,patch 过程还未发生意味着旧 vnode 仍未被移除,函数内还能访问、操作旧节点,此时便可以挂载相关的过渡动画钩子。值得一提的是,使用 mergeVNodeHook() 在新旧 transition 上挂载的过渡动画钩子都是“一次性”的,一旦被调用则标记禁止下次调用,避免内存泄漏。

为了方便理解,在 Transition 组件中使用的 mergeVNodeHook(data, key, fn) 可以简单看做是 data[key] = fn,只不过 fn 在经过处理后在调用一次后就销毁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// src/platforms/web/runtime/components/transition.js

// 获得 slot 中的子组件,过滤掉文本和空节点,如果不存在子节点直接 return
let children: any = this.$slots.default
if (!children) return
children = children.filter(isNotTextNode)
if (!children.length) return

// 过渡模式
const mode: string = this.mode

// 如果 <transition> 是当前组件的根节点,且祖先节点有 transition 则直接返回子节点,不做任何转换
const rawChild: VNode = children[0]
if (hasParentTransition(this.$vnode)) return rawChild

// 找到第一个不为 abstract 类型(如 keep-alive 组件)的子节点
const child: ?VNode = getRealChild(rawChild)
if (!child) return rawChild

if (this._leaving) return placeholder(h, rawChild)

// 给子节点添加该过渡组件中唯一的 key
const id: string = `__transition-${this._uid}-`
child.key = child.key == null
? child.isComment
? id + 'comment'
: id + child.tag
: isPrimitive(child.key)
? (String(child.key).indexOf(id) === 0 ? child.key : id + child.key)
: child.key

// 将传入 <transition> 组件的参数和动画事件挂载到子组件的的 data.transition 中
const data: Object = (child.data || (child.data = {})).transition = extractTransitionData(this)

const oldRawChild: VNode = this._vnode
const oldChild: VNode = getRealChild(oldRawChild)

// 如果检查到子组件是由 v-show 指令控制展示的,则需要将动画执行的控制移交给 v-show 的钩子
if (child.data.directives && child.data.directives.some(isVShowDirective)) {
child.data.show = true
}

if (
oldChild && oldChild.data &&
!isSameChild(child, oldChild) && !isAsyncPlaceholder(oldChild) &&
!(oldChild.componentInstance && oldChild.componentInstance._vnode.isComment)
) {
const oldData: Object = oldChild.data.transition = extend({}, data)

// transition mode 相关逻辑

}
return rawChild

以上代码都是一个 组件 render 函数在初始化 vnode 时进行的一些数据预操作,包括

  • 找到真正需要渲染的子节点
  • 为子节点赋值该过渡中的唯一 key
  • 挂载此次过渡所需参数
  • 判断子节点是否由 v-show 控制等

其中,child.data.transition 对象是稍后执行过渡动画最重要的数据,由以下代码生成

1
const data: Object = (child.data || (child.data = {})).transition = extractTransitionData(this)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function extractTransitionData (comp: Component): Object {
const data = {}
const options: ComponentOptions = comp.$options

// 挂载参数
for (const key in options.propsData) {
data[key] = comp[key]
}
// 挂载动画事件
const listeners: ?Object = options._parentListeners
for (const key in listeners) {
data[camelize(key)] = listeners[key]
}
return data
}

处理过渡模式

在两个不同节点之间过渡时,Vue 使用过渡模式以确定两节点先后出现的顺序。可选值为分别 out-inin-out,如不设置则新旧节点的相应过渡会几乎同时进行(新节点在前,旧节点在后)。

out-in 模式下, render 函数返回一个 placeholder 节点(emptyVNode);在旧节点完成过渡后,调用 this.$forceUpdate() 强制生成真正的 vnode。emptyVNode 与新 vnode 在 patch 的过程中,就能顺利调用新节点的过渡动画逻辑了:

1
2
3
4
5
6
7
8
9
10
if (mode === 'out-in') {
this._leaving = true
mergeVNodeHook(oldData, 'afterLeave', () => {
this._leaving = false
this.$forceUpdate() // 在旧节点移除完成后,再生成新节点
})
return placeholder(h, rawChild)
} else if (mode === 'in-out') {
// ...
}

in-out 模式下,旧节点的离开动画会被延迟到在新节点的进入动画结束后再执行。使用 delayedLeave 函数,在旧节点被 patch 移除时赋予具体的离开动画逻辑,并在新节点进入完成后的 afterEnterenterCancelled 钩子中再调用。这种情况下,旧节点在 delayedLeave 执行时才真正从 DOM 中移除(如果使用 v-show 控制则是设置 style.display = none

1
2
3
4
5
6
7
8
9
10
if (mode === 'out-in') {
// ...
} else if (mode === 'in-out') {
if (isAsyncPlaceholder(child)) return oldRawChild
let delayedLeave
const performLeave = () => { delayedLeave() }
mergeVNodeHook(data, 'afterEnter', performLeave)
mergeVNodeHook(data, 'enterCancelled', performLeave)
mergeVNodeHook(oldData, 'delayLeave', leave => { delayedLeave = leave })
}

动画执行逻辑

Transition 的 Vue modules 中,动画执行逻辑定义了三个 patch hook 方法: create, activateremove。在这三个钩子里,都会根据上文中在 render() 里为子节点 vnode 定义的 data.show 属性,如果检查到子组件是由 v-show 指令控制展示的,则需要将动画执行的控制移交给 v-show 的指令钩子:

1
2
3
4
5
6
7
8
9
10
11
12
// src/platforms/web/runtime/modules/transition.js
function _enter (_: any, vnode: VNodeWithData) {
if (vnode.data.show !== true) enter(vnode)
}
export default {
create: _enter,
activate: _enter,
remove (vnode: VNode, rm: Function) {
if (vnode.data.show !== true) leave(vnode, rm)
else rm()
}
}

enterleave 这两个函数是过渡执行逻辑的核心,在使用纯 CSS 过渡的思路下:

  1. 插入初始样式和过渡逻辑
  2. 删除初始样式并插入目标样式,让 CSS 控制从初始样式到目标样式的过渡,同时监听过渡/动画结束事件(transitionend/animationend)
  3. 过渡完成,删除过渡逻辑和目标样式

同时在每个步骤调用相应的过渡事件钩子函数,使节点支持 JS 动画操作。我们关注重点实现,省略一些对 transition class 的处理和其他杂项的解释,具体可参考源码仓库。

enter

首先一进来会检查当前元素上是不是已有 _leaveCb(),如有则证明当前元素上一次的 leave 动作未结束,还属于 pending element。此时则需要调用该 _leaveCb(),手动取消遗留的 leave 过渡:

1
2
3
4
5
6
7
8
9
function enter (vnode: VNodeWithData, toggleDisplay: ?() => void) {
const el: any = vnode.elm

if (isDef(el._leaveCb)) {
el._leaveCb.cancelled = true
el._leaveCb()
}
// ... 省略
}

在开始正式的过渡逻辑前,如果节点不是由 v-show 控制,则往当前 vnode 的 insert 钩子添加回调函数,在当前 vnode 生成 DOM 元素并插入到文档后会调用这个回调,运行结束后立刻销毁。在拥有相同 vnode.key 的元素上,当新 enter 过渡发生时,如果上次的 leave 过渡仍未完成,直接调用他们的 _leaveCb 停止过渡。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (!vnode.data.show) {
// remove pending leave element on enter by injecting an insert hook
mergeVNodeHook(vnode, 'insert', () => {
const parent = el.parentNode
const pendingNode = parent && parent._pending && parent._pending[vnode.key]
if (pendingNode &&
pendingNode.tag === vnode.tag &&
pendingNode.elm._leaveCb
) {
pendingNode.elm._leaveCb()
}
enterHook && enterHook(el, cb)
})
}

接下来就是正式的过渡逻辑执行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 调用 before-enter
beforeEnterHook && beforeEnterHook(el)

// 插入初始样式和过渡逻辑
addTransitionClass(el, startClass)
addTransitionClass(el, activeClass)

nextFrame(() => {
// 在下一帧中删除初始样式,CSS 的过渡自动开工
removeTransitionClass(el, startClass)
if (!cb.cancelled) {
addTransitionClass(el, toClass) //过渡动画没被取消的话则添加目标样式
if (isValidDuration(explicitEnterDuration)) {
// 如果用户指定过渡时间,使用 setTimeout 判断过渡结束时机
setTimeout(cb, explicitEnterDuration)
} else {
whenTransitionEnds(el, type, cb) // 没有指定过渡时间则监听过渡结束事件
}
}
})

过渡结束后需要调用清理回调函数,这是提前定义好的 cb,主要是清除过渡过程中添加的样式,和调用 afterEnterHook。同时也判断 el._enterCb() 是否在进入过渡被取消的情况下调用的,若被取消则删除初始样式的类,以及调用 enterCancelledHook

1
2
3
4
5
6
7
8
9
10
11
12
const cb = el._enterCb = once(() => {
removeTransitionClass(el, toClass)
removeTransitionClass(el, activeClass)

if (cb.cancelled) {
removeTransitionClass(el, startClass)
enterCancelledHook && enterCancelledHook(el)
} else {
afterEnterHook && afterEnterHook(el)
}
el._enterCb = null
})

代码中使用了 whenTransitionEnds 来监听过渡结束事件,并在结束时调用 el._enterCb() 清理环境,同时移除过渡结束事件的的监听:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export function whenTransitionEnds (el: Element, expectedType: ?string, cb: Function) {
// 通过当前元素 CSS 规则获取过渡相关信息
const { type, timeout, propCount } = getTransitionInfo(el, expectedType)
if (!type) return cb()
const event: string = type === TRANSITION ? transitionEndEvent : animationEndEvent
let ended = 0
const end = () => {
el.removeEventListener(event, onEnd)
cb()
}
const onEnd = e => {
if (e.target === el) {
if (++ended >= propCount) end()
}
}
setTimeout(() => { // 防止 transitionend/animationend 不触发
if (ended < propCount) end()
}, timeout + 1)
el.addEventListener(event, onEnd) // 监听 transitionend/animationend
}

然而这里不仅监听了过渡结束事件,还另外使用了 setTimeout 在过渡时长结束后调用 el._enterCb(),这是为防止transitionend/animationend 不触发的情况。当一个过渡中的 CSS 属性在过渡完成前被去掉,或者元素的 display 变为 none,就会导致 transitionend/animationend 不触发。其实这是一个挺常见的坑,我们经常会在 Vue 中写这种简单的渐变过渡:

1
2
3
4
5
6
.v-enter, .v-leave-to {
opacity: 0;
}
.v-enter-active, .v-leave-active{
transition: all 1s;
}

由于 v-enter 在被插入后,第二帧就被去掉让透明度过渡从0过渡为默认值1,但因为在过渡完成前,这个过渡的属性就被去除了,因此1秒后不会触发 transitionend 事件,除非我们额外添加:

1
2
3
.v-enter-to {
opacity: 1;
}

leave

leave 过渡的逻辑与 enter 差不多,这里就不详细介绍源码了。主要有两个重点需要关注:

  1. el._leaveCb() 里会调用 rm(),对于有 v-show 指令的节点来说就是 toggleDisplay(),否则是删除 DOM 节点的 removeNode();
  2. 如果 data.delayLeave 不为空,代表当前 transition 的过渡模式是 in-out,需要将离开过渡延迟到新节点进入后再执行,则将真实的离开过渡代码传入到 delayLeave 中:
1
2
3
4
5
if (delayLeave) {
delayLeave(performLeave)
} else {
performLeave() // 真实的离开过渡逻辑
}

v-show 触发过渡动画

在前面的篇幅里我们可以看到 Transition 对由 v-show 控制展示的节点做了许多的特殊处理,我们结合 v-show 源码了解背后的逻辑。

v-showv-if 的区别在于,v-if 是“真正”的条件渲染,因为它会确保在切换过程中,条件内的事件监听器和子组件适当销毁和重建,而 v-show 仅是在 CSS 层面上对 display 进行切换,从它的源码中就可以看出来:在指令加载时,记录元素初始的 display 值,如果在 Transition 模块里且 v-showtrue,则引入、调用上文提到的 enter() 函数,确保节点在过渡开始后按初始 display 值展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/platforms/web/runtime/directives/show.js
bind (el: any, { value }: VNodeDirective, vnode: VNodeWithData) {
vnode = locateNode(vnode)
const transition = vnode.data && vnode.data.transition
const originalDisplay = el.__vOriginalDisplay =
el.style.display === 'none' ? '' : el.style.display
// 除非 v-show 为 false,否则 display 不为 none
if (value && transition) {
vnode.data.show = true
enter(vnode, () => {
el.style.display = originalDisplay
})
} else {
el.style.display = value ? originalDisplay : 'none'
}
},

指令的 bind 钩子会在 patch create hook 中被调用,详见 src/core/vdom/modules/directives.js

<transition>enter 中,因为节点元素本身没有改变,不需要处理 pending 的过渡节点,在元素插入过渡初始样式和过渡逻辑后,便执行 toggleDisplayenterHook

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function enter(vnode, toggleDisplay) {
// ... 初始化

if (!vnode.data.show) {
mergeVNodeHook(vnode, 'insert', () => {
// ... 在vnode 插入 DOM 后
enterHook && enterHook(el, cb)
})
}

// ... 执行过渡主要逻辑

if (vnode.data.show) {
toggleDisplay && toggleDisplay() // 确保元素显示
enterHook && enterHook(el, cb)
}
}

v-show 的值更新时,虚拟DOM发生了 patch 并调用了 patch update hook,此时会调用指令的 update 函数。如果值为 true 则在 enter 中将 display 设为初始值;否则调用 Transition 的 leave 函数执行正常的离开过渡,并在 rm 的位置将 display 设为 none,此时不需要删除节点,只需隐藏它,:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
update (el: any, { value, oldValue }: VNodeDirective, vnode: VNodeWithData) {
if (!value === !oldValue) return
vnode = locateNode(vnode)
const transition = vnode.data && vnode.data.transition
if (transition) { // transition 在这里处理
vnode.data.show = true
if (value) {
enter(vnode, () => {
el.style.display = el.__vOriginalDisplay
})
} else {
// leave(vnode, rm), rm 会在离开过渡完成后被调用
leave(vnode, () => {
el.style.display = 'none'
})
}
} else {
el.style.display = value ? el.__vOriginalDisplay : 'none'
}
}

TransitionGroup 与 FLIP

FLIP 必知必会

当在业务开发中遇到动画场景,我们经常会使用 requestAnimationFrame 手动计算被变换属性的过渡值。这种方法不仅需要人工维护复杂的动画中间态计算,还会面临掉帧导致的卡顿现象,甚至会因为操作不当导致页面疯狂重绘、回流,严重影响性能。FLIP技术以一种高性能的方式简化了 DOM 元素动效操作,将复杂的动画效果从 JS 让渡给了 CSS 或者 Web Animation API。

FLIP 的具体概念是:

  • First 元素的初始状态,包括位置、尺寸和样式
  • Last 元素经过执行代码后的最终目标状态
  • Invert 计算与初始状态 - 目标状态的差值,使用 transform 等方法应用差值的转换,使当前元素“反转”回初始状态,创建它处于还未执行动画的错觉
  • Play 删除在 Invert 阶段的转换,使得元素往目标状态过渡,可以使用 CSS 的 transition/animation 或 Web Animation API 控制该过程使其动画化

其实看到这里,会发现 Transition 的原理本质上也参考了 FLIP 中 Play 的操作:首先应用初始样式,在第二帧立刻删除初始样式使得 CSS 开始控制过渡。

TransitionGroup 组件详解

TransitionGroup 支持多个子组件切换的动画,并且需要给每个组件都赋予在这个过渡中的唯一 key。除了与 Transition 类似的数据初始化过程外,TransitionGroup 将子节点的更新强制分成两个步骤进行:

  1. 强制移除所有需要被移除的节点,触发他们的离开过渡;
  2. 插入/移动剩余的节点到他们目标位置,触发这些节点的进入过渡或移动效果。

这样做的原因是 Vue 的 vdom Diff 算法是不稳定的,他不能保证 diff 过后被移除元素的相对位置与 diff 前一样,这会导致被移除节点离开过渡的初始位置错乱。因此需要原地移除元素后,再进行新元素的插入和移动。

我们首先看看 render 的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
render (h: Function) {
// <transition-group> 组件默认生成真实的元素
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 = [] // 保存所有有 key 的新子节点
const transitionData: Object = extractTransitionData(this) // 提取传入 <transition-group> 的 props

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 // 添加刚刚提取的 transition 数据
} else if (process.env.NODE_ENV !== 'production') {
// 列表里内部元素总是需要提供唯一的 key 属性值,否则报错
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)
}
}
// 生成一棵只保留了 vnode 节点在这次更新中完全没变的 vnode 树
this.kept = h(tag, null, kept)
this.removed = removed
}
return h(tag, null, children) // 没有 key 的节点就被忽略了
}

这里比较有趣的是,在遍历旧节点的过程中,生成一棵只保留了 vnode 节点在这次更新中完全没变的 vnode 树。这是为了强制触发移除节点在原地的离开过渡,在源码中的 beforeMount 钩子中可以看到:

1
2
3
4
5
6
7
8
9
10
11
beforeMount () {
const update = this._update
this._update = (vnode, hydrating) => {
const restoreActiveInstance = setActiveInstance(this)
// 强制触发被移除的节点的原地离开
this.__patch__(this._vnode, this.kept, false, true /** removeOnly */ )
this._vnode = this.kept
restoreActiveInstance()
update.call(this, vnode, hydrating)
}
}

在组件挂载前劫持了当前 vm_update 函数,当新旧 vdom 进行 patch 时,首先手动调用一次 vm.__patch__ 来处理旧节点列表和 this.kept 列表,使得被移除的元素能在这个阶段直接销毁并触发离开过渡;最后调用原 update,将真正的新节点列表与不变节点列表进行 Diff,触发新节点的进入过渡,以及不变节点列表可能存在的顺序变化引发的位置移动。

updated 钩子里,即可应用新节点的过渡和移动,具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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
}
// 分三次遍历是为了防止频繁触发浏览器重排
children.forEach(callPendingCbs)
children.forEach(recordPosition) // 获得最新的位置、尺寸样式 (FLIP 中的 Last 信息)
children.forEach(applyTranslation) // 应用 FLIP 中的 Invert 转换

// 强制页面重排重绘,确保元素经过 Invert 后已被反转回初始位置
this._reflow = document.body.offsetHeight

children.forEach((c: VNode) => {
if (c.data.moved) {
const el: any = c.elm
const s: any = el.style

// 应用 FLIP 中的 Play,删除在 Invert 阶段的转换,使得元素往目标状态过渡
addTransitionClass(el, moveClass)
s.transform = s.WebkitTransform = s.transitionDuration = ''

// FLIP 完成后清理步骤
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)
}
})
}
})
}

在开始应用 FLIP 前,因为需要获取节点最新位置和尺寸信息,并进行 FLIP 的 Invert 步骤将他们反转回初始位置,为了防止DOM元素上不断取值+赋值可能引发的浏览器重排,TransitionGroup 对最新的子节点列表进行了三次遍历。可以简单分析下三次遍历的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 清理未完成的进入/离开过渡
function callPendingCbs (c: VNode) {
if (c.elm._moveCb) c.elm._moveCb()
if (c.elm._enterCb) c.elm._enterCb()
}
// 使用 getBoundingClientRect 记录元素新状态
function recordPosition (c: VNode) {
c.data.newPos = c.elm.getBoundingClientRect()
}
// 利用 CSS transform 实现 FLIP Invert
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'
}
}

总结

到这里我们就大致过了一遍 Transition 的源码,并简单介绍了一下 v-show 指令源码、Vue patch hooks 以及 FLIP 的概念和用法。从 Transition 的设计理念来看,无论是使用 CSS 过渡或是 JS 动画,都是使用了 Transition 提供的一套钩子定义过渡过程中元素的不同状态,使得开发者能更专注于业务交互本身的展示,复杂的流程性逻辑就交给框架一并处理。

最后的碎碎念:从八月底开始看 Vue 的源码,陆陆续续看完了,之前总觉得网上 Vue 的源码分析文章实在多如牛毛,自己不想做复读机就一直没有将所学的以文章形式记下来,这是我第一次写源码分析文章……hhhh是个好开头加油~