Pull to refresh
2095.26

JavaScript: заметка о requestAnimationFrame и requestIdleCallback

Reading time10 min
Views45K


Привет, друзья!


Можете ли вы ответить на вопрос о том, в чем заключается разница между requestAnimationFrame и requestIdleCallback?


Если можете, то я завидую глубине ваших знаний. Я не смог, когда меня об этом спросили. Более того, в тот момент я даже не знал о существовании интерфейса requestIdleCallback. Теперь знаю и хочу с вами этими знаниями поделиться.


Сразу уточним, что названные интерфейсы предоставляются браузером и к ECMAScript отношения не имеют.


Что касается поддержки, то с requestAnimationFrame все хорошо, а с requestIdleCallback, в основном из-за Safari, этого современного IE, ситуация хуже.


Рассматриваемые интерфейсы позволяют разработчикам получать доступ к процессу рендеринга страницы. Также они очень тесно связаны с циклом событий (event loop) браузера.


В сети имеется большое количество замечательных материалов, посвященных рендерингу и циклу событий. Я не вижу смысла дублировать здесь эти материалы.


Что касается рендеринга, могу порекомендовать следующее (от абстрактного к конкретному):



Также советую взглянуть на эти видео, посвященные циклу событий:



Потратьте время на ознакомление с данными материалами, не пожалеете. Я подожду :)


Итак, что мы имеем в сухом остатке?


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-канале

Tags:
Hubs:
Total votes 18: ↑18 and ↓0+18
Comments6

Articles

Information

Website
timeweb.cloud
Registered
Founded
Employees
201–500 employees
Location
Россия
Representative
Timeweb Cloud