Мануал: обновление данных синхронно с частотой экрана через requestAnimationFrame
Задача
Есть страница, которая показывает координаты курсора мыши:
<div class="page">
Cursor:
X • <span class="x" id="x"></span>
Y • <span class="y" id="y"></span>
</div>И есть скрипт, который обновляет координаты при каждом событии mousemove:
let spanX = document.querySelector('#x');
let spanY = document.querySelector('#y');
document.addEventListener('mousemove', function(e) {
let x = e.clientX;
let y = e.clientY;
spanX.textContent = x;
spanY.textContent = y;
});Такой код работает, но обновляет DOM на каждое событие mousemove.
Событий mousemove может быть больше или меньше, чем кадров, которые браузер рисует на экране. Из-за этого мы можем делать лишние обновления DOM, которые не всегда нужны пользователю между кадрами.
Лучше обновлять DOM не чаще, чем браузер рисует кадры. Для этого используется requestAnimationFrame.
Итоговая идея
Мы придем к такой схеме:
- При движении мыши сохраняем последние координаты.
- Не обновляем DOM сразу.
- Запрашиваем у браузера обновление через
requestAnimationFrame. - Браузер сам вызовет нашу функцию перед ближайшей отрисовкой кадра.
- Если обновление уже запланировано, не запрашиваем его повторно.
Шаг 1. Сохраняем координаты в переменные
Сначала уберем немедленное обновление DOM.
Вместо того чтобы сразу писать координаты в span, мы будем сохранять их в переменные lastX и lastY.
// Получаем элементы, в которые будем выводить координаты
let spanX = document.querySelector('#x');
let spanY = document.querySelector('#y');
// Здесь будем хранить последние координаты курсора
let lastX = 0;
let lastY = 0;
// Слушаем движение мыши
document.addEventListener('mousemove', function(e) {
// При каждом событии mousemove сохраняем координаты в переменные
lastX = e.clientX;
lastY = e.clientY;
// DOM пока не обновляем
});После этого шага координаты на странице перестанут обновляться. Это нормально.
Мы только подготовили данные, но еще не выводим их на страницу.
Шаг 2. Выносим обновление DOM в отдельную функцию
Теперь создадим функцию update(), которая будет отвечать только за вывод данных в DOM.
Это удобно, потому что дальше мы сможем вызывать эту функцию не напрямую из mousemove, а через requestAnimationFrame.
// Получаем элементы, в которые будем выводить координаты
let spanX = document.querySelector('#x');
let spanY = document.querySelector('#y');
// Переменные для хранения последних координат
let lastX = 0;
let lastY = 0;
// Эта функция обновляет текст на странице
function update() {
spanX.textContent = lastX;
spanY.textContent = lastY;
}
// Слушаем движение мыши
document.addEventListener('mousemove', function(e) {
// Сохраняем последние координаты
lastX = e.clientX;
lastY = e.clientY;
// Пока вызываем update напрямую
// Позже заменим этот вызов на requestAnimationFrame
update();
});Теперь код снова работает как раньше: координаты обновляются при движении мыши.
Но мы уже разделили ответственность:
document.addEventListener('mousemove', function(e) {
// Здесь только получаем данные
});
function update() {
// Здесь только обновляем DOM
}Это нужно для следующего шага.
Шаг 3. Обновляем DOM через requestAnimationFrame
Теперь сделаем так, чтобы update() вызывалась не на каждое событие mousemove, а только перед ближайшей отрисовкой кадра.
Для этого используем requestAnimationFrame.
Также добавим флаг isWaiting. Он будет показывать, что обновление уже запланировано.
// Получаем элементы, в которые будем выводить координаты
let spanX = document.querySelector('#x');
let spanY = document.querySelector('#y');
// Переменные для хранения последних координат
let lastX = 0;
let lastY = 0;
// Флаг: ждем ли мы сейчас кадр от браузера
let isWaiting = false;
// Эта функция обновляет текст на странице
function update() {
spanX.textContent = lastX;
spanY.textContent = lastY;
}
// Слушаем движение мыши
document.addEventListener('mousemove', function(e) {
// При каждом движении мыши сохраняем последние координаты
lastX = e.clientX;
lastY = e.clientY;
// Если обновление еще не запланировано
if (!isWaiting) {
// Отмечаем, что теперь мы ждем кадр
isWaiting = true;
// Просим браузер выполнить код перед следующей отрисовкой
requestAnimationFrame(function() {
// Обновляем DOM
update();
// Сбрасываем флаг, чтобы можно было запланировать следующее обновление
isWaiting = false;
});
}
});Разберем подробнее.
Когда происходит mousemove, мы делаем только это:
lastX = e.clientX;
lastY = e.clientY;То есть просто сохраняем последние координаты.
Если mousemove сработает 10 раз подряд до следующего кадра, мы все равно запомним только самые последние значения.
Дальше проверяем:
if (!isWaiting) {Если isWaiting === false, значит мы еще не просили браузер обновить экран.
Тогда ставим:
isWaiting = true;И вызываем:
requestAnimationFrame(function() {
update();
isWaiting = false;
});Браузер сам вызовет эту функцию перед ближайшей отрисовкой.
После обновления сбрасываем флаг:
isWaiting = false;Чтобы следующее запланированное обновление могло произойти на следующем кадре.
Шаг 4. Делаем универсальную обертку
Теперь оформим эту логику как отдельную функцию.
Назовем ее rafThrottle.
Она будет принимать функцию, которую нужно выполнять не чаще одного раза за кадр, и возвращать новую функцию-обертку.
// Получаем элементы, в которые будем выводить координаты
let spanX = document.querySelector('#x');
let spanY = document.querySelector('#y');
// Универсальная обертка для requestAnimationFrame
function rafThrottle(callback) {
// Флаг: ждем ли мы сейчас кадр от браузера
let isWaiting = false;
// Здесь будем хранить аргументы последнего вызова
let lastArgs = null;
// Возвращаем функцию-обертку
return function(...args) {
// Запоминаем аргументы последнего вызова
// Например, для mousemove здесь будет объект события
lastArgs = args;
// Если обновление еще не запланировано
if (!isWaiting) {
// Отмечаем, что теперь ждем кадр
isWaiting = true;
// Просим браузер выполнить callback перед следующей отрисовкой
requestAnimationFrame(function() {
// Вызываем исходную функцию с последними аргументами
callback(...lastArgs);
// Сбрасываем флаг, чтобы можно было запланировать следующий кадр
isWaiting = false;
});
}
};
}
// Создаем обработчик mousemove через нашу обертку
const onMouseMove = rafThrottle(function(e) {
spanX.textContent = e.clientX;
spanY.textContent = e.clientY;
});
// Вешаем обработчик на движение мыши
document.addEventListener('mousemove', onMouseMove);Что делает rafThrottle
Функция rafThrottle принимает функцию:
function(e) {
spanX.textContent = e.clientX;
spanY.textContent = e.clientY;
}И возвращает новую функцию.
Эта новая функция делает следующее:
- Запоминает аргументы последнего вызова.
lastArgs = args;- Проверяет, не запланировано ли уже обновление.
if (!isWaiting) {- Если не запланировано, ставит флаг ожидания.
isWaiting = true;- Запрашивает кадр у браузера.
requestAnimationFrame(function() {
// ...
});- В кадре вызывает исходную функцию с последними аргументами.
callback(...lastArgs);- Сбрасывает флаг ожидания.
isWaiting = false;Почему это лучше прямого обновления в mousemove
Было:
document.addEventListener('mousemove', function(e) {
spanX.textContent = e.clientX;
spanY.textContent = e.clientY;
});Здесь DOM обновляется на каждое событие mousemove.
Стало:
const onMouseMove = rafThrottle(function(e) {
spanX.textContent = e.clientX;
spanY.textContent = e.clientY;
});
document.addEventListener('mousemove', onMouseMove);Теперь DOM обновляется только перед кадром браузера.
Если между двумя кадрами произойдет много событий mousemove, мы все равно обновим DOM один раз, используя только самые последние координаты.
Краткая схема работы
mousemove
↓
запоминаем event
↓
если кадр еще не запланирован:
↓
requestAnimationFrame
↓
браузер рисует кадр
↓
выполняется callback
↓
DOM обновляется один раз за кадр
Итоговый минимальный рабочий вариант
<div class="page">
Cursor:
X • <span class="x" id="x"></span>
Y • <span class="y" id="y"></span>
</div>let spanX = document.querySelector('#x');
let spanY = document.querySelector('#y');
// Обертка: вызывает callback не чаще одного раза за кадр
function rafThrottle(callback) {
let isWaiting = false;
let lastArgs = null;
return function(...args) {
// Сохраняем аргументы последнего вызова
lastArgs = args;
// Если обновление уже запланировано, больше ничего не делаем
if (!isWaiting) {
isWaiting = true;
// Браузер вызовет этот код перед следующей отрисовкой
requestAnimationFrame(function() {
callback(...lastArgs);
isWaiting = false;
});
}
};
}
// Обновление координат
const onMouseMove = rafThrottle(function(e) {
spanX.textContent = e.clientX;
spanY.textContent = e.clientY;
});
// Подписываемся на движение мыши
document.addEventListener('mousemove', onMouseMove);Такой подход полезен везде, где нужно часто обновлять интерфейс в ответ на события мыши, тачпада, касаний, скролла или изменения размера окна.