Современные сайты — это сложные проекты, требующие много времени на обработку JavaScript. А современные пользователи — это требовательные люди, готовые убежать к конкуренту при ощущении «что‑то сайт подтормаживает». Такое ощущение у пользователя может вызываться большим Total Blocking Time, когда он подолгу не может взаимодействовать со страницей.

Что в такой ситуации делать? На нашей конференции HolyJS Виктор Хомяков из Яндекса рассказал о том, как там делали инициализацию скриптов на странице поиска более дружественной к человеку и не блокирующей UI. А также о том, как и вам уменьшить TBT, не ухудшая другие показатели.

Доклад понравился зрителям, поэтому теперь для Хабра мы сделали текстовую версию (но кому удобнее видео, может посмотреть запись). Далее повествование идёт от лица Виктора.


О себе

Я четыре года участвовал в разработке страницы результатов поиска в Яндексе:

Если более точно, то я работал в команде скорости. Цель нашей команды — чтобы эта страница открывалась и работала максимально быстро на всех устройствах — от старенького телефона до мощного игрового компьютера. 

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

Немного теории

Сначала немного теории и терминов, без которых понять мой дальнейший рассказ будет невозможно. Самое важное, что нужно знать об устройстве JavaScript в браузере — event loop или, как некоторые его называют, Иван Тулуп. Более подробно узнать о нем можно из лекции (и вот еще два хороших видео на английском языке).

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

Можно провести аналогию с реализацией многозадачности в операционных системах. В новейших ОС это вытесняющая многозадачность: сама ОС в нужный ей момент прерывает одну задачу и дает управление другой. Так все задачи выполняются плавно и одна задача не может затормозить всю систему. Но в старых 16-битных ОС Windows 9x была кооперативная многозадачность — там ОС не могла вмешаться в выполнение задачи и дать управление кому-то другому. Сама задача должна была явно закончиться или сигнализировать ОС, что она может отдать управление. 

Главный поток в браузере — это аналог кооперативной многозадачности. То есть или мы добровольно отдаем управление event loop, или он вынужден нас ждать бесконечно долго. 

Если открыть в Chrome Developer Tools вкладку Performance, то там задачи из event loop изображаются прямоугольниками с надписями «Task». Причем те задачи, которые занимают больше 50 миллисекунд, называются «длинными» (есть устоявшийся термин «long task») и визуально выделяются: в верхнем правом углу есть красный треугольник, и превышение 50 миллисекунд штрихуется красным.

Следующее важное понятие — First Contentful Paint (FCP). Это тот момент, когда на экране отображается первая часть основного содержимого — не какой-нибудь фоновый градиент, а текст или картинка. То есть это первый кусочек того контента, который пользователь хочет увидеть на вашей странице. 

Следующая метрика производительности — Time to Interactive (TTI). Это момент от начала навигации, когда ваша страница стала полностью интерактивной. То есть уже произошел FCP, на большинство видимых элементов навешены обработчики и страница может гарантированно отреагировать на любые ваши действия быстрее, чем за 50 миллисекунд (то есть в event loop отсутствуют long task).

Еще одна метрика, о которой нужно знать — Total Blocking Time (TBT). Это суммарное время, на которое был заблокирован пользовательский ввод. Она зависит от всех предыдущих метрик. Фактически это сумма всех превышений 50-миллисекундного лимита long task’ами в интервале времени от момента FCP до момента TTI. Она показывает, насколько был загружен event loop от начала отрисовки до наступления полной интерактивности. 

Все эти метрики можно увидеть в developer tools на вкладке Lighthouse. Там выводятся понятные объяснения и ссылки на документацию. 

Зачем нам всё это нужно знать? При открытии страницы, написанной на любом существующем фреймворке, браузер выполняет JavaScript: 

  • Если это React и Single-page application, то нужно сделать первый рендер компонентов а затем их смонтировать. 

  • Если мы используем Server-side rendering, то компоненты всё равно нужно гидрировать. 

  • Если у нас legacy-проекты на старых библиотеках типа jQuery, то всё равно нужно проинициализировать виджеты и компоненты, которые отвечают за UI. 

То есть без JavaScript на современных страницах ничего не может произойти. А отсюда следствие: у нас много JavaScript, соответственно, много long tasks и много времени занят event loop. Следовательно, мы получаем большие величины у метрик TBT и TTI. Пользователям это не нравится: они чувствуют, что страница медленно открывается и тормозит. И, если есть возможность, они переходят к конкурентам или просто реже возвращаются на вашу страницу. Соответственно вы теряете деньги, посещаемость и прочее. Впридачу некоторые поисковики вроде Google сами измеряют характеристики скорости ваших страниц и могут дополнительно их понижать в выдаче. Вот почему важно это знать и заботиться о метриках скорости.

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

