Вам знакомы муки, когда пытаешься добиться чего-то от тормознутого сайта, а он не реагирует на щелки мыши или пробуксовывает при прокрутке? Подобные проблемы с производительностью могут провоцировать:
Нервозное перещёлкивание (rage clicking)
Повышенный отток пользователей и снижение показателей конверсии
Потерю позиций в поисковой выдаче
Более трёх лет мобильная версия Википедии сбоила из-за фрагмента кода на JavaScript, выполнение которого могло занимать более 600 мс при загрузке страницы на маломощных устройствах. В результате работать со страницей становилось попросту невозможно.
В этой статье будет рассмотрено несколько простых шагов, при помощи которых можно значительно сократить время выполнения этой задачи — примерно на 50%.
Общее время блокировки: почему так важны длительные задачи
Может показаться, что 600 мс синхронного выполнения JavaScript — не так долго. Но представьте себе, что пользователь пытался нажать кнопку в процессе загрузки страницы, а тут начал выполняться этот код на 600 мс. Поскольку в главном потоке браузера в любой момент времени может выполняться только одна задача, должны завершиться все следующие шаги, прежде, чем страница визуально обновится:
Выполнится задача JavaScript на 600 мс
Выполнится задача соответствующего обработчика щелчков
Браузер выполнит все необходимые этапы рендеринга, нужные, чтобы страница визуально обновилась
На каждый шаг требуется время, а любое взаимодействие, при котором на завершение визуального обновления уходит более 100 мс, пользователь может воспринимать как медленное. Поэтому Google трактует любую задачу, занимающую более 50 мс, как «длительную», и такая задача может снизить отзывчивость страницы при приёме пользовательского ввода. В Google даже разработали специальную метрику для оценки этого показателя, именуемую «Total Blocking Time» (Общее время блокировки) или TBT.
Что такое «общее время блокировки»?
Метрика TBT измеряет совокупную блокирующую составляющую всех длительных задач, выполняемых в главном потоке браузера между первым существенным отображением (First Contentful Paint, FCP) и временем до интерактивности (Time To Interactive, TTI). «Блокирующая составляющая» — это время, затрачиваемое на каждую длительную задачу сверх первых 50 мс.
Давайте попробуем подсчитать TBT в нижеприведённом примере:
Задача на 80 мс выполняется сверх 50 мс ещё 30 мс — записываем в TBT 30 мс.
Задача на 30 мс ничего не привносит в TBT, поскольку на её выполнение уходит менее 50 мс, и она НЕ является длительной.
Задача на 100 мс выполняется сверх 50 мс ещё 50 мс, соответственно, она добавляет в TBT 50 мс.
Поскольку TBT — это сумма всего времени сверх первых 50 мс, затрачиваемых на каждую длительную задачу, в данном примере TBT составляет 30 мс + 50 мс = 80 мс.
При тестировании на среднестатистическом мобильном устройстве Google рекомендует поддерживать TBT мобильной версии сайта на уровне менее 200 миллисекунд. Но в Википедии была задача, на выполнение которой могло уйти более 600 миллисекунд — что втрое выше рекомендуемого предела TBT, причём в рамках всего одной задачи.
Как же можно улучшить TBT?
Как сократить общее время блокировки
Поскольку TBT – это сумма всего времени сверх первых 50 мс, затрачиваемых на каждую длительную задачу, в данном примере TBT составляет 30 мс + 50 мс = 80 мс.
При тестировании на среднестатистическом мобильном устройстве Google рекомендует поддерживать TBT мобильной версии сайта на уровне менее 200 миллисекунд. Но в Википедии была задача, на выполнение которой могло уйти более 600 миллисекунд — что втрое выше рекомендуемого предела TBT, причём, в рамках всего одной задачи.
Как же можно улучшить TBT?Как сократить общее время блокировки
Чтобы сократить TBT, требуется сделать одно из двух:
Чтобы сократить TBT, требуется сделать одно из двух:
Выполнять меньше работы в главном потоке между первым существенным отображением и временем до интерактивности.
Не уменьшать количество работы, но дробить длительные задачи на более мелкие, на выполнение каждой из которых тратится не более 50 мс.
Данная статья рассказывает, как выиграть время за счёт первого из двух этих вариантов.
Шаг 1: Удалите ненужный JavaScript
Притом, что в главном потоке действительно выполняются многие вещи, в том числе, синтаксический разбор HTML, отрисовка, сборка мусора, именно долгое выполнение JavaScript зачастую провоцирует проблемы с TBT. В конце концов, JavaScript – это самый быстрый способ замедлить сайт.
Занимаясь профилированием мобильной версии Википедии, я обнаружил, что большая часть времени уходит на выполнение метода _enable
. Этот метод инициализировал раскрытие и схлопывание секций на странице в мобильной версии сайта. Также профилировка показала, что внутри метода _enable
выполняется вызов метода .on("click")
библиотеки jQuery, также очень медленный.
function _enable( $container, prefix, page, isClosed ) {
...
// Ограничено ссылками, созданными редакторами – и, следовательно, вне нашего контроля
// T166544 – не делайте этого для ссылок на источники – они будут обрабатываться не здесь
var $link = $container.find("a:not(.reference a)");
$link.on("click", function () {
// ссылка может указывать на раздел внутри той же статьи и сопровождаться символом #
// if – это проверка, нужно ли нам раскрывать какие-либо разделы
if (
$link.attr("href") !== undefined &&
$link.attr("href").indexOf("#") > -1
) {
checkHash();
}
});
util.getWindow().on("hashchange", function () {
checkHash();
});
}
Вызов .on("click")
, привёл к тому, что почти ко всем ссылкам в содержимом страницы были прикреплены слушатели щелчка по ссылке так, что соответствующая секция открывалась, если в адресе нажатой ссылки содержался #. В коротких статьях со считанными ссылками влияние на общую производительность оставалось пренебрежимым. Но в длинных статьях, например, «United States» содержится по 4 000 ссылок и более, в результате чего на маломощных устройствах эта страница открывалась по 200 мс и более.
Хуже того, такое поведение просто не требуется. Расположенный ниже код, слушавший событие hashchange
, уже вызывал тот же самый метод, что и слушатель события щелчка. Если в области просмотра, открытой в окне, не было прямого указания на то, где находится ссылка, то при щелчке по ссылке метод checkHash
вызывался дважды: один раз для обработчика события щелчка по ссылке, а второй раз для обработчика hashchange
.
Следовательно, наилучшим решением в данном случае было просто убрать этот блок JavaScript и высвободить в главном потоке почти 200 мс, сохранив практически весь имеющийся функционал.
При профилировании всегда проверяйте, на что тратится больше всего времени. Затем смотрите, есть ли код, который можно оптимизировать или, ещё лучше — удалить.
Помните, что самый верный способ ускорить сайт — удалить JavaScript.
Шаг 2: Оптимизируйте имеющийся JavaScript
Дополнительная проверка производительности показала, что на выполнение метода initMediaViewer
тратится ~100 мс. Этот метод отвечал за прикрепление слушателя щелчков к каждому эскизу в контенте на странице так, что щелчок по эскизу должен был открывать средство просмотра медиа:
/**
* Обработчик события, срабатывающий при щелчке по изображению-эскизу
*
* @param {jQuery.Event} ev
* @ignore
*/
function onClickImage(ev) {
ev.preventDefault();
routeThumbnail($(this).data("thumb"));
}
/**
* Добавление маршрутов, ведущих к изображениям, и обработка щелчков
*
* @method
* @ignore
* @param {jQuery.Object} [$container] Опциональный контейнер для поиска
*/
function initMediaViewer($container) {
currentPageHTMLParser.getThumbnails($container).forEach(function (thumb) {
thumb.$el.off().data("thumb", thumb).on("click", onClickImage);
});
}
Как и в примере со ссылкой из шага 1, такая операция, как прикрепление слушателя событий к каждому эскизу на странице плохо масштабируется. Редакторы статей Википедии могут создавать статьи с тысячами картинок (и так и делают). На выполнение этого блока кода могло уходить гораздо более 100 миллисекунд, если на странице было много картинок — соответственно, метрика TBT этой страницы увеличивалась. Какой альтернативный подход здесь возможен?
Делегируйте события.
Делегирование событий — мощный приём, пользуясь которым, можно прикрепить всего один слушатель событий к тому элементу, который является общим предком многих других элементов. Зачастую делегирование событий оказывается эффективнее, если приходится иметь дело с пользовательским контентом, допускающим добавление любого числа элементов. В данном случае мы с пользой применяем всплытие событий, и вся система работает вот так:
Прикрепляем слушатель событий к контейнерному элементу.
В обработчике событий используем параметр
event
, проверяем свойствоevent.target
, чтобы посмотреть источник события. Опционально используем APIevent.target.closest(selector)
, чтобы проверить, каков предковый элемент.Если источником события является интересующий нас элемент или его потомок — обрабатываем его.
Обновлённый код выглядит примерно так:
/**
* Обработчик события, срабатывающий при щелчке по изображению-эскизу
*
* @param {MouseEvent} ev
* @ignore
*/
function onClickImage(ev) {
var el = ev.target.closest(PageHTMLParser.THUMB_SELECTOR);
if (!el) {
return;
}
var thumb = currentPageHTMLParser.getThumbnail($(el));
if (!thumb) {
return;
}
ev.preventDefault();
routeThumbnail(thumb);
}
/**
* Добавление маршрутов, ведущих к изображениям, и обработка щелчков
*
* @method
* @ignore
* @param {HTMLElement} container Container to search within
*/
function initMediaViewer(container) {
container.addEventListener("click", onClickImage);
}
В данном случае:
Был пересмотрен метод
initMediaViewer
: теперь один слушатель щелчков прикреплялся к единственному контейнерному элементу, в котором содержались все изображения.В методе
onClickImage
использовался APIev.target.closest(selector)
, при помощи которого проверялось, откуда поступил щелчок: от эскиза или от дочернего элемента эскиза. Если ни первое, ни второе не подтверждается, то код сразу возвращается, ведь нас интересуют только щелчки по эскизам. Если первое или второе подтверждается, то происходит обработка события.
Но каковы были результаты этой работы?
Заключение
Состоялся релиз оптимизаций, обрисованных в шагах 1 и 2. Они были развёрнуты в продакшен в два этапа: сначала шаг 1, затем шаг 2.
По данным синтетического теста производительности Википедии, первая развёрнутая часть позволила сократить TBT примерно на 200 мс, а вторая позволила улучшить TBT ещё примерно на 80 мс при тестировании на настоящем смартфоне Moto G (5). В целом два этих шага позволили сократить TBT примерно на 300 мс при просмотре длинных статей на таких устройствах как Moto G (5).
Притом, что здесь ещё определённо осталось пространство для улучшений, особенно при работе с маломощными устройствами, достигнут значительный прогресс. Чтобы ещё сильнее сократить TBT, нам, возможно, будет необходимо подразделить задачу на более мелкие.
Этот опыт показывает, что можно добиться значительного улучшения производительности, выполнив мелкую прицельную оптимизацию. Удалив или оптимизировав конкретные фрагменты кода, можно существенно повлиять на общую производительность сайта. Такой опыт лишний раз напоминает, что для повышения отзывчивости при работе с браузером практически на любом устройстве не всегда требуется вносить в базу кода сложные и обширные изменения. Иногда наибольшая разница достигается малой кровью.