requestAnimationFrame 优化

深入学习requestAnimationFrame的高效使用,实现流畅的Canvas动画循环 requestAnimationFrame是浏览器提供的专门用于动画的API,它能够在最佳时机执行回调,实现流畅的动画效果。

基本概念

requestAnimationFrame vs setTimeout/setInterval

const animationMethodsComparison = {
  requestAnimationFrame: {
    pros: [
      '与浏览器刷新率同步',
      '自动节流,页面不可见时暂停',
      '更精确的时间控制',
      '更流畅的动画效果'
    ],
    cons: [
      '需要手动管理取消',
      '不支持IE9及以下'
    ],
    useCase: '动画、游戏循环、实时渲染'
  },
  
  setTimeout: {
    pros: [
      '兼容性好',
      '可设置任意延迟'
    ],
    cons: [
      '不与刷新率同步',
      '可能造成帧丢失',
      '页面不可见时仍执行'
    ],
    useCase: '延迟执行、定时任务'
  },
  
  setInterval: {
    pros: [
      '自动重复执行'
    ],
    cons: [
      '不精确的时间间隔',
      '可能造成回调堆积',
      '难以动态调整频率'
    ],
    useCase: '轮询、周期性任务'
  }
}

基础动画循环

class AnimationLoop {
  constructor(callback) {
    this.callback = callback
    this.animationId = null
    this.isRunning = false
    this.lastTime = 0
    this.deltaTime = 0
    this.frameCount = 0
    this.fps = 0
    this.fpsUpdateTime = 0
  }
  
  start() {
    if (this.isRunning) return
    
    this.isRunning = true
    this.lastTime = performance.now()
    this.fpsUpdateTime = this.lastTime
    
    this.loop()
  }
  
  stop() {
    if (this.animationId) {
      cancelAnimationFrame(this.animationId)
      this.animationId = null
    }
    this.isRunning = false
  }
  
  loop() {
    if (!this.isRunning) return
    
    const currentTime = performance.now()
    this.deltaTime = (currentTime - this.lastTime) / 1000
    this.lastTime = currentTime
    
    this.frameCount++
    if (currentTime - this.fpsUpdateTime >= 1000) {
      this.fps = this.frameCount
      this.frameCount = 0
      this.fpsUpdateTime = currentTime
    }
    
    this.callback(this.deltaTime, this.fps)
    
    this.animationId = requestAnimationFrame(() => this.loop())
  }
  
  getFPS() {
    return this.fps
  }
  
  getDeltaTime() {
    return this.deltaTime
  }
}

帧率控制

固定帧率

class FixedFrameRateLoop {
  constructor(callback, targetFPS = 60) {
    this.callback = callback
    this.targetFPS = targetFPS
    this.frameInterval = 1000 / targetFPS
    this.animationId = null
    this.isRunning = false
    this.lastTime = 0
    this.accumulator = 0
  }
  
  start() {
    if (this.isRunning) return
    
    this.isRunning = true
    this.lastTime = performance.now()
    this.accumulator = 0
    
    this.loop()
  }
  
  stop() {
    if (this.animationId) {
      cancelAnimationFrame(this.animationId)
      this.animationId = null
    }
    this.isRunning = false
  }
  
  loop() {
    if (!this.isRunning) return
    
    const currentTime = performance.now()
    const elapsed = currentTime - this.lastTime
    this.lastTime = currentTime
    
    this.accumulator += elapsed
    
    while (this.accumulator >= this.frameInterval) {
      this.callback(this.frameInterval / 1000)
      this.accumulator -= this.frameInterval
    }
    
    this.animationId = requestAnimationFrame(() => this.loop())
  }
  
  setTargetFPS(fps) {
    this.targetFPS = fps
    this.frameInterval = 1000 / fps
  }
}

自适应帧率

