Понимание таймеров в JavaScript. Callback-функции, setTimeout, setInterval и requestAnimationFrame

Stas Bagretsov
12 min readSep 24, 2018

--

В предыдущей статье Путешествие по JavaScript таймерам в сети от Нолана Лоусона многие в сети и в офлайне высказали недопонимание того, о чём там пишется, но тема всем показалась очень интересной и занимательной. Поэтому я решил исправиться и продолжить тему более детально, собрав хороший материал по каждому таймеру, где объясняется конкретно то, как он работает.

В статье присутствует адаптированный и дополненный материал с переводами статей:

What the heck is a Callback?

Javascript Scheduling: setTimeout and setInterval

Efficient Animations with requestAnimationFrame

👉Мой Твиттер — там много из мира фронтенда, да и вообще поговорим🖖. Подписывайтесь, будет интересно: ) ✈️

Что такое callback-функции?

Или просто колбэки. Прежде, чем вообще начинать понимать таймеры и асинхронность, нужно разобраться с callback функциями. Что же это такое?

Простое определение: колбэк это функция, которая выполнится после другой функции, завершившей своё выполнение. Следовательно, отсюда и название, ‘call back’.

Определение посложнее: В JavaScript, функции это объекты. Поэтому, функции, могут брать другие функции в виде аргументов и также могут быть возвращены другими функциями. Функции которые так делают, называются функциями высшего порядка. Любая функция, которая передается как аргумент — именуется callback-функцией.

Что-то много слов. Давайте посмотрим на примерах и разберемся поглубже.

Зачем нам вообще колбэки?

По одной простой и важной причине — JavaScript это событийно-ориентированный язык. Это говорит нам о том, что вместо ожидания ответа для последующего шага, JavaScript продолжит выполнение, следя за другими событиями (ну или ивентам, кому как удобнее). Давайте взглянем на простой пример:

function first(){
console.log(1);
}
function second(){
console.log(2);
}
first();
second();

Как вы и ожидали, функция first выполнится первой, а функция second выполнится второй — все это выдаст в консоль следующее:

// 1
// 2

Но что, если функция first будет содержать код, который не может быть немедленно выполнен. Для примера, API запрос, где нам нужно отправить информацию, а затем подождать ответ? Чтобы симулировать такое действие, мы применим setTimeout (дальше будет подробнее про него), который является функцией JavaScript, вызывающей другую функцию после определенного количества времени. То есть, мы задержим нашу функцию на 500 миллисекунд, чтобы симулировать API запрос. Таким образом, наш новый код будет выглядеть так:

function first(){
// Симулируем задержку кода
setTimeout( function(){
console.log(1);
}, 500 );
}
function second(){
console.log(2);
}
first();
second();

Пока что совершенно неважно, понимаете ли вы то, как работает setTimeout(). Всё, что важно — это то, чтобы вы увидели, что мы отсрочили console.log(1) на 500 миллисекунд. И так, что случится, когда мы вызовем наши функции?

first();
second();
// 2
// 1

Пусть даже мы и вызываем first() первой, мы выводим в лог результат этой функции, после функции second().

Не то чтобы JavaScript не выполняет наши функции в том порядке, в котором нам надо, просто вместо этого, JavaScript не ждал ответа от first() перед тем, чтобы идти дальше, для выполнения second().

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

Создаём callback

Достаточно разговоров, давайте создадим callback!

Во-первых, откройте Chrome Developer Console (Windows: Ctrl + Shift + J)(Mac: Cmd + Option + J) и введите следующий код функции:

function doHomework(subject) {
alert( Starting my ${subject} homework. );
}

Итак, выше мы создали функцию doHomework. Наша функция берёт одну переменную, это subject. Вызовите функцию, введя следующее в вашу консоль:

doHomework('math');
// Alerts: Starting my math homework.

Теперь давайте добавим callback — как последний параметр в функции doHomework(), мы можем передать callback. Callback-функция теперь является вторым аргументом вызова doHomework().

function doHomework(subject, callback) {
alert( Starting my ${subject} homework. );
callback();
}
doHomework('math', function() {
alert('Finished my homework');
});

Как вы видите, введя код выше в вашу консоль, вы получите один за другим два оповещения. Первое starting homework и второе, которое последует за ним finished homework.

Но колбэкам необязательно всегда быть определенными в вызове функции. Они могут быть определены где угодно в коде. Например как тут:

function doHomework(subject, callback) {
alert( Starting my ${subject} homework. );
callback();
}
function alertFinished(){
alert('Finished my homework');
}
doHomework('math', alertFinished);

Результат этого примера точно такой же, как и результат предыдущего, но настройка идёт немного по-другому. Как вы видите, мы передали функцию alertFinished как аргумент, во время вызова функции doHomework().

Пример из реального мира

