Перейти к содержимому
Тема 17 из 21
Теория

Таймеры и асинхронность

Зачем это нужно

До сих пор программы выполнялись строго сверху вниз: строка за строкой, без пауз. Но настоящим программам постоянно нужно ждать: показать подсказку через секунду, обновлять часы каждую секунду, дождаться ответа сервера, отреагировать на клик. Код, который выполняется не сразу, а потом, называют асинхронным.

Таймеры — самый простой асинхронный код. На них хорошо видно главное правило JavaScript: отложенный код никогда не прерывает текущий, а ждёт своей очереди. Без понимания этого правила не понять ни промисы, ни async/await, ни то, когда React обновляет страницу.

setTimeout — выполнить один раз позже

setTimeout(() => {
  console.log("Прошла секунда");
}, 1000);

console.log("Таймер поставлен");

Вывод:

Таймер поставлен
Прошла секунда
  • Первый аргумент — функция (колбэк), которую нужно вызвать.
  • Второй — задержка в миллисекундах: 1000 мс = 1 секунда.
  • setTimeout не ждёт: он записывает «вызвать через секунду» и сразу возвращает управление. Поэтому «Таймер поставлен» печатается первым.

Колбэку можно передать данные через замыкание:

function remind(text, ms) {
  setTimeout(() => console.log("Напоминание: " + text), ms);
}

remind("попить воды", 200);
remind("размяться", 100);

Вывод:

Напоминание: размяться
Напоминание: попить воды

Срабатывают в порядке времени, а не в порядке вызова.

Отменить: clearTimeout

setTimeout возвращает номер таймера. По нему таймер можно отменить, пока он не сработал:

const id = setTimeout(() => console.log("Бум!"), 500);
clearTimeout(id);
console.log("Таймер отменён");

Вывод:

Таймер отменён

«Бум!» не напечатается никогда.

setInterval — повторять

setInterval(колбэк, мс) вызывает функцию снова и снова с паузой мс, пока его не остановят через clearInterval:

let count = 0;
const id = setInterval(() => {
  count++;
  console.log("Тик " + count);
  if (count === 3) {
    clearInterval(id);
    console.log("Стоп");
  }
}, 100);

Вывод:

Тик 1
Тик 2
Тик 3
Стоп

Забытый clearInterval — частая ошибка: интервал будет работать вечно.

Почему отложенный код ждёт: цикл событий

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

  1. Сначала выполняется весь обычный (синхронный) код — сверху вниз.
  2. Готовые к запуску колбэки (сработавший таймер, клик, ответ сервера) встают в очередь.
  3. Когда текущий код закончился, цикл событий (event loop) берёт из очереди следующий колбэк и выполняет его целиком. Потом следующий — и так всё время работы программы.

Поэтому даже таймер с задержкой 0 не срабатывает сразу:

console.log("1");
setTimeout(() => console.log("2"), 0);
console.log("3");

Вывод:

1
3
2

setTimeout(…, 0) значит «как можно скорее, но после текущего кода». А задержка — это минимальное время ожидания: если программа занята долгим циклом, таймер подождёт, пока она освободится.

const start = Date.now();
setTimeout(() => console.log("Таймер опоздал: " + (Date.now() - start >= 100)), 10);

while (Date.now() - start < 100) {
  // занятый цикл на 100 мс — очередь ждёт
}
console.log("Цикл закончился");

Вывод:

Цикл закончился
Таймер опоздал: true

Таймер был готов через 10 мс, но выполнился только после того, как цикл освободил поток. Поэтому в браузере тяжёлые вычисления «замораживают» страницу: клики тоже ждут в очереди.

Колбэки и «ад колбэков»

Асинхронная функция не может просто вернуть результат через return — результата ещё нет. Поэтому её просят: «когда будет готово, вызови вот эту функцию»:

function loadUser(id, callback) {
  setTimeout(() => callback({ id, name: "Аня" }), 50);   // как будто ответ сервера
}

loadUser(1, (user) => console.log("Загружен: " + user.name));
console.log("Загружаем...");

Вывод:

Загружаем...
Загружен: Аня