class AdaptiveFrameRateLoop {
  constructor(callback, options = {}) {
    this.callback = callback
    this.options = {
      minFPS: 15,
      maxFPS: 60,
      targetFrameTime: 16.67,
      ...options
    }
    
    this.animationId = null
    this.isRunning = false
    this.lastTime = 0
    this.frameTimeHistory = []
    this.historySize = 10
    this.currentQuality = 1
  }
  
  start() {
    if (this.isRunning) return
    
    this.isRunning = true
    this.lastTime = performance.now()
    this.frameTimeHistory = []
    
    this.loop()
  }
  
  stop() {
    if (this.animationId) {
      cancelAnimationFrame(this.animationId)
      this.animationId = null
    }
    this.isRunning = false
  }
  
  loop() {
    if (!this.isRunning) return
    
    const currentTime = performance.now()
    const deltaTime = currentTime - this.lastTime
    this.lastTime = currentTime
    
    this.frameTimeHistory.push(deltaTime)
    if (this.frameTimeHistory.length > this.historySize) {
      this.frameTimeHistory.shift()
    }
    
    this.adjustQuality()
    
    this.callback(deltaTime / 1000, this.currentQuality)
    
    this.animationId = requestAnimationFrame(() => this.loop())
  }
  
  adjustQuality() {
    if (this.frameTimeHistory.length < this.historySize) return
    
    const avgFrameTime = this.frameTimeHistory.reduce((a, b) => a + b, 0) / this.frameTimeHistory.length
    const targetFrameTime = 1000 / this.options.maxFPS
    const maxFrameTime = 1000 / this.options.minFPS
    
    if (avgFrameTime > maxFrameTime && this.currentQuality > 0.25) {
      this.currentQuality -= 0.1
    } else if (avgFrameTime < targetFrameTime * 0.8 && this.currentQuality < 1) {
      this.currentQuality += 0.1
    }
    
    this.currentQuality = Math.max(0.25, Math.min(1, this.currentQuality))
  }
  
  getQuality() {
    return this.currentQuality
  }
}

时间步进

固定时间步进

class FixedTimeStepLoop {
  constructor(updateCallback, renderCallback, fixedDeltaTime = 1/60) {
    this.updateCallback = updateCallback
    this.renderCallback = renderCallback
    this.fixedDeltaTime = fixedDeltaTime
    
    this.animationId = null
    this.isRunning = false
    this.lastTime = 0
    this.accumulator = 0
    this.maxAccumulator = 0.25
    
    this.totalTime = 0
    this.frameCount = 0
  }
  
  start() {
    if (this.isRunning) return
    
    this.isRunning = true
    this.lastTime = performance.now()
    this.accumulator = 0
    
    this.loop()
  }
  
  stop() {
    if (this.animationId) {
      cancelAnimationFrame(this.animationId)
      this.animationId = null
    }
    this.isRunning = false
  }
  
  loop() {
    if (!this.isRunning) return
    
    const currentTime = performance.now()
    let deltaTime = (currentTime - this.lastTime) / 1000
    this.lastTime = currentTime
    
    if (deltaTime > this.maxAccumulator) {
      deltaTime = this.maxAccumulator
    }
    
    this.accumulator += deltaTime
    
    while (this.accumulator >= this.fixedDeltaTime) {
      this.updateCallback(this.fixedDeltaTime)
      this.totalTime += this.fixedDeltaTime
      this.frameCount++
      this.accumulator -= this.fixedDeltaTime
    }
    
    const alpha = this.accumulator / this.fixedDeltaTime
    this.renderCallback(alpha)
    
    this.animationId = requestAnimationFrame(() => this.loop())
  }
  
  getStats() {
    return {
      totalTime: this.totalTime,
      frameCount: this.frameCount,
      avgFPS: this.frameCount / this.totalTime
    }
  }
}

插值渲染

class InterpolatedRenderLoop {
  constructor(canvas) {
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
    
    this.entities = []
    this.animationId = null
    this.isRunning = false
    this.lastTime = 0
    this.accumulator = 0
    this.fixedDeltaTime = 1/60
  }
  