На прошлой неделе я опубликовал статью о том, как создать бота в Twitter в 38 строчек кода. Единственной причиной, по которой код в этой статье работал, был API от Twitter. Когда вы делаете запросы по API, вам нужно подождать ответа, перед тем как вы сможете как-то с ним работать и соответственно на него воздействовать. Вот то, как выглядит сам запрос.

T.get('search/tweets', params, function(err, data, response) {
if(!err){
// This is where the magic will happen
} else {
console.log(err);
}
})

T.get просто означает то, что мы делаем get запрос к Twitter.

У этого запроса есть три параметра: ‘search/tweets’, которые служит маршрутом для нашего запроса, params которые являются параметрами поиска и дальше идёт анонимная функция, которая и является колбэком.

Колбэк тут крайне важен, так как нам нужно подождать ответа сервера, перед тем как идти дальше в выполнении кода. Мы понятия не имеем, будет ли наш API запрос успешным или нет, так что после отправки наших параметров к search/tweets через запрос get — мы ждём. Как только Twitter ответит, вызовется наша callback-функция. Twitter либо отправит err объект (т.е. ошибку) или объект response. В нашем колбэке мы можем применить if(), чтобы определить был ли наш запрос проведен успешно или нет, а за тем уже соответственно работать с новыми данными.

В этой теме можно было бы ещё коснуться рекурсий, но это немного другая песня, которая требует более детального понимания.

Таймеры setTimeout и setInterval

Каждый блок JavaScript кода, как правило, выполняется синхронно. Но в коробке у JavaScript уже есть нативные функции (таймеры), которые позволяют задерживать выполнение какого-либо кода.

Это setTimeout() и setInterval(). Они позволят вам запустить кусок JavaScript кода в определенный момент в будущем. Такой подход называется “отложенным вызовом”. Далее вы узнаете как работают эти два метода и увидите несколько практических примеров.

setTimeout()

Эту функцию вы видели выше, а сейчас узнаете про неё ещё детальнее. Она используется в основном в тех случаях, если вы хотите запустить вашу функцию через конкретное количество миллисекунд после вызова самого setTimeout(). Синтаксис для этого метода такой:

setTimeout ( expression, timeout );

Тут expression в JavaScript коде запустится по прошествии миллисекунд, указанных в аргументе timeout.

setTimeout() также возвращает ID для тайм-аута, чтобы его можно было отследить. Но в основном оно используется для метода clearTimeout(), который останавливает выполнение отложенной функции. В качестве аргумента тут нужно вставить ID(название) функции.

Вот ещё один пример:

<input type="button" name="sayHello" value="Wait for my Hello!"onclick="setTimeout('alert(\'Hello!\')', 4000)"/>

При нажатии на кнопку запускается setTimeout() метод. Выражение, запуск которого по вашему предусмотрению должен произойти с задержкой в 4000ms или 4 секунды, уже передано.

Тут стоит обратить внимание на то, что setTimeout() не останавливает выполнение дальнейшего скрипта во время периода тайм-аута. Он просто откладывает выполнение указанного блока кода на заложенное количество времени. После вызова функции setTimeout(), скрипт продолжит выполняться обычным образом, с таймером на фоне.

То, что выше — это простой пример со всем кодом для alert бокса в setTimeout вызове. На практике же, вы будете вызывать функции внутри таймеров гораздо чаще. Следующий пример даст вам лучшее понимание о вызове функций с помощью setTimeout().

Для примера, код ниже, вызывает sayHello() через одну секунду:

function sayHello() {alert('Hello');}setTimeout(sayHello, 1000);

Вы можете также передавать аргументы вместе с функцией, например, как тут:

function sayHello(message, person) {alert( message + ', '+ person );}setTimeout(sayHello, 1000, "Hi", "Monica"); // Hi, Monica

Как вы видите, для setTimeout() сначала передаётся функция аргумент, затем время задержки и уже только потом аргументы для функции аргумента(пардон за каламбур).

Если первый аргумент это строка, то JavaScript может создать из неё функцию. Так что вот это тоже сработает:

setTimeout("alert('Hello')", 1000);

Но применение такого метода не рекомендуется, лучше используйте функции, как тут:

setTimeout(() => alert('Hello'), 1000);

setInterval()

Эта функция, как и предполагается из названия, в основном используется для задержки функций, которые будут выполняться снова и снова, например анимации. Функция setInterval() очень близка к setTimeout(), у них даже такой же синтаксис:

setInterval ( expression, interval );

Но разница тут вот в чём. setTimeout() запускает expression только единожды, в то время, как setInterval() продолжает запускать expression на регулярной основе после заданного временного интервала, пока вы не скажете стоп.

Для того, чтобы остановить последующие вызовы в setInterval(), вам нужно вызывать clearInterval(timerId), где timerId это имя функции setInterval.

