
Привет, друзья!
Можете ли вы ответить на вопрос о том, в чем заключается разница между requestAnimationFrame и requestIdleCallback?
Если можете, то я завидую глубине ваших знаний. Я не смог, когда меня об этом спросили. Более того, в тот момент я даже не знал о существовании интерфейса requestIdleCallback. Теперь знаю и хочу с вами этими знаниями поделиться.
Сразу уточним, что названные интерфейсы предоставляются браузером и к ECMAScript отношения не имеют.
Что касается поддержки, то с requestAnimationFrame все хорошо, а с requestIdleCallback, в основном из-за Safari, этого современного IE, ситуация хуже.
Рассматриваемые интерфейсы позволяют разработчикам получать доступ к процессу рендеринга страницы. Также они очень тесно связаны с циклом событий (event loop) браузера.
В сети имеется большое количество замечательных материалов, посвященных рендерингу и циклу событий. Я не вижу смысла дублировать здесь эти материалы.
Что касается рендеринга, могу порекомендовать следующее (от абстрактного к конкретному):
- How the browser renders a web page? — DOM, CSSOM, and Rendering
- Inside look at modern web browser (part 3)
- Оптимизация выполнения JavaScript
- Производительность визуализации
- Using requestIdleCallback
Также советую взглянуть на эти видео, посвященные циклу событий:
- Филипп Робертс: Что за чертовщина такая event loop? | JSConf EU 2014
- Джейк Арчибальд. В цикле — JSConf.Asia
Потратьте время на ознакомление с данными материалами, не пожалеете. Я подожду :)
Итак, что мы имеем в сухом остатке?
requestAnimationFrame
Метод requestAnimationFrame предоставляет разработчикам доступ к жизненному циклу фрейма, позволяя выполнять операции перед вычислением стилей и формированием макета (layout) документа браузером. Вот почему данный метод отлично подходит для реализации анимации. Собственно, для этого он и предназначен.
Во-первых, он вызывается не чаще и не реже, чем браузер вычисляет макет (правильная частота). Во-вторых, он вызывается перед формированием макета (правильное время). Поэтому rAF также отлично подходит для внесения изменений в DOM или CSSOM. Он синхронизирован с vsync, как и любой другой механизм рендеринга, используемый браузером.
Рассмотрим пример анимирования элемента с помощью rAF.
<div class="box_for_animation"></div> <button class="button_for_animation">Start animation</button>
У нас имеется анимируемый контейнер и кнопка для запуска анимации.
Стили, с вашего позволения, я опущу, поскольку в них нет ничего особенного (в конце раздела будет ссылка на песочницу).
const animationBox = document.querySelector('.box_for_animation') const animationButton = document.querySelector('.button_for_animation') let animationStart let requestId
Получаем ссылки на DOM-элементы и определяем глобальные переменные для времени начала анимации и идентификатора запроса.
function startAnimation() { requestId = window.requestAnimationFrame(animate) animationButton.style.opacity = 0 }
Определяем функцию для запуска анимации. requestAnimationFrame возвращает идентификатор запроса, который мы присваиваем созданной ранее переменной. Кнопка для запуска анимации после клика по ней плавно скрывается.
animationButton.addEventListener('click', startAnimation, { once: true })
Добавляем одноразовый обработчик события click.
function animate(timestamp) { if (!animationStart) { animationStart = timestamp } const progress = timestamp - animationStart animationBox.style.transform = `translateX(${progress / 5}px)` const x = animationBox.getBoundingClientRect().x + 100 // 6px - scrollbar width if (x <= window.innerWidth - 6) { window.requestAnimationFrame(animate) } else { window.cancelAnimationFrame(requestId) } }
Определяем функцию для анимирования контейнера. Функция принимает timestamp — время, прошедшее с начала выполнения запроса в мс. Анимирование заключается в постепенном сдвиге элемента до достижения им правой границы области просмотра. 100 — это ширина контейнера, а 6 — ширина панели прокрутки, установленные с помощью стилей. Когда значение координаты x элемента с учетом его ширины становится равным или больше значения ширины области просмотра, анимация отменяется.
Зацикливание анимации с помощью rAF часто используется при рисовании на canvas, например, при разработке 2D-игр.
rAF иногда также применяется для оптимизации обработчиков события scroll. Делается это следующим образом (источник):
let scheduledAnimationFrame // читаем и обновляем страницу function readAndUpdatePage(){ console.log('read and update') scheduledAnimationFrame = false } function onScroll () { // сохраняем значение прокрутки для будущего использования const lastScrollY = window.scrollY // предотвращаем множественный вызов колбека, переданного `rAF` if (scheduledAnimationFrame) { return } scheduledAnimationFrame = true window.requestAnimationFrame(readAndUpdatePage) } window.addEventListener('scroll', onScroll)
В теории, использование данного паттерна откладывает выполнение операции readAndUpdatePage, как минимум, до следующего фрейма — действительно, изменять макет чаще, чем его рендерит браузер, не имеет смысла.
Однако индикатор scheduledAnimationFrame бесполезен, поскольку событие scroll возникает при рендеринге позиции прокрутки браузером. Это означает, что данное событие синхронизировано с рендерингом. По сути, это то, что делает rAF — позволяет синхронизировать запуск колбека с рендерингом страницы.
Вот как можно убедиться в бесполезности scheduledAnimationFrame:
if (scheduledAnimationFrame) { console.log('prevented rAF callback') return }
Сообщение prevented rAF callback никогда не попадет в консоль. Следовательно, данный код является мертвым (dead code).
Для того чтобы получить максимальную выгоду от использования rAF, колбек должен запускаться под определенным условием.
Рассмотрим пример вывода сообщения о том, что элемент находится в области просмотра.
<p class="message_for_scroll"></p> <div class="box_for_scroll"></div>
У нас имеется сообщение и контейнер, за нахождением которого в области просмотра мы будем следить при обработке события прокрутки.
const scrollMessage = document.querySelector('.message_for_scroll') const scrollBox = document.querySelector('.box_for_scroll')
Получаем ссылки на DOM-элементы.
function showMessage() { // код функции будет выполняться только один раз if (!scrollMessage.textContent) { scrollMessage.textContent = 'scrollBox is in viewport' scrollMessage.style.opacity = 1 } }
Определяем функцию для отображения сообщения о том, что контейнер находится в области просмотра.
function onScroll() { const { top, bottom } = scrollBox.getBoundingClientRect() // если контейнер находится в области просмотра if (top < window.scrollY && bottom > 0) { // зацикливаем "анимацию" window.requestAnimationFrame(showMessage) // иначе, если имеется сообщение } else if (scrollMessage.textContent) { scrollMessage.style.opacity = 0 // выполняем задержку для плавного скрытия сообщения const timerId = setTimeout(() => { scrollMessage.textContent = '' clearTimeout(timerId) }, 500) } } window.addEventListener('scroll', onScroll)
Определяем обработчик прокрутки и регистрируем его на объекте window.
Демо анимирования элемента и обработки прокрутки с помощью rAF:
requestIdleCallback
Метод requestIdleCallback позволяет выполнять низкоприоритетные операции в период простоя браузера (отсюда idle) внутри фрейма (обычно, это происходит после вычисления браузером макета и его перерисовки, когда осталось какое-то время перед синхронизацией). Даже если с точки зрения пользователя страница "подвисает", могут быть периоды, когда браузер находится в режиме ожидания. Максимальная продолжительность времени, формально предоставляемая rIC для выполнения задачи, составляет 50 мс. Фактически же в нашем распоряжении имеется всего 0.5-10 мс. Поэтому, если внутри rIC вызывается функция для изменения DOM, ее следует вызывать с помощью rAF. Это объясняется тем, что модификация DOM — это потенциально продолжительная операция, на выполнение которой в rIC может не хватить времени.
Обработку события прокрутки вполне можно отнести к низкоприоритетным задачам. Поэтому для задержки вызова таких обработчиков можно использовать rIC. При этом, для реализации дополнительной задержки можно использовать setTimeout. Здесь можно найти примеры реализации debounce и throttle с помощью rIC и setTimeout.
Рассмотрим более интересный пример: совместное использование rIC и rAF для выполнения низкоприоритетной, но потенциально продолжительной задачи — "пакетному" рендерингу новых DOM-элементов.
<div class="buttons"> <button data-type="square" class="button">Create square</button> <button data-type="polygon" class="button">Create polygon</button> <button data-type="circle" class="button">Create circle</button> <button data-type="render" class="button">Render shapes</button> </div> <div class="stat"> <p>Squares: <span class="counter" data-for="square">0</span></p> <p>Polygons: <span class="counter" data-for="polygon">0</span></p> <p>Circles: <span class="counter" data-for="circle">0</span></p> </div>
У нас имеются кнопки для создания "виртуальных" фигур (квадрата, многоугольника и круга) и рендеринга этих фигур, а также статистика виртуальных фигур.
// ссылки на DOM-элементы const shapeButtons = document.querySelector('.buttons') const statBox = document.querySelector('.stat') // поисковая таблица для значений счетчиков const counterByShape = { square: statBox.querySelector("[data-for='square']"), polygon: statBox.querySelector("[data-for='polygon']"), circle: statBox.querySelector("[data-for='circle']") } // см. ниже let nextUnitOfWork = null let shapesToRender = [] let render = false let randomShape = true // поисковая таблица для определения следующей (произвольной) фигуры const randomShapeMap = { square: 'polygon', polygon: 'circle', circle: 'square' }
Создаем 4 глобальных переменных:
nextUniOfWork— следующая единица работы: задача, которая будет выполняться браузером в период простояshapesToRender— хранилище для виртуальных фигурrender— индикатор начала рендеринга виртуальных фигурrandomShape— индикатор произвольной фигуры
window.requestIdleCallback = window.requestIdleCallback || function (handler) { const start = Date.now() return setTimeout(() => { handler({ didTimeout: false, timeRemaining: () => Math.max(0, 50 - (Date.now() - start)) }) }, 1) }
Поскольку поддержка requestIdleCallback оставляет желать лучшего, нам необходим такой shim. Это не polyfill — настоящий rIC работает немного иначе.
function workLoop(deadline) { while (nextUnitOfWork && deadline.timeRemaining() > 0) { nextUnitOfWork = performUnitOfWork(nextUnitOfWork) } if (!nextUnitOfWork && render) { window.requestAnimationFrame(updateDom) } window.requestIdleCallback(workLoop) } window.requestIdleCallback(workLoop)
С помощью 2 requestIdleCallback мы создаем бесконечный цикл workLoop. В колбеке выполняется 2 проверки:
- если имеется следующая единица работы (
nextUnitOfWork) и у браузера есть время на ее выполнение (deadline.timeRemaining() > 0), вызывается функцияperformUnitOfWork, выполняющая задачу и возвращающая следующую задачу (при наличии таковой) - если все единицы работы выполнены и индикатор рендеринга имеет значение
true, с помощьюrAFвызывается функцияupdateDom, выполняющая рендеринг виртуальных элементов
function performUnitOfWork(type) { const shape = document.createElement('div') shape.className = `shape ${type}` shapesToRender.push(shape) counterByShape[type].textContent = Number(counterByShape[type].textContent) + 1 if (randomShape) { randomShape = false return randomShapeMap[type] } return null }
В функции performUnitOfWork на основе типа (type) создается та или иная фигура, которая не рендерится сразу, а помещается в хранилище (shapesToRender). Затем обновляется значение соответствующего счетчика (мы можем позволить себе выполнение этой операции, поскольку уверены в ее "легкости" с точки зрения производительности и времени выполнения). Наконец, в качестве следующей единицы работы возвращается тип произвольной фигуры. Обратите внимание, что тип произвольной фигуры возвращается один раз для каждой "пользовательской" фигуры.
function updateDom() { const shapeBox = document.createElement('div') shapeBox.className = 'shapes' shapesToRender.forEach((el, i) => { el.classList.add('show') el.style.animationDelay = i * 0.5 + 's' shapeBox.append(el) }) document.body.append(shapeBox) Object.values(counterByShape).forEach((counter) => { counter.textContent = '0' }) shapesToRender = [] render = false }
В функции updateDom создается контейнер, в который помещаются фигуры из хранилища, и который затем рендерится в теле документа. Значения счетчиков обнуляются. Хранилище очищается. Индикатору начала рендеринга присваивается значение false.
shapeButtons.addEventListener('click', (e) => { const { type } = e.target.dataset if (!type) return if (type === 'render') { render = true return } randomShape = true nextUnitOfWork = type })
В обработчике нажатия кно��ки мы получаем тип из атрибута data-type. Если типом является render, индикатору начала рендеринга присваивается значение true. Это приводит к вызову updateDom с помощью rAF внутри workLoop, зацикленной с помощью rIC. Иначе индикатору произвольной фигуры присваивается значение true, а следующей единице работы — тип пользовательской фигуры.
Таким образом, мы имеем следующий flow:
- у пользователя есть возможность генерировать любое количество виртуальных фигур без ущерба для производительности приложения (представим, что вместо фигур у нас выполняются "тяжелые" задачи, что может плохо повлиять на пользовательский опыт за счет снижения интерактивности страницы)
- рендеринг виртуальных фигур полностью контролируется пользователем — при нажатии кнопки
Renderпользователь осознает возможные негативные последствия, о которых говорилось выше, и готов к ним (снижение производительности является ожидаемым) - браузер выполняет создание виртуальных фигур, только если у него имеется такая возможность
Тонкий момент: в качестве второго опционального параметра rIC принимает объект с настройками. Единственной доступной на сегодняшний день настройкой является timeout:
requestIdleCallback(callback, { timeout: 1000 })
Эта настройка позволяет гарантировать запуск колбека по истечение указанного времени (в мс), даже если браузер при этом не находится в режиме ожидания. Проверяется это следующим образом:
while (nextUnitOfWork && deadline.timeRemaining() > 0 || deadline.didTimeout) { nextUnitOfWork = performUnitOfWork(nextUnitOfWork) }
В данном случае по истечении 1 сек с момента вызова rIC свойство didTimeout получит значение true. Это приведет к принудительному запуску колбека.
Обратной стороной медали является то, что принудительный вызов колбека может произойти в неподходящее время, например, когда у браузера имеется более важная задача, такая как обработка пользовательского ввода или плавное анимирование элемента.
Учитывая частоту проверок (60 раз в секунду или 1 раз в 16,67 мс), вероятность того, что запуск колбека будет отложен на ощутимое для пользователя время, практически исключается. Этим объясняется отсутствие timeout в нашем примере.
Демо приложения:
Пожалуй, это все, чем я хотел поделиться с вами в данной статье.
Благодарю за внимание и хорошего дня!
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩

