关于transition:如何实现动画过渡效果

88次阅读

共计 12195 个字符,预计需要花费 31 分钟才能阅读完成。

简介

动画这个概念十分宽泛,波及各个领域,这里咱们把范畴放大到前端网页利用层面上,不必讲游戏畛域的 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>
#app {
    width: 100px;
    height: 100px;
    background-color: red;
    border-radius: 50%;
}
// 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 无奈解决的动画,这个时候,能够有两种解决方案:

  1. 通过 ref 获取 dom,而后采纳咱们传统的 js 计划。
  2. 通过 state 状态保护绘制 dom 的数据,一直通过 setState 更新 state 类驱动视图主动刷新

正文完
 0