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


Posted in Javascript onFebruary 05, 2021

简介

动画这个概念非常宽泛,涉及各个领域,这里我们把范围缩小到前端网页应用层面上,不用讲游戏领域的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类驱动视图自动刷新

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

Javascript 相关文章推荐
JavaScript脚本性能优化注意事项
Nov 18 Javascript
一个cssQuery对象 javascript脚本实现代码
Jul 21 Javascript
div移动 输入框不能输入的问题
Nov 19 Javascript
动态创建样式表在各浏览器中的差异测试代码
Sep 13 Javascript
让人印象深刻的10个jQuery手风琴效果应用
May 08 Javascript
Angularjs 动态改变title标题(兼容ios)
Dec 29 Javascript
node.js发送邮件email的方法详解
Jan 06 Javascript
利用js查找数组中指定元素并返回该元素的所有索引示例
Mar 29 Javascript
微信小程序 开发之全局配置
May 05 Javascript
vue中 this.$set的用法详解
Sep 06 Javascript
JavaScript实现移动端拖动元素
Nov 24 Javascript
vue-treeselect的基本用法以及解决点击无法出现拉下菜单
Apr 30 Vue.js
原生js拖拽功能制作滑动条实例代码
Feb 05 #Javascript
jQuery是用来干什么的 jquery其实就是一个js框架
Feb 04 #jQuery
如何在JavaScript中使用localStorage详情
Feb 04 #Javascript
vue浏览器返回监听的具体步骤
Feb 03 #Vue.js
vue实现禁止浏览器记住密码功能的示例代码
Feb 03 #Vue.js
详解React中共享组件逻辑的三种方式
Feb 02 #Javascript
详解微信小程序轨迹回放实现及遇到的坑
Feb 02 #Javascript
You might like
php小技巧 把数组的键和值交换形成了新的数组,查找值取得键
2011/06/02 PHP
关于php中一些字符串总结
2016/05/05 PHP
JavaScript 密码强度判断代码
2009/09/05 Javascript
Javascript Math ceil()、floor()、round()三个函数的区别
2010/03/09 Javascript
Jquery为a标签的href赋值实现代码
2013/05/03 Javascript
jquery实现商品拖动选择效果代码(自写)
2013/05/28 Javascript
javascript中不易分清的slice,splice和split三个函数
2016/03/29 Javascript
AngularJS 单元测试(二)详解
2016/09/21 Javascript
从零开始学习Node.js系列教程三:图片上传和显示方法示例
2017/04/13 Javascript
xmlplus组件设计系列之分隔框(DividedBox)(8)
2017/05/02 Javascript
js微信分享实现代码
2020/10/11 Javascript
为什么我们要做三份 Webpack 配置文件
2017/09/18 Javascript
浅析Node.js非对称加密方法
2018/01/29 Javascript
Node错误处理笔记之挖坑系列教程
2018/06/05 Javascript
解决vue数组中对象属性变化页面不渲染问题
2018/08/09 Javascript
vue 微信扫码登录(自定义样式)
2020/01/06 Javascript
Python字符遍历的艺术
2008/09/06 Python
Python实现删除Android工程中的冗余字符串
2015/01/19 Python
通过实例浅析Python对比C语言的编程思想差异
2015/08/30 Python
Python 描述符(Descriptor)入门
2016/11/20 Python
python IDLE添加行号显示教程
2020/04/25 Python
keras在构建LSTM模型时对变长序列的处理操作
2020/06/29 Python
pytorch学习教程之自定义数据集
2020/11/10 Python
Django多数据库联用实现方法解析
2020/11/12 Python
旧时光糖果:Old Time Candy
2018/02/05 全球购物
英国受欢迎的运动鞋和街头服装商店:Footasylum
2018/06/12 全球购物
Raffaello Network西班牙:意大利拉斐尔时尚购物网
2019/03/12 全球购物
Clarks西班牙官方在线商店:clarks鞋
2019/05/03 全球购物
俄罗斯美容和健康网上商店:Созвездие Красоты
2019/07/23 全球购物
岗位竞聘书范文
2014/03/31 职场文书
领导干部失职检讨书
2015/05/05 职场文书
暑期家教宣传单
2015/07/14 职场文书
公文写作:工伤事故分析报告怎么写?
2019/11/05 职场文书
使用 Apache 反向代理的设置技巧
2022/01/18 Servers
MySQL sql模式设置引起的问题
2022/05/15 MySQL
python实现学生信息管理系统(面向对象)
2022/06/05 Python