А можно постараться разделить весь JavaScript, выполняемый при открытии страницы, на отдельные задачи в event loop, дав возможность интерфейсу реагировать на пользовательский ввод. 

В этом случае суммарно JavaScript займет столько же времени, но метрики TBT и TTI могут сильно уменьшиться.

Event loop в legacy-проектаx

Перейдем к реальным проектам. Я начну иллюстрацию с legacy-части страницы результатов поиска. Как вы знаете, поиску в Яндексе уже больше 20 лет. Он был начат тогда, когда не существовало никакого React, и в проекте всё ещё присутствует legacy-часть, написанная на jQuery. 

Для ее инициализации в браузере пользователя нужно сделать следующие вещи:

  1. Найти в DOM соответствующие элементы для блоков UI.

  2. Проинициализировать соответствующие компоненты.

В коде это выглядит так:

findDomElem(document, '.i-bem').each(function() {
  // …
  new BlockClass(…);
});

Мы ищем в DOM нужные элементы и в цикле создаем классы соответствующих виджетов. При профилировании в DevTools вся инициализация закономерно объединяется в один большой long task: 

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

Event loop в проектах на React

За время жизни нашего проекта появились новые библиотеки и фреймворки, и наш проект не стоит на месте — он постепенно переписывается на React. Одна из особенностей в том, что там нельзя выбросить всё и написать с нуля новую страницу результатов поиска, поэтому миграция происходит по частям. То есть переписываются отдельные фичи, такие как галерея картинок и список картинок на скриншоте: 

То, что обведено в красные рамочки, — это отдельные React-приложения, каждое из которых вставляется в свой корневой элемент в DOM. Вся страница снаружи — это просто HTML, а не React-приложение. Таким образом мы можем мигрировать по частям.

Также мы сразу используем SSR. То есть даже с медленным интернетом или с отключенным JavaScript пользователь всё равно увидит какое-то содержимое, как минимум сможет его прочитать и перейти по ссылке.

Мы рендерим разметку на сервере, отдаем ее клиенту, а дальше на клиенте JavaScript находит все React-фичи и гидрирует их. При этом мы используем оптимизацию: непосредственно в первый момент времени мы гидрируем только фичи, которые находятся на первом экране. А те фичи, которые не видны сразу, мы гидрируем отложенно. А так как основная страница не «реактовая», то мы можем большую часть разметки вообще не гидрировать. В коде это выглядит так:

const roots = document.querySelectorAll('.Root');
for (let i = 0; i < roots.length; i++) {
  // гидрируем сразу только то, что попадает во вьюпорт
  if (isWithinWindow(roots[i])) {
    hydrate(roots[i]);
  } else {
    requestAnimationFrame(hydrate.bind(null, roots[i]));
  }
}

Мы находим в DOM нужные корневые элементы реактовых приложений и в цикле производим гидрацию, если они попадают в видимую область. Или с помощью requestAnimationFrame мы откладываем гидрацию невидимых частей. Получилось очень похоже на legacy-часть на jQuery. Закономерная проблема в том, что все вызовы requestAnimationFrame склеиваются в один большой long task.

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

Как убрать long task

Перед командой скорости встала задача. Long task — плохо: метрики скорости неудовлетворительные, пользователи недовольны тормозами, реже возвращаются на такую страницу и уходят к конкурентам. Как убрать этот long task?

Если говорить «высокоуровнево», ответ простой: за раз выполнять по одной задаче инициализации компонентов или гидрации React-компонентов. А когда закончилась предыдущая задача, с помощью setTimeout планировать выполнение следующих задач. Тогда они в event loop разбиваются на отдельные таски, и между ними браузер может дальше обрабатывать ввод, перерисовывать содержимое и т. д. 

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

В итоге у нас получилась асинхронная очередь задач. Схематически работу event loop можно изобразить так:

Мы берем одну задачу (создание UI-компонента jQuery или гидрацию React-компонента), выполняем ее, вызываем setTimeout. После этого даём браузеру и event loop работать, дальше срабатывает setTimeout и мы выполняем следующую задачу и так далее. 

В коде это выглядит вот так:

const executionQueue = [];

const asyncQueue = {
  push(task) {
    executionQueue.push(task);
    if (executionQueue.length === 1)
      setTimeout(this.execute.bind(this), 0);
  },

  execute() {
    try {
      const task = executionQueue.shift();
      task.fn.call(task.ctx || null);
    } catch (e) {
      // Ошибка не должна ломать исполнение всей очереди
    }

    if (executionQueue.length > 0)
      setTimeout(this.execute.bind(this), 0);
  }
};

Вся реализация асинхронной очереди буквально помещается на один экран. Здесь есть метод добавления задачи push — у нас есть массив, в который мы добавляем переданную нам задачу. Если массив изначально был пустой (то есть очередь остановлена) и после добавления в нем оказалась одна задача, то мы в первый раз запускаем нашу очередь.