// Hello показывается каждые 3 секунды
let timerId= setInterval(() => alert('Hello'), 3000);
// Повторения прекращаются после 6 секунд с id таймера.
setTimeout(() => { clearInterval(timerId); alert('Bye'); }, 6000);

Когда вам нужно использовать setInterval()? Когда вам не нужно вызывать setTimeout() в конце спланированной функции. Также, во время использования setInterval(), фактически не существует задержки между одним срабатыванием настоящего выражения и последующим. А в setTimeout() существует относительно долгая задержка, во время выполнения выражения, вызова функции и выставления нового setTimeout. Так что если вам нужен обычный точный таймер и надо, чтобы что-то делалось повторно после определенного временного интервала, тогда setInterval это ваш выбор.
Итак, сейчас мы подобрались к самому интересному. А именно к requestAnimationFrame. А про него нужно рассказать максимально подробно.

requestAnimationFrame()

Если вы используете анимации в своих веб-приложениях, то вы в любом случае хотите, чтобы они выполнялись как по маслу. И самым простым способом для этого является использование requestAnimationFrame, ну или просто rAF- метода который делает это непринужденно и легко.

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

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

Почему нужно использовать requestAnimationFrame?

Чем же он так хорош requestAnimationFrame? Давайте посмотрим на некоторые вещи, которые requestAnimationFrame делает значительно лучше, чем setInterval и setTimeout.

Оптимизация браузером

Использование requestAnimationFrame даёт браузеру возможность оптимизировать анимации, чтобы делать их плавнее и более ресурсоэффективными. Не будем сильно вдаваться в детали того, как браузер это делает, просто знайте, что requestAnimationFrame исключает возможность ненужных отрисовок и может связывать вместе несколько анимаций в одно целое и цикл перерисовки.

Анимации работают, когда их видно

Используя requestAnimationFrame ваши анимации будут работать только тогда, когда вкладка со страницей видима пользователю. А это означает меньшее CPU, GPU и использование памяти, что приводит нас к последнему моменту эффективности.

Меньшее потребление питания

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

Используем requestAnimationFrame

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

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

Пример ниже показывает то, как настроить рекурсивную функцию, которая использует requestAnimationFrame.

// Анимируем
function animate(highResTimestamp) {
requestAnimationFrame(animate);
// Анимируем что-нибудь…
}
// Запускаем анимацию.
requestAnimationFrame(animate);

Стоит упомянуть, что у вас есть только 16.67 миллисекунд, чтобы отрендерить каждый кадр. С точки зрения времени это не очень хорошо, так что вам нужно быть осторожным с тем, что вы хотите выполнить внутри колбэк функции. Если ваш кадр требует больше 16.67 секунд на обработку, то анимация может выйти не совсем плавной.

requestAnimationFrame отдаст requestID, который может быть использован для отмены запланированного кадра анимации.

var requestID = requestAnimationFrame(animate);

Отменяем кадры анимации

Чтобы отменить кадр анимации вы можете использовать метод cancelAnimationFrame. Этот метод должен принять requestID для кадра, который вы хотите отменить.

cancelAnimationFrame(requestID);

Позже вы узнаете то, как отслеживать актуальный requestID.

Полифил

Есть великолепный полифилл для requestAnimationFrame, который разработал Эрик Мюллер из Opera и который далее доработали Пол Айриш и Тино Зийдел. Код лежит тут.

Создаём простую демку с requestAnimationFrame

После того, как вы поняли теорию по requestAnimationFrame, давайте создадим простую демку. Вот она.

Подготавливаем HTML и CSS

Откройте свой любимый текстовый редактор и создайте файл под названием index.html. После этого, добавьте следующий код в новый файл.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>requestAnimationFrame Demo</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="page-wrapper">
<h1>requestAnimationFrame Demo</h1>
<div class="controls">
<button type="button" id="startBtn">Start Animation</button>
<button type="button" id="stopBtn">Stop Animation</button>
<button type="button" id="resetBtn">Reset</button>
</div>
<canvas id="stage" width="640" height="100"></canvas>
</div>
<script src="raf-polyfill.js"></script>
<script src="script.js"></script>
</body>
</html>

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

Стоит обратить внимание, что тут есть два файла в <script>, который находится в конце этой разметки. raf-polyfill.js это полифил о котором мы говорили в предыдущей секции. Убедитесь в том, что вы скачали этот файл и сохранили его в той же директории, что и index.html. Вам также надо будет скопировать style.css отсюда в папку вашего проекта.

Настраиваем JavaScript

Теперь, как только вы разобрались с HTML и CSS, настало время писать JavaScript код, который будет обрабатывать отрисовку анимации в <canvas>. Если до этого вы не использовали Canvas API, то не беспокойтесь, я объясню всё что нужно по мере прочтения статьи.