  addEntity(entity) {
    this.entities.push(entity)
  }
  
  start() {
    if (this.isRunning) return
    
    this.isRunning = true
    this.lastTime = performance.now()
    
    this.loop()
  }
  
  stop() {
    if (this.animationId) {
      cancelAnimationFrame(this.animationId)
      this.animationId = null
    }
    this.isRunning = false
  }
  
  loop() {
    if (!this.isRunning) return
    
    const currentTime = performance.now()
    const deltaTime = (currentTime - this.lastTime) / 1000
    this.lastTime = currentTime
    
    this.accumulator += deltaTime
    
    while (this.accumulator >= this.fixedDeltaTime) {
      this.update(this.fixedDeltaTime)
      this.accumulator -= this.fixedDeltaTime
    }
    
    const alpha = this.accumulator / this.fixedDeltaTime
    this.render(alpha)
    
    this.animationId = requestAnimationFrame(() => this.loop())
  }
  
  update(dt) {
    this.entities.forEach(entity => {
      entity.prevX = entity.x
      entity.prevY = entity.y
      entity.update(dt)
    })
  }
  
  render(alpha) {
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
    
    this.entities.forEach(entity => {
      const renderX = entity.prevX + (entity.x - entity.prevX) * alpha
      const renderY = entity.prevY + (entity.y - entity.prevY) * alpha
      
      entity.render(this.ctx, renderX, renderY)
    })
  }
}

暂停与恢复

可暂停动画循环

class PausableAnimationLoop {
  constructor(callback) {
    this.callback = callback
    this.animationId = null
    this.isRunning = false
    this.isPaused = false
    this.lastTime = 0
    this.pauseTime = 0
    this.totalPausedTime = 0
  }
  
  start() {
    if (this.isRunning) return
    
    this.isRunning = true
    this.isPaused = false
    this.lastTime = performance.now()
    this.totalPausedTime = 0
    
    this.loop()
  }
  
  stop() {
    this.pause()
    this.isRunning = false
  }
  
  pause() {
    if (!this.isRunning || this.isPaused) return
    
    this.isPaused = true
    this.pauseTime = performance.now()
    
    if (this.animationId) {
      cancelAnimationFrame(this.animationId)
      this.animationId = null
    }
  }
  
  resume() {
    if (!this.isRunning || !this.isPaused) return
    
    this.isPaused = false
    this.totalPausedTime += performance.now() - this.pauseTime
    this.lastTime = performance.now()
    
    this.loop()
  }
  
  toggle() {
    if (this.isPaused) {
      this.resume()
    } else {
      this.pause()
    }
  }
  
  loop() {
    if (!this.isRunning || this.isPaused) return
    
    const currentTime = performance.now()
    const deltaTime = (currentTime - this.lastTime) / 1000
    this.lastTime = currentTime
    
    this.callback(deltaTime)
    
    this.animationId = requestAnimationFrame(() => this.loop())
  }
  
  getElapsed() {
    if (!this.isRunning) return 0
    
    if (this.isPaused) {
      return (this.pauseTime - this.lastTime - this.totalPausedTime) / 1000
    }
    
    return (performance.now() - this.lastTime - this.totalPausedTime) / 1000
  }
}

实战示例

