10-7 transition
在我们平时的前端项目开发中,经常会遇到如下需求,一个 DOM 节点的插入和删除或者是显示和隐藏,我们不想让它特别生硬,通常会考虑加一些过渡效果。
Vue.js 除了实现了强大的数据驱动,组件化的能力,也给我们提供了一整套过渡的解决方案。它内置了 组件,我们可以利用它配合一些 CSS3 样式很方便地实现过渡动画,也可以利用它配合 JavaScript 的钩子函数实现过渡动画,在下列情形中,可以给任何元素和组件添加 entering/leaving 过渡:
条件渲染 (使用 v-if)
条件展示 (使用 v-show)
动态组件
组件根节点
那么举一个最简单的实例,如下:
let vm = new Vue({
el: '#app',
template: '<div id="demo">' +
'<button v-on:click="show = !show">' +
'Toggle' +
'</button>' +
'<transition :appear="true" name="fade">' +
'<p v-if="show">hello</p>' +
'</transition>' +
'</div>',
data() {
return {
show: true
}
}
})
.fade-enter-active, .fade-leave-active {
transition: opacity .5s;
}
.fade-enter, .fade-leave-to {
opacity: 0;
}
复制代码
当我们点击按钮切换显示状态的时候,被 包裹的内容会有过渡动画。那么接下来我们从源码的角度来分析它的实现原理。
#内置组件
组件和 组件一样,都是 Vue 的内置组件,而 的定义在 src/platforms/web/runtime/component/transtion.js 中,之所以在这里定义,是因为 组件是 web 平台独有的,先来看一下它的实现:
已下内容可看文档。
总结
那么到此为止基本的 过渡的实现分析完毕了,总结起来,Vue 的过渡实现分为以下几个步骤:
自动嗅探目标元素是否应用了 CSS 过渡或动画,如果是,在恰当的时机添加/删除 CSS 类名。
如果过渡组件提供了 JavaScript 钩子函数,这些钩子函数将在恰当的时机被调用。
如果没有找到 JavaScript 钩子并且也没有检测到 CSS 过渡/动画,DOM 操作 (插入/删除) 在下一帧中立即执行。
所以真正执行动画的是我们写的 CSS 或者是 JavaScript 钩子函数,而 Vue 的 只是帮我们很好地管理了这些 CSS 的添加/删除,以及钩子函数的执行时机。
10-11 transition-group
前一节我们介绍了 组件的实现原理,它只能针对单一元素实现过渡效果。我们做前端开发经常会遇到列表的需求,我们对列表元素进行添加和删除,有时候也希望有过渡效果,Vue.js 提供了 组件,很好地帮助我们实现了列表的过渡效果。那么接下来我们就来分析一下它的实现原理。
为了更直观,我们也是通过一个示例来说明:
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)
复制代码
不同于 组件, 组件非抽象组件,它会渲染成一个真实元素,默认 tag 是 span。 prevChildren 用来存储上一次的子节点;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,这个是为了当它在 enter 和 leave 的钩子函数中有过渡动画,我们在上节介绍 transition 的实现中说过。接着又调用了原生 DOM 的 getBoundingClientRect 方法获取到原生 DOM 的位置信息,记录到 vnode.data.pos 中,然后判断一下 vnode.key 是否在 map 中,如果在则放入 kept 中,否则表示该节点已被删除,放入 removed 中,然后通过执行 h(tag, null, kept) 渲染后放入 this.kept 中,把 removed 用 this.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 样式,设置 display 为 none,添加到组件根节点上;接下来通过 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 的过渡效果,让我们的列表过渡动画更加丰富。