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


Можете ли вы ответить на вопрос о том, в чем заключается разница между 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 в нашем примере.


Демо приложения:



Пожалуй, это все, чем я хотел поделиться с вами в данной статье.


Благодарю за внимание и хорошего дня!




Комментарии (6)


  1. ht-pro
    10.11.2021 17:05
    +1

    За статью спасибо, было интересно почитать.

    Вот этот момент я не совсем понял:

    Даже если с точки зрения пользователя страница "подвисает", могут быть периоды, когда браузер находится в режиме ожидания.

    Если браузер простаивает, почему страница для юзера может подвиснуть?


    1. movl
      11.11.2021 08:06
      +2

      Возможно имелось ввиду, что если на странице производится какие-то сложные изменения макета с просадкой фреймрейта, то все равно будут моменты с периодами ожидания. То есть даже если вычисления в колбеке requestAnimationFrame, по каким-то причинам длятся несколько фреймов, то после окончательного вычисления кадра, и до вызова следующего requestAnimationFrame, все равно пройдет некоторое время, так как их вызовы этих колбеков синхронизируются через vsync. Собственно, время после вычисления кадра и до непосредственного вывода результата, можно дополнительно загрузить полезной работой.


  1. movl
    11.11.2021 09:02

    рендеринг виртуальных фигур полностью контролируется пользователем — при нажатии кнопки Render пользователь осознает возможные негативные последствия, о которых говорилось выше, и готов к ним (снижение производительности является ожидаемым)

    Пример показался немного странным. По сути получается, что на момент вызова колбека, уже есть вычисленный макет текущего кадра, что позволяет произвести оценку того, какое влияние оказывает изменения в DOM, на производительность рендера. Таким образом можно добавлять по столько элементов, чтобы пользователь не чувствовал заметных просадок по производительности. Просто какой смысл в пакетном добавлении элементов, если это полностью зависит от действий пользователя, которому необходимо смиряться с негативными последствиями этих действий. А данная функция, наоборот позволяет более точно контролировать эти негативные последствия, без участия со стороны пользователя.

    За статью спасибо. Не знал об этой функции, и уже примерно представляю, что и где смогу улучшить с ее помощью.


  1. demimurych
    16.11.2021 09:05
    +1

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

    requestAnimationFrame

    основная задача requestAnimationFrame заключается не в том, чтобы Вы писали с его помощью анимации. Это следствие. Его основная задача, предоставить программисту сигнал, когда он может, без боязни просадить производительность, работать с DOM.

    Дело в том, что даже чтение некоторых свойств DOM дерева может приводит к полной перерисовке макета в окне браузера. Что, как мы понимаем, является ОЧЕНЬ дорогой операцией.

    Я хочу подчеркнуть - даже чтение некоторых свойств DOM. Еще раз. Не модификации DOM. А чтении.

    Для наиболее любознательных рекомендую гуглить DOM Reflow. Или начать с этой ссылки https://gist.github.com/paulirish/5d52fb081b3570c81e3a где популярно разбирается этот процесс, и причины, которые приводят к серьезным потерям производительности.

    Так вот именно requestAnimationFrame является той волшебной палочкой, которая дает нам отмашку, когда мы безбоязненно можем вертеть нашим DOM как нам хочется.

    requestIdleCallback

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

    Изменять DOM внутри этого callback нельзя не по той причине, что нам выдали слишком мало времени. А по той причине, что requestIdleCallback может сработать в любой момент времени. Даже тогда, когда наша неаккуратная работа с DOM может привести к постоянным Reflow. И именно по этой причине в этом апи не рекомендуется работать с DOM, если, конечно, вы не понимаете что делаете. А рекомендуется организовывать очередь, для следующего срабатывания requestAnimationFrame

    Safari

    И автор совершенно прав. Safari это IE современности. Я очень не завидую проектам, которые обязаны его поддерживать. Потому как этот браузер не просто IE современного мира, он значительно хуже. Так как мейнтенеры, не просто отказывают в поддержке большого количества таких вкусных вещей, но и не дают им никаких альтернатив, возвращая нас во время 2000 годов, с хаками вида setTimeout( func, 0);


    1. MikUrrey
      01.12.2021 02:43

      У вашего комментария почему-то нет плюсов, надеюсь, другие читающие смогут это исправить.

      А меня, как начинающего понимать несомненную пользу rAF, в контексте всего прочитанного интересует сейчас такой вопрос: как однозначно понять, когда он необходим, а когда - бесполезен? Есть ли смысл использовать его там, где перерисовка DOM теоретически происходит значительно реже, чем перерисовка кадров? Например вот так:

      const recalcWidth = (time) => {
        let incrW = diffWidth*time/trackDuration;
        if (leftElement) {
          leftWidth = leftWidth - incrW;
          leftElement.style.setProperty('width', `${leftWidth}px`);
        }
        if (rightElement) {
        	let rightWidth = rightWidth + incrW;
          rightElement.style.setProperty('width', `${rightWidth}px`);
        }
      };
      audio.addEventListener('timeupdate', (event) => {
          requestAnimationFrame(() => recalcWidth(event.detail.time));
      });

      И есть ли смысл использовать его для изменения стилей группы transform?

      P.S. Из документа по ссылке:

      Это было прямо как если бы мне в 3 года сказали, что Деда Мороза нет)))


      1. demimurych
        01.12.2021 10:22

        Строго говоря, событие raf нужно использовать всегда, когда Вы собираетесь вносить изменения в DOM. Чем Вы гарантируете себе ситуацию, когда не вызовете лишних Reflow. И при этом будучи абсолютно уверенным в том, что ваши изменения отобразятся ровно так скоро как это возможно.

        tramsform насколько я помню не приводит к reflow. Но я могу и забыть. Всегда нужно контролировать себя через тот же DevTools вкладка Perfomance, записать trace. Там есть отдельный таймлайн для процессов с layout. Вот тут https://developers.google.com/web/fundamentals/performance/rendering/avoid-large-complex-layouts-and-layout-thrashing инструкция с картинками