Метод execute — это выполнение задач в очереди. Он берет следующую задачу, выполняет ее и, если в очереди ещё остались задачи (любая задача может создать и добавить в очередь ещё задач), то планирует выполнение последующих задач с помощью setTimeout.

После этого мы провели А/В‑тестирование — выкатили в продакшен эту версию кода и сравнили, как она себя ведет по сравнению с оригинальной версией. Получили интересные и неожиданные результаты:

Здесь две колонки — значение метрики в оригинальном коде и как оно изменилось в эксперименте. Метрика TBT исходно составляла примерно одну секунду и уменьшилась на 200 миллисекунд. Это именно то, чего мы добивались — event loop стал меньше блокироваться длинными задачами. Но при этом TTI, то есть момент наступления полной интерактивности от начала навигации на страницу, очень сильно замедлился. Еще одна метрика — JS framework inited — показывает длительность полной инициализации нашего legacy-фреймворка на jQuery, то есть момент, когда его инициализация закончилась. Этот показатель тоже очень сильно замедлился — на 800 миллисекунд. Мы не хотели ухудшать эти метрики и поэтому стали разбираться, в чём причина замедления.

Я написал небольшой кусочек кода, который гоняет нашу асинхронную очередь с пустыми задачами и просто замеряет время:

let t = performance.now();
for (let i = 0; i < 50; i++) {
  asyncQueue.push({
    fn: function() {
      const t2 = performance.now();
      console.log(t2 - t);
      t = t2;
    }
  });
}

Я увидел, что setTimeout(0) на самом деле не дает задержку в ноль миллисекунд. Реальная длительность тайм-аута после первых четырех–пяти вызовов увеличивается до четырех–пяти миллисекунд во всех браузерах.

Это был неожиданный сюрприз. Мы думали, что там 0 миллисекунд, а получили дополнительную задержку. Вторым сюрпризом оказалось то, что нужно просто внимательнее читать спецификацию, потому что эта особенность заложена в официальный стандарт уже много лет. Там есть строка, которая говорит: «После пяти вложенных вызовов setTimeout минимальная задержка должна быть не меньше четырех миллисекунд». Мы ее на практике и обнаружили. А могли бы просто внимательно читать спецификацию и заранее про это знать ????

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

Первая мысль была — уменьшить количество вызовов setTimeout. Что для этого нужно сделать? Объединить задачи в пачки. При этом длительность общей пачки не должна превышать длительность long task. Мы выбрали порог не в 50 миллисекунд, а в 30 с запасом на всякий случай, чтобы последняя задача не вышла за предел. Код нашей асинхронной очереди немного усложнился: мы добавили дополнительную проверку.

const MAX_BATCH_DURATION_MS = 30;
const asyncQueue = {
  //…
  execute() {
    var startTime = performance.now();
    while (executionQueue.length > 0 &&
      performance.now() - startTime < MAX_BATCH_DURATION_MS) {
      var task = executionQueue.shift();
      try {
        task.fn.call(task.ctx || null);
      } catch (e) {
        // Ошибка не должна ломать исполнение всей очереди
      }
    }
    if (executionQueue.length > 0)
      setTimeout(this.execute.bind(this), 0);
  }
};

Если мы при выполнении всё ещё вписываемся в лимит времени, то берем и выполняем следующую задачу без вызова setTimeout. Сразу же возникла более хитрая и сложная проблема. Допустим, в очереди две задачи. Если первая из них выполнилась за 25 миллисекунд и всё ещё укладывается в наш порог в 30 миллисекунд, то мы возьмем на выполнение следующую задачу. Если она заняла, например, 40 миллисекунд, то в сумме наша пачка из двух задач займет 65 миллисекунд. Мы получим long task, увеличение метрики TBT и понижение в рейтинге Google, чего не хотелось бы.

Поэтому наш планировщик должен быть более умным и заранее предотвращать создание таких long task. Мы стали использовать ML и big data (шутка ????).

const LONG_TASK_NAMES = {
  'ajax-updater': 1, // ~60ms
  'footer': 1,       // 35...200ms
  'video-player': 1  // ~100@ms
};

С помощью профилирования в продакшене мы узнали, сколько длится каждая задача инициализации отдельного React- и jQuery-компонента. Посчитали и свели это в отдельную табличку и теперь мы еще до запуска конкретной задачи знаем, сколько миллисекунд она в среднем занимает. Поэтому мы можем предсказать, какие задачи будут образовывать long task. Соответственно при постановке задачи в очередь мы передаем в виде флажка информацию, является ли инициализация данного компонента медленной.

Ya.asyncQueue.push({
  fn: component.init,
  ctx: component,
  isLongTask: component.name in LONG_TASK_NAMES,
});
//…
Ya.asyncQueue.execute();

В коде очереди, когда заканчивается выполнение задачи, мы смотрим: если следующая задача из очереди явно длительная и не впишется в лимит, то мы не пытаемся ее выполнять, досрочно завершаем выполнения пачки задач и вызываем setTimeout.

