使用requestAnimationFrame代替定时器实现平滑动画效果

1,300 阅读5分钟

1、定时器

定时器(setTimeout、setInterval)一直都是JS实现动画效果的核心技术,但使用定时器容易出现卡顿。

定时器出现卡顿的具体原因

异步任务执行原理

定时器是异步任务,在JS中,只有当主线程的任务队列执行完,才会去检查异步任务队列任务是否需要执行,所以定时器的执行时间总是会比实际规定的时间慢一拍;当异步任务队列拥挤时,也会造成执行延迟。

  • 场景一:主线程任务比较耗时
    
        setTimeout(() => {
            // 执行内容
        }, 300)
        
        for(let i=1 ; i<9999999 ; i++) {
            // 用多次循环模拟复杂且耗时的计算任务
        }
    

    当主线程队列执行时间>定时器等待时间时,会造成定时器延迟执行。

  • 场景二:异步任务队列拥挤
    
        setInterVal(() => {
            // AJAX轮询 模拟拥挤的异步任务(假设这里的AJAX网络请求全都在1000ms之内收到响应,)
        }, 10)
        
        setTimeout(() => {
            // 执行内容
        }, 1000)
    

    当异步任务队列拥挤时,即使主线程队列执行时间<定时器等待时间,会优先执行先进队列的异步任务从而造成执定时器延迟执行。

物理设备刷新率

image.png

屏幕刷新率受电脑分辨率和屏幕尺寸影响,不同型号的屏幕刷新率也不同。当电脑刷新率为60hz时,每帧刷新时间约为16.7ms,通过定时器去不断改变dom的css属性之后的新画面,是随着电脑一帧一帧刷新而不断呈现的。

    当前屏幕刷新率:60hz(16.7ms一帧) 定时器设定:dom元素每间隔10ms向下移动10px,此时元素的top:0px
    -0ms    屏幕未刷新 waiting...
    -10ms   屏幕未刷新 waiting...  定时器执行回调,设置元素top:10px
    -16.7ms 屏幕刷新   元素在画面上开始向下移动了10px (0px -> 10px)    第一次画面刷新
    -20ms   屏幕未刷新 waiting...  定时器执行回调,设置元素top:20px
    -30ms   屏幕未刷新 waiting...  定时器执行回调,设置元素top:30px
    -33.4ms 屏幕刷新   元素在画面上开始向下移动了20px (10px -> 30px)   第二次画面刷新

当定时器设置的时间间隔和屏幕刷新率不一致时,会出现卡顿的情况,如第一次屏幕刷新和第二次屏幕刷新,元素移动的距离不同,此时呈现出卡顿的视觉效果。

2、requestAnimationFrame

调用这个函数时,浏览器会在下一次重绘之前执行传入该函数的回调函数(每次画面更新:执行rAF回调 -> 回流重绘 -> 物理设备刷新最新一帧屏幕)。回调函数通常每秒执行60次,与你电脑屏幕刷新频率相匹配,当前电脑刷新率是60hz,则约每16.7ms(1000ms/60)执行一次,电脑屏幕刷新率为75hz时,则约每13.3ms(1000ms/75)执行一次,这样通过rAF(requestAnimationFrame)去实现动画效果,就不会出现丢帧、卡顿的情况。

    // 调用方式
    
    // 调用window.requestAnimationFrame函数时,浏览器下次重绘会执行rAF函数接收的回调
    // rAF的接收回调函数在被执行时会传入一个参数:回调被执行时的时间戳
    window.requestAnimationFrame(function step(DOMHighResTimeStamp) { // 回调函数会被传入
        
        // 执行内容
        ...
        
    })
    

兼容性问题可以查看:MDN-requestAnimationFrame

3、定时器 和 rAF 案例对比

在了解了上述特性之后,针对于同一个场景,我使用两种不同的方式去实现动画效果

使用rAF实现