完整动画框架

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Canvas requestAnimationFrame - 动画框架</title>
  <style>
    body {
      margin: 0;
      padding: 20px;
      background: #1a1a2e;
      font-family: system-ui, sans-serif;
    }
    
    .container {
      max-width: 900px;
      margin: 0 auto;
    }
    
    h1 {
      color: #eee;
      text-align: center;
    }
    
    .canvas-wrapper {
      background: #16213e;
      border-radius: 8px;
      padding: 10px;
      margin-bottom: 20px;
    }
    
    canvas {
      display: block;
      background: #0f0f23;
      border-radius: 4px;
      margin: 0 auto;
    }
    
    .stats {
      color: #00ff00;
      font-family: monospace;
      font-size: 12px;
      text-align: center;
      margin-top: 10px;
    }
    
    .controls {
      display: flex;
      gap: 10px;
      justify-content: center;
      flex-wrap: wrap;
    }
    
    button {
      padding: 10px 20px;
      background: #e94560;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      font-size: 14px;
    }
    
    button:hover {
      background: #ff6b6b;
    }
    
    .slider-group {
      display: flex;
      align-items: center;
      gap: 10px;
      color: #eee;
    }
    
    input[type="range"] {
      width: 100px;
    }
  </style>
</head>
<body>
  <div class="container">
    <h1>requestAnimationFrame 动画框架</h1>
    
    <div class="canvas-wrapper">
      <canvas id="mainCanvas" width="800" height="400"></canvas>
      <div class="stats" id="stats">FPS: 0 | 帧时间: 0ms | 质量: 100%</div>
    </div>
    
    <div class="controls">
      <button onclick="toggleAnimation()">开始/暂停</button>
      <button onclick="resetAnimation()">重置</button>
      
      <div class="slider-group">
        <label>目标FPS:</label>
        <input type="range" id="fpsSlider" min="15" max="60" value="60" onchange="setTargetFPS(this.value)">
        <span id="fpsValue">60</span>
      </div>
      
      <div class="slider-group">
        <label>粒子数:</label>
        <input type="range" id="particleSlider" min="50" max="500" value="200" onchange="setParticleCount(this.value)">
        <span id="particleValue">200</span>
      </div>
    </div>
  </div>
  
  <script>
    class Particle {
      constructor(canvasWidth, canvasHeight) {
        this.reset(canvasWidth, canvasHeight)
      }
      
      reset(canvasWidth, canvasHeight) {
        this.x = Math.random() * canvasWidth
        this.y = Math.random() * canvasHeight
        this.prevX = this.x
        this.prevY = this.y
        this.vx = (Math.random() - 0.5) * 100
        this.vy = (Math.random() - 0.5) * 100
        this.radius = Math.random() * 4 + 2
        this.hue = Math.random() * 360
      }
      
      update(dt, canvasWidth, canvasHeight) {
        this.prevX = this.x
        this.prevY = this.y
        
        this.x += this.vx * dt
        this.y += this.vy * dt
        
        if (this.x < this.radius || this.x > canvasWidth - this.radius) {
          this.vx *= -1
          this.x = Math.max(this.radius, Math.min(canvasWidth - this.radius, this.x))
        }
        if (this.y < this.radius || this.y > canvasHeight - this.radius) {
          this.vy *= -1
          this.y = Math.max(this.radius, Math.min(canvasHeight - this.radius, this.y))
        }
      }
      
      render(ctx, alpha = 1) {
        const renderX = this.prevX + (this.x - this.prevX) * alpha
        const renderY = this.prevY + (this.y - this.prevY) * alpha
        
        ctx.beginPath()
        ctx.arc(renderX, renderY, this.radius, 0, Math.PI * 2)
        ctx.fillStyle = `hsla(${this.hue}, 70%, 60%, 0.8)`
        ctx.fill()
      }
    }
    
    class AnimationFramework {
      constructor(canvas) {
        this.canvas = canvas
        this.ctx = canvas.getContext('2d')
        
        this.particles = []
        this.targetFPS = 60
        this.fixedDeltaTime = 1/60
        
        this.animationId = null
        this.isRunning = false
        this.lastTime = 0
        this.accumulator = 0
        
        this.frameCount = 0
        this.fps = 0
        this.fpsUpdateTime = 0
        this.frameTime = 0
        
        this.quality = 1
        
        this.init()
      }
      
      init() {
        this.createParticles(200)
      }
      
      createParticles(count) {
        this.particles = []
        for (let i = 0; i < count; i++) {
          this.particles.push(new Particle(this.canvas.width, this.canvas.height))
        }
      }
      
      start() {
        if (this.isRunning) return
        
        this.isRunning = true
        this.lastTime = performance.now()
        this.accumulator = 0
        this.fpsUpdateTime = this.lastTime
        
        this.loop()
      }
      
      stop() {
        if (this.animationId) {
          cancelAnimationFrame(this.animationId)
          this.animationId = null
        }
        this.isRunning = false
      }
      
      toggle() {
        if (this.isRunning) {
          this.stop()
        } else {
          this.start()
        }
      }
      
      loop() {
        if (!this.isRunning) return
        
        const currentTime = performance.now()
        const deltaTime = (currentTime - this.lastTime) / 1000
        this.lastTime = currentTime
        
        this.frameTime = deltaTime * 1000
        
        this.frameCount++
        if (currentTime - this.fpsUpdateTime >= 1000) {
          this.fps = this.frameCount
          this.frameCount = 0
          this.fpsUpdateTime = currentTime
        }
        
        this.accumulator += deltaTime
        
        const maxIterations = 5
        let iterations = 0
        while (this.accumulator >= this.fixedDeltaTime && iterations < maxIterations) {
          this.update(this.fixedDeltaTime)
          this.accumulator -= this.fixedDeltaTime
          iterations++
        }
        
        const alpha = this.accumulator / this.fixedDeltaTime
        this.render(alpha)
        
        this.adjustQuality(deltaTime)
        
        this.animationId = requestAnimationFrame(() => this.loop())
      }
      
      update(dt) {
        this.particles.forEach(p => {
          p.update(dt, this.canvas.width, this.canvas.height)
        })
      }
      
      render(alpha) {
        this.ctx.fillStyle = 'rgba(15, 15, 35, 0.3)'
        this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height)
        
        const renderCount = Math.ceil(this.particles.length * this.quality)
        
        for (let i = 0; i < renderCount; i++) {
          this.particles[i].render(this.ctx, alpha)
        }
      }
      
      adjustQuality(deltaTime) {
        const targetFrameTime = 1 / this.targetFPS
        
        if (deltaTime > targetFrameTime * 1.5 && this.quality > 0.3) {
          this.quality -= 0.05
        } else if (deltaTime < targetFrameTime * 0.8 && this.quality < 1) {
          this.quality += 0.02
        }
        
        this.quality = Math.max(0.3, Math.min(1, this.quality))
      }
      
      setTargetFPS(fps) {
        this.targetFPS = fps
        this.fixedDeltaTime = 1 / fps
      }
      
      setParticleCount(count) {
        this.createParticles(count)
      }
      
      reset() {
        this.createParticles(this.particles.length)
        this.quality = 1
      }
      
      getStats() {
        return {
          fps: this.fps,
          frameTime: this.frameTime.toFixed(2),
          quality: (this.quality * 100).toFixed(0)
        }
      }
    }
    
    const canvas = document.getElementById('mainCanvas')
    const stats = document.getElementById('stats')
    const fpsSlider = document.getElementById('fpsSlider')
    const fpsValue = document.getElementById('fpsValue')
    const particleSlider = document.getElementById('particleSlider')
    const particleValue = document.getElementById('particleValue')
    
    const framework = new AnimationFramework(canvas)
    
    function updateStats() {
      const s = framework.getStats()
      stats.textContent = `FPS: ${s.fps} | 帧时间: ${s.frameTime}ms | 质量: ${s.quality}%`
      requestAnimationFrame(updateStats)
    }
    
    function toggleAnimation() {
      framework.toggle()
    }
    
    function resetAnimation() {
      framework.reset()
    }
    
    function setTargetFPS(value) {
      framework.setTargetFPS(parseInt(value))
      fpsValue.textContent = value
    }
    
    function setParticleCount(value) {
      framework.setParticleCount(parseInt(value))
      particleValue.textContent = value
    }
    
    fpsSlider.addEventListener('input', (e) => {
      fpsValue.textContent = e.target.value
    })
    
    particleSlider.addEventListener('input', (e) => {
      particleValue.textContent = e.target.value
    })
    
    framework.start()
    updateStats()
  </script>
