在学习 element ui 时,发现组件的过渡用的是 Vue.js 提供的
标签。这里来好好认识下 vue 的过渡到底是如何工作的。
简介
废话不多说,详细的内容请看官方文档,里面有详细的分析和例子够你看懂了(就是费时间~)。简单说说我对 vue 过渡的理解。经过一下午的折腾,总结出以下几点:
- 有四种情况会触发过渡效果:
1 v-if
2 v-show
3 动态组件(如 component 的 is 属性)
4 组件根节点发生变化(如 v-if v-else 切换根节点) - 过渡效果 CSS 命名规律:(name 属性,默认为 v)-(行为:enter、leave、appear、move)-(阶段:无、active、to)
- 有三种方式来设置过渡样式:
1 为标签设定 name 属性。
2 在标签中插入 enter-active-class
等设置自定义过渡类名。
3 使用 JavaScript 在过渡的钩子处修改过渡样式。 - 个人理解:
标签用于单个元素的进入和离开效果。
标签用于处理如v-for
遍历这样多个元素的过渡动画。
自己实现个过渡方法
先来两个简单例子理解下 transition(为了节省篇幅和便于查看写在 JSFiddle 中)有兴趣的朋友可以看下~
例1:v-enter 和 v-leave 简单实现
例2:v-move 简单实现
transition 学习
1. 基本原理是什么?
基本原理还是 CSS3 的 transition
、transform
、animation
这几个属性。用户定义过渡效果,Vue.js 进行处理。下面我们通过
- 插入元素
- 解析
标签,获取对应的过渡类名。这里默认就 v-
开头了。 - 为元素定义 v-enter 和 v-enter-active 两个类。
class="v-enter v-enter-active"
。 - 下一帧移除 v-enter,添加 v-enter-to。
class="v-enter-active v-enter-to"
。 - 获取过渡时间,延时执行回调函数。
- 回调函数中移除 v-enter、v-enter-active 和 v-enter-to 的这些过渡类名,完成过渡。
- 在整个过程中调用了
beforeEnterHook
、enterHook
、afterEnterHook
、enterCancelledHook
四个函数,执行相应的 JavaScript 钩子。
下面是 enter
函数的代码及注释:
// 进入过渡效果
export function enter (vnode: VNodeWithData, toggleDisplay: ?() => void) {
const el: any = vnode.elm
// call leave callback now 执行 leave 回调函数
if (isDef(el._leaveCb)) {
el._leaveCb.cancelled = true
el._leaveCb()
}
// 解析 transition 的数据(class、tag、name等)
const data = resolveTransition(vnode.data.transition)
if (isUndef(data)) {
return
}
/* istanbul ignore if */
if (isDef(el._enterCb) || el.nodeType !== 1) {
return
}
const {
css,
type,
enterClass,
enterToClass,
enterActiveClass,
appearClass,
appearToClass,
appearActiveClass,
beforeEnter,
enter,
afterEnter,
enterCancelled,
beforeAppear,
appear,
afterAppear,
appearCancelled,
duration
} = data
// 将作为子组件的根节点放置时,我们需要检查 的父元素是否出现检查。
let context = activeInstance
let transitionNode = activeInstance.$vnode
while (transitionNode && transitionNode.parent) {
transitionNode = transitionNode.parent
context = transitionNode.context
}
const isAppear = !context._isMounted || !vnode.isRootInsert
if (isAppear && !appear && appear !== '') {
return
}
// 获取进入的 class
// v-enter
const startClass = isAppear && appearClass
? appearClass
: enterClass
// v-enter-active
const activeClass = isAppear && appearActiveClass
? appearActiveClass
: enterActiveClass
// v-enter-to
const toClass = isAppear && appearToClass
? appearToClass
: enterToClass
// 4个生命周期钩子函数
const beforeEnterHook = isAppear
? (beforeAppear || beforeEnter)
: beforeEnter
const enterHook = isAppear
? (typeof appear === 'function' ? appear : enter)
: enter
const afterEnterHook = isAppear
? (afterAppear || afterEnter)
: afterEnter
const enterCancelledHook = isAppear
? (appearCancelled || enterCancelled)
: enterCancelled
// https://cn.vuejs.org/v2/guide/transitions.html#显性的过渡持续时间
const explicitEnterDuration: any = toNumber(
isObject(duration)
? duration.enter
: duration
)
if (process.env.NODE_ENV !== 'production' && explicitEnterDuration != null) {
checkDuration(explicitEnterDuration, 'enter', vnode)
}
const expectsCSS = css !== false && !isIE9
const userWantsControl = getHookArgumentsLength(enterHook)
// 完成进入过渡后的回调函数
const cb = el._enterCb = once(() => {
if (expectsCSS) {
// 移除 v-enter-to 和 v-enter-active
removeTransitionClass(el, toClass)
removeTransitionClass(el, activeClass)
}
if (cb.cancelled) {
if (expectsCSS) {
// 移除 v-enter
removeTransitionClass(el, startClass)
}
// 调用 enter-cancelled
enterCancelledHook && enterCancelledHook(el)
} else {
afterEnterHook && afterEnterHook(el)
}
el._enterCb = null
})
if (!vnode.data.show) {
// 通过注入一个 insert 钩子,将待处理的 leave 元素移除。
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)
})
}
// start enter transition
beforeEnterHook && beforeEnterHook(el)
// 预期 CSS
if (expectsCSS) {
// 添加 v-enter v-enter-active
addTransitionClass(el, startClass)
addTransitionClass(el, activeClass)
// 下一帧
nextFrame(() => {
// 移除 v-enter
removeTransitionClass(el, startClass)
if (!cb.cancelled) {
// 添加 v-enter-to
addTransitionClass(el, toClass)
if (!userWantsControl) {
// 预期进入时间
if (isValidDuration(explicitEnterDuration)) {
setTimeout(cb, explicitEnterDuration)
} else {
// 当 transition 结束
whenTransitionEnds(el, type, cb)
}
}
}
})
}
if (vnode.data.show) {
toggleDisplay && toggleDisplay()
enterHook && enterHook(el, cb)
}
if (!expectsCSS && !userWantsControl) {
cb()
}
}
2. 过渡的类名和自定义过渡的类名如何用于 中?
在
export const transitionProps = {
name: String,
appear: Boolean,
css: Boolean,
mode: String,
type: String,
enterClass: String,
leaveClass: String,
enterToClass: String,
leaveToClass: String,
enterActiveClass: String,
leaveActiveClass: String,
appearClass: String,
appearActiveClass: String,
appearToClass: String,
duration: [Number, String, Object]
}
可以看到其中就有这些自定义过渡类名,如 enterClass。这些属性如被传入到
// extractTransitionData 函数返回组件的所有 propsData 和 listener
const data: Object = (child.data || (child.data = {})).transition = extractTransitionData(this)
而这个 data.transition 对象在 enter 函数中用到:
const data = resolveTransition(vnode.data.transition)
resolveTransition
函数:
// 解析 transition 过渡 CSS
export function resolveTransition (def?: string | Object): ?Object {
if (!def) {
return
}
// 合并过渡类名和自定义过渡类名
if (typeof def === 'object') {
const res = {}
if (def.css !== false) {
// 使用 name,默认为 v
extend(res, autoCssTransition(def.name || 'v'))
}
extend(res, def)
return res
} else if (typeof def === 'string') {
return autoCssTransition(def)
}
}
// 通过 name 属性获取过渡 CSS 类名
const autoCssTransition: (name: string) => Object = cached(name => {
return {
enterClass: `${name}-enter`,
enterToClass: `${name}-enter-to`,
enterActiveClass: `${name}-enter-active`,
leaveClass: `${name}-leave`,
leaveToClass: `${name}-leave-to`,
leaveActiveClass: `${name}-leave-active`
}
})
resolveTransition 函数合并了过渡类名和自定义过渡类名,返回最终的过渡类名。之后就是使用这些类名来实现过渡动画。
PS:从源码中可以知道自定义过渡类名要优先于 name 定义的过渡类名。
小结一下就是:Vue.js 通过
3. JavaScript 钩子如何实现?
从 enter
函数中可以知道,在特定时间点会调用指定 JavaScript 钩子函数,所以我们只需绑定好函数即可按时间点触发。像这样:
enterHook && enterHook(el, cb)
4. transition 组件和 transition-group 标签的基本原理是什么?
其实就是 Vue.js 的组件,在其中实现了过渡效果而已。
transition 中只能包含一个子元素,标签通过 render 函数来渲染子元素(不渲染自身,所以我们在 DOM 中看不到 transition 节点)。主要用于控制元素的进入和离开,当元素离开后元素就从 DOM 中移除了。
transition-group 可以包含多个子元素,也是用 render 函数,渲染为指定标签名的元素。相比 transition 多了一个 v-move 属性用于控制多个组件间的移动速度。
5. v-if、v-show、component 等组件变化如何监听?
在使用 v-if、v-else 和 component 切换组件的时候,v-if、v-else 需要传入 key 以区分相同标签的不同元素。而 component 标签不需要。在代码中会解析 key 和 component 名组成新的 key,所以两个不同的 component 也会拥有不同的 key 实现切换效果。
var id = "__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;
而对于 v-show,做了特殊标记 —— 当有 v-show 指令时标记 child.data.show 为 true:
if (child.data.directives && child.data.directives.some(d => d.name === 'show')) {
child.data.show = true
}
之后再过渡的逻辑中对 v-show 做了些处理,实现过渡效果。
同时,在 v-show
的源码 src/platforms/web/runtime/directives/show.js
中对于 transition 也做了一些处理。比如在 update 方法中获取 transition,如果有过渡则 v-show 使用过渡效果,否则使用 style.display
来隐藏元素。
update (el: any, { value, oldValue }: VNodeDirective, vnode: VNodeWithData) {
if (value === oldValue) return
vnode = locateNode(vnode)
// 过渡效果
const transition = vnode.data && vnode.data.transition
if (transition) {
vnode.data.show = true
if (value) {
enter(vnode, () => {
el.style.display = el.__vOriginalDisplay
})
} else {
leave(vnode, () => {
el.style.display = 'none'
})
}
} else {
// 隐藏
el.style.display = value ? el.__vOriginalDisplay : 'none'
}
},
6. transition 中两个相同标签的组件为何要用 key 分开?
使用 key 和 tagName 来判断是否为同一个节点。
function isSameChild (child: VNode, oldChild: VNode): boolean {
return oldChild.key === child.key && oldChild.tag === child.tag
}
8. 过渡逻辑和过渡组件如何作用于一起
在源码中有四个过渡相关的源码:
-
src/platforms/web/runtime/components/transition.js
组件源码。 -
src/platforms/web/runtime/components/transition-group.js
组件源码 -
src/platforms/web/runtime/transition-util.js
过渡工具代码。 -
src/platforms/web/runtime/modules/transition.js
过渡逻辑代码。
前三个很好理解,最后一个 transition.js 其实是在 patch 方法中和 v-show 中使用的~
// src/platforms/web/runtime/directives/show.js
import { enter, leave } from '../modules/transition'
v-show 中调用了 transition 的 enter 和 leave 函数,在 v-show 作用于过渡效果时调用。
另外一个使用的地方比较隐蔽,先来看看 transition.js 导出的内容:
// src/platforms/web/runtime/modules/transition.js
function _enter (_: any, vnode: VNodeWithData) {
if (vnode.data.show !== true) {
enter(vnode)
}
}
export default inBrowser ? {
create: _enter,
activate: _enter,
remove (vnode: VNode, rm: Function) {
/* istanbul ignore else */
if (vnode.data.show !== true) {
leave(vnode, rm)
} else {
rm()
}
}
} : {}
这里讲 enter 和 leave 函数在方法中使用并导出(如果是浏览器的话)。继续往下找:
// src/platforms/web/runtime/modules/index.js
import transition from './transition'
导入到 modules 文件夹 index.js,index.js 在 patch.js 中使用了。
// src/platforms/web/runtime/patch.js
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 })
在此处合并 modules,并且创建了 patch 方法。这个 patch 方法在之前写的Vue.js 源码学习六 —— VNode虚拟DOM学习中提到过,用于对比虚拟 DOM,实现差异化更新。
可以看下 modules 在 createPatchFunction 方法中做了些什么?
const hooks = ['create', 'activate', 'update', 'remove', 'destroy']
export function createPatchFunction (backend) {
let i, j
const cbs = {}
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.js 中导出的 create、activate 和 remove 方法都是 patch 的生命周期函数。也就是说当元素创建、激活、移除行为时就会执行 transition.js 中的逻辑,而
8. 过渡模式 mode 的实现原理是啥
在
// 控制离开/进入的过渡时间序列。有效的模式有 "out-in" 和 "in-out";默认同时生效。
if (mode === 'out-in') {
// return placeholder node and queue update when leave finishes
this._leaving = true
mergeVNodeHook(oldData, 'afterLeave', () => {
this._leaving = false
this.$forceUpdate()
})
return placeholder(h, rawChild)
} 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 })
}
这里就是 mode 的实现代码了,先看看两种 mode 的用法
- in-out:新元素先进行过渡,完成之后当前元素过渡离开。
- out-in:当前元素先进行过渡,完成之后新元素过渡进入。
可以看到,在 out-in 逻辑中,当切换元素时,先不渲染第二个组件而是返回,之后才会返回 placeholder 函数结果,当第一个元素完全 leave 后加载第二个元素。而在 in-out 元素中做的是将第一个元素延时到第二个元素 enter 后再 leave。
9. 的 v-move 重新排序一组内容,如何实现的移动变化?
比如我们有一个 1-5 的数组使用 v-for 遍历显示到 transition-group 中,当数组发生变化时,会做如下操作:
- 初始数组
[ 1, 2, 3, 4, 5 ]
- 数组发生变化
[ 1, 4, 3, 2, 5 ]
- 在 render 函数中记录变化前后额数组 preChildren 和 children 两个 VNode 数组。
- 在 render 函数中使用 getBoundingClientRect() 方法记录变化前每个元素的位置 oldPos。
- 获取要保留和移除的元素数组。
- 渲染变化后的数组元素。
- 在 beforeUpdate 方法中使用 patch 方法移除要移除的元素。
- 进入 Updated 方法中,注意此时渲染结果已经是新数组
[ 1, 4, 3, 2, 5 ]
了。 - 获取过渡类名和子元素数组 children。
- 遍历调用
- 执行回调函数
- 计算当前各元素位置 newPos
- 根据 oldPos 和 newPos,使用内联样式
translate(${dx}px,${dy}px)
将元素移动到之前的位置,看着就像是[ 1, 2, 3, 4, 5 ]
- 最后遍历元素,添加 moveClass 类名,移除
translate(${dx}px,${dy}px)
内联样式。绑定 transitionend 事件。
代码太长,就不多贴了~可以点击这里跳转查看。总结下来就是先改变元素,然后把元素移动成之前的样子,然后使用过渡类名定义过渡时间实现过渡效果。
v-move 的关键就是“假装元素位置没变”的行为。让我们看上去像是慢慢移动的。
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) {
// 定义 0 秒的 translate 内联样式把元素移动到原来的样子
c.data.moved = true
const s = c.elm.style
s.transform = s.WebkitTransform = `translate(${dx}px,${dy}px)`
s.transitionDuration = '0s'
}
}
10. vue 的 transition 和 CSS3 的 transition 有何不同?
基本原理都是使用了 CSS3 的 transition,但是 Vue 的 transition 组件是配合着 VDOM 来写的、同时提供了过渡各阶段效果的 CSS 和 JS 控制,便于我们快捷、精确、安全地实现一些简单或复杂的过渡效果。
最后
原本只是想看看 transition 如何实现,却扯出这么一堆问题。其中关于 transition 和 transition-group 组件讲的有点草率,有兴趣可以再深入学习下~
从本次学习中我学到了:
- 更加优雅高效的 JS 逻辑写法(patch 中的生命周期统一处理 DOM 操作中的逻辑)
- 更加熟悉 CSS3 的 transition 过渡属性。
- 解决了我对 transition 的各种疑问。
OK,关于 Vue 的过渡效果就聊到这儿了,写了三天……我得去休息休息了 0.0