Путешествие по JavaScript таймерам в сети
Касаясь темы синхронности и асинхронности, невозможно не затронуть тему таймеров в JavaScript. В этой статье вы узнаете про работу не только самых основных из них, но и довольно продвинутых, а так же прочитаете их сравнение, и что самое важное — получите представление о них.
Перевод статьи A tour of JavaScript timers on the web
Быстрый опрос: в чем разница между этими JavaScript таймерами?
Promises
setTimeout
setInterval
setImmediate
requestAnimationFrame
requestIdleCallback
А конкретнее, если мы выставим в очередь все эти таймеры за раз, то будет ли у вас представление о том, в каком порядке они сработают?
Если нет, то вы не один. Я работал с JavaScript и веб программированием годами, я работал на компанию разработчика браузера два года и только недавно я пришел к пониманию того, как работают эти таймеры и как они срабатывают вместе.
В этом посте я дам вам высококлассный обзор их работы и когда их нужно использовать. Я также расскажу про функции из Lodash — debounce()
и throttle()
, так как тоже нахожу их довольно полезными.
👉Мой Твиттер — там много из мира фронтенда, да и вообще поговорим🖖. Подписывайтесь, будет интересно: ) ✈️
Промисы и микротаски
Давайте сразу с этим разберемся, так как возможно это самое простое из того, что будет. Колбэк промиса также известен как “микротаск” и он работает в такой же частотности, как и MutationObserver колбэки.
Я уже много написал про промисы. Есть одно небольшое заблуждение их плане, которое стоит упомянуть — это то, что они не дают браузеру продохнуть. Только потому, что вы выстраиваете в очередь асинхронный колбек, не означает того, что браузер может отрендерить или обработать ввод или сделать что-то иное, чего мы требуем.
Для примера, скажем, что у нас есть функция которая блокирует основной поток на одну секунду:
function block() {var start = Date.now()while (Date.now() — start < 1000) { /* wheee */ }}Если бы мы выставили в очередь несколько микротасков для вызова этой функции:for (var i = 0; i < 100; i++) {Promise.resolve().then(block)}
Это бы заблокировало браузер примерно на 100 секунд. Буквально это то же самое, если бы мы сделали так:
for (var i = 0; i < 100; i++) {block()}
Микротаски выполняются сразу после завершения любого синхронного выполнения. Нет возможности вставить выполнение какой-либо работы между ними двумя. Так что, если вы думаете, что можете разбить длинный таск, разделив его на микротаски, то у вас не получится задуманное — оно просто не будет работать так, как вы предполагали.
setTimeout and setInterval
Это два братца: setTimeout
ставит таск в очередь на несколько миллисекунд, в то время как setInterval
откладывает выполнение повторяющегося таска на указанное количество тех же миллисекунд.
Суть в том, что браузеры по факту не соблюдают эти миллисекунды. Исторически веб-разработчики злоупотребляли setTimeout
. Очень много. До такой степени, что браузерам пришлось добавить поблажки для setTimeout(/* … */, 0)
, чтобы избежать блокировки основного потока, так как кучи сайтов склонны разбрасываться setTimeout(0)
как конфетти.
Это причина, по которой многие фичи в crashmybrowser.com больше не работают, например выстраивание в очередь setTimeout
, который вызывает более двух других setTimeout
, которые вызывают еще больше двух других setTimeout
и так далее. Тут рассказано про это с точки зрения Edge.
Проще говоря, setTimeout(0)
на самом деле не запускается в ноль миллисекунд. Обычно это происходит в 4. А иногда и в 16 (так делает Edge, когда девайс работает от аккумулятора). Иногда это может быть сжато до одной секунды. Есть несколько трюков, которые придумали браузеры, чтобы предотвратить то, когда веб-страница сжирает ваш CPU, выполняя бесполезную setTimeout
работу.
Это говорит о том, что setTimeout
позволяет браузеру делать какие-либо операции перед срабатыванием колбэка. Но если вы хотите позволить ввод или рендеринг перед колбэком, то setTimeout
это обычно не лучший выбор, так как он в случайном порядке позволяет этим вещам срабатывать. В наши дни, есть хорошие API браузера, которые могут цепляться непосредственно к системе рендеринга браузера.
setImmediate
Перед тем как двигаться дальше к API получше, стоит упомянуть кое-что ещё, а именно setImmediate
, который как бы это сказать за отсутствием слова получше — странный. Если вы поищите его на caniuse.com, то увидите, что только браузеры от Microsoft могут его поддерживать. И пока что это есть в Node.js и имеет множество полифиллов в npm. Но что это такое?
setImmediate
был изначально предложен Microsoft, чтобы охватить проблемы с setTimeout
, описанные выше. Попросту, setTimeout
использовали сильно злоупотребляя и таким образом суть была в том, чтобы создать что-то новое, что будет setImmediate(0)
и на самом деле будет работать как setImmediate(0)
без качелей в 4 миллисекунды.
К сожалению, setImmediate
был принят только в IE и Edge. Отчасти это до сих пор используется, потому что имеет некую суперсилу в IE, где он позволяет вставлять события с клавиатуры и клика мышки, чтобы перепрыгнуть всю очередь и сработать перед выполнением колбэка в setImmediate
, тогда как IE не может похвастаться таким волшебством с setTimeout
.
Так же, факт того, что setImmediate
существует в Node, означает то, что много node-полифилл кода используется в браузере без реального понимания того, что он делает. Он не помогает в том, что разницы между setImmediate
и process.nextTick
в Node довольно запутанны и даже официальная документация Node говорит о том, что названия должны быть поменяны местами.
Суть в том, чтобы использовать setImmediate
, если вы знаете, что вы делаете и пытаетесь оптимизировать работу с вводом в IE. Если же нет, то даже не заморачивайтесь или только используйте его в Node.
requestAnimationFrame
И так, теперь мы подошли к самому важному заменителю setTimeout
, таймеру который цепляется к циклу рендера браузера. Кстати, если вы не знаете того, как работает цикл событий в браузере, то я вам очень рекомендую это видео от Джейка Арчибальда. Посмотрите, мы подождем.
И так, requestAnimationFrame
на самом деле работает так: это, как и setTimeout
, только вместо ожидания неясного количества времени (4ms, 16ms или секунды), оно выполняется перед следующим шагом стилевых и шаблонных вычислений. Как указывалось в видео выше, нет ничего плохого в том, что выполнение происходит после этого шага в Safari, IE и Edge < 18, но давайте пока забудем про это, так как это обычно совершенно неважная деталь.
Вот, что я думаю о requestAnimationFrame
: всякий раз, когда я хочу проделать какую-либо работу, которая будет модифицировать стиль или шаблон — к примеру, изменение CSS свойств или запуск анимации — Я прикреплю это к requestAnimationFrame
, далее rAF
. Это гарантирует мне вот что:
У меня с меньшей долей вероятности поедет шаблон, так как все изменения в DOM выстраиваются в очередь и координируются.
Мой код естественным образом адаптируется к характеристикам производительности браузера. Для примера, если это дешевое устройство, которое испытывает проблемы с рендерингом элементов, rAF
замедлится исходя из обычных интервалов в 16.7ms (На экранах 60 Hertz) и, следовательно устройство не заглючит так, как это могло бы случиться с setTimeout
и setInterval
.
Вот почему библиотеки анимаций, которые не полагаются на CSS переходы или keyframes, такие, как GreenSock или React Motion, будут обычно делать изменения в колбэке rAF
. Если вы анимируете элемент между opacity: 0
и opacity: 1
, то нет смысла в выстраивании миллиона колбэков и анимирования всех возможных промежуточных состояний, включая opacity: 0.0000001
и opacity: 0.9999999
.
Вместо этого, вы бы лучше использовали rAF
, чтобы сказать браузеру, то сколько фреймов вы можете отрисовать за заданный период времени и вычислить “анимацию” для этого времени. Таким образом, медленные устройства окончат выполнение с медленным фреймрейтом, а быстрые с быстрым, что было бы не совсем правдой, используйте вы что-то вроде setTimeout
, который работает независимо от скорости рендеринга браузера.
requestIdleCallback
rAF
это возможно самый полезный таймер в этом наборе, но requestIdleCallback
стоит упоминания. Поддержка браузерами тут не очень хороша, но есть полифиллы, которые работают вполне отлично, используя rAF
в своей основе.
Во многих случаях rAF
очень схож с requestIdleCallback
. Далее я буду его называть rIC
.
Как и rAF
, rIC
естественным образом будет подстраиваться под характеристики производительности браузера: если устройство под большой нагрузкой, то rIC
может выполниться с задержкой. Разница тут в том, что rIC
запускает во время “ленивого” состояния браузера, то есть тогда, когда браузер осознает, что у него нет каких-либо тасков, микротасков или событий ввода которые требуется обработать и тогда вы можете делать свою работу. Также это дает вам некий “дедлайн”, чтобы отслеживать как вы используете свои ресурсы, что кстати есть вполне хорошая фича.
У Дэна Абрамова есть хорошая речь на JSConf Iceland 2018, где он показывает как можно было бы использовать rIC
. В этой речи, у него веб-приложение, которое вызывает rIC
для каждого клавиатурного события, в то время как пользователь печатает и затем обновляет отрендеренное состояние внутри колбэка. Это отлично, так как быстрая печать может вызывать множество keydown/keyup
событий, которые будут быстро запускаться, но по факту вам необязательно нужно обновлять отрендеренное состояние страницы при каждом нажатии на клавишу.
Ещё один хороший пример для этого — это “показатель оставшихся символов” как в Twitter или в Mastodon. Я использую rIC
в Pinafore, так как мне не очень важно будет ли индикатор обновляться при каждом нажатии. Если я пишу быстро, то лучше приоритизировать отклик на ввод, так чтобы не выбиваться из ритма.
Кое-что, что я подметил по поводу rIC
, это то, что он немного привередливый в Chrome. В Firefox срабатывает тогда, когда я захочу, интуитивно понимая, что браузер в “ленивом” состоянии и готовясь запустить код. В мобильном Chrome для Android, я подметил то, что всегда, когда я скроллю тачем, то может быть задержка с rIC
на несколько секунд, даже после того как я перестал прикасаться к экрану и браузер перестал вообще что-то делать.
В любом случае, rIC
это ещё один отличный инструмент, чтобы добавить его в свой инструментарий. Я склонен полагать, что rAF
нужно использовать для важного рендеринга, а rIC
нет.
debounce and throttle
Есть две функции, которые не встроены в браузер, но они настолько полезны, что они стоят своего упоминания. Если вы с ними не знакомы, то вот тут есть их разбор на CSS Tricks.
Обычно я использую debounce
в пределах resize
колбэка. Когда пользователь ресайзит браузер, то нет смысла обновлять шаблон при каждом resize колбэке, так как он срабатывает слишком часто. Вместо этого, вы можете использовать debounce
на несколько сотен миллисекунд, чтобы удостовериться в том, что колбек в конечном итоге сработал, после того как пользователь завершить вращать окно браузера как гармошку.
throttle
это то, что я использую более обильно. Для примера, хорошо его использовать вместе с событием scroll. И снова, обычно бессмысленно пытаться обновлять отрендеренное состояние приложения при каждом scroll колбэке, так как он срабатывает слишком часто, а частотность может варьироваться от браузера к браузеру и от метода ввода к методу ввода. В общем понятно.
Использование throttle
нормализует такое поведение и дает удостовериться, что оно срабатывает каждые X миллисекунд. Вы также можете настроить throttle
или debounce
в Lodash, чтобы они срабатывали при начале задержки, в её конце или в обоих случаях.
Для контраста, я бы не стал использовать debounce
для скроллинга, так как я не хочу, чтобы UI обновлялся после того, как пользователь перестанет скроллить. Это может раздражать или даже запутывать, так как пользователь может разочароваться и попытаться продолжать скроллить с целью обновить состояние UI. В этом случае throttle
лучше, так как он не ждет scroll события, чтобы прекратить выполнение.
throttle
это функция, которую я использую повсеместно во всех вводах и даже для некоторых регулярных задач, таких как очистка БД. Это очень полезно. Может когда-нибудь её и включат в браузеры.
Завершение
И так, это мой шальной тур по разным таймерам, доступным в браузерах и тому, как вы можете их использовать. Возможно, я некоторые пропустил, так как есть уж определенно экзотические, такие как postMessage или событийный цикл. Но я надеюсь, что это хотя бы дает хорошее представление о том, как я вижу JavaScript таймеры в сети.