</body>
</html>

最佳实践

requestAnimationFrame 使用原则

const rafBestPractices = {
  timing: {
    title: '时间管理',
    tips: [
      '使用performance.now()获取精确时间',
      '计算deltaTime而非假设固定值',
      '限制最大deltaTime防止跳帧',
      '使用固定时间步进保证物理稳定'
    ]
  },
  
  performance: {
    title: '性能优化',
    tips: [
      '避免在回调中创建对象',
      '使用对象池复用对象',
      '根据性能动态调整质量',
      '跳过不必要的渲染帧'
    ]
  },
  
  lifecycle: {
    title: '生命周期管理',
    tips: [
      '正确取消动画帧',
      '处理页面可见性变化',
      '实现暂停/恢复功能',
      '清理不再使用的资源'
    ]
  },
  
  compatibility: {
    title: '兼容性处理',
    tips: [
      '提供降级方案(setTimeout)',
      '检测浏览器前缀',
      '处理不支持的情况'
    ]
  }
}

function getRequestAnimationFrame() {
  return window.requestAnimationFrame ||
         window.webkitRequestAnimationFrame ||
         window.mozRequestAnimationFrame ||
         window.oRequestAnimationFrame ||
         window.msRequestAnimationFrame ||
         function(callback) {
           return setTimeout(callback, 16.67)
         }
}

