Ты пишешь интерактивную диаграмму: столбцы анимированно вырастают при загрузке. Или игру: персонаж двигается плавно при нажатии клавиш. Или canvas-анимацию: частицы летят по экрану. CSS тут не поможет — нужен JavaScript. requestAnimationFrame — единственный правильный способ делать это.
setInterval для анимаций — плохой выбор: он не синхронизирован с перерисовкой экрана (может обновить элемент посередине кадра = тearing), работает в фоновых вкладках (тратит CPU/батарею), и не гарантирует точность timing'а. requestAnimationFrame решает все эти проблемы.
anim.finished в Web Animations API — это Promisefunction 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)
}let rafId = null
function start() {
rafId = requestAnimationFrame(tick)
}
function stop() {
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
}
// Обязательно отменяй при unmount компонента!
// useEffect(() => { start(); return () => stop(); }, [])| | setTimeout/setInterval | requestAnimationFrame |
|---|---|---|
| Синхронизация с монитором | Нет | Да (16.67ms при 60fps) |
| Фоновая вкладка | Продолжает работать | Автоматически паузирует |
| Точность timing | ~4ms минимум | Синхронно с render pipeline |
| Потребление CPU/батареи | Высокое в фоне | Минимальное в фоне |
// ПЛОХО: чередование чтения и записи — принудительный 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)`
})Симуляция 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(' - Не вызывает лишних перерисовок')Ты пишешь интерактивную диаграмму: столбцы анимированно вырастают при загрузке. Или игру: персонаж двигается плавно при нажатии клавиш. Или canvas-анимацию: частицы летят по экрану. CSS тут не поможет — нужен JavaScript. requestAnimationFrame — единственный правильный способ делать это.
setInterval для анимаций — плохой выбор: он не синхронизирован с перерисовкой экрана (может обновить элемент посередине кадра = тearing), работает в фоновых вкладках (тратит CPU/батарею), и не гарантирует точность timing'а. requestAnimationFrame решает все эти проблемы.
anim.finished в Web Animations API — это Promisefunction 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)
}let rafId = null
function start() {
rafId = requestAnimationFrame(tick)
}
function stop() {
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
}
// Обязательно отменяй при unmount компонента!
// useEffect(() => { start(); return () => stop(); }, [])| | setTimeout/setInterval | requestAnimationFrame |
|---|---|---|
| Синхронизация с монитором | Нет | Да (16.67ms при 60fps) |
| Фоновая вкладка | Продолжает работать | Автоматически паузирует |
| Точность timing | ~4ms минимум | Синхронно с render pipeline |
| Потребление CPU/батареи | Высокое в фоне | Минимальное в фоне |
// ПЛОХО: чередование чтения и записи — принудительный 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)`
})Симуляция 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