почему setInterval неточен?

Как мы знаем, setTimeout означает запуск скрипта после минимального порога (единицы MS), а setInterval означает непрерывное выполнение указанного скрипта с периодом минимального порогового значения. Обратите внимание, что здесь я использую термин «минимальный порог», потому что он не всегда точен.

Почему setTimeout и setInterval не точны?

Чтобы ответить на этот вопрос, вы должны понимать, что в хост-среде JavaScript (браузере или Node.js) существует механизм, называемый циклом обработки событий. Фронтенд-разработчикам необходимо понимать этот механизм. Я также представлю его подробно в более позднем рассказе. Итак, теперь кратко представьте:

Во-первых, зачем нужен цикл событий?

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

Для API setTimeout и setInterval они особенные, поскольку оба они могут генерировать задачи макросов. Так что же такое макрозадачи?

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

То есть setTimeout и setInterval не будут выполняться сразу. Когда мы вызываем их в коде, сначала они будут добавлены в очередь задач для постановки в очередь, а затем механизм выполнения JS вынесет выполнение задачи из головы очереди, когда она простаивает.

Если это запланированная задача, она проверит, истекло ли время. Если срок его действия истек, он будет изъят и казнен. Если он не истечет, будет выполнена следующая задача макроса.

После завершения выполнения он начнет новый раунд цикла и продолжит проверку головы очереди задач.

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

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

let count = 0;
const startTime = Date.now();
setInterval(() => {
  count += 1000;
  console.log('deviation', Date.now() - (startTime + count));
}, 1000);
// deviation: 3
// deviation: 5
// deviation: 4
// deviation: 10

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

Как получить относительно точный setInterval?

1. Использовать во время

Простой и грубый, мы можем напрямую использовать оператор while, чтобы заблокировать основной поток и постоянно вычислять разницу между текущим временем и следующим временем. Как только он станет больше или равен 0, он будет выполнен немедленно.

Код выглядит следующим образом:

function intervalTimer(time) {
  let counter = 1;
  const startTime = Date.now();
  function main() {
    const nowTime = Date.now();
    const nextTime = startTime + counter * time;
    if (nowTime - nextTime >= 0) {
      console.log('deviation', nowTime - nextTime);
      counter += 1;
    }
  }
  while (true) {
    main();
  }
}
intervalTimer(1000);
// deviation 0
// deviation 0
// deviation 0
// deviation 0

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

2. Использовать requestAnimationFrame

Браузер предоставляет API requestAnimationFrame, который сообщает браузеру, что вы хотите выполнить анимацию, и просит браузер вызвать указанную функцию обратного вызова для обновления анимации перед следующей перерисовкой. Эта функция обратного вызова будет выполнена перед следующей перерисовкой браузера. Количество выполнений в секунду будет определяться в соответствии с частотой обновления экрана. Частота обновления 60 Гц означает, что будет 60 раз в секунду, что составляет около 16,7 мс.

function intervalTimer(time) {
  let counter = 1;
  const startTime = Date.now();
  function main() {
    const nowTime = Date.now();
    const nextTime = startTime + counter * time;
    if (nowTime - nextTime >= 0) {
      console.log('deviation', nowTime - nextTime);
      counter += 1;
    }
    window.requestAnimationFrame(main);
  }
  main();
}
intervalTimer(1000);
// deviation 5
// deviation 7
// deviation 9
// deviation 12

Мы можем обнаружить, что из-за интервала выполнения 16,7 мс легко вызвать неточность времени.

3. Использовать setTimeout + смещение системного времени

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

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

Это здорово, не так ли? Мы достигаем стабильного и относительно точного setInterval без блокировки основного потока! Это может быть лучшим решением для получения точного обратного отсчета.

Спасибо, что прочитали. Если вам нравятся такие истории и вы хотите поддержать меня, рассмотрите возможность стать участником Medium. Это стоит 5 долларов в месяц и дает вам неограниченный доступ к контенту Medium. Я получу небольшую комиссию, если вы зарегистрируетесь по моей ссылке.

Ваша поддержка очень важна для меня — спасибо.