requestAnimationFrame

深入学习requestAnimationFrame API,掌握现代Canvas动画的核心技术。requestAnimationFrame是浏览器专门为动画设计的API,是创建流畅Canvas动画的首选方法。

什么是requestAnimationFrame

requestAnimationFrame(简称rAF)是浏览器提供的动画专用API,它会在浏览器下一次重绘之前调用指定的回调函数。

基本语法

const requestId = requestAnimationFrame(callback)

// callback: 回调函数,接收一个高精度时间戳参数
// 返回值: 请求ID,用于取消动画

基本用法

function animate(timestamp) {
  // timestamp: 高精度时间戳(毫秒)
  
  update(timestamp)
  draw()
  
  requestAnimationFrame(animate)
}

requestAnimationFrame(animate)

与setInterval的区别

特性requestAnimationFramesetInterval
帧率自动匹配显示器刷新率固定间隔
页面隐藏自动暂停继续运行
时间精度高精度时间戳依赖系统时间
性能浏览器优化可能浪费资源
回调时机重绘前固定间隔

基本动画

简单动画循环

const canvas = document.getElementById('canvas')
const ctx = canvas.getContext('2d')

let x = 0

function animate() {
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  
  ctx.fillStyle = '#3498db'
  ctx.beginPath()
  ctx.arc(x, 100, 30, 0, Math.PI * 2)
  ctx.fill()
  
  x += 2
  if (x > canvas.width + 30) x = -30
  
  requestAnimationFrame(animate)
}

requestAnimationFrame(animate)

基本动画演示

requestAnimationFrame 基本动画

时间戳参数

高精度时间戳

回调函数接收一个DOMHighResTimeStamp参数,表示从页面加载开始经过的毫秒数。

function animate(timestamp) {
  console.log(`当前时间: ${timestamp.toFixed(2)}ms`)
  requestAnimationFrame(animate)
}

requestAnimationFrame(animate)

计算帧间隔

let lastTime = 0

function animate(currentTime) {
  const deltaTime = currentTime - lastTime
  lastTime = currentTime
  
  console.log(`帧间隔: ${deltaTime.toFixed(2)}ms`)
  
  requestAnimationFrame(animate)
}

requestAnimationFrame(animate)

时间驱动动画

let lastTime = 0
let x = 0
const speed = 200  // 像素/秒

function animate(currentTime) {
  if (lastTime === 0) lastTime = currentTime
  
  const deltaTime = (currentTime - lastTime) / 1000  // 转换为秒
  lastTime = currentTime
  
  // 基于时间更新位置
  x += speed * deltaTime
  if (x > canvas.width) x = 0
  
  draw()
  requestAnimationFrame(animate)
}

时间驱动演示

时间驱动动画(恒定速度)

动画控制

停止动画

使用cancelAnimationFrame停止动画:

let animationId = null

function start() {
  if (!animationId) {
    animationId = requestAnimationFrame(animate)
  }
}

function stop() {
  if (animationId) {
    cancelAnimationFrame(animationId)
    animationId = null
  }
}

function animate(timestamp) {
  update()
  draw()
  animationId = requestAnimationFrame(animate)
}

暂停和继续

const animation = {
  animationId: null,
  isPaused: false,
  lastTime: 0,
  x: 0,
  
  start() {
    this.isPaused = false
    this.lastTime = 0
    this.animate(performance.now())
  },
  
  pause() {
    this.isPaused = true
    if (this.animationId) {
      cancelAnimationFrame(this.animationId)
      this.animationId = null
    }
  },
  
  resume() {
    if (this.isPaused) {
      this.isPaused = false
      this.lastTime = 0
      this.animationId = requestAnimationFrame(ts => this.animate(ts))
    }
  },
  
  animate(currentTime) {
    if (this.isPaused) return
    
    if (this.lastTime === 0) this.lastTime = currentTime
    const deltaTime = currentTime - this.lastTime
    this.lastTime = currentTime
    
    this.update(deltaTime)
    this.draw()
    
    this.animationId = requestAnimationFrame(ts => this.animate(ts))
  }
}

动画控制演示

动画控制(点击画布切换状态)

多动画管理

动画队列

class AnimationManager {
  constructor() {
    this.animations = new Map()
    this.animationId = null
  }
  
  add(id, updateFn) {
    this.animations.set(id, updateFn)
    if (!this.animationId) {
      this.start()
    }
  }
  
  remove(id) {
    this.animations.delete(id)
    if (this.animations.size === 0) {
      this.stop()
    }
  }
  
  start() {
    const loop = (timestamp) => {
      this.animations.forEach(updateFn => updateFn(timestamp))
      this.animationId = requestAnimationFrame(loop)
    }
    this.animationId = requestAnimationFrame(loop)
  }
  
  stop() {
    if (this.animationId) {
      cancelAnimationFrame(this.animationId)
      this.animationId = null
    }
  }
}

const manager = new AnimationManager()

