前端如何实现动画过渡效果
简介
动画这个概念非常宽泛,涉及各个领域,这里我们把范围缩小到前端网页应用层面上,不用讲游戏领域的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(endedOK,到了这里,根据上面源代码的注释分析,我们可以发现:
- 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类驱动视图自动刷新以上就是前端如何实现动画过渡效果的详细内容,更多关于前端实现动画过渡效果的资料请关注毛票票其它相关文章!