前端如何实现动画过渡效果
简介
动画这个概念非常宽泛,涉及各个领域,这里我们把范围缩小到前端网页应用层面上,不用讲游戏领域的Animate,一切从最简单的开始。
目前大部分网页应用都是基于框架开发的,比如Vue,React等,它们都是基于数据驱动视图的,那么让我们来对比一下,还没有这些框架的时候我们如何实现动画或者过渡效果,然后使用数据驱动又是如何实现的。
传统过渡动画
动画效果对体验有着非常重要的效果,但是对于很多开发者来讲,可能是个非常薄弱的环节。在css3出现之后,很多初学者最常用的动画过渡可能就是css3的能力了。
css过渡动画
css启动过渡动画非常简单,书写transition属性就可以了,下面写一个demo
.normal{
width:100px;
height:100px;
background-color:red;
transition:all0.3s;
}
.normal:hover{
background-color:yellow;
width:200px;
height:200px;
}
效果还是很赞的,css3的transition基本满足了大部分动画需求,如果不满足还有真正的css3animation。
animate-css
大名鼎鼎的css动画库,谁用谁知道。
不管是css3transition还是css3animation,我们简单使用都是通过切换class类名,如果要做回调处理,浏览器也提供了ontransitionend,onanimationend等动画帧事件,通过js接口进行监听即可。
varel=document.querySelector('#app')
el.addEventListener('transitionstart',()=>{
console.log('transitionstart')
})
el.addEventListener('transitionend',()=>{
console.log('transitionend')
})
ok,这就是css动画的基础了,通过js封装也可以实现大部分的动画过渡需求,但是局限性在与只能控制css支持的属性动画,相对来说控制力还是稍微弱一点。
js动画
js毕竟是自定义编码程序,对于动画的控制力就很强大了,而且能实现各种css不支持的效果。那么js实现动画的基础是什么?
简单来讲,所谓动画就是在时间轴上不断更新某个元素的属性,然后交给浏览器重新绘制,在视觉上就成了动画。废话少说,还是先来个栗子:
//Tween仅仅是个缓动函数
varel=document.querySelector('#app')
vartime=0,begin=0,change=500,duration=1000,fps=1000/60;
functionstartSport(){
varval=Tween.Elastic.easeInOut(time,begin,change,duration);
el.style.transform='translateX('+val+'px)';
if(time<=duration){
time+=fps
}else{
console.log('动画结束重新开始')
time=0;
}
setTimeout(()=>{
startSport()
},fps)
}
startSport()
在时间轴上不断更新属性,可以通过setTimeout或者requestAnimation来实现。至于Tween缓动函数,就是类似于插值的概念,给定一系列变量,然后在区间段上可以获取任意时刻的值,纯数学公式,几乎所有的动画框架都会使用,想了解的可以参考张鑫旭的Tween.js
OK,这个极简demo也是js实现动画的核心基础了,可以看到我们通过程序完美的控制了过渡值的生成过程,所有其他复杂的动画机制都是这个模式。
传统和Vue/React框架对比
通过前面的例子,无论是css过渡还是js过渡,我们都是直接获取到dom元素的,然后对dom元素进行属性操作。
Vue/React都引入了虚拟dom的概念,数据驱动视图,我们尽量不去操作dom,只控制数据,那么我们如何在数据层面驱动动画呢?
Vue框架下的过渡动画
可以先看一遍文档
Vue过渡动画
我们就不讲如何使用了,我们来分析一下Vue提供的transition组件是如何实现动画过渡支持的。
transition组件
先看transition组件代码,路径“src/platforms/web/runtime/components/transition.js”
核心代码如下:
//辅助函数,复制props的数据
exportfunctionextractTransitionData(comp:Component):Object{
constdata={}
constoptions:ComponentOptions=comp.$options
//props
for(constkeyinoptions.propsData){
data[key]=comp[key]
}
//events.
constlisteners:?Object=options._parentListeners
for(constkeyinlisteners){
data[camelize(key)]=listeners[key]
}
returndata
}
exportdefault{
name:'transition',
props:transitionProps,
abstract:true,//抽象组件,意思是不会真实渲染成dom,辅助开发
render(h:Function){
//通过slots获取到真实渲染元素children
letchildren:any=this.$slots.default
constmode:string=this.mode
constrawChild:VNode=children[0]
//添加唯一key
//componentinstance.Thiskeywillbeusedtoremovependingleavingnodes
//duringentering.
constid:string=`__transition-${this._uid}-`
child.key=getKey(id)
:child.key
//data上注入transition属性,保存通过props传递的数据
constdata:Object=(child.data||(child.data={})).transition=extractTransitionData(this)
constoldRawChild:VNode=this._vnode
constoldChild:VNode=getRealChild(oldRawChild)
//importantfordynamictransitions!
constoldData:Object=oldChild.data.transition=extend({},data)
//handletransitionmode
if(mode==='out-in'){
//returnplaceholdernodeandqueueupdatewhenleavefinishes
this._leaving=true
mergeVNodeHook(oldData,'afterLeave',()=>{
this._leaving=false
this.$forceUpdate()
})
returnplaceholder(h,rawChild)
}elseif(mode==='in-out'){
letdelayedLeave
constperformLeave=()=>{delayedLeave()}
mergeVNodeHook(data,'afterEnter',performLeave)
mergeVNodeHook(data,'enterCancelled',performLeave)
mergeVNodeHook(oldData,'delayLeave',leave=>{delayedLeave=leave})
}
returnrawChild
}
}
可以看到,这个组件本身功能比较简单,就是通过slots拿到需要渲染的元素children,然后把transition的props属性数据copy到data的transtion属性上,供后续注入生命周期使用,mergeVNodeHook就是做生命周期管理的。
modules/transition
接着往下看生命周期相关,路径:
src/platforms/web/runtime/modules/transition.js
先看默认导出:
function_enter(_:any,vnode:VNodeWithData){
if(vnode.data.show!==true){
enter(vnode)
}
}
exportdefaultinBrowser?{
create:_enter,
activate:_enter,
remove(vnode:VNode,rm:Function){
if(vnode.data.show!==true){
leave(vnode,rm)
}
}
}:{}
这里inBrowser就当做true,因为我们分析的是浏览器环境。
接着看enter和leave函数,先看enter:
exportfunctionaddTransitionClass(el:any,cls:string){
consttransitionClasses=el._transitionClasses||(el._transitionClasses=[])
if(transitionClasses.indexOf(cls)<0){
transitionClasses.push(cls)
addClass(el,cls)
}
}
exportfunctionremoveTransitionClass(el:any,cls:string){
if(el._transitionClasses){
remove(el._transitionClasses,cls)
}
removeClass(el,cls)
}
exportfunctionenter(vnode:VNodeWithData,toggleDisplay:?()=>void){
constel:any=vnode.elm
//callleavecallbacknow
if(isDef(el._leaveCb)){
el._leaveCb.cancelled=true
el._leaveCb()
}
//上一步注入data的transition数据
constdata=resolveTransition(vnode.data.transition)
if(isUndef(data)){
return
}
/*istanbulignoreif*/
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
letcontext=activeInstance
lettransitionNode=activeInstance.$vnode
constisAppear=!context._isMounted||!vnode.isRootInsert
if(isAppear&&!appear&&appear!==''){
return
}
//获取合适的时机应该注入的className
conststartClass=isAppear&&appearClass
?appearClass
:enterClass
constactiveClass=isAppear&&appearActiveClass
?appearActiveClass
:enterActiveClass
consttoClass=isAppear&&appearToClass
?appearToClass
:enterToClass
constbeforeEnterHook=isAppear
?(beforeAppear||beforeEnter)
:beforeEnter
constenterHook=isAppear
?(typeofappear==='function'?appear:enter)
:enter
constafterEnterHook=isAppear
?(afterAppear||afterEnter)
:afterEnter
constenterCancelledHook=isAppear
?(appearCancelled||enterCancelled)
:enterCancelled
constexplicitEnterDuration:any=toNumber(
isObject(duration)
?duration.enter
:duration
)
constexpectsCSS=css!==false&&!isIE9
constuserWantsControl=getHookArgumentsLength(enterHook)
//过渡结束之后的回调处理,删掉进入时的class
constcb=el._enterCb=once(()=>{
if(expectsCSS){
removeTransitionClass(el,toClass)
removeTransitionClass(el,activeClass)
}
if(cb.cancelled){
if(expectsCSS){
removeTransitionClass(el,startClass)
}
enterCancelledHook&&enterCancelledHook(el)
}else{
afterEnterHook&&afterEnterHook(el)
}
el._enterCb=null
})
//dom进入时,添加startclass进行过渡
beforeEnterHook&&beforeEnterHook(el)
if(expectsCSS){
//设置过渡开始之前的默认样式
addTransitionClass(el,startClass)
addTransitionClass(el,activeClass)
//浏览器渲染下一帧删除默认样式,添加toClass
//添加end事件监听,回调就是上面的cb
nextFrame(()=>{
removeTransitionClass(el,startClass)
if(!cb.cancelled){
addTransitionClass(el,toClass)
if(!userWantsControl){
if(isValidDuration(explicitEnterDuration)){
setTimeout(cb,explicitEnterDuration)
}else{
whenTransitionEnds(el,type,cb)
}
}
}
})
}
if(vnode.data.show){
toggleDisplay&&toggleDisplay()
enterHook&&enterHook(el,cb)
}
if(!expectsCSS&&!userWantsControl){
cb()
}
}
enter里使用了一个函数whenTransitionEnds,其实就是监听过渡或者动画结束的事件:
exportlettransitionEndEvent='transitionend'
exportletanimationEndEvent='animationend'
exportfunctionwhenTransitionEnds(
el:Element,
expectedType:?string,
cb:Function
){
const{type,timeout,propCount}=getTransitionInfo(el,expectedType)
if(!type)returncb()
constevent:string=type===TRANSITION?transitionEndEvent:animationEndEvent
letended=0
constend=()=>{
el.removeEventListener(event,onEnd)
cb()
}
constonEnd=e=>{
if(e.target===el){
if(++ended>=propCount){
end()
}
}
}
setTimeout(()=>{
if(ended
OK,到了这里,根据上面源代码的注释分析,我们可以发现:
- Vue先是封装了一些列操作domclassName的辅助方法addClass/removeClass等。
- 然后在生命周期enterHook之后,马上设置了startClass也就是enterClass的默认初始样式,还有activeClass
- 紧接着在浏览器nextFrame下一帧,移除了startClass,添加了toClass,并且添加了过渡动画的end事件监听处理
- 监听到end事件之后,调动cb,移除了toClass和activeClass
leave的过程和enter的处理过程是一样,只不过是反向添加移除className
结论:Vue的动画过渡处理方式和传统dom本质上是一样,只不过融入了Vue的各个生命周期里进行处理,本质上还是在dom添加删除的时机进行处理
React里的过渡动画
噢,我们翻篇了React的文档,也没有发现有过渡动画的处理。嘿,看来官方不原生支持。
但是我们可以自己实现,比如通过useState维护一个状态,在render里根据状态进行className的切换,但是复杂的该怎么办?
所幸在社区找到了一个轮子插件react-transition-group
嗯,直接贴源码,有了前面Vue的分析,这个非常容易理解,反而更简单:
classTransitionextendsReact.Component{
staticcontextType=TransitionGroupContext
constructor(props,context){
super(props,context)
letparentGroup=context
letappear=
parentGroup&&!parentGroup.isMounting?props.enter:props.appear
letinitialStatus
this.appearStatus=null
if(props.in){
if(appear){
initialStatus=EXITED
this.appearStatus=ENTERING
}else{
initialStatus=ENTERED
}
}else{
if(props.unmountOnExit||props.mountOnEnter){
initialStatus=UNMOUNTED
}else{
initialStatus=EXITED
}
}
this.state={status:initialStatus}
this.nextCallback=null
}
//初始dom的时候,更新默认初始状态
componentDidMount(){
this.updateStatus(true,this.appearStatus)
}
//data更新的时候,更新对应的状态
componentDidUpdate(prevProps){
letnextStatus=null
if(prevProps!==this.props){
const{status}=this.state
if(this.props.in){
if(status!==ENTERING&&status!==ENTERED){
nextStatus=ENTERING
}
}else{
if(status===ENTERING||status===ENTERED){
nextStatus=EXITING
}
}
}
this.updateStatus(false,nextStatus)
}
updateStatus(mounting=false,nextStatus){
if(nextStatus!==null){
//nextStatuswillalwaysbeENTERINGorEXITING.
this.cancelNextCallback()
if(nextStatus===ENTERING){
this.performEnter(mounting)
}else{
this.performExit()
}
}elseif(this.props.unmountOnExit&&this.state.status===EXITED){
this.setState({status:UNMOUNTED})
}
}
performEnter(mounting){
const{enter}=this.props
constappearing=this.context?this.context.isMounting:mounting
const[maybeNode,maybeAppearing]=this.props.nodeRef
?[appearing]
:[ReactDOM.findDOMNode(this),appearing]
consttimeouts=this.getTimeouts()
constenterTimeout=appearing?timeouts.appear:timeouts.enter
//noenteranimationskiprighttoENTERED
//ifwearemountingandrunningthisitmeansappear_must_beset
if((!mounting&&!enter)||config.disabled){
this.safeSetState({status:ENTERED},()=>{
this.props.onEntered(maybeNode)
})
return
}
this.props.onEnter(maybeNode,maybeAppearing)
this.safeSetState({status:ENTERING},()=>{
this.props.onEntering(maybeNode,maybeAppearing)
this.onTransitionEnd(enterTimeout,()=>{
this.safeSetState({status:ENTERED},()=>{
this.props.onEntered(maybeNode,maybeAppearing)
})
})
})
}
performExit(){
const{exit}=this.props
consttimeouts=this.getTimeouts()
constmaybeNode=this.props.nodeRef
?undefined
:ReactDOM.findDOMNode(this)
//noexitanimationskiprighttoEXITED
if(!exit||config.disabled){
this.safeSetState({status:EXITED},()=>{
this.props.onExited(maybeNode)
})
return
}
this.props.onExit(maybeNode)
this.safeSetState({status:EXITING},()=>{
this.props.onExiting(maybeNode)
this.onTransitionEnd(timeouts.exit,()=>{
this.safeSetState({status:EXITED},()=>{
this.props.onExited(maybeNode)
})
})
})
}
cancelNextCallback(){
if(this.nextCallback!==null){
this.nextCallback.cancel()
this.nextCallback=null
}
}
safeSetState(nextState,callback){
//Thisshouldn'tbenecessary,butthereareweirdraceconditionswith
//setStatecallbacksandunmountingintesting,soalwaysmakesurethat
//wecancancelanypendingsetStatecallbacksafterweunmount.
callback=this.setNextCallback(callback)
this.setState(nextState,callback)
}
setNextCallback(callback){
letactive=true
this.nextCallback=event=>{
if(active){
active=false
this.nextCallback=null
callback(event)
}
}
this.nextCallback.cancel=()=>{
active=false
}
returnthis.nextCallback
}
//监听过渡end
onTransitionEnd(timeout,handler){
this.setNextCallback(handler)
constnode=this.props.nodeRef
?this.props.nodeRef.current
:ReactDOM.findDOMNode(this)
constdoesNotHaveTimeoutOrListener=
timeout==null&&!this.props.addEndListener
if(!node||doesNotHaveTimeoutOrListener){
setTimeout(this.nextCallback,0)
return
}
if(this.props.addEndListener){
const[maybeNode,maybeNextCallback]=this.props.nodeRef
?[this.nextCallback]
:[node,this.nextCallback]
this.props.addEndListener(maybeNode,maybeNextCallback)
}
if(timeout!=null){
setTimeout(this.nextCallback,timeout)
}
}
render(){
conststatus=this.state.status
if(status===UNMOUNTED){
returnnull
}
const{
children,
//filterpropsfor`Transition`
in:_in,
mountOnEnter:_mountOnEnter,
unmountOnExit:_unmountOnExit,
appear:_appear,
enter:_enter,
exit:_exit,
timeout:_timeout,
addEndListener:_addEndListener,
onEnter:_onEnter,
onEntering:_onEntering,
onEntered:_onEntered,
onExit:_onExit,
onExiting:_onExiting,
onExited:_onExited,
nodeRef:_nodeRef,
...childProps
}=this.props
return(
//allowsfornestedTransitions
{typeofchildren==='function'
?children(status,childProps)
:React.cloneElement(React.Children.only(children),childProps)}
)
}
}
可以看到,和Vue是非常相似的,只不过这里变成了在React的各个生命周期函数了进行处理。
到了这里,我们会发现不管是Vue的transiton组件,还是React这个transiton-group组件,着重处理的都是css属性的动画。
数据驱动的动画
而实际场景中总是会遇到css无法处理的动画,这个时候,可以有两种解决方案:
通过ref获取dom,然后采用我们传统的js方案。
通过state状态维护绘制dom的数据,不断通过setState更新state类驱动视图自动刷新
以上就是前端如何实现动画过渡效果的详细内容,更多关于前端实现动画过渡效果的资料请关注毛票票其它相关文章!