const asyncQueue = {
  //…
  execute() {
    var startTime = performance.now();
    while (executionQueue.length > 0 &&
      performance.now() - startTime < MAX_BATCH_DURATION_MS) {
      var task = executionQueue.shift();
      try {
        task.fn.call(task.ctx || null);
      } catch (e) {
        // Ошибка не должна ломать исполнение всей очереди
      }
      // Заканчиваем пачку, если следующая задача длинная
      if (executionQueue.length > 0 &&
        executionQueue[0].isLongTask)
        break;
    }
    if (executionQueue.length > 0)
      setTimeout(this.execute.bind(this), 0);
  }
};

Таким образом мы в legacy-части страницы избавились от long tasks:

Вернемся к новому стеку на React. Напомню, как выглядел код гидрации компонентов: 

if (isWithinWindow(roots[i])) {
  hydrate(roots[i]);
} else {
  requestAnimationFrame(
    hydrate.bind(null, roots[i])
  );
}

Во-первых, мы избавились от requestAnimationFrame, потому что пользователь может открыть нашу страницу в фоне, и она должна уметь там инициализироваться. Это как раз идеальные условия, чтобы выполнять тяжелые вычисления — пользователь всё равно не видит, что там в фоновой вкладке происходит. Во-вторых, задачи гидрации компонентов за первым экраном мы отправили в наш планировщик:

if (isWithinWindow(roots[i])) {
  hydrate(roots[i]);
} else {
  window.Ya.asyncQueue.push({
    fn: hydrate.bind(null, roots[i])
  });
}

В DevTools видно, что один большой long task гидрации компонентов React теперь отсутствует, вместо него есть отдельные задачи:

Задача гидрации хорошо разбивается на отдельные таски в event loop, и с метриками всё должно стать лучше. Мы провели следующую итерацию A/B-эксперимента, в которой сравнили три варианта кода:

  • оригинальный,

  • планировщик инициализации только legacy-компонентов («async init»),

  • планировщик инициализации legacy-компонентов и гидрации компонентов React («async init + async hydrate»).

Вот какие результаты мы получили:

Метрика TBT всё так же хорошо улучшается на 230 миллисекунд. Метрика Full load (это время полной загрузки страницы, когда у вас пропадает крутящийся спиннер на вкладке) тоже сильно ускорилась, что было неожиданным сюрпризом для нас. Метрика TTI ухудшается, но уже не так сильно, как раньше. Метрика JS framework inited тоже ухудшается не так сильно. Напомню: в первой итерации было ухудшение на 800 миллисекунд. Раз мы стали использовать асинхронную очередь для гидрации React-компонентов, то метрика окончания гидрации React тоже замедлилась. Это ожидаемо, и так как эти компоненты находятся за видимой частью экрана и пользователь их не видит, то этой метрикой мы можем пожертвовать. Вполне допустимо замедлить на 300 миллисекунд гидрацию невидимых компонентов.

У нас получился умный планировщик задач, который умеет объединять мелкие задачки в пачки и умеет приоритизировать задачи. То есть мы можем при добавлении задачи явно указать, ставить ее в начало очереди, в середину или в конец. 

Главный недостаток нашей получившейся очереди — четыре миллисекунды из-за вызова setTimeout. Из-за этого метрики TTI и инициализации фреймворка ухудшаются, чего мы не хотим.

Как убрать задержку в 4 миллисекунды

Следующий вставший перед нами вопрос — как же избавиться от этой задержки в четыре миллисекунды? Тут как раз пригодился пост Дэвида Бэрона, одного из разработчиков браузера Firefox. Еще в 2010 году он описал проблему с тем, что setTimeout дает задержку в четыре миллисекунды, и описал пути ее решения. Эта статья пролежала в закладках больше десяти лет и наконец-то всё-таки нам пригодилась!

В своем посте Дэвид пишет, что есть такое API, как postMessage. Оно в event loop порождает отдельный таск, не блокирует UI и задержка между отправкой и приёмкой сообщения у него составляет намного меньше четырех миллисекунд. Всё это в сумме позволяет сделать полный аналог setTimeout, но только более быстрый.

Точных чисел в статье не было, поэтому я решил посчитать, какую задержку дает postMessage. У меня получились такие числа:

Для браузеров Chrome и Firefox — сотые доли миллисекунды. Для Safari — десятые доли миллисекунды. То есть это на один–два порядка лучше, чем четыре миллисекунды от setTimeout!

Наверняка кто-то из вас подумал: «У нас же есть Promise.resolve.then. Почему не использовать его?». У Promise есть одна интересная тонкость, из-за которой Promise.resolve.then нельзя использовать для разбиения задачи на отдельные таски в event loop.

Посмотрим на исходную диаграмму, как выполнялся код до того, как мы начали его резать на таски:

