1. 程式人生 > 程式設計 >前端如何實現動畫過渡效果

前端如何實現動畫過渡效果

簡介

動畫這個概念非常寬泛,涉及各個領域,這裡我們把範圍縮小到前端網頁應用層面上,不用講遊戲領域的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,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類驅動檢視自動重新整理

以上就是前端如何實現動畫過渡效果的詳細內容,更多關於前端實現動畫過渡效果的資料請關注我們其它相關文章!