function getCancelAnimationFrame() {
  return window.cancelAnimationFrame ||
         window.webkitCancelAnimationFrame ||
         window.mozCancelAnimationFrame ||
         window.oCancelAnimationFrame ||
         window.msCancelAnimationFrame ||
         function(id) {
           clearTimeout(id)
         }
}

页面可见性处理

class VisibilityAwareLoop {
  constructor(callback) {
    this.callback = callback
    this.animationId = null
    this.isRunning = false
    this.isPaused = false
    this.lastTime = 0
    
    this.setupVisibilityHandler()
  }
  
  setupVisibilityHandler() {
    document.addEventListener('visibilitychange', () => {
      if (document.hidden) {
        this.pause()
      } else if (this.isRunning) {
        this.resume()
      }
    })
  }
  
  start() {
    if (this.isRunning) return
    
    this.isRunning = true
    this.isPaused = false
    this.lastTime = performance.now()
    
    this.loop()
  }
  
  stop() {
    this.pause()
    this.isRunning = false
  }
  
  pause() {
    if (!this.isRunning || this.isPaused) return
    
    this.isPaused = true
    if (this.animationId) {
      getCancelAnimationFrame()(this.animationId)
      this.animationId = null
    }
  }
  
  resume() {
    if (!this.isRunning || !this.isPaused) return
    
    this.isPaused = false
    this.lastTime = performance.now()
    this.loop()
  }
  
  loop() {
    if (!this.isRunning || this.isPaused) return
    
    const currentTime = performance.now()
    const deltaTime = (currentTime - this.lastTime) / 1000
    this.lastTime = currentTime
    
    this.callback(deltaTime)
    
    this.animationId = getRequestAnimationFrame()(() => this.loop())
  }
}

总结

requestAnimationFrame是Canvas动画的核心API:

  1. 同步刷新:与浏览器刷新率同步,避免撕裂
  2. 自动节流:页面不可见时自动暂停
  3. 帧率控制:实现固定帧率和自适应帧率
  4. 时间步进:固定时间步进保证物理稳定
  5. 生命周期:正确管理动画的开始、暂停、停止

合理使用requestAnimationFrame,可以实现流畅、高效的Canvas动画效果。