Стратегії завантаження ресурсів - preload, prefetch, modulepreload
Стратегії завантаження ресурсів — це <link rel> підказки (resource hints), які повідомляють браузеру, коли і як завантажувати ресурси до того, як вони реально знадобляться сторінці. Preload забирає критичні ресурси з високим пріоритетом у момент виявлення підказки. Prefetch ставить ресурси майбутньої сторінки в чергу з найнижчим пріоритетом. Modulepreload завчасно завантажує граф ES-модулів без виконання жодного коду.
Теорія
TL;DR
- Аналогія: preload = шеф-кухар бере інгредієнти, потрібні прямо зараз (високий пріоритет). prefetch = поповнення комори для завтрашнього рецепта (фон, тільки під час простою). modulepreload = підготовка модульних інгредієнтів без початку готування.
- Різниця в пріоритеті: preload запускається з тим самим пріоритетом, що й CSS, вставлений парсером. prefetch отримує найнижчий слот і виконується лише коли черга вільна. modulepreload має високий пріоритет, але тільки для ES-модулів.
- Правило вибору: зображення LCP або критичний шрифт → preload. Ресурс наступної сторінки → prefetch. Точка входу ES-модуля та весь граф імпортів → modulepreload.
- Важливо:
preloadбез атрибутаasChrome ігнорує з повідомленням "Did not assign resource type". Preload шрифту безcrossoriginдає промах кешу при кожному завантаженні.
Швидкий приклад
<head>
<!-- Високий пріоритет, спрацьовує при виявленні, як і CSS -->
<link rel="preload" href="/hero.avif" as="image" fetchpriority="high">
<!-- Шрифт потребує crossorigin, інакше браузер завантажить його двічі -->
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
<!-- Найнижчий пріоритет, виконується у фоні під час простою -->
<link rel="prefetch" href="/checkout.js">
<!-- Завантажує app.mjs та всі його імпорти паралельно, без виконання -->
<link rel="modulepreload" href="/app.mjs">
</head>
<body>
<img src="/hero.avif" alt="Hero"> <!-- Вже в кеші, без мерехтіння -->
<script type="module" src="/app.mjs"></script> <!-- Залежності готові -->
</body>Зображення-герой відображається менш ніж за 100ms. Шрифт завантажується без спалаху невидимого тексту (FOIT). Імпорти модулів беруться з кешу замість послідовного ланцюжка мережевих запитів.
Ключова різниця
Preload — це високопріоритетний запит, який спрацьовує при парсингу <head>, ще до того, як браузер знайшов би ресурс у звичайному потоці. Prefetch — спекуляція під час простою: браузер завантажує ресурс лише тоді, коли немає нічого важливішого, і зберігає його для майбутньої навігації. Modulepreload орієнтований саме на ES-модулі. Він завантажує вхідний модуль, рекурсивно знаходить граф import-залежностей і завантажує все паралельно, але V8 не парсить і не виконує жодного з модулів на цьому етапі. Ти отримуєш повну мережеву перевагу без навантаження на основний потік до моменту, коли реально виконається тег <script type="module">.
Коли використовувати
- Зображення LCP або відео вище лінії згину → preload з
fetchpriority="high" - Веб-шрифт, що використовується в критичному CSS → preload з
as="font"іcrossorigin - JavaScript, який блокує перший рендер → preload з
as="script" - CSS вище лінії згину → preload з
as="style" - Ресурс на сторінці, куди користувач, найімовірніше, перейде → prefetch
- Точки входу ES-модулів та весь граф імпортів → modulepreload
- Сторонні скрипти, потрібні, але не термінові → prefetch
- Не використовуй більше 4-5 preload на сторінці: вони конкурують між собою за пропускну здатність.
Таблиця порівняння
| preload | prefetch | modulepreload | |
|---|---|---|---|
| Пріоритет | Високий (як CSS) | Найнижчий (тільки простій) | Високий (тільки модулі) |
| Коли спрацьовує | Одразу при виявленні | Коли браузер вільний | До тега <script> |
| Блокує рендер | Ні | Ні | Ні |
| Що завантажує | Будь-який тип (потрібен as) | Будь-який тип | JS-модулі + граф імпортів |
| Виконує код | Ні | Ні | Ні |
| Основний кейс | Зображення LCP, шрифт | Навігація на наступну сторінку | SPA та ES-модульні застосунки |
Потрібен crossorigin | Для шрифтів — так | Ні | Залежить від CORS-політики |
| Коли використовувати | Критичний шлях зараз | Спекулятивне майбутнє | Сучасні JS-застосунки |
Як браузер обробляє це зсередини
Коли браузер натрапляє на <link rel="preload"> під час парсингу <head>, Chromium ставить запит у чергу MEDIUM або HIGH у LoadingScheduler::Priority — той самий рівень, що й таблиці стилів, вставлені парсером. Prefetch отримує рівень LOWEST і виконується тільки після того, як вся робота з вищим пріоритетом завершена.
Modulepreload (Chrome 66+, всі основні браузери з 2023 року) використовує внутрішній ModuleGraph браузера. Він запускає fetch() для вхідного модуля, парсить import-вирази, знаходить залежності та завантажує все паралельно. V8 пропускає етапи парсингу й виконання. Це усуває класичний каскад модулів: без modulepreload граф з 3 рівнями глибини потребує 3 послідовних мережевих round trip-и до початку виконання коду. З ним все завантажується в один паралельний пакет.
Safari 16.4 і раніше підтримував лише частину специфікації без рекурсивного розв'язання залежностей. Повна підтримка з'явилася в Safari 17.0.
preload на практиці
Атрибут as обов'язковий. Без нього Chrome ігнорує підказку та виводить попередження в консоль. Браузеру потрібен as, щоб визначити правильні заголовки запиту та зв'язати попередньо завантажений ресурс з тим, хто його пізніше запросить.
<!-- Правильно: браузер знає тип, ключ кешу збігається -->
<link rel="preload" href="/api-data.json" as="fetch" crossorigin>
<!-- Браузер ігнорує. При пізнішому запиті кеш не спрацює. -->
<link rel="preload" href="/api-data.json">Для шрифтів crossorigin — не побажання, а вимога. Шрифти завантажуються через CORS. Preload без crossorigin і фактичний запит шрифту використовують різні ключі кешу. Шрифт завантажується двічі, що зводить нанівець усю ідею.
prefetch для навігації
Prefetch добре працює, коли намір користувача передбачуваний. На сторінці товару — prefetch скрипту кошика. На сторінці входу — prefetch бандла дашборду. Браузер завантажує у фоні і ніколи не конкурує з поточною сторінкою.
<!-- Сторінка товару: користувач, швидше за все, піде в кошик -->
<link rel="prefetch" href="/cart.js">
<link rel="prefetch" href="/cart.css">
<!-- Список статей: користувач клацне по статті -->
<link rel="prefetch" href="/article-renderer.js">Варто пам'ятати: service worker перехоплює prefetch-запити так само, як і будь-який інший fetch. Якщо у твого SW є cache-first стратегія для цього URL, ресурс може прийти з кешу SW, а не з мережі.
modulepreload для ES-модульних застосунків
Без modulepreload завантаження ES-модульного застосунку відбувається послідовно: браузер завантажує app.mjs, парсить, знаходить import { utils } from './utils.mjs', завантажує utils.mjs, парсить, знаходить наступний імпорт і так далі. Граф з 3 рівнями — це 3 послідовних round trip до запуску жодного рядка коду.
<!-- Без modulepreload: послідовні запити, ~600ms на 3G -->
<script type="module" src="/app.mjs"></script>
<!-- З modulepreload: все завантажується паралельно при виявленні -->
<link rel="modulepreload" href="/app.mjs">
<link rel="modulepreload" href="/utils.mjs">
<link rel="modulepreload" href="/api.mjs">
<script type="module" src="/app.mjs"></script>Vite робить це автоматично. У продакшн-збірці Vite генерує <link rel="modulepreload"> для кожного чанка в графі модулів, перетворюючи каскад на один паралельний пакет запитів.
preconnect і dns-prefetch
Ці дві підказки працюють на рівні з'єднання, а не ресурсу. Корисні для сторонніх origin-ів, до яких ти точно звернешся.
preconnect робить ранє рукостискання: DNS-запит, TCP-з'єднання і TLS-переговори. Це економить 100-500ms на першому запиті до того origin-у.
dns-prefetch вирішує тільки DNS. Дешевше, але й виграш менший (20-120ms). Використовуй для доменів, до яких можеш звернутися, але не гарантовано.
<!-- Fonts: ти точно звернешся сюди -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<!-- Аналітика: скоріш за все, але некритично -->
<link rel="dns-prefetch" href="https://analytics.google.com">Обмеж preconnect двома-трьома origin-ами. Кожен тримає відкрите TCP/TLS з'єднання, що коштує пам'яті. Більше трьох — і ти витрачаєш ресурси на з'єднання, які можуть закінчитися таймаутом до першого запиту.
Атрибут fetchpriority
Це окремий важіль від типу підказки (Chrome 101+). Він дозволяє підняти або опустити ресурс всередині його стандартного пріоритетного кошика.
<!-- Підняти LCP-зображення вище інших високопріоритетних ресурсів -->
<img src="/hero.jpg" fetchpriority="high" loading="eager">
<!-- Опустити піксель відстеження поза критичним шляхом -->
<img src="/tracking-pixel.jpg" fetchpriority="low">
<!-- Комбінація, яку використовує Next.js для Image з пропом priority -->
<link rel="preload" href="/hero.jpg" as="image" fetchpriority="high">Дані HTTP Archive показують покращення LCP на 30-50% при поєднанні preload і fetchpriority="high" для зображень вище лінії згину. Next.js застосовує цей шаблон автоматично для зображень з пропом priority.
Типові помилки
Відсутній as у preload
<!-- Chrome виводить "Did not assign resource type". Запит ігнорується. -->
<link rel="preload" href="script.js">
<!-- Правильно -->
<link rel="preload" href="script.js" as="script">prefetch для критичного CSS поточної сторінки
<!-- Найнижчий пріоритет. CSS може прийти після рендеру. FOUC у продакшні. -->
<link rel="prefetch" href="critical.css" as="style">
<!-- Правильно: якщо потрібно зараз, використовуй preload -->
<link rel="preload" href="critical.css" as="style">modulepreload для класичного скрипту
<!-- Нічого не робить для не-модульних скриптів. Залежності не вирішуються. -->
<link rel="modulepreload" href="bundle.js">
<!-- Працює тільки разом з type="module" -->
<link rel="modulepreload" href="app.mjs">
<script type="module" src="app.mjs"></script>Preload шрифту без crossorigin
<!-- Промах кешу: preload і фактичний запит шрифту мають різні ключі. -->
<link rel="preload" href="font.woff2" as="font">
<!-- Правильно -->
<link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin>Забагато preload одночасно
Преленти 8-10 ресурсів потрапляють в одну високопріоритетну чергу і конкурують між собою та з CSS, що блокує рендер. Тримай кількість preload в межах 4-5 на сторінку.
Де зустрічається
- Next.js: автоматично додає
<link rel="preload">для зображень з пропомpriorityта для_next/static/cssчанків, зfetchpriority="high"для LCP-зображень. - Vite: у продакшн-збірці генерує
<link rel="modulepreload">для кожного чанка в графі модулів. - React 18 streaming SSR: вставляє modulepreload-підказки у HTML-потік для залежностей гідратації.
- Lighthouse: аудит "Preload key requests" позначає LCP-ресурси без preload.
- Chrome DevTools, вкладка Network: колонка Priority показує High для цілей preload і Low для кандидатів на prefetch.
Питання на співбесіді
Q: Яка різниця між пріоритетами preload і prefetch у планувальнику Chromium?
A: Preload потрапляє на рівень MEDIUM/HIGH у LoadingScheduler::Priority, той самий, що й CSS від парсера. Prefetch — на рівень LOWEST і виконується лише коли черга вільна.
Q: Чи блокує preload парсинг HTML?
A: Ні. Запит виконується паралельно з парсингом. Якщо ти навмисно затримуєш рендер через обробник onload на елементі preload — це твій свідомий вибір, а не автоматична блокування.
Q: Яка різниця між modulepreload і dns-prefetch?
A: modulepreload завантажує повний вміст модулів і вирішує весь граф імпортів, наповнюючи кеш модулів реальним вмістом. dns-prefetch тільки перетворює ім'я хоста на IP-адресу. Жодного контенту не завантажується.
Q: Якщо preloaded ресурс повертає 404, чи отруює це кеш?
A: Ні. Кожен наступний запит іде в мережу заново. Можна додати обробник події error до елемента preload-посилання, щоб відловити збій і підставити запасне джерело — корисно в SPA-роутерах з динамічними шляхами до ресурсів.
Q: Чи взаємодіє preload з service worker?
A: Так, service worker перехоплює preload-запити, як і будь-який fetch. Якщо у SW є cache-first стратегія для того URL, ресурс прийде з кешу SW, а не з мережі.
Q: Ти в продакшні, потрібен максимальний LCP. Який повний шаблон?
A: Поєднуй preload + fetchpriority="high" + loading="eager" на елементі LCP-зображення. Потім вимірюй через PerformanceObserver з типом largest-contentful-paint. На повільних з'єднаннях пропуск цієї комбінації додає ~1 секунду до TTI для сторінок із зображеннями. HTTP Archive показує покращення LCP на 30-50% для зображень вище лінії згину.
Приклади
Базовий: preload, prefetch і modulepreload на одній сторінці
<!DOCTYPE html>
<html>
<head>
<!-- Preload: високий пріоритет, спрацьовує при парсингу head -->
<link rel="preload" href="/hero.avif" as="image" fetchpriority="high">
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
<!-- Prefetch: фоновий запит під час простою -->
<link rel="prefetch" href="/checkout.js">
<!-- Modulepreload: app.mjs та його імпорти завантажуються паралельно -->
<link rel="modulepreload" href="/app.mjs">
<link rel="modulepreload" href="/utils.mjs">
</head>
<body>
<!-- hero.avif вже в кеші, відображається одразу -->
<img src="/hero.avif" alt="Hero image" loading="eager">
<!-- Залежності модуля закешовані, без каскаду -->
<script type="module" src="/app.mjs"></script>
<!-- При кліці checkout.js вже в кеші -->
<a href="/checkout">Оформити замовлення</a>
</body>
</html>Зображення-герой знаходиться в кеші до того, як браузер дійде до тега <img>. Шрифт завантажується без спалаху невидимого тексту. Граф модулів вирішується з кешу приблизно за 10ms замість 3 послідовних round trip-ів.
Середній рівень: продакшн-налаштування Next.js (оптимізація LCP)
Це шаблон, який Next.js генерує для сторінки з hero-зображенням і власним шрифтом.
<head>
<!-- LCP-зображення: preload + fetchpriority — шаблон Next.js Image з priority -->
<link rel="preload" href="/hero.avif" as="image" fetchpriority="high">
<!-- Шрифт: crossorigin обов'язковий, type прискорює визначення MIME -->
<link rel="preload"
href="/_next/static/fonts/inter.woff2"
as="font" type="font/woff2" crossorigin>
<!-- Критичний CSS-чанк зі збірки Next.js -->
<link rel="preload" href="/_next/static/css/app.css" as="style">
<!-- Prefetch ймовірного наступного маршруту -->
<link rel="prefetch" href="/_next/static/chunks/dashboard.js">
</head>Без комбінації preload + fetchpriority LCP-зображення конкурує з іншими ресурсами. З нею Lighthouse показує LCP 0.8s проти 2.5s без. CLS залишається на рівні 0, бо шрифт готовий до першого рендеру тексту.
Просунутий: modulepreload з динамічними імпортами (усунення каскаду)
<head>
<!-- Декларуємо всі модулі графа на етапі парсингу head -->
<link rel="modulepreload" href="/app.mjs">
<link rel="modulepreload" href="/utils.mjs">
<link rel="modulepreload" href="/api.mjs">
</head>
<script type="module">
// Без modulepreload:
// Round 1 - завантажити app.mjs, знайти utils.mjs + api.mjs
// Round 2 - завантажити utils.mjs + api.mjs
// Round 3 - виконати: ~600ms на 3G
// З modulepreload вище:
// Всі три завантажені паралельно при парсингу head
// Динамічний імпорт вирішується з кешу за ~10ms
import('./app.mjs').then(app => app.init());
</script>Timeline у Chrome DevTools показує, що всі три модулі завантажуються паралельно з моменту парсингу <head>: 200ms загалом. Без modulepreload вони завантажуються послідовно після парсингу app.mjs: 600ms. Ці 400ms різниці відчутні як регресія TTI на повільних з'єднаннях і одразу видно в Lighthouse.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.