Привет, друзья!
В этой заметке я хочу рассказать о двух вещах:
- Сканере предварительной загрузки (теоретическая часть).
- Пропуске невидимого контента (практическая часть).
Обе технологии используются браузером для повышения скорости загрузки веб-приложений.
Теоретическая часть представляет собой адаптированный и дополненный перевод этой статьи. Практическая часть - это небольшой эксперимент по применению новых свойств CSS
, о которых рассказывается в этой статье (перевод).
Сканер предварительной загрузки
Что такое сканер предварительной загрузки?
В каждом браузере есть основной парсер (primary parser) HTML
(далее - просто парсер), который то кенизирует (tokenize) разметку и преобразует ее в объектную модель. Разбор разметки продолжается до тех пор, пока парсер не встретит блокирующий ресурс, такой как стили, загружаемые через элемент link
, или скрипт, загружаемый через элемент script
без атрибута async
или defer
.
На приведенной диаграмме парсер блокируется загрузкой внешнего CSS-файла
с помощью элемента link
. Браузер не будет разбирать остальной документ и ничего не будет рендерить до тех пор, пока не загрузит и не разберет эту таблицу.
В случае с файлами CSS
парсинг и рендеринг блокируются во избежание вспышки нестилизованного контента (flash of unstyled content, FOUC), когда на мгновение появляется нестилизованная версия страницы перед применением к ней соответствующих стилей.
Браузер также блокирует парсинг и рендеринг страницы, когда встречает тег script
без атрибута async
или defer
.
Обратите внимание: скрипты с type="module"
загружаются отложено по умолчанию.
Причина такого поведения браузера состоит в том, что скрипт может модифицировать DOM до завершения обработки разметки парсером.
Блокировка парсинга и рендеринга является нежелательной, поскольку может помешать обнаружению других важных ресурсов. К счастью, кроме основного, в браузере также имеется дополнительный парсер (secondary parser), который называется сканером предварительной загрузки (preload scanner) (далее - просто сканер).
Основной парсер блокируется обработкой CSS
и не "видит" изображение в body
. Однако сканер продолжает свою работу, обнаруживает изображение и загружает его, не дожидаясь разблокировки парсера.
Роль сканера - исследование разметки с целью обнаружения ресурсов для предварительной загрузки, т.е. до обнаружения этих ресурсов парсером.
Как увидеть работу сканера?
Рассмотр им страницу со стилизованными изображением и текстом. Блокировка парсинга и рендеринга файлом CSS
позволяет реализовать искусственную задержку в 2 секунды с помощью прокси-сервера. Задержка помогает увидеть работу сканера на сетевом водопаде (network waterfall):
Сканер обнаружил элемент img
, несмотря на блокировку парсинга и рендеринга. Без этой оптимизации браузер не сможет предварительно загружать ресурсы в период блокировки. Это означает, что будет больше последовательных запросов и меньше параллельных.
Может ли разработчик помочь сканеру? Нет. Но он может ему не мешать. Рассмотрим парочку примеров.
Внедренные скрипты
Предположим, что в head
у нас имеется такой код:
<script>
const script$ = document.createElement('script')
script$.src = '/ym.min.js'
document.head.append(script$)
</script>
Внедренные (injected) скрипты (ym.min.js
) по умолчанию являются асинхронными (как будто у них имеется атрибут async
). Такие скрипты запускаются сразу и не блокируют рендеринг. Звучит прекрасно, не правда ли? Да, но если расположить такой script
после link
, загружающего внешний файл CSS
, то результат будет следующим:
Страница содержит одну таблицу стилей и встроенный асинхронный скрипт. Сканер не может обнаружить такой скрипт в период блокировки, поскольку он внедряется на клиенте.
Вот что здесь происходит:
- Сначала запрашивается основной документ.
- На 1,4 секунды прибывает первый байт навигационного запроса.
- На 2 секунде запрашиваются стили и изображение.
- Парсер блокируется загрузкой стилей и встроенный
JS
, который внедряет асинхронный скрипт, выполняется только на 2,6 секунды.
Таким образом, запрос на получение скрипта выполняется только после завершения загрузки стилей. Это может негативно сказаться на времени до интерактивности (Time to Interactive, TTI).
Перепишем код следующий образом:
<script src="/ym.min.js" async></script>
Результат:
Сканер обнаруживает асинхронный скрипт в период блокировки и загружает его одновременно со стилями.
Данную проблему также можно решить с помощью rel="preload":
Предварительная загрузка решает задачу, но возникает другая проблема: в первом примере асинхронный скрипт загружается с "низким" приоритетом, а таблица стилей с "наивысшим", во втором случае стили также загружаются с "наивысшим" приоритетом, а приоритет загрузки скрипта становится "высоким".
Чем выше приоритет загрузки ресурса, тем больше пропускной способности выделяется ему браузером. Высокий приоритет скрипта может стать проблемой при медленном соединении или большом размере файла.
Ответ прост: если скрипт требуется при запуске приложения, не внедряйте его в DOM
с п омощью встроенного JS
.
Ленивая загрузка с помощью JS
Ленивая или отложенная (lazy) загрузка - отличный метод обработки данных, часто применяемый к изображениям. Однако неправильное применение этой техники может привести к тому, что важные ресурсы не будут своевременно обнаружены сканером (получение ссылки на изображение, его загрузка, декодирование и отображение).
Взглянем на такую разметку:
<img data-src="example.png" alt="Example" width="384" height="255">
Использование атрибута data-*
является распространенной практикой, используемой ленивыми загрузчиками (lazy loaders, например, vanilla-lazyload). Когда изображение попадает в область просмотра, загрузчик меняет data-src
на src
. Обычно, это реализуется с помощью Intersection Observer API. Это заставляет браузер загрузить ресурс.
Паттерн работает до тех пор, пока изображение не находится в области просмотра при запуске приложения. Поскольку сканер не читает атрибут data-src
как атрибут src
(или srcset
), ссылка на изображения остается скрытой. Более того, загрузка изображения откладывается до загрузки, компиляции и выполнения кода ленивого загрузчика.
В отдельных случаях при нахождении "ленивого" изображения в области просмотра при запуске приложения замены data-src
на src
вообще не происходит, поэтому при использовании, например, react-lazyload, иногда приходится вручную вызывать метод forceCheck
для определения нахождения элемента в области просмотра.
Обратите внимание: у элемента img
(и iframe
) есть специальный атрибут для ленивой загрузки - loading="lazy". Как только поддержка этого атрибута Safari
станет стабильной, всем будет счастье. Много изображений на сайте? Рассмотрите возможность использования Imgproxy.
В зависимости от размера изображения или размера занимаемой им части области просмотра, изображение может стать целью для оценки скорости загрузки основного контента (Largest Contentful Paint, LCP) страницы.
Убедитесь в том, что изображения и другие ресурсы, находящиеся в области просмотра при запуске приложения, загружаются незамедлительно (eager):
<img src="/example.png" alt="Example" width="384" height="255">
Фоновые изображения CSS
Помните, что сканер исследует разметку. Он не сканирует другие ресурсы, такие как стили, в которых изображения могут запрашиваться в свойстве background-image.
Как и HTML
, CSS
преобразуется в объектную модель - CSSOM. При обнаружении внешних ресурсов при конструировании CSSOM
, они запрашиваются во время обнаружения, а не предварительно.
Предположим, что кандидат LCP
- это элемент с background-image
:
Загрузка изображения начинается только после его обнаружения парсером CSS
.
Эту проблему можно решить предварительной загрузкой изображения с помощью rel="preload"
:
<!-- в <head> после таблицы стилей -->
<link rel="preload" as="image" href="lcp-image.jpg">