Создайте новый файл в папке проекта под название script.js и добавьте туда этот код:

(function() {// Get the buttons.
var startBtn = document.getElementById('startBtn');
var stopBtn = document.getElementById('stopBtn');
var resetBtn = document.getElementById('resetBtn');
// Остальной код будет тут…
}());

Вы создали три переменные и ассоциировали их с кнопками из разметки.

Дальше вам надо написать немного кода, чтобы настроить Canvas. Скопируйте этот код в свой script.js файл.

// Canvasvar canvas = document.getElementById('stage');// 2d контекст отрисовки.var ctx = canvas.getContext('2d');// Стиль наполнения для контекста отрисовки.ctx.fillStyle = '#212121';// Переменная в которой будет храниться requestID.var requestID;// Переменные для отрисовки позиций и объекта.var posX = 0;var boxWidth = 50;var pixelsPerFrame = 5; // Количество пикселей, на которое должен двинуться блок в каждом кадре.// Отрисовка изначального блока в canvas.ctx.fillRect(posX, 0, boxWidth, canvas.height);

Тут сначала мы создаем переменную под названием сanvas и ассоциируем её с элементом из разметки. Далее вы задаёте 2d контекст отрисовки для него. Это даёт нам методы для отрисовки объектов на нём, так же как и методы контроля стилей этих объектов.

Следующая строка кода выставляет свойству fillStyle для контекста отрисовки #212121.

requestID переменная будет использоваться, чтобы отслеживать requestID, возвращенный методом requestAnimationFrame.

Переменные posX, boxWidth и pixelPerFrame используются для выставления позиции блоку при отрисовке; ширина блока; и число пикселей на которое блок должен двигаться при каждом кадре.

Под конец вы вызываете контекст отрисовки методом fillReact, передав ему X и Y координаты положения прямоугольника вместе с его высотой и шириной.

Написание анимированной функции

Далее вам надо написать функцию animate, которая будет ответственна за прорисовку кадров.

Скопируйте следующий код в ваш script.js файл.

// Animate.function animate() {requestID = requestAnimationFrame(animate);// Проверка если блок не достиг конца отрисовки в канвасе.// В противном случае завершается анимация.if (posX <= (canvas.width — boxWidth)) {ctx.clearRect((posX — pixelsPerFrame), 0, boxWidth, canvas.height);ctx.fillRect(posX, 0, boxWidth, canvas.height);posX += pixelsPerFrame;} else {cancelAnimationFrame(requestID);}}

Вызов requestAnimateFrame вверху функции запланирует следующий кадр анимации. Он размещается сначала, так как мы можем подобраться как можно ближе к 60 FPS, при использовании setTimeout фолбэка, которое применяет полифил.

Далее у вас будет if проверка, которая проверяет достиг ли блок крайней правой стороны canvas’а. Если блок ещё этого не сделал, то вы используете clearRect метод, чтобы удалить блок, отрисованный в предыдущем кадре и затем отрисовать блок на новой позиции с использованием fillRect. Если же блок достиг конца canvas’а, то вы вызываете cancelAnimationFrame, чтобы отменить планирование кадра в начале animate функции. И под конец, вы обновляете posX переменную с позицией на которой блок должен быть отрисован при следующем кадре.

Цепляем кнопки

Последнее, что нам нужно сделать для того, чтобы демо работало — это настроить срабатывания по событиям для кнопок старта, остановки и сброса. Добавьте следующий код в ваш script.js файл.

// EventListener для кнопки старта.startBtn.addEventListener(‘click’, function(e) {e.preventDefault();// Запуск анимации.requestID = requestAnimationFrame(animate);});// EventListener для кнопки стоп.stopBtn.addEventListener(‘click’, function(e) {e.preventDefault();// завершаем анимацию;cancelAnimationFrame(requestID);});// EventListener для кнопки сброса.resetBtn.addEventListener(‘click’, function(e) {e.preventDefault();// Сбрасываем X позицию на ноль.posX = 0;// Очищаем canvas.ctx.clearRect(0, 0, canvas.width, canvas.height);// Отрисовываем изначальный блок.ctx.fillRect(posX, 0, boxWidth, canvas.height);});

Тут у нас три обработчика событий. Первые два запускают и останавливают анимацию, а последний запускается при нажатии на кнопку reset. Это выставит переменной posX значение 0. Также это очистит canvas — мы не так сильно обеспокоены производительностью, так что это нормально делать таким ленивым способом — и отрисовывать блок обратно на его стартовой позиции.

Теперь ваша демка готова! Откройте index.html в браузере и кликните на кнопку start, чтобы увидеть анимацию. Вот и сама демка.

--

--

Stas Bagretsov

Надеюсь верую вовеки не придет ко мне позорное благоразумие. webdev/sports/books