← JavaScript/requestAnimationFrame#166 из 383← ПредыдущийСледующий →+30 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: JS базаПрактика: async и сетьТермин: Closure

requestAnimationFrame

Ты пишешь интерактивную диаграмму: столбцы анимированно вырастают при загрузке. Или игру: персонаж двигается плавно при нажатии клавиш. Или canvas-анимацию: частицы летят по экрану. CSS тут не поможет — нужен JavaScript. requestAnimationFrame — единственный правильный способ делать это.

Какую проблему решает

setInterval для анимаций — плохой выбор: он не синхронизирован с перерисовкой экрана (может обновить элемент посередине кадра = тearing), работает в фоновых вкладках (тратит CPU/батарею), и не гарантирует точность timing'а. requestAnimationFrame решает все эти проблемы.

На основе предыдущих уроков

  • Кривые Безье: easing-функции применяются к прогрессу анимации
  • CSS анимации: rAF используется когда CSS-анимаций недостаточно
  • Promise: anim.finished в Web Animations API — это Promise
  • Базовый паттерн rAF

    function animate(timestamp) {
      // timestamp — время в мс с момента загрузки страницы
      // Обновляем состояние и DOM...
      element.style.transform = `translateX(${position}px)`
    
      requestAnimationFrame(animate)  // Запрашиваем следующий кадр
    }
    
    requestAnimationFrame(animate)  // Запускаем

    Правильный паттерн: прогресс от времени

    Никогда не полагайся на количество кадров — их число варьируется. Считай прогресс от реального времени:

    function createAnimation({ duration, easing, onFrame, onComplete }) {
      let startTime = null
    
      function tick(timestamp) {
        if (!startTime) startTime = timestamp
    
        const elapsed  = timestamp - startTime
        const progress = Math.min(elapsed / duration, 1)  // 0..1
        const easedT   = easing(progress)
    
        onFrame(easedT, progress)  // пользователь обновляет UI
    
        if (progress < 1) {
          requestAnimationFrame(tick)
        } else {
          onComplete?.()
        }
      }
    
      return requestAnimationFrame(tick)
    }

    cancelAnimationFrame — остановка анимации

    let rafId = null
    
    function start() {
      rafId = requestAnimationFrame(tick)
    }
    
    function stop() {
      if (rafId !== null) {
        cancelAnimationFrame(rafId)
        rafId = null
      }
    }
    
    // Обязательно отменяй при unmount компонента!
    // useEffect(() => { start(); return () => stop(); }, [])

    rAF vs setTimeout/setInterval

    | | setTimeout/setInterval | requestAnimationFrame |

    |---|---|---|

    | Синхронизация с монитором | Нет | Да (16.67ms при 60fps) |

    | Фоновая вкладка | Продолжает работать | Автоматически паузирует |

    | Точность timing | ~4ms минимум | Синхронно с render pipeline |

    | Потребление CPU/батареи | Высокое в фоне | Минимальное в фоне |

    Layout thrashing — частая ошибка

    // ПЛОХО: чередование чтения и записи — принудительный reflow
    elements.forEach(el => {
      const width = el.offsetWidth   // ЧИТАЕМ — вызывает layout
      el.style.width = width + 'px'  // ПИШЕМ — инвалидирует layout
    })
    
    // ХОРОШО: сначала все чтения, потом все записи
    const widths = elements.map(el => el.offsetWidth)  // читаем
    elements.forEach((el, i) => {
      el.style.width = widths[i] + 'px'  // пишем
    })

    Типичные ошибки

    Ошибка 1: Фиксированный шаг вместо delta time

    // ПЛОХО — зависит от fps
    function tick() {
      position += 5  // на 60fps = 300px/s, на 30fps = 150px/s!
      requestAnimationFrame(tick)
    }
    
    // ХОРОШО — скорость не зависит от fps
    let lastTime = 0
    function tick(timestamp) {
      const delta = timestamp - lastTime
      lastTime = timestamp
      position += SPEED * (delta / 1000)  // px/sec × секунды
      requestAnimationFrame(tick)
    }

    Ошибка 2: Не отменять rAF при unmount

    // ПЛОХО — утечка: анимация продолжается после удаления компонента
    connectedCallback() { requestAnimationFrame(this.tick) }
    
    // ХОРОШО — отменяем в disconnectedCallback
    connectedCallback() { this._rafId = requestAnimationFrame(this.tick.bind(this)) }
    disconnectedCallback() { cancelAnimationFrame(this._rafId) }

    Ошибка 3: Изменять не GPU-свойства

    // ПЛОХО — reflow на каждый кадр
    requestAnimationFrame(() => {
      el.style.left = position + 'px'  // reflow!
    })
    
    // ХОРОШО — GPU
    requestAnimationFrame(() => {
      el.style.transform = `translateX(${position}px)`
    })

    В реальных проектах

  • Canvas игры: game loop с rAF — стандарт для 2D/3D игр
  • D3.js визуализации: анимированные диаграммы, данные в реальном времени
  • Параллакс-эффекты: плавное смещение слоёв при скролле
  • Scroll-анимации: smooth scroll, анимация при прокрутке
  • WebGL: рендеринг 3D-сцены синхронизирован с rAF
  • Примеры

    Симуляция rAF анимационного цикла: прогресс-бар, пружинная анимация, параллельные анимации

    // Симуляция requestAnimationFrame без браузера
    // В реальном коде заменяем simulateLoop на requestAnimationFrame
    
    // ===== Симулятор 60fps цикла =====
    function simulateLoop(duration, onFrame, onComplete) {
      const frameMs = 1000 / 60  // ~16.67ms
      let time = 0
    
      while (time <= duration + frameMs) {
        const progress = Math.min(time / duration, 1)
        onFrame(progress, time)
        if (progress >= 1) break
        time += frameMs
      }
    
      onComplete?.()
    }
    
    // Easing функции
    const easing = {
      linear:  t => t,
      easeOut: t => 1 - Math.pow(1 - t, 3),
      easeIn:  t => Math.pow(t, 3),
      spring:  t => {
        if (t === 0) return 0
        if (t === 1) return 1
        const c4 = (2 * Math.PI) / 3
        return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1
      },
    }
    
    // Универсальная анимация значения
    function animateValue(from, to, duration, easingFn, onUpdate) {
      simulateLoop(duration, progress => {
        const value = from + (to - from) * easingFn(progress)
        onUpdate(value, progress)
      })
    }
    
    // ===== Demo 1: Анимированный прогресс-бар =====
    console.log('=== Прогресс-бар: 0% → 100% за 500ms (easeOut) ===')
    
    const BAR_WIDTH = 20
    const keyPoints = new Set([0, 25, 50, 75, 100])
    
    let lastLoggedPct = -1
    animateValue(0, 100, 500, easing.easeOut, (value) => {
      const pct = Math.round(value)
      if (keyPoints.has(pct) && pct !== lastLoggedPct) {
        lastLoggedPct = pct
        const filled = Math.round(pct / 100 * BAR_WIDTH)
        const bar    = '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled)
        console.log(`  [${bar}] ${String(pct).padStart(3)}%`)
      }
    })
    
    // ===== Demo 2: Spring — пружинная анимация =====
    console.log('\n=== Spring анимация: 0→100 (значения могут быть > 100!) ===')
    
    const springLog = new Set()
    const springPoints = [0, 0.2, 0.4, 0.6, 0.8, 1.0]
    
    animateValue(0, 100, 800, easing.spring, (value, progress) => {
      const nearest = springPoints.find(p => Math.abs(progress - p) < 0.02)
      if (nearest !== undefined && !springLog.has(nearest)) {
        springLog.add(nearest)
        const val = Math.round(value)
        const bar = val <= 0 ? '' : '▓'.repeat(Math.max(0, Math.round(Math.min(val, 120) / 5)))
        const over = val > 100 ? ` (перелёт: +${val - 100}px)` : ''
        console.log(`  t=${nearest.toFixed(1)} val=${String(val).padStart(4)}  ${bar}${over}`)
      }
    })
    
    // ===== Demo 3: Параллельные анимации =====
    console.log('\n=== Параллельные анимации (все в одном rAF цикле) ===')
    console.log('(translateX, opacity, scale анимируются одновременно)')
    console.log()
    
    const state = { x: 0, opacity: 0, scale: 0.8 }
    const snapshots = new Map()
    
    simulateLoop(400, (progress) => {
      // Каждое свойство может иметь своё easing
      state.x       = easing.easeOut(progress) * 200
      state.opacity = easing.linear(progress)
      state.scale   = 0.8 + easing.easeOut(progress) * 0.2
    
      // Снэпшот на каждые 25%
      const key = Math.round(progress * 4) * 25
      if (!snapshots.has(key)) snapshots.set(key, { ...state })
    })
    
    console.log('progress  x(px)   opacity  scale')
    console.log('-'.repeat(40))
    for (const [pct, snap] of snapshots) {
      console.log(
        String(pct + '%').padEnd(10) +
        String(Math.round(snap.x)).padEnd(8) +
        String((snap.opacity).toFixed(2)).padEnd(9) +
        snap.scale.toFixed(2)
      )
    }
    
    // ===== Demo 4: cancelAnimationFrame паттерн =====
    console.log('\n=== Паттерн rAF с отменой ===')
    
    class Animator {
      constructor() {
        this._rafId  = null
        this._running = false
      }
    
      start(duration, onFrame) {
        if (this._running) return
        this._running = true
        let startTime = null
    
        const tick = (timestamp) => {
          if (!startTime) startTime = timestamp
          const progress = Math.min((timestamp - startTime) / duration, 1)
          onFrame(progress)
          if (progress < 1 && this._running) {
            this._rafId = requestAnimationFrame(tick)
          } else {
            this._running = false
          }
        }
    
        this._rafId = requestAnimationFrame(tick)
        console.log('  Animator: запущен, rafId =', this._rafId)
      }
    
      stop() {
        if (this._rafId !== null) {
          cancelAnimationFrame(this._rafId)
          this._rafId  = null
          this._running = false
          console.log('  Animator: остановлен через cancelAnimationFrame')
        }
      }
    }
    
    const animator = new Animator()
    console.log('Паттерн использования:')
    console.log('  animator.start(500, progress => el.style.opacity = progress)')
    console.log('  animator.stop()  // в disconnectedCallback или onUnmount')
    console.log()
    console.log('Почему rAF лучше setInterval для анимаций:')
    console.log('  - Синхронизирован с монитором (60fps)')
    console.log('  - Автоматически паузируется в фоновой вкладке')
    console.log('  - Не вызывает лишних перерисовок')

    requestAnimationFrame

    Ты пишешь интерактивную диаграмму: столбцы анимированно вырастают при загрузке. Или игру: персонаж двигается плавно при нажатии клавиш. Или canvas-анимацию: частицы летят по экрану. CSS тут не поможет — нужен JavaScript. requestAnimationFrame — единственный правильный способ делать это.

    Какую проблему решает

    setInterval для анимаций — плохой выбор: он не синхронизирован с перерисовкой экрана (может обновить элемент посередине кадра = тearing), работает в фоновых вкладках (тратит CPU/батарею), и не гарантирует точность timing'а. requestAnimationFrame решает все эти проблемы.

    На основе предыдущих уроков

  • Кривые Безье: easing-функции применяются к прогрессу анимации
  • CSS анимации: rAF используется когда CSS-анимаций недостаточно
  • Promise: anim.finished в Web Animations API — это Promise
  • Базовый паттерн rAF

    function animate(timestamp) {
      // timestamp — время в мс с момента загрузки страницы
      // Обновляем состояние и DOM...
      element.style.transform = `translateX(${position}px)`
    
      requestAnimationFrame(animate)  // Запрашиваем следующий кадр
    }
    
    requestAnimationFrame(animate)  // Запускаем

    Правильный паттерн: прогресс от времени

    Никогда не полагайся на количество кадров — их число варьируется. Считай прогресс от реального времени:

    function createAnimation({ duration, easing, onFrame, onComplete }) {
      let startTime = null
    
      function tick(timestamp) {
        if (!startTime) startTime = timestamp
    
        const elapsed  = timestamp - startTime
        const progress = Math.min(elapsed / duration, 1)  // 0..1
        const easedT   = easing(progress)
    
        onFrame(easedT, progress)  // пользователь обновляет UI
    
        if (progress < 1) {
          requestAnimationFrame(tick)
        } else {
          onComplete?.()
        }
      }
    
      return requestAnimationFrame(tick)
    }

    cancelAnimationFrame — остановка анимации

    let rafId = null
    
    function start() {
      rafId = requestAnimationFrame(tick)
    }
    
    function stop() {
      if (rafId !== null) {
        cancelAnimationFrame(rafId)
        rafId = null
      }
    }
    
    // Обязательно отменяй при unmount компонента!
    // useEffect(() => { start(); return () => stop(); }, [])

    rAF vs setTimeout/setInterval

    | | setTimeout/setInterval | requestAnimationFrame |

    |---|---|---|

    | Синхронизация с монитором | Нет | Да (16.67ms при 60fps) |

    | Фоновая вкладка | Продолжает работать | Автоматически паузирует |

    | Точность timing | ~4ms минимум | Синхронно с render pipeline |

    | Потребление CPU/батареи | Высокое в фоне | Минимальное в фоне |

    Layout thrashing — частая ошибка

    // ПЛОХО: чередование чтения и записи — принудительный reflow
    elements.forEach(el => {
      const width = el.offsetWidth   // ЧИТАЕМ — вызывает layout
      el.style.width = width + 'px'  // ПИШЕМ — инвалидирует layout
    })
    
    // ХОРОШО: сначала все чтения, потом все записи
    const widths = elements.map(el => el.offsetWidth)  // читаем
    elements.forEach((el, i) => {
      el.style.width = widths[i] + 'px'  // пишем
    })

    Типичные ошибки

    Ошибка 1: Фиксированный шаг вместо delta time

    // ПЛОХО — зависит от fps
    function tick() {
      position += 5  // на 60fps = 300px/s, на 30fps = 150px/s!
      requestAnimationFrame(tick)
    }
    
    // ХОРОШО — скорость не зависит от fps
    let lastTime = 0
    function tick(timestamp) {
      const delta = timestamp - lastTime
      lastTime = timestamp
      position += SPEED * (delta / 1000)  // px/sec × секунды
      requestAnimationFrame(tick)
    }

    Ошибка 2: Не отменять rAF при unmount

    // ПЛОХО — утечка: анимация продолжается после удаления компонента
    connectedCallback() { requestAnimationFrame(this.tick) }
    
    // ХОРОШО — отменяем в disconnectedCallback
    connectedCallback() { this._rafId = requestAnimationFrame(this.tick.bind(this)) }
    disconnectedCallback() { cancelAnimationFrame(this._rafId) }

    Ошибка 3: Изменять не GPU-свойства

    // ПЛОХО — reflow на каждый кадр
    requestAnimationFrame(() => {
      el.style.left = position + 'px'  // reflow!
    })
    
    // ХОРОШО — GPU
    requestAnimationFrame(() => {
      el.style.transform = `translateX(${position}px)`
    })

    В реальных проектах

  • Canvas игры: game loop с rAF — стандарт для 2D/3D игр
  • D3.js визуализации: анимированные диаграммы, данные в реальном времени
  • Параллакс-эффекты: плавное смещение слоёв при скролле
  • Scroll-анимации: smooth scroll, анимация при прокрутке
  • WebGL: рендеринг 3D-сцены синхронизирован с rAF
  • Примеры

    Симуляция rAF анимационного цикла: прогресс-бар, пружинная анимация, параллельные анимации

    // Симуляция requestAnimationFrame без браузера
    // В реальном коде заменяем simulateLoop на requestAnimationFrame
    
    // ===== Симулятор 60fps цикла =====
    function simulateLoop(duration, onFrame, onComplete) {
      const frameMs = 1000 / 60  // ~16.67ms
      let time = 0
    
      while (time <= duration + frameMs) {
        const progress = Math.min(time / duration, 1)
        onFrame(progress, time)
        if (progress >= 1) break
        time += frameMs
      }
    
      onComplete?.()
    }
    
    // Easing функции
    const easing = {
      linear:  t => t,
      easeOut: t => 1 - Math.pow(1 - t, 3),
      easeIn:  t => Math.pow(t, 3),
      spring:  t => {
        if (t === 0) return 0
        if (t === 1) return 1
        const c4 = (2 * Math.PI) / 3
        return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1
      },
    }
    
    // Универсальная анимация значения
    function animateValue(from, to, duration, easingFn, onUpdate) {
      simulateLoop(duration, progress => {
        const value = from + (to - from) * easingFn(progress)
        onUpdate(value, progress)
      })
    }
    
    // ===== Demo 1: Анимированный прогресс-бар =====
    console.log('=== Прогресс-бар: 0% → 100% за 500ms (easeOut) ===')
    
    const BAR_WIDTH = 20
    const keyPoints = new Set([0, 25, 50, 75, 100])
    
    let lastLoggedPct = -1
    animateValue(0, 100, 500, easing.easeOut, (value) => {
      const pct = Math.round(value)
      if (keyPoints.has(pct) && pct !== lastLoggedPct) {
        lastLoggedPct = pct
        const filled = Math.round(pct / 100 * BAR_WIDTH)
        const bar    = '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled)
        console.log(`  [${bar}] ${String(pct).padStart(3)}%`)
      }
    })
    
    // ===== Demo 2: Spring — пружинная анимация =====
    console.log('\n=== Spring анимация: 0→100 (значения могут быть > 100!) ===')
    
    const springLog = new Set()
    const springPoints = [0, 0.2, 0.4, 0.6, 0.8, 1.0]
    
    animateValue(0, 100, 800, easing.spring, (value, progress) => {
      const nearest = springPoints.find(p => Math.abs(progress - p) < 0.02)
      if (nearest !== undefined && !springLog.has(nearest)) {
        springLog.add(nearest)
        const val = Math.round(value)
        const bar = val <= 0 ? '' : '▓'.repeat(Math.max(0, Math.round(Math.min(val, 120) / 5)))
        const over = val > 100 ? ` (перелёт: +${val - 100}px)` : ''
        console.log(`  t=${nearest.toFixed(1)} val=${String(val).padStart(4)}  ${bar}${over}`)
      }
    })
    
    // ===== Demo 3: Параллельные анимации =====
    console.log('\n=== Параллельные анимации (все в одном rAF цикле) ===')
    console.log('(translateX, opacity, scale анимируются одновременно)')
    console.log()
    
    const state = { x: 0, opacity: 0, scale: 0.8 }
    const snapshots = new Map()
    
    simulateLoop(400, (progress) => {
      // Каждое свойство может иметь своё easing
      state.x       = easing.easeOut(progress) * 200
      state.opacity = easing.linear(progress)
      state.scale   = 0.8 + easing.easeOut(progress) * 0.2
    
      // Снэпшот на каждые 25%
      const key = Math.round(progress * 4) * 25
      if (!snapshots.has(key)) snapshots.set(key, { ...state })
    })
    
    console.log('progress  x(px)   opacity  scale')
    console.log('-'.repeat(40))
    for (const [pct, snap] of snapshots) {
      console.log(
        String(pct + '%').padEnd(10) +
        String(Math.round(snap.x)).padEnd(8) +
        String((snap.opacity).toFixed(2)).padEnd(9) +
        snap.scale.toFixed(2)
      )
    }
    
    // ===== Demo 4: cancelAnimationFrame паттерн =====
    console.log('\n=== Паттерн rAF с отменой ===')
    
    class Animator {
      constructor() {
        this._rafId  = null
        this._running = false
      }
    
      start(duration, onFrame) {
        if (this._running) return
        this._running = true
        let startTime = null
    
        const tick = (timestamp) => {
          if (!startTime) startTime = timestamp
          const progress = Math.min((timestamp - startTime) / duration, 1)
          onFrame(progress)
          if (progress < 1 && this._running) {
            this._rafId = requestAnimationFrame(tick)
          } else {
            this._running = false
          }
        }
    
        this._rafId = requestAnimationFrame(tick)
        console.log('  Animator: запущен, rafId =', this._rafId)
      }
    
      stop() {
        if (this._rafId !== null) {
          cancelAnimationFrame(this._rafId)
          this._rafId  = null
          this._running = false
          console.log('  Animator: остановлен через cancelAnimationFrame')
        }
      }
    }
    
    const animator = new Animator()
    console.log('Паттерн использования:')
    console.log('  animator.start(500, progress => el.style.opacity = progress)')
    console.log('  animator.stop()  // в disconnectedCallback или onUnmount')
    console.log()
    console.log('Почему rAF лучше setInterval для анимаций:')
    console.log('  - Синхронизирован с монитором (60fps)')
    console.log('  - Автоматически паузируется в фоновой вкладке')
    console.log('  - Не вызывает лишних перерисовок')

    Задание

    Реализуй `animateValue(from, to, duration, easing, onUpdate)` — универсальную функцию анимации. Используй предоставленный `simulateAnimation` (заменитель rAF). Функция должна: - Принимать начальное/конечное значение, длительность, функцию плавности и колбэк - Вызывать `onUpdate(value, progress, timeMs)` на каждом кадре - Финальное значение должно быть точно равно `to`

    Подсказка

    animateValue: simulateAnimation(duration, (progress, time) => { const easedProgress = easing(progress); const value = from + (to - from) * easedProgress; onUpdate(value, progress, time) }). Финальное значение гарантировано: when progress=1, easedProgress=1, value = from + (to-from)*1 = to

    Загружаем среду выполнения...
    Загружаем AI-помощника...