Когда мы разбили его с помощью setTimeout, у нас получились фиксированные зазоры в четыре миллисекунды:

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

С помощью postMessage мы убрали этот зазор в четыре миллисекунды.

Принципиальное отличие Promise.resolve.then от setTimeout и postMessage в том, что Promise создает микротаски в очереди микротасок. Это отдельная очередь, и она отличается от очереди обычных тасок тем, что создаваемые микротаски выполняются тут же, в этой же итерации event loop. То есть браузер не получает возможности обрабатывать события, перерисовывать экран и т. д.

Мы фактически возвращаемся к исходной ситуации, когда у нас один большой long task, только теперь с Promise. Это можно легко воспроизвести — написать простейший кусочек кода, генерирующий Promise.resolve.then в бесконечном цикле. Таким способом вы эффективно подвесите свою страничку и проверите это утверждение. Эта особенность Promise описана в том числе и на Mozilla Developer Network

Мы определились, какой API мы можем использовать, а какой нет. Дальше мы позаимствовали код у Дэвида Бэрона и организовали передачу–приёмку сообщений через window.postMessage:

const scheduledTasks = [];
const messageName = 'zero-timeout-message';

window.addEventListener('message', function(e) {
  if (e.source === window && e.data === messageName) {
    e.stopPropagation();
    if (scheduledTasks.length > 0)
      scheduledTasks.shift()();
  }
}, true);

function schedule(fn) {
  scheduledTasks.push(fn);
  window.postMessage(messageName, '*');
}

Тут всё тривиально: у нас есть массив задач, есть слушатель события message и функция, которая вызывает window.postMessage. Изменения в коде нашей асинхронной очереди были минимальны — мы заменили setTimeout на вызов schedule. 

Провели следующую итерацию A/B-тестирования, в которой сравнили характеристики setTimeout и postMessage:

Мы видим, что хорошие вещи, которые мы улучшали (TBT и полная загрузка страницы) остались всё так же хорошими. При этом негативные эффекты (замедленные TTI и инициализация JS framework) заметно ослабли. То есть postMessage заметно лучше, чем setTimeout. Но замедление всё-таки есть. Хотелось бы разобраться и по возможности убрать и его. 

Что же можно улучшить в использовании postMessage? Во-первых, мы заметили, что почти наверняка у клиента при выполнении скриптов есть и другие слушатели события message. Поэтому, когда мы отправляем наше событие, они тоже просыпаются, проверяют, что событие пришло не им, и засыпают дальше. Но при этом они отъедают какие-то ресурсы.

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

И третий момент: у нас всё ещё осталась от старой реализации эвристика по объединению задач в пачки, чтобы как можно меньше вызывать setTimeout. Но она уже не нужна, потому что postMessage автоматически каждую из задач оборачивает в отдельный таск в event loop. 

Что же мы сделали? Мы использовали еще один вариант вызова postMessage — MessageChannel. Это такой объект, на котором есть всего один отправитель и один слушатель. По сравнению с window.postMessage у него никаких лишних событий и лишних слушателей нет, поэтому мы избегаем overhead от лишних срабатываний. 

const executionQueue = [];
const channel = new MessageChannel();

channel.port1.onmessage = function() {
  if (executionQueue.length > 0)
    executionQueue.shift()();
};

function schedule() {
  channel.port2.postMessage(undefined);
}

В асинхронной очереди событий мы сильно упрощаем код и выбрасываем всё ненужное:

const asyncQueue = {
  push(task) {
    if (task.isHighPriority) {
      executionQueue.unshift(task);
    } else {
      executionQueue.push(task);
    }
    schedule();
  },

  execute() {
    // Пустая функция
  }
};

То есть мы просто вызываем schedule, а в execute ничего не надо делать. Код был и так маленький, но сократился еще сильнее. 

После этого мы провели еще одну итерацию A/B-тестирования и посмотрели, что у нас получилось. Я свёл все эксперименты в одну табличку:

По метрике TBT улучшение как было хорошим (230 миллисекунд), практически таким и осталось (215 миллисекунд). Незапланированное ускорение полной загрузки страницы (это когда у вас пропадает спиннер и страница считается загруженной полностью) стало даже еще значительнее. Мы ускорили его на 10%. Замедления TTI практически нет — там осталось всего 25 миллисекунд. Кроме этого, у нас получилось перераспределение между задачами инициализации legacy-фреймворка и гидрации React-компонентов, которые не видны пользователю. То есть мы ускорили инициализацию legacy JS-фреймворка, который нужен пользователю практически сразу же, и примерно на 300 миллисекунд отложили гидрацию невидимых фич. Это такой размен, на который мы готовы пойти, и хороший дополнительный бонус от нашей асинхронной очереди задач. 

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

А что у других?

