问题发现
最近准备对团队里公共的插件做一些小动效,优化用户体验。这次的先从最简单的toast
插件入手。
主要的文件有如下两个:
index.js
import Toast from './Toast.vue';
const _TOAST = {
show: false,
component: null
};
export default {
install(Vue) {
// 添加实例方法
Vue.prototype.$toast = (text, options = {duration: 2000}) => {
if (_TOAST.show) {
return;
}
if (!_TOAST.component) {
let ToastComponent = Vue.extend(Toast);
_TOAST.component = new ToastComponent();
let element = _TOAST.component.$mount().$el;
document.body.appendChild(element);
}
_TOAST.component.duration = options.duration || 2000;
_TOAST.component.whiteSpace = options.whiteSpace || 'inherit';
_TOAST.component.position = options.position || 'center';
_TOAST.component.text = text;
_TOAST.component.show = _TOAST.show = true;
setTimeout(() => {
_TOAST.component.show = _TOAST.show = false;
}, options.duration);
};
Vue.prototype.$killToast = () => {
if (_TOAST.component) {
_TOAST.component.show = _TOAST.show = false;
}
};
}
};
Toast.vue
<template>
<div v-show="show" class="toast" :style="styleObject">
{{text}}
</div>
</template>
<script>
export default {
name: 'Toast',
data() {
return {
show: false,
text: 'toast',
// 默认显示2s
duration: 2000,
// 默认换行
whiteSpace: 'inherit',
// 显示的位置
position: 'center'
};
},
computed: {
styleObject() {
return {
webkitAnimation: 'show-toast ' + this.duration / 1000 + 's linear forwards',
animation: 'show-toast ' + this.duration / 1000 + 's linear forwards',
whiteSpace: this.whiteSpace,
// toast的位置
top: this.position === 'up' ? '15%' : this.position === 'bottom' ? '85%' : '50%'
};
}
}
};
</script>
<style scoped>
@keyframes show-toast {
0% {opacity: 0;}
25% {opacity: 1; z-index: 9999}
50% {opacity: 1; z-index: 9999}
75% {opacity: 1; z-index: 9999}
100% {opacity: 0; z-index: 0}
}
.toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 999;
background-color: #000;
opacity: .7;
color: #fff;
box-sizing: border-box;
min-height: 80px;
padding: 20px 30px;
line-height: 50px;
min-width: 364px;
max-width: 80%;
border-radius: 15px;
font-size: 28px;
text-align: center;
word-wrap: break-word;
}
</style>
这都是最普通的插件写法,使用的时候,improt toast form XXX
引入index.js
,并且Vue.use
一下,就能直接在组件中用this.$toast
使用。
再来说一下动效的问题。上面的Toast.vu
e代码中,在styleObject
里默认写了一个动效show-toast
,并且根据duration
计算他的动效时长。
上面的代码逻辑上没有毛病,但是在实际运行时,看不出动效的效果。
难道是动效时长太快了?我用Chrome的Performance工具录制了整个toast
出现时每一帧的渲染情况:
可以看到,toast
是直接出现的,并没有一个我们想要的过渡动效。
那么,问题出在哪里了呢?
问题分析
猜想1:transition和display冲突?
因为v-show
的本质是display
,参考周俊鹏大神的《解决transition动画与display冲突的几种方法》,会不会是因为,浏览器的UI线程在处理UI操作时,将多个css
属性的set
操作加入在同一个tick
中处理,所以就造成了这样一种情况:
我们在display=block
的同时加入了一个animation
属性,这两个操作被同时执行,所以得到了一个瞬间显示出来的效果。
要验证这样的猜想其实很简单,只需要把v-show
改成v-if
:
<div v-if="show" class="toast" :style="styleObject">
{{text}}
</div>
惹不起我们曲线救国总行吧,让视图重绘,从注释直接渲染成一个dom
,绕过display
的问题,这样问题是不是就解决了呢?
too young too simple。
动画还是依旧没有出现。
猜想2:styleObject计算问题
我们通过打断点的方式,一步一步看插件渲染流程。我们发现插件的render
函数是这样实现的:
在class中的样式,比如宽高等都能正常渲染,但是style
中的动效就是不行,那么会不会是因为render
的时候,一个是staicClass
,一个是绑定的_vm.styleObject
,一个是静态,一个是动态。难道是因为静态的才能生效?
为了验证猜想,我们就直接暴力的把style
改成静态的
<div v-if="show" class="toast" style="animation: show-toast 10s linear forwards">
{{text}}
</div>
这时候插件渲染流程就变成了这样:
而dom上也渲染出了style
里的animation
属性。这样问题是不是就解决了呢?
sometimes naive。
动画还是依旧没有出现。
猜想3:style和class区别处理
折腾了半天,连个动画都没有搞出来,连个正常的对照都没有。所以我们用最原始最暴力的方法,直接在class
里面加上这个show-toas
t动画,然后去掉styleObject
,看看他能不能正常渲染:
.toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 999;
background-color: #000;
opacity: .7;
color: #fff;
box-sizing: border-box;
min-height: 80px;
padding: 20px 30px;
line-height: 50px;
min-width: 364px;
max-width: 80%;
border-radius: 15px;
font-size: 28px;
text-align: center;
word-wrap: break-word;
animation: show-toast 2s linear forwards;
}
这次动画终于出现了!这时候我们在看看toast
出现时每一帧的渲染情况:
可以明显看出,有一个透明度的渐变效果。
那么为什么猜想2里暴力style
不生效,这里的暴力class
就行呢?
我们来比较一下渲染后的样式:
暴力style:
暴力class:
仔细对比两者,终于发现了问题的症结:show-toast
show-toast-data-v-19ed0bfa
这两个动画的名称为什么不一样呢?那是因为scoped
的原因。
在vue
文件中的style
标签上,有一个特殊的属性:scoped
。当一个style
标签拥有scoped
属性时,它的CSS
样式就只能作用于当前的组件,也就是说,该样式只能适用于当前组件元素。通过该属性,可以使得组件之间的样式不互相污染。
vue
中的scoped
属性的效果主要通过PostCSS
转译实现,在加上scoped
后,我们的dom
在编译前是这样
<template>
<div v-show="show" class="toast">
{{text}}
</div>
</template>
<style scoped>
.toast {
position: fixed;
}
</style>
编译后是这样
<template>
<div data-v-19ed0bfa class="toast" style="display: none;">
请勾选授权信息
</div>
</template>
<style>
.toast[data-v-19ed0bfa] {
position: fixed;
}
</style>
PostCSS
给一个组件中的所有dom
添加了一个独一无二的动态属性,然后,给CSS
选择器额外添加一个对应的属性选择器来选择该组件中dom
,这种做法使得样式只作用于含有该属性的dom
——组件内部dom
。
所以问题的症结就在于,通过scoped
的作用,我们写在<style>
里的动效名show-toast
被编译成了show-toast-data-v-19ed0bfa
真正导致动效不生效的原因,是因为我们在styleObject里写的动效名是show-toast
,而不是编译后的show-toast-data-v-19ed0bfa
,动效名对不上,所以并没有执行里面的动画。
问题解决
原因是找到了,但是问题还没有解决。
如果我们直接暴力的在class
里写动效,就像猜想3里做的那样,动效是能实现,但是我们怎么去动态更改动效时长呢?毕竟这个toast
的插件是可以通过设置duration
来改变他的展示时长的。
首先,需要明确的是,scoped
作用的是class
中的名称,而不是属性,
我们把最开始的那个带有动态styleObject
的dom
生成的样式拿出来看看:
其实出问题的只是animation-name
这一个属性,其他的属性因为style
的优先级要高于class
,所以都能正确覆盖.toast
里的属性。例如这里的top
。
其次,仔细分析一下styleObject
里的东西,其实只有一样是动态的,那就是动效时长这个属性,于是我们可以绕开animation-name
,直接去修改animation-duration
computed: {
styleObject() {
return {
animationDuration: this.duration / 1000 + 's',
whiteSpace: this.whiteSpace,
// toast的位置
top: this.position === 'up' ? '15%' : this.position === 'bottom' ? '85%' : '50%'
};
}
}
然后再像上面一样在class里写animation-name
,这样做后,样式就变成了:
这样,我们再css里写的animation-duration
就被styleObject
里的正确覆盖了,这样就能实现动态修改动效时间的需求。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。