浅析Element的collapse-transition折叠动画
前言
作为一个前端菜鸡,理解上可能会有诸多不对的地方,望大家指正,本篇文章也是本人这几天苦思冥想的结果,也不能保证所说全对,也算是抛砖引玉了,期待大佬们的指正。
简单了解Render
饿了么的折叠动画源码位于:src/transitions
目录下,gayhub地址:链接
由于内容不多,我直接贴出来源码:
import { addClass, removeClass } from 'element-ui/src/utils/dom';
class Transition {
beforeEnter(el) {
addClass(el, 'collapse-transition');
if (!el.dataset) el.dataset = {};
el.dataset.oldPaddingTop = el.style.paddingTop;
el.dataset.oldPaddingBottom = el.style.paddingBottom;
el.style.height = '0';
el.style.paddingTop = 0;
el.style.paddingBottom = 0;
}
enter(el) {
el.dataset.oldOverflow = el.style.overflow;
if (el.scrollHeight !== 0) {
el.style.height = el.scrollHeight + 'px';
el.style.paddingTop = el.dataset.oldPaddingTop;
el.style.paddingBottom = el.dataset.oldPaddingBottom;
} else {
el.style.height = '';
el.style.paddingTop = el.dataset.oldPaddingTop;
el.style.paddingBottom = el.dataset.oldPaddingBottom;
}
el.style.overflow = 'hidden';
}
afterEnter(el) {
// for safari: remove class then reset height is necessary
removeClass(el, 'collapse-transition');
el.style.height = '';
el.style.overflow = el.dataset.oldOverflow;
}
beforeLeave(el) {
if (!el.dataset) el.dataset = {};
el.dataset.oldPaddingTop = el.style.paddingTop;
el.dataset.oldPaddingBottom = el.style.paddingBottom;
el.dataset.oldOverflow = el.style.overflow;
el.style.height = el.scrollHeight + 'px';
el.style.overflow = 'hidden';
}
leave(el) {
if (el.scrollHeight !== 0) {
// for safari: add class after set height, or it will jump to zero height suddenly, weired
addClass(el, 'collapse-transition');
el.style.height = 0;
el.style.paddingTop = 0;
el.style.paddingBottom = 0;
}
}
afterLeave(el) {
removeClass(el, 'collapse-transition');
el.style.height = '';
el.style.overflow = el.dataset.oldOverflow;
el.style.paddingTop = el.dataset.oldPaddingTop;
el.style.paddingBottom = el.dataset.oldPaddingBottom;
}
}
export default {
name: 'ElCollapseTransition',
functional: true,
render(h, { children }) {
const data = {
on: new Transition()
};
return h('transition', data, children);
}
};
先不去看Transition 类,这个js文件就是通过render的方式创建了一个transition
元素。
render函数有两个参数,第一个是h
,也就是用于创建 VNode元素的方法(createElement),h是通用的也是官方推荐的缩略名称。第二个是context
上下文对象,通过上下文对象可以获取当前组件的数据,如props、data、slots等等一系列的数据对象。
context上下文必须是声明为函数式组件才会有
context:
props
:提供所有 prop 的对象children
:VNode 子节点的数组slots
:一个函数,返回了包含所有插槽的对象scopedSlots
:(2.6.0+) 一个暴露传入的作用域插槽的对象。也以函数形式暴露普通插槽。data
:传递给组件的整个数据对象,作为createElement
的第二个参数传入组件parent
:对父组件的引用listeners
:(2.3.0+) 一个包含了所有父组件为当前组件注册的事件监听器的对象。这是data.on
的一个别名。injections
:(2.3.0+) 如果使用了inject
选项,则该对象包含了应当被注入的 property。
在饿了么这个transtion中, children
就是你使用它时,在其内部书写的元素。
例:
<template>
<div>
<el-collapse-transition>
<!-- 我是children -->
<div v-if="show">xxxx</div>
</el-collapse-transition>
</div>
<template>
<script>
import ElCollapseTransition from 'element-ui/src/transitions/collapse-transition';
export default {
data(){
return {
show: false,
}
},
components: {
ElCollapseTransition
},
}
</script>
使用children 就可以省去传统vue文件形式使用插槽来接收内容(不用写vue文件,也就不用写template)。
h函数的参数
- 一个 HTML 标签名、组件选项对象,或者resolve 了上述任何一种的一个 async 函数。必填项。 {String | Object | Function}
- 一个与模板中 attribute 对应的数据对象。可选。 {String | Array}
- 子级虚拟节点 (VNodes),由
createElement()
构建而成,也可以使用字符串来生成“文本虚拟节点”。可选。
具体官方解释:createElement 参数
第一个参数就不多解释了,就是一个标签名,而第二个参数,则是attribute属性,如:click事件绑定,style、class、props这些设置,都是在第二个参数中,第三个参数就是他的子集节点,也就是children
事件的绑定,在render中,是采用on对象的形式,具体参考官方文档: 事件 & 按键修饰符
on: {
'click': this.doThisInCapturingMode,
'keyup': this.doThisOnce,
'mouseover': this.doThisOnceInCapturingMode
}
由于饿了么的折叠动画,使用的是js动画钩子,属于自定义的事件,所以我们的on对象最终应该是这样的:
on: {
'beforeEnter': function(){},
'enter': function(){},
'afterEnter': function(){},
'beforeLeave': function(){},
'leave': function(){},
'afterLeave': function(){},
}
合并一下
export default {
name: 'ElCollapseTransition',
functional: true,
render(h, { children }) {
const data = {
on: {
'beforeEnter': function(){},
'enter': function(){},
'afterEnter': function(){},
'beforeLeave': function(){},
'leave': function(){},
'afterLeave': function(){},
}
};
return h('transition', data, children);
}
};
函数式组件声明的作用
官方推荐在没有管理任何状态,也没有监听任何传递给它的状态,也没有生命周期方法,只是一个接受一些 prop 的函数,推荐使用functional
函数式组件!
我们的transition本身也不需要处理任何状态,也没有生命周期,只是prop接受一些自定义事件函数而已。
更重要的一点是,声明functional
,h函数才能有第二个上下文件对象参数。才能拿到children。
当然,你使用插槽也可以拿到,但是children是拿到所有,而slots插槽是有具名属性,如果使用了具名,你拿到的可能就是不完整的子节点。
使用插槽
不声明函数式组件
export default {
name: 'ElCollapseTransition',
render(h) {
const data = {
on: {
'beforeEnter': function(){},
'enter': function(){},
'afterEnter': function(){},
'beforeLeave': function(){},
'leave': function(){},
'afterLeave': function(){},
}
};
return h('transition', data, this.$slots.default);
}
};
其实这样也是可以的,但是个人更推荐函数式,因为它更快(某种意义上)。
到这,render的了解就差不多了,如何创建transtion,以及子节点处理,数据绑定,为什么要声明函数式组件,想必应该都有了简单了解。
使用class类产生的bug
on监听的事件,在render中使用一个对象存储,那么多事件,绑定的时候必然是for循环,拿到每一个键值对进行绑定,但是使用class,会有一个问题:for循环无法拿到class中的属性方法
为什么?
因为class中,会将beforeEnter这些方法声明为prototype上的属性,并且设置为不可枚举。防止我们设置的方法被for循环时获取到,影响使用。
所以,饿了么这种做法应该是有问题的:
const data = {
on: new Transition()
};
虽然它返回了一个对象,但是这个对象上的属性方法无法被for循环出来,on就无法正确绑定。
为了印证我的猜测,我手动操作了几个属性后,发现on绑定就生效了,代码如下:
export default {
name: 'ElCollapseTransition',
functional: true,
render(h, { children }) {
const t = new Transition();
console.log(Object.getOwnPropertyDescriptor(t.__proto__, "beforeEnter"));
//{writable: true, enumerable: false, configurable: true, value: ƒ}
//手动设置几个属性可枚举
Object.defineProperties(t.__proto__, {
beforeEnter: {
enumerable: true
},
enter: {
enumerable: true
},
afterEnter: {
enumerable: true
},
})
const data = {
on: t
};
return h('transition', data, children);
}
};
处理这个bug
我们可以把class改成普通对象即可,或者自己手动设置每个属性的说明对象,将enumerable
设为真。
动画-js钩子了解
js动画有8个钩子,也可以理解为动画的生命周期:
- beforeEnter 进入-动画开始之前
- enter 进入-动画开始
- afterEnter 进入-动画结束
- enterCancelled 进入-动画被取消
- beforeLeave 离开-动画开始之前
- leave 离开-动画开始
- afterLeave 离开-动画结束
- leaveCancelled 离开动画被取消
leaveCancelled
钩子只有在使用v-show
的时候会触发。
enterCancelled
钩子会在动画切换速度很快时,开始动画还未结束就触发离开动画时。
常用的钩子应该就是去除了leaveCancelled
和enterCancelled
,剩下的钩子都能使用。
需要注意一点的是,动画切换过快,动画结束的生命周期是不会触发的:afterEnter
、afterLeave
所以,一定要在动画开始之前,做好对样式的初始化,比如如果我想让元素的height发生动画,从0到元素实际高度,一定是在beforeEnter 时,先将元素的宽度设为0,然后开始时设为实际高度,或者取消0的设置,这样元素就会有从0到有的一个过渡。
钩子的参数
所有的钩子,第一个参数都是触发动画的元素dom,统称el
。
enter
和leave
,拥有第二个参数done
,done是一个回调函数,如果你在函数上写了这个形参,那么vue就会放弃默认的动画事件监听,只有当你运行done()
时,动画才会表示结束,进入到下一个生命钩子。
done可以让你发挥更多的空间,有点像promise的回调,只有触发了才会结束,否则外面将继续等待。
const Transition = {
beforeEnter(el) {},
enter(el, done) {},
afterEnter(el) {},
beforeLeave(el) {},
leave(el, done) {},
afterLeave(el) {},
}
如果不使用done,vue会嗅探你的css中是否存在transition
或者animation
,然后分别监听对应的动画结束事件:
transitionend
animationend
在事件回调里运行done
,进入到下一个动画钩子。
但是,在一些场景中,你需要给同一个元素同时设置两种过渡动效,比如animation
很快的被触发并完成了,而transition
效果还没结束。在这种情况中,你就需要使用type
attribute 并设置animation
或transition
来明确声明你需要 Vue 监听的类型。
显然,饿了么的折叠动画没有使用done,我这里说到done,是为了方便我们待会印证一些猜测。
小试牛刀
既然已经明白了钩子的含义,那么我们就可以开始动手了,手动写一个自己的动画,比如宽度动画。
显示组件
<template>
<div>
<button @click="show=!show">显隐</button>
<MuWidthTransition>
<div v-show="show" class="box">xxx</div>
</MuWidthTransition>
<div>
</template>
<script>
import MuWidthTransition from "@/components/default/transitions/width-transition";
export default {
data() {
return {
show: false
}
},
components: {
MuWidthTransition
}
}
</script>
<style lang="scss" scoped>
.box {
width: 200px;
height: 200px;
background-color: red;
}
.mu-width-transition {
transition: width 0.25s;
}
</style>
动画组件
const Transition = {
beforeEnter(el) {
el.classList.add("mu-width-transition");
el.style.width = "0";
el.style.overflow = 'hidden';
},
enter(el) {
el.style.width = "";
},
afterEnter(el) {
el.classList.remove('mu-width-transition');
el.style.width = "";
el.style.overflow = "";
},
beforeLeave(el) {
el.classList.add("mu-width-transition");
el.style.width = "";
el.style.overflow = "hidden";
},
leave(el) {
el.style.width = "0";
},
afterLeave(el) {
el.style.width = "";
el.style.overflow = '';
el.classList.remove('mu-width-transition');
},
}
export default {
name: 'MuWidthTransition',
functional: true,
render(h, { children }) {
const data = {
on: Transition
};
return h('transition', data, children);
}
};
我们的逻辑很简单,就是:
- 动画开始前让元素宽度为0,overflow : "hidden";添加动画过渡的class:mu-width-transition
- 动画开始时宽度设置取消,元素宽度开始还原
- 动画结束时删除过渡class,overflow 清空
- 离开之前添加过渡class,宽度清空,overflow : "hidden";
- 离开时宽度为0,元素开始收缩
- 离开结束,元素宽度清空,overflow 清空,删除过渡class
理论上,我们这路子是没错。
但是,你会得到这么一个结果:进入时无动画,离开时有动画,在离开时再点击进入,有动画
Way???
小试牛刀-失败的原因
仔细回想下以前写css动画时,是不是有那么一个经验,在元素display:none
转为block
时,先设置display再延迟一会,设置css样式或者添加class。元素才会有动画,会不会是这个问题。
我们改改:
enter(el, done) {
setTimeout(() => {
el.style.width = "";
setTimeout(() => {
done();
}, 250)
}, 20)
},
利用setTimeout我们延迟20ms触发width设置,然后等待动画完成触发done。
效果确实可以:
是不是觉得,果然如此,但是,“向来如此”便是对的吗?
为什么我们需要延迟,而饿了么的不需要,它有什么神奇之处?
为什么我们需要延迟?
我们要知道,css的动画,是一帧一帧进行的,每帧的间隔大约在17ms,但是不同浏览器这个时间也会有所不同,可能会更短。
而js代码运行是很快的,多行代码的运行完成可能都不需要1ms,甚至是0.xxxms。
然而就是因为太快,导致元素被瞬间加上css样式,动画的帧率都来不及反应,导致我们的动画失效了,理论知识没错,但是就是太快。
打个比方就是:这个小家伙一出生就死了,都没来得及哭出声!
更深层的理解,我个人想法:
当元素被渲染时,在这一帧还未触发时,宽度变成0,然后瞬间又清空宽度设置,而这一帧的渲染会按照最终的样式结果渲染,此时元素的宽度设置和未设置之前一样,所以不会有动画产生,一毛一样还怎么动画,直接显示就完了。
收缩时有动画?
宽度已经有了,我们只是让他收缩,并不对动画帧产生影响,正常渲染。
为什么饿了么动画不需要延时?
不知道饿了么有意还是无意,下了一记神仙手,这一招让整盘棋都活了过来。不过在了解之前,我们先了解下:浏览器的回流与重绘
参考掘金的这篇文章:《浏览器的回流与重绘 (Reflow & Repaint)》
其中js也能让浏览器产生回流:
clientWidth
、clientHeight
、clientTop
、clientLeft
offsetWidth
、offsetHeight
、offsetTop
、offsetLeft
scrollWidth
、scrollHeight
、scrollTop
、scrollLeft
scrollIntoView()
、scrollIntoViewIfNeeded()
getComputedStyle()
getBoundingClientRect()
scrollTo()
回流会触发重绘,会使得浏览器清空当前元素的计算队列,重新计算。这样我们js才能得到准确的结果。
我们再回过头看下饿了么的代码,其中就有el.scrollHeight
这个字眼,很明显,scrollHeight会触发浏览器的回流。
那么回流在这里做了什么?
回流会重新计算元素的样式,其中就包括你在动画开始前添加的样式,height="0"
;而且因为重新计算,会在同步线程上产生一些延迟,而这个元素的上一帧最终高度将为0,下一帧时,我们js设置高度为指定高度或者清空,此时会产生两个不同的结果,他们之前产生了差别,动画也就产生了。
大试牛刀
既然我们搞明白了原因,是时候出手了
动画组件
const Transition = {
beforeEnter(el) {
el.classList.add("mu-width-transition");
el.style.width = "0";
el.style.overflow = 'hidden';
},
enter(el) {
el.offsetWidth;
el.style.width = "";
},
afterEnter(el) {
el.classList.remove('mu-width-transition');
el.style.width = "";
el.style.overflow = "";
},
beforeLeave(el) {
el.classList.add("mu-width-transition");
el.style.width = "";
el.style.overflow = "hidden";
},
leave(el) {
el.style.width = "0";
},
afterLeave(el) {
el.style.width = "";
el.style.overflow = '';
el.classList.remove('mu-width-transition');
},
}
export default {
name: 'MuWidthTransition',
functional: true,
render(h, { children }) {
const data = {
on: Transition
};
return h('transition', data, children);
}
};
效果很满意,完全达到我们的预期。
我们也充分利用了危险,毕竟,回流和重绘,一直是前端书写时需要避免的一个巨坑,但是知己知彼,敌人也能成为某种意义上的朋友。帮我们一把。
小机灵鬼(等待大佬解答)
这里就会有人想了,既然回流会让帧的最终结果改变,那么我们在动画之前,所有属性配置完毕再回流一下岂不美哉。
beforeEnter(el) {
el.classList.add("mu-width-transition");
el.style.width = "0";
el.style.overflow = 'hidden';
el.offsetWidth;
},
enter(el) {
el.style.width = "";
},
事实上这么干反倒没有了动画效果,beforeEnter和enter显然不是同一个任务,这就应该会涉及到js的运行方面的机制,个人是个菜鸡,也不是很懂,盲目分析了一下:
offsetWidth触发回流重绘后,浏览器会触发元素重新计算,但是beforeEnter和enter不是同一个任务,虽然offsetWidth会延迟,但是和js运行不是同一个管道,加上js很快,唰的一下就给改了,导致offsetWidth重新计算时width又成了""
,动画效果也就没了。
而enter钩子中,offsetWidth和el.style.width = ""
一起写时,因为是同一个任务,所以el.style.width = ""
会等待前面的搞定我再运行。
还有一个原因是,都在beforeEnter处理,太快了,offsetWidth这些计算就算重绘完了,帧也没开始。
毕竟按道理,我们在beforeEnter也可以触发动画,只要在帧数之前得到初始化好的数据,然后再后面的帧数再得到一个不同的结果。
beforeEnter(el) {
el.classList.add("mu-width-transition");
el.style.width = "0";
el.style.overflow = 'hidden';
el.offsetWidth;
el.style.width = "";
},
enter(el) {
},
这样写,也不能产生动画,因为忽略了另一个因素,动画帧率时间。你就算重新生成了新的结果,我帧率来不及反应,也没用。
为了判断这个想法,我输出了时间戳:
beforeEnter(el) {
el.classList.add("mu-width-transition");
el.style.width = "0";
el.style.overflow = 'hidden';
console.time("off")
el.offsetWidth;
console.timeEnd("off")
el.style.width = "";
},
enter(el) {
},
offsetWidth的用时:0.136962890625 ms;无动画
我改用promise+setTimeout+asyns来做延迟处理
async beforeEnter(el) {
el.classList.add("mu-width-transition");
el.style.width = "0";
el.style.overflow = 'hidden';
console.time("off")
await new Promise((resolve, reject) => {
setTimeout(() => {
el.style.width = "200px";
resolve();
}, 1)
})
console.timeEnd("off")
el.style.width = "";
},
await 用时:1.535888671875 ms;有动画
那么我们再计算下之前动画的用时
beforeEnter(el) {
el.classList.add("mu-width-transition");
el.style.width = "0";
el.style.overflow = 'hidden';
console.time("off")
},
enter(el) {
el.offsetWidth;
console.timeEnd("off")
el.style.width = "";
},
用时:0.3779296875 ms
很明显,在beforeEnter处进行回流,速度是非常快的,太快了,导致帧率没跟上,后面的写法,时间上都稍微慢了一些,动画就出现了。
总结
动画的产生有两个条件:
- 前后结果要不同
- 要在帧率合理的反应时间内,不能太快
那么我们也能解释的通,为啥元素display由none
变为block
时,添加的css变化不会有动画产生,因为太快了,前一帧的结果与后面的帧数结果相同,无法产生动画,none
时元素也不会渲染,计算都在显示的那一瞬间完成。
估计这也是为啥,元素显示状态时,动画随便加都有效果的原因吧!
本文系作者 @木灵鱼儿 原创发布在木灵鱼儿站点。未经许可,禁止转载。
暂无评论数据