manager.add('ball1', (timestamp) => {
  // 更新球1
})

manager.add('ball2', (timestamp) => {
  // 更新球2
})

多动画演示

多个动画同时运行

页面可见性

自动暂停机制

requestAnimationFrame在页面不可见时会自动暂停,节省资源。

// 监听页面可见性变化
document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    console.log('页面隐藏,动画自动暂停')
  } else {
    console.log('页面显示,动画自动恢复')
  }
})

手动处理可见性

let animationId = null

function animate(timestamp) {
  update(timestamp)
  draw()
  animationId = requestAnimationFrame(animate)
}

document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    if (animationId) {
      cancelAnimationFrame(animationId)
      animationId = null
    }
  } else {
    if (!animationId) {
      animationId = requestAnimationFrame(animate)
    }
  }
})

性能优化

减少重绘区域

// 只清除需要更新的区域
function animate() {
  const oldX = ball.x
  const oldY = ball.y
  
  update()
  
  // 只清除旧位置
  ctx.clearRect(oldX - radius, oldY - radius, radius * 2, radius * 2)
  // 清除新位置(如果位置变化)
  ctx.clearRect(ball.x - radius, ball.y - radius, radius * 2, radius * 2)
  
  draw()
  
  requestAnimationFrame(animate)
}

使用离屏Canvas

const offscreenCanvas = document.createElement('canvas')
offscreenCanvas.width = 200
offscreenCanvas.height = 200
const offCtx = offscreenCanvas.getContext('2d')

// 预渲染静态内容
function prerender() {
  offCtx.fillStyle = '#3498db'
  offCtx.fillRect(0, 0, 200, 200)
  // ... 复杂绘制
}

function animate() {
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  
  // 直接绘制缓存内容
  ctx.drawImage(offscreenCanvas, 0, 0)
  
  // 只更新动态内容
  drawDynamicContent()
  
  requestAnimationFrame(animate)
}

跳过帧

let lastTime = 0
const targetFPS = 30
const frameInterval = 1000 / targetFPS

function animate(currentTime) {
  const elapsed = currentTime - lastTime
  
  if (elapsed >= frameInterval) {
    lastTime = currentTime - (elapsed % frameInterval)
    
    update()
    draw()
  }
  
  requestAnimationFrame(animate)
}

requestAnimationFrame(animate)

兼容性处理

Polyfill

(function() {
  let lastTime = 0
  const vendors = ['webkit', 'moz', 'ms', 'o']
  
  for (let i = 0; i < vendors.length && !window.requestAnimationFrame; i++) {
    window.requestAnimationFrame = window[vendors[i] + 'RequestAnimationFrame']
    window.cancelAnimationFrame = window[vendors[i] + 'CancelAnimationFrame'] ||
                                   window[vendors[i] + 'CancelRequestAnimationFrame']
  }
  
  if (!window.requestAnimationFrame) {
    window.requestAnimationFrame = function(callback) {
      const currentTime = Date.now()
      const timeToCall = Math.max(0, 16 - (currentTime - lastTime))
      const id = setTimeout(() => callback(currentTime + timeToCall), timeToCall)
      lastTime = currentTime + timeToCall
      return id
    }
  }
  
  if (!window.cancelAnimationFrame) {
    window.cancelAnimationFrame = function(id) {
      clearTimeout(id)
    }
  }
})()

实际应用

动画计时器

class AnimationTimer {
  constructor(duration, onUpdate, onComplete) {
    this.duration = duration
    this.onUpdate = onUpdate
    this.onComplete = onComplete
    this.startTime = null
    this.animationId = null
  }
  
  start() {
    this.startTime = performance.now()
    this.tick()
  }
  
  stop() {
    if (this.animationId) {
      cancelAnimationFrame(this.animationId)
      this.animationId = null
    }
  }
  
  tick() {
    const elapsed = performance.now() - this.startTime
    const progress = Math.min(elapsed / this.duration, 1)
    
    this.onUpdate(progress)
    
    if (progress < 1) {
      this.animationId = requestAnimationFrame(() => this.tick())
    } else {
      this.onComplete && this.onComplete()
    }
  }
}

const timer = new AnimationTimer(
  1000,
  progress => console.log(`进度: ${(progress * 100).toFixed(0)}%`),
  () => console.log('完成')
)

timer.start()

平滑滚动

function smoothScroll(targetY, duration = 500) {
  const startY = window.scrollY
  const distance = targetY - startY
  const startTime = performance.now()
  
  function scroll(currentTime) {
    const elapsed = currentTime - startTime
    const progress = Math.min(elapsed / duration, 1)
    
    // 使用缓动函数
    const eased = progress * (2 - progress)
    
    window.scrollTo(0, startY + distance * eased)
    
    if (progress < 1) {
      requestAnimationFrame(scroll)
    }
  }
  
  requestAnimationFrame(scroll)
}

smoothScroll(1000)