// dom左右平滑移动
Util.moveToSmoonthly = (element, targetPos, delay=300, direction="x", callback) => {

  let start = null // 初始化开始时间
  const currentPos = direction === "x" ? formatPos(element.style.left) : formatPos(element.style.top) // 移动方向

  const formatPos = (pxPos) => { // 去除单位,方便计算
    return Number(pxPos.split("px")[0])
  }
  const moveTo = (pos) => { // 根据方向,移动到具体位置
    if(direction === "x") {
      element.style.left = pos + 'px'
    } else {
      element.style.top = pos + 'px'
    }
  }

  window.requestAnimationFrame(function step(currentTime) {
    start = start ? start : currentTime // 闭包初始化开始时间为第一次回调执行传入的时间戳
    const progressTime = currentTime - start // 每帧之间经过的时间
    
    if (targetPos > currentPos) {
      const progressPos = (progressTime / delay) * (targetPos - currentPos) // 根据经过的时间百分比得出此时应移动的距离
      moveTo(currentPos + progressPos)
    } else {
      const progressPos = (progressTime / delay) * (currentPos - targetPos)
      moveTo(currentPos - progressPos)
    }
    
    
    if(progressTime > delay) { // 判断是否到达指定时间,到时间则跳出递归
      moveTo(targetPos)
      callback && callback()
    } else {
      window.requestAnimationFrame(step) // 递归调用
    }

  })
}

使用定时器实现

// dom左右移动(非平滑)
Util.moveTo = (element, targetPos, delay=300, direction="x", callback) => {
  
  const currentPos = direction === "x" ? formatPos(element.style.left) : formatPos(element.style.top)// 方向
  const frameRate = 16.7 // 定时器调用间隔,这里尽量保持和屏幕刷新率保持一致(类比帧率)
  const speed = (targetPos - currentPos) / (delay / frameRate) // 速度(类比每帧移动的距离)
  let count = 0 // 调用次数(类比帧数)
  
  const formatPos = (pxPos) => {
    return Number(pxPos.split("px")[0])
  }
  const move = (pos) => {
    if(direction === "x") {
      element.style.left = pos + 'px'
    } else {
      element.style.top = pos + 'px'
    }
  }

  const timer = setInterval(() => {
    count++
    // 出口
    if(count*speed > Math.abs(targetPos - currentPos)) {
      move(targetPos)
      clearInterval(timer)
      callback && callback()
      return
    }
    
    if(targetPos > currentPos) {
      move(currentPos + speed*count)
    } else {
      move(currentPos - speed*count)
    }

  }, frameRate)

}

调用对比

       <div id="container">
        <div id="circle1">
            <span>rAF</span>
        </div>
        <div id="circle2">
            <span>定时器</span>
        </div>
      </div>
        // 要求两颗圆球在3000ms在x轴上 从 0px 移动到 500px处
        Util.moveToSmoonthly(circle1, 500, 3000, "x"); 
        Util.moveTo(circle2, 500, 3000, "x")

演示设备 刷新率60hz(16.7ms刷新一次)的屏幕

动画场景 小球向右移动,rAF案例刷新率与屏幕一致(16.7ms执行一次回调),定时器案例刷新率尽量与当前屏幕刷新率吻合(执行间隔设定为16.7ms)

演示效果 定时器设置的执行时间间隔(案例中的frameRate)无论怎么适配不同的屏幕都无法做到和用户屏幕刷新率完全一致从而不能做到平滑的移动,即使像案例中,定时器时间间隔设置到了16.7ms,肉眼上定时器在当前屏幕上已经比较顺畅了,但是一和rAF进行对比就能发现,还是有明显卡顿效果。由于我使用的录屏软件掉帧严重(这里的gif图片并不能很好的展示区别),大家可以下来自己用同一个动画场景去做一个对比。 QQ录屏20230309104708.gif

参考:

requestAnimationFrame基础知识 - 一点儿土 - 博客园 (cnblogs.com) requestAnimationFrame与setTimeout的性能比较 - 百度文库 (baidu.com)