Встает интересный вопрос: скорее всего, кто-то уже встречался с такой же проблемой и хотел добиться того же, что и мы. Так что сейчас я рассмотрю существующие решения на эту же тему — «Как бороться с длительным временем инициализации JS и в принципе с его выполнением на клиенте». 

SSR + ленивая гидрация

Я уже кратко упоминал об SSR и о том, что желательно гидрировать компоненты по частям. Уже существуют готовые библиотеки ленивой гидрации для Vue и для React:

Мы оборачиваем компоненты в LazyHydrate и указываем условия, по которым нужно их гидрировать на клиенте:

  • Не гидрировать вообще, если нам нужна только разметка и там нет никаких кнопок и других интерактивных вещей.

  • Гидрировать, когда страница проскроллится до этого объекта.

  • Гидрировать, когда браузер ничем не занят.

Такими готовыми штуками очень легко пользоваться в существующем приложении.

Еще одна интересная и свежая вещь — фреймворк Qwik. Его разрабатывает Мишко Эвери, один из разработчиков Angular. Он где-то с 2021 года начал работу над этим фреймворком. В Qwik из коробки как раз заложено требование максимально легко и быстро проводить гидрацию своих фич на клиенте. В придачу у него есть интеграция с React-компонентами, то есть в нем можно переиспользовать готовые React-компоненты с помощью этих команд:

npm create qwik@latest
cd qwik-app
npm run qwik add react

Вот как выглядит код: 

/** @jsxImportSource react */

import {qwikify$} from '@builder.io/qwik-react';
import {Button, Slider} from '@mui/material';

export const MUIButton = qwikify$(Button);

// eagerness: 'load' | 'visible' | 'idle' | 'hover';
export const MUISlider = qwikify$(Slider, {eagerness: 'hover'});


Мы импортируем React-компоненты из какой-либо готовой библиотеки и оборачиваем их в функцию qwikify$. После этого она волшебным образом добавляет возможности фреймворка Qwik для гидрации на клиенте по разным условиям:

  • Можно гидрировать всегда максимально быстро.

  • Можно гидрировать, когда компонент станет видимым на экране (если он не попадает в первый экран, то он просто будет ждать и не гидрироваться).

  • Можно гидрировать по условию idle — это когда браузер ничем не занят.

  • Ещё одно интересное условие — hover: можно начинать гидрацию компонента только когда пользователь наводит на него мышкой и вот-вот готовится на него нажать, а до тех пор вообще ничего не делать. Представьте, что у вас сотни похожих кнопок, стрелок и прочих интерактивных вещей, и мы можем гидрировать только ту из них, которую пользователь прямо сейчас хочет нажать. Это очень хорошая вещь по ускорению JavaScript на клиенте.

React 18+

В свежих версиях React постепенно идёт подготовка к чуть ли не революционным изменениям, которые позволят сильно ускорить выполнение JavaScript и сократить время, которое на него требуется на клиенте. Во-первых, это автоматический batching, то есть несколько последовательных изменений состояния автоматически группируются в одно. Подготовку к этому вы могли видеть уже сейчас — некоторые изменения состояния группируются уже сейчас, и появился так называемый strict mode, который делает каждый рендер по два раза. Он позволяет выловить большинство багов и сделать изменения состояния легко переносимыми, откладываемыми или игнорируемыми. Это и есть подготовительный этап для того, чтобы иметь возможность легко и без слома приложения объединять задачи в пачки или вообще выбрасывать их, если React поймёт, что они уже не нужны. 

Следующая интересная фича — это streaming SSR. До сих пор мы без каких-то костылей не могли на сервере рендерить React-разметку по частям и отдавать ее на клиент по мере готовности. А сейчас это будет возможно с помощью Suspense-компонента прямо из коробки. То есть скоро можно будет большую страницу отдавать на клиент по частям по мере рендеринга. А браузер хорошо умеет отображать такой стриминг по мере его поступления. 

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

Планировщик в React

Также в React появился собственный планировщик. Это отдельная open source библиотека, то есть вы можете ее прямо сейчас скачать отдельно от React и независимо от него использовать. Или просто посмотреть, как она устроена, и почерпнуть оттуда интересные мысли. Мы посмотрели, что она изоморфная и знает, что на сервере можно использовать setImmediate, а на клиенте — postMessage. Мы заглянули внутрь, чтобы увидеть, как она устроена и как использует postMessage. Код, который мы увидели, был очень похож на наш:

if (typeof MessageChannel !== 'undefined') {
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWork;
  schedule = () => {
    port.postMessage(null);
  };
} else {
  schedule = () => {
    setTimeout(performWork, 0);
  };
}

Точно такой же MessageChannel, который отправляет сам себе сообщение. Еще одна интересная вещь — когда вызвался обработчик, он выполняет те задачи, которые ему положили в очередь, пачками по 5 миллисекунд. Также он знает про новое интересное API в браузерах Chrome: isInputPending. То есть как только он видит, что пользователь хочет сделать какой-то ввод (например, нажать на кнопку), он прерывает выполнение этой пачки своих тяжёлых задач и отдает управление пользователю.

В планировщике есть пять уровней приоритетов, по которым задачи сортируются в очереди: 

const ImmediatePriority = 1;
const UserBlockingPriority = 2;
const NormalPriority = 3;
const LowPriority = 4;
const IdlePriority = 5;

Для каждого из приоритетов есть свой тайм-аут в виде константы: 

var IMMEDIATE_PRIORITY_TIMEOUT = -1;
var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
var IDLE_PRIORITY_TIMEOUT = 1073741823;

Она означает, сколько миллисекунд эта задача может подождать до фактического начала выполнения. Когда мы ставим задачу в очередь, мы берем текущее время, прибавляем к нему тайм-аут, получаем expiration time — момент времени, к которому задача должна быть выполнена, и по этому expiration time сортируем нашу очередь: 

var expirationTime = currentTime + timeout;
newTask.sortIndex = expirationTime;


Фактически, это реализация одного из алгоритмов честной очереди (fair queuing). Она гарантирует, что низкоприоритетные задачи не будут бесконечно откладываться и рано или поздно будут выполнены. 

Планировщик в Google Maps

Еще один интересный планировщик есть в Google Maps, но он, к сожалению, закрытый и исходников нет. Есть только текстовое описание, сделанное одним из разработчиков Google. Главная цель планировщика — поддержать ровный FPS и дать возможность пользователю максимально плавно скроллить карту, чтобы она как можно меньше дергалась. При этом он знает, что FPS не везде равен 60 Гц. Есть новые телефоны, у которых частота обновления экрана 90 или 120 Гц. А есть десктопы с мониторами на 144 Гц. Если он не может выдержать полную частоту кадров, то он сваливается на ближайшие дольные — половину, треть и так далее. Как и наш планировщик, он знает о тяжелых задачах, на которых в принципе не сможет выдержать ровный FPS (например, переход в 3D-режим и обратно). Как только он обнаруживает, что такие задачи поступили в очередь, он отключается и выполняет их максимально быстро, не разбивая на отдельные асинхронные кусочки. А потом включается обратно. 

Также у этого планировщика есть несколько приоритетов задач, которые планируются с помощью разных API (requestAnimationFrame, requestIdleCallback, postMessage, setTimeout). 

Планировщик LRT

Еще один интересный планировщик open source — LRT. Его автор — Дмитрий Филатов из Яндекса. Этот планировщик — изоморфный, он может использовать несколько разных API, в том числе requestAnimationFrame, setTimeout, postMessage. Причем он использует именно window.postMessage, ровно то, что нам не подошло. Так что в этом месте его можно улучшить — присылайте свой pull request. 

Вот как выглядит пример кода с использованием этого планировщика:

function* task1Generator() {
  let i = 0;
  while(i < 10) { // 10 units will be executed
    doPartOfTask1();
    i++;
    yield;
  }
  return i;
}

function* task2Generator() { /* ... */ }

const scheduler = createScheduler();
// Run both tasks concurrently
const task1 = scheduler.runTask(task1Generator());
const task2 = scheduler.runTask(task2Generator());

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

Браузерные API

В заключение — самое интересное. Это нативное API, которое позволит нам писать меньше кода. Хорошо, когда браузер уже умеет что-то делать за нас и нам не нужно тащить дополнительные библиотеки. В Chrome 87 появилась такое API, которое называется isInputPending. Оно позволяет из JavaScript узнать, что пользователь хочет сделать какой-то ввод — нажать на кнопку, ссылку или выделить текст. Код может выглядеть вот так:

while (true && !navigator.scheduling.isInputPending()) {
  doTasks();
}

Мы можем не беспокоиться заранее о разбиении какой-то тяжелой задачи на части: как только появился пользовательский ввод, мы сразу прерываем её выполнение. 

Еще одно нативное API — postTask. Это нативный планировщик, который уже есть в Chrome и Firefox. Он позволяет планировать выполнение задач с разными приоритетами. Им уже можно пользоваться:

scheduler.postTask(fn1, {priority: 'background'});
scheduler.postTask(fn2, {priority: 'user-visible'});
scheduler.postTask(fn3, {priority: 'user-blocking'});

Спасибо за внимание! Все ссылки из доклада и даже больше вы можете найти на отдельной странице.

Минутка рекламы от организаторов HolyJS: если вы хотите больше погрузиться в оптимизацию и другие темы из мира JS-разработки, то приходите на весеннюю HolyJS. Она пройдёт 15–16 мая в онлайне и 21–22 мая в формате «Москва + онлайн»: можно будет хоть лично прийти на конференционную площадку, хоть подключиться с другого конца света. Первые доклады и билеты — уже на сайте.

Комментарии (13)


  1. BigDflz
    00.00.0000 00:00

    почему нельзя сформировать полностью готовую страницу на сервере? а уж если что-то юзер вводит обрабатывать. а то получается передаем заготовку, передаем данные, и загружаем их в заготовку. причем не используем innerHTML. какая разница серверу формировать сразу строку html, и ещё json, передавать клиенту, а там парсить json, заполнять объекты? для браузера проще и быстрее обработать одну строку html. не делать т.н. серверный рендеринг, тратя время на формирование json, а потом преобразовывать в html, а сразу строить конечный html, уж если гонимся за милисекундами , то надо во всей системе, а не кровати двигать


    1. mayorovp
      00.00.0000 00:00
      +1

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


      1. ru1z
        00.00.0000 00:00

        Гидратация — это вывод генерируемой сервером страницы? А «гидрировать» — это применять гидратацию на клиенте? Ну точно «Иван Тулуп», поиск в интернете становится все более запутанным.


        1. skeevy
          00.00.0000 00:00

          Гидратация - инициализация скриптов на клиенте, потому что изначально они выполнились на сервере, а точнее - "довешивание" отсутсвующих событий и т.д.


        1. victor-homyakov
          00.00.0000 00:00

          https://letmegooglethat.com/?q=что+такое+гидратация+React

          Даже здесь на Хабре можно найти какое-никакое описание: https://habr.com/ru/post/515100/


          1. ru1z
            00.00.0000 00:00
            +1

            Мой коммент вообще не об этом, погуглите «гидрировать», к примеру. Душность заключается в том, что оригинальный термин, ну, так себе, захламляет поиск нужного значения, кто-то обязательно поймет не так как нужно, и он становится еще более странным при переводе на русский. Вот у вас в комментарии ниже, более логичное использование, имхо.


    1. victor-homyakov
      00.00.0000 00:00

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

      Вы неправильно понимаете смысл термина "серверный рендеринг" (SSR). Именно про это в статье и написано - на сервере SSR, клиенту отдаётся строка HTML. Никакого JSON. Но на полученный HTML надо навесить обработчики, оживить реактовые компоненты. Это называется "гидрация" или "гидратация". Так вот гидрация может быть тяжёлой, и пока она не закончится - браузер может не реагировать на пользовательский ввод.


      1. BigDflz
        00.00.0000 00:00
        -2

        на сервере SSR, 

        ssr - это преобразование чего-то, в html. а зачем это "чего-то" ? почему нельзя сразу в html?
        >>Но на полученный HTML надо навесить обработчики - это ж сколько обработчиков надо навешать?
        вообще то обработчики располагаются в конце страницы и вступают в действие когда вся страница уже отрендерена браузером.
        >> оживить реактовые компоненты. это ж надо городить ещё такое, что б ещё и оживлять.


      1. BigDflz
        00.00.0000 00:00
        -1

        как раз я правильно понял, да на сервере создается строка html, но она создается в два приема, сначала строится нечто вроде json, а потом "эмуляцией" браузера формируется html. но это двойная работа сервера. как вариант - html строку таблицы практически любой сложности можно сформировать в sql запросе и это не будет большой нагрузкой на sql-сервер (проверено).
        повторно, по навешиванию обработчиков. - сколько их? к примеру на таблицу любых размеров достаточно 2-х обработчиков - клик и дблклик, для доступа к любой ячейки. это такого добавления никаких тормозов не будет. сколько ещё ? ну 10-15 на остальные элементы, но и это не тормоз.
        а если использовать template - то и больше обрабатываемых элементов можно использовать без торможения страницы.
        следовательно - (если читать вики: Гидрата́ция (от др.-греч. ὕδωρ «вода») — присоединение молекул воды  ) эти действия и есть присоединение воды, а точнее переливание из пустого в порожнее.


  1. mixsture
    00.00.0000 00:00
    +2

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


    1. saege5b
      00.00.0000 00:00

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


      1. Sigest
        00.00.0000 00:00

        Вот интересно, разность архитектур кому мешает? Многие языки уже умеют мультипоточность на всевозможных платформах, да даже тот же JS умеет, насколько я знаю. Но браузерная разработка до сих пор держится всеми клешнями в однопоток. Вот кто бы рассказал почему так происходит. Я только одну причину вижу - это заставит разработчиков ответственнее подходить к разработке, если они хотят ускорить работу своего приложения.


  1. Revertis
    00.00.0000 00:00

    Лет 20 назад, когда я писал на Delphi свои утилитки, мне иногда надо было во время тяжёлой обработки чего-то в цикле не блокировать работу с окном. Так я просто внутрь цикла добавлял Application.ProcessMessages();, и окно оставалось максимально отзывчивым.

    Может вместо всех этих адовых планировщиков, каналов с сообщениями и так далее, надо просто сделать ручку в виде window.processInput();, которая бы любые события в своей внутренней очереди браузера обрабатывала?