Если шагов несколько и каждый ждёт предыдущий, колбэки вкладываются друг в друга:

loadUser(1, (user) => {
  loadOrders(user, (orders) => {
    loadDetails(orders[0], (details) => {
      // ...и ещё глубже
    });
  });
});

Такую «лесенку» называют адом колбэков (callback hell): её трудно читать, а ошибки приходится обрабатывать на каждом уровне. Эту проблему решают промисы — следующая тема.

Debounce: ждать, пока пользователь остановится

Полезный приём на таймерах. Пользователь печатает в поиске, и на каждую букву отправлять запрос на сервер расточительно. Лучше подождать, пока он перестанет печатать:

function debounce(fn, ms) {
  let timer;                            // живёт в замыкании между вызовами
  return (...args) => {
    clearTimeout(timer);                // новый вызов отменяет старый таймер
    timer = setTimeout(() => fn(...args), ms);
  };
}

const search = debounce((q) => console.log("Ищем: " + q), 100);
search("j");
search("ja");
search("jav");

Вывод:

Ищем: jav

Три быстрых вызова — один запрос, с последним значением.

Как это работает внутри

Сами таймеры считает не JavaScript, а окружение: браузер или Node.js. Когда вызывается setTimeout, окружение запоминает функцию и время. Когда время подходит, оно кладёт колбэк в очередь задач. Движок JavaScript выполняет синхронный код до конца, а потом цикл событий достаёт задачи из очереди по одной.

Отсюда три следствия:

  • колбэк таймера никогда не выполнится посреди другой функции;
  • задержка — это «не раньше чем», а не «ровно через»;
  • пока колбэков нет, программа просто ждёт. В Node.js программа завершается, когда очередь пуста и не осталось ни одного таймера.

Частые ошибки

  1. Вызов вместо передачи. setTimeout(sayHi(), 1000) вызывает sayHi сразу и передаёт таймеру её результат. Нужно setTimeout(sayHi, 1000) или setTimeout(() => sayHi(), 1000).
  2. Ждать результат через return. Значение, полученное внутри колбэка таймера, нельзя вернуть из внешней функции: она уже завершилась. Нужен колбэк (или промис).
  3. Забыли clearInterval. Интервал работает, пока его не остановят.
  4. Секунды вместо миллисекунд. setTimeout(fn, 5) — это 5 мс, а не 5 секунд.
  5. Думать, что 0 — это «сразу». Колбэк выполнится только после всего текущего кода.

Шпаргалка

Что Как
Выполнить один раз позже const id = setTimeout(fn, ms)
Отменить clearTimeout(id)
Повторять const id = setInterval(fn, ms)
Остановить повтор clearInterval(id)
После текущего кода setTimeout(fn, 0)
Порядок сначала весь синхронный код, потом очередь
Debounce clearTimeout(timer); timer = setTimeout(...)

Отложенное приветствие

Лёгкая+10 XP

Напиши функцию greetLater(name, ms, callback). Она не возвращает результат сразу, а через ms миллисекунд вызывает callback со строкой Hello, <name>!.

greetLater("Ann", 100, (text) => console.log(text));
console.log("waiting...");

Вывод:
waiting...
Hello, Ann!        ← через 100 мс

Подключи коннектор, чтобы решать задачи

Код задач выполняется на твоём компьютере — так быстрее и надёжнее, чем в браузере. Для этого нужна бесплатная программа-коннектор CodeWithMe. Подключить её нужно один раз.

Ищем коннектор на этом компьютере…

  1. 1
    Скачай коннектор для своей системы (Windows, macOS или Linux).Скачать коннекторНа странице загрузки выбери файл со своей системой в названии, например windows-amd64.
  2. 2Запусти его и выбери папку, где будут лежать твои решения. Python и Node.js он поставит туда сам, когда понадобится.
  3. 3
    Введи ниже код из 6 цифр — он написан в окне коннектора («Код для сайта CodeWithMe»). Если браузер спросит про доступ к локальной сети — разреши.

Коннектор открытый (исходный код на GitHub), работает только на твоём компьютере и выполняет код лишь после ввода кода сопряжения.