前端如何实现动画过渡效果

2022-04-15 0 875
目录
  • 简介
  • 传统过渡动画
  • css过渡动画
  • js动画
  • 传统和Vue/React框架对比
  • Vue框架下的过渡动画
    • transition组件
    • modules/transition
  • React里的过渡动画
    • 数据驱动的动画

      简介

      动画这个概念非常宽泛,涉及各个领域,这里我们把范围缩小到前端网页应用层面上,不用讲游戏领域的Animate,一切从最简单的开始。

      目前大部分网页应用都是基于框架开发的,比如Vue,React等,它们都是基于数据驱动视图的,那么让我们来对比一下,还没有这些框架的时候我们如何实现动画或者过渡效果,然后使用数据驱动又是如何实现的。

      传统过渡动画

      动画效果对体验有着非常重要的效果,但是对于很多开发者来讲,可能是个非常薄弱的环节。在css3出现之后,很多初学者最常用的动画过渡可能就是css3的能力了。

      css过渡动画

      css启动过渡动画非常简单,书写transition属性就可以了,下面写一个demo

      <div id="app" class="normal"></div>
      .normal {
        width: 100px;
        height: 100px;
        background-color: red;
        transition: all 0.3s;
      }
      .normal:hover {
        background-color: yellow;
        width: 200px;
        height: 200px;
      }

      效果还是很赞的,css3的transition基本满足了大部分动画需求,如果不满足还有真正的css3 animation。

      animate-css

      大名鼎鼎的css动画库,谁用谁知道。

      不管是css3 transition 还是 css3 animation,我们简单使用都是通过切换class类名,如果要做回调处理,浏览器也提供了 ontransitionend , onanimationend等动画帧事件,通过js接口进行监听即可。

      var el = document.querySelector('#app')
      el.addEventListener('transitionstart', () => {
        console.log('transition start')
      })
      el.addEventListener('transitionend', () => {
        console.log('transition end')
      })

      ok,这就是css动画的基础了,通过js封装也可以实现大部分的动画过渡需求,但是局限性在与只能控制css支持的属性动画,相对来说控制力还是稍微弱一点。

      js动画

      js毕竟是自定义编码程序,对于动画的控制力就很强大了,而且能实现各种css不支持的效果。 那么 js 实现动画的基础是什么?
      简单来讲,所谓动画就是在 时间轴上不断更新某个元素的属性,然后交给浏览器重新绘制,在视觉上就成了动画。废话少说,还是先来个栗子:

       <div id="app" class="normal"></div>
      // Tween仅仅是个缓动函数
      var el = document.querySelector('#app')
      var time = 0, begin = 0, change = 500, duration = 1000, fps = 1000 / 60;
      function startSport() {
        var val = 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的数据
      export function extractTransitionData (comp: Component): Object {
       const data = {}
       const options: ComponentOptions = comp.$options
       // props
       for (const key in options.propsData) {
        data[key] = comp[key]
       }
       // events.
       const listeners: ?Object = options._parentListeners
       for (const key in listeners) {
        data[camelize(key)] = listeners[key]
       }
       return data
      }
      
      export default {
       name: 'transition',
       props: transitionProps,
       abstract: true, // 抽象组件,意思是不会真实渲染成dom,辅助开发
      
       render (h: Function) {
        // 通过slots获取到真实渲染元素children
        let children: any = this.$slots.default
        
        const mode: string = this.mode
      
        const rawChild: VNode = children[0]
      
        // 添加唯一key
        // component instance. This key will be used to remove pending leaving nodes
        // during entering.
        const id: string = `__transition-${this._uid}-`
        child.key = getKey(id)
          : child.key
        // data上注入transition属性,保存通过props传递的数据
        const data: Object = (child.data || (child.data = {})).transition = extractTransitionData(this)
        const oldRawChild: VNode = this._vnode
        const oldChild: VNode = getRealChild(oldRawChild)
      
        
         // important for dynamic transitions!
         const oldData: Object = oldChild.data.transition = extend({}, data)
       // handle transition mode
         if (mode === 'out-in') {
          // return placeholder node and queue update when leave finishes
          this._leaving = true
          mergeVNodeHook(oldData, 'afterLeave', () => {
           this._leaving = false
           this.$forceUpdate()
          })
          return placeholder(h, rawChild)
         } else if (mode === 'in-out') {
          let delayedLeave
          const performLeave = () => { delayedLeave() }
          mergeVNodeHook(data, 'afterEnter', performLeave)
          mergeVNodeHook(data, 'enterCancelled', performLeave)
          mergeVNodeHook(oldData, 'delayLeave', leave => { delayedLeave = leave })
         }
        return rawChild
       }
      }

      可以看到,这个组件本身功能比较简单,就是通过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)
       }
      }
      export default inBrowser ? {
       create: _enter,
       activate: _enter,
       remove (vnode: VNode, rm: Function) {
        if (vnode.data.show !== true) {
         leave(vnode, rm)
        } 
       }
      } : {}

      这里inBrowser就当做true,因为我们分析的是浏览器环境。
      接着看enter 和 leave函数,先看enter:

      export function addTransitionClass (el: any, cls: string) {
       const transitionClasses = el._transitionClasses || (el._transitionClasses = [])
       if (transitionClasses.indexOf(cls) < 0) {
        transitionClasses.push(cls)
        addClass(el, cls)
       }
      }
      
      export function removeTransitionClass (el: any, cls: string) {
       if (el._transitionClasses) {
        remove(el._transitionClasses, cls)
       }
       removeClass(el, cls)
      }
      export function enter (vnode: VNodeWithData, toggleDisplay: ?() => void) {
       const el: any = vnode.elm
      
       // call leave callback now
       if (isDef(el._leaveCb)) {
        el._leaveCb.cancelled = true
        el._leaveCb()
       }
       // 上一步注入data的transition数据
       const data = resolveTransition(vnode.data.transition)
       if (isUndef(data)) {
        return
       }
      
       /* istanbul ignore if */
       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
      
       
       let context = activeInstance
       let transitionNode = activeInstance.$vnode
      
       const isAppear = !context._isMounted || !vnode.isRootInsert
      
       if (isAppear && !appear && appear !== '') {
        return
       }
       // 获取合适的时机应该注入的className
       const startClass = isAppear && appearClass
        ? appearClass
        : enterClass
       const activeClass = isAppear && appearActiveClass
        ? appearActiveClass
        : enterActiveClass
       const toClass = isAppear && appearToClass
        ? appearToClass
        : enterToClass
      
       const beforeEnterHook = isAppear
        ? (beforeAppear || beforeEnter)
        : beforeEnter
       const enterHook = isAppear
        ? (typeof appear === 'function' ? appear : enter)
        : enter
       const afterEnterHook = isAppear
        ? (afterAppear || afterEnter)
        : afterEnter
       const enterCancelledHook = isAppear
        ? (appearCancelled || enterCancelled)
        : enterCancelled
      
       const explicitEnterDuration: any = toNumber(
        isObject(duration)
         ? duration.enter
         : duration
       )
      
       const expectsCSS = css !== false && !isIE9
       const userWantsControl = getHookArgumentsLength(enterHook)
       // 过渡结束之后的回调处理,删掉进入时的class
       const cb = 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进入时,添加start class进行过渡
       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,其实就是监听过渡或者动画结束的事件:

      export let transitionEndEvent = 'transitionend'
      export let animationEndEvent = 'animationend'
      export function whenTransitionEnds (
       el: Element,
       expectedType: ?string,
       cb: Function
      ) {
       const { type, timeout, propCount } = getTransitionInfo(el, expectedType)
       if (!type) return cb()
       const event: string = type === TRANSITION ? transitionEndEvent : animationEndEvent
       let ended = 0
       const end = () => {
        el.removeEventListener(event, onEnd)
        cb()
       }
       const onEnd = e => {
        if (e.target === el) {
         if (++ended >= propCount) {
          end()
         }
        }
       }
       setTimeout(() => {
        if (ended < propCount) {
         end()
        }
       }, timeout + 1)
       el.addEventListener(event, onEnd)
      }

      OK,到了这里,根据上面源代码的注释分析,我们可以发现:

      • Vue先是封装了一些列操作dom className的辅助方法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的分析,这个非常容易理解,反而更简单:

      class Transition extends React.Component {
       static contextType = TransitionGroupContext
      
       constructor(props, context) {
        super(props, context)
        let parentGroup = context
        let appear =
         parentGroup && !parentGroup.isMounting ? props.enter : props.appear
      
        let initialStatus
      
        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) {
        let nextStatus = 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) {
         // nextStatus will always be ENTERING or EXITING.
         this.cancelNextCallback()
      
         if (nextStatus === ENTERING) {
          this.performEnter(mounting)
         } else {
          this.performExit()
         }
        } else if (this.props.unmountOnExit && this.state.status === EXITED) {
         this.setState({ status: UNMOUNTED })
        }
       }
      
       performEnter(mounting) {
        const { enter } = this.props
        const appearing = this.context ? this.context.isMounting : mounting
        const [maybeNode, maybeAppearing] = this.props.nodeRef
         ? [appearing]
         : [ReactDOM.findDOMNode(this), appearing]
      
        const timeouts = this.getTimeouts()
        const enterTimeout = appearing ? timeouts.appear : timeouts.enter
        // no enter animation skip right to ENTERED
        // if we are mounting and running this it means appear _must_ be set
        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
        const timeouts = this.getTimeouts()
        const maybeNode = this.props.nodeRef
         ? undefined
         : ReactDOM.findDOMNode(this)
      
        // no exit animation skip right to EXITED
        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) {
        // This shouldn't be necessary, but there are weird race conditions with
        // setState callbacks and unmounting in testing, so always make sure that
        // we can cancel any pending setState callbacks after we unmount.
        callback = this.setNextCallback(callback)
        this.setState(nextState, callback)
       }
      
       setNextCallback(callback) {
        let active = true
      
        this.nextCallback = event => {
         if (active) {
          active = false
          this.nextCallback = null
      
          callback(event)
         }
        }
      
        this.nextCallback.cancel = () => {
         active = false
        }
      
        return this.nextCallback
       }
       // 监听过渡end
       onTransitionEnd(timeout, handler) {
        this.setNextCallback(handler)
        const node = this.props.nodeRef
         ? this.props.nodeRef.current
         : ReactDOM.findDOMNode(this)
      
        const doesNotHaveTimeoutOrListener =
         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() {
        const status = this.state.status
      
        if (status === UNMOUNTED) {
         return null
        }
      
        const {
         children,
         // filter props for `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 (
         // allows for nested Transitions
         <TransitionGroupContext.Provider value={null}>
          {typeof children === 'function'
           ? children(status, childProps)
           : React.cloneElement(React.Children.only(children), childProps)}
         </TransitionGroupContext.Provider>
        )
       }
      }
      

      可以看到,和Vue是非常相似的,只不过这里变成了在React的各个生命周期函数了进行处理。

      到了这里,我们会发现不管是Vue的transiton组件,还是React这个transiton-group组件,着重处理的都是css属性的动画。

      数据驱动的动画

      而实际场景中总是会遇到css无法处理的动画,这个时候,可以有两种解决方案:

      通过ref获取dom,然后采用我们传统的js方案。
      通过state状态维护绘制dom的数据,不断通过setState更新state类驱动视图自动刷新

      以上就是前端如何实现动画过渡效果的详细内容,更多关于前端实现动画过渡效果的资料请关注NICE源码其它相关文章!

      免责声明:
      1、本网站所有发布的源码、软件和资料均为收集各大资源网站整理而来;仅限用于学习和研究目的,您必须在下载后的24个小时之内,从您的电脑中彻底删除上述内容。 不得使用于非法商业用途,不得违反国家法律。否则后果自负!

      2、本站信息来自网络,版权争议与本站无关。一切关于该资源商业行为与www.niceym.com无关。
      如果您喜欢该程序,请支持正版源码、软件,购买注册,得到更好的正版服务。
      如有侵犯你版权的,请邮件与我们联系处理(邮箱:skknet@qq.com),本站将立即改正。

      NICE源码网 JavaScript 前端如何实现动画过渡效果 https://www.niceym.com/31354.html