Планировщик задач: не замораживаем вкладку при открытии страницы

a60cde5d5760c1a5b2094b07a7b92a6b.GIF

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

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

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

О себе

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

e6aae8102bbf56cd631b7aef7b297bfb.png

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

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

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

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

3e98c0968648747ebe4645dae4f590e9.png

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

e66dcf5796a6d54f4dfde8c6e043fc70.png

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

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

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

940cb36b92ed58d444b3f167e571dc85.png

Следующее важное понятие — 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. Там выводятся понятные объяснения и ссылки на документацию. 

d03a5455c19928be8e000b52e6165b55.png

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

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

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

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

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

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

bc15e1ffb564e6a476dbdc61509c5e61.png

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

80d0f17f213bb8485cac5b5708b254c3.png

В этом случае суммарно 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:  

228a8e1fecb01371ab2e6b6116f15b83.png

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

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

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

06e94dc1a21e1b87ff642869e759fb68.png

То, что обведено в красные рамочки, — это отдельные 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.

f6bffad34184ca90ad79389dd1a9c6a8.png

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

Как убрать long task

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

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

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

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

c8d1776700ac38bbfef1ca1c892c4705.png

Мы берем одну задачу (создание 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.

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

7bcb8678398d2cd646e2f3eae922fd2f.png

Здесь две колонки — значение метрики в оригинальном коде и как оно изменилось в эксперименте. Метрика 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) на самом деле не дает задержку в ноль миллисекунд. Реальная длительность тайм-аута после первых четырех–пяти вызовов увеличивается до четырех–пяти миллисекунд во всех браузерах.

5d6069aecf57a58ca59d9203b9d227f0.png

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

© Habrahabr.ru