Предварительная загрузка изображений с помощью JavaScript
- среда, 15 апреля 2026 г. в 00:00:05
Я узнал, что задача предварительной загрузки изображений с помощью JavaScript удивительно сложна. Существует несколько способов это сделать, и лучший из них зависит от требований конкретного приложения.
В последней версии JamComments появилась возможность перетаскивать и вставлять изображения в поле для комментариев. Это очень похоже на добавление изображения в запрос на слияние (PR) на GitHub:

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

Неприятная, но решаемая проблема. Мне просто нужно было получать доступ к только что добавленному изображению. К моменту отправки комментария пользователем оно будет кэшировано и отобразится мгновенно.
В то время мне были известны два основных способа предварительной загрузки изображений в JavaScript (как вы увидите позже, их существует гораздо больше).
Этот способ существует уже очень давно: создаем новый объект Image и устанавливаем его атрибут src. Это запускает немедленную загрузку, и данные кэшируются для последующего рендеринга:
const img = new Image(); // Приводит к получению и кэшированию изображения img.src = "https://picperf.io/whatever.png";
У такого подхода есть несколько приятных преимуществ: можно запланировать выполнение кода после завершения загрузки изображения и получить доступ к таким его свойствам, как размеры, прежде чем что-либо отображать на странице:
const img = new Image(); img.src = url; img.onload = () => { console.log('image loaded!'); console.log({ 'height': img.naturalHeight, 'width': img.naturalWidth }); };
В большинстве случаев это отличный вариант. Создадим простой пример для экспериментирования. На стороне клиента изображение отображается через две секунды:
<img src="" id="imageEl" style="display: none" /> <script> const imageEl = document.getElementById("imageEl"); const url = "http://localhost:3000/image.png"; // Кэшируем изображения const img = new Image(); // По умолчанию приоритет загрузки низкий ("low") img.fetchPriority = "high"; img.src = url; setTimeout(() => { // Загружается моментально благодаря кэшированию imageEl.src = url; imageEl.style.display = "block"; }, 2000); </script>
На стороне сервера простая конечная точка Express возвращает статическое изображение:
app.get('/image.png', (_req, res) => { res.sendFile(path.join(__dirname, 'image.png')); });
Результат полностью соответствует нашим ожиданиям. Через две секунды уже кэшированное изображение мгновенно отображается. Без задержек.

На вкладке «Сеть» мы видим только один запрос изображения, который выполняется при загрузке страницы (благодаря нашему new Image()).

Все логично. Изображение было мгновенно загружено (и кэшировано), что означает возможность очень быстрого доступа к нему при необходимости. Однако у этой тактики есть один подвох.
Это работает только потому, что полученное изображение сохраняется в HTTP-кэше (HTTP cache) браузера после первоначального запроса. Обычно можно смело предполагать, что так и произойдет. Даже если у изображения нет собственного заголовка Cache-Control, эвристическое кэширование браузера все равно выручит:
Протокол HTTP разработан таким образом, чтобы максимально кэшировать данные, поэтому даже если заголовок
Cache-Controlне указан, данные будут сохранены и повторно использованы при соблюдении определенных условий. Это называется эвристическим кэшированием.
Тем не менее, это предположение рано или поздно окажется неверным. Давайте посмотрим, что произойдет, когда по какой-либо причине изображение вернется с заголовком Cache-Control, который явно указывает браузеру не кэшировать его:
app.get('/image.png', (_req, res) => { + res.setHeader('Cache-Control', 'no-store'); + res.sendFile(path.join(__dirname, 'image.png')); });
Теперь мы видим два запроса одного и того же изображения:

И опыт пользователя оставляет желать лучшего:

Причина, конечно же, в том, что мы не кэшируем изображение, поскольку руководствуемся стратегией кэширования, предписанной сервером. Однако есть и хорошие новости. Существует более надежный способ это сделать.
В браузере уже почти десять лет существует способ декларативной предварительной загрузки ресурсов. Добавление следующей разметки в HTML сообщает браузеру, что ресурс на этой странице определенно потребуется в будущем, поэтому он должен загрузить его с повышенным приоритетом:
<head> <link rel="preload" href="https://example.com/image.png" as="image"> </head>
Это можно реализовать и с помощью JS. Создайте элемент и добавьте его в документ. Как только он будет смонтирован, браузер начнет его использовать (обратите внимание: при добавлении тега <link rel="preload" /> с помощью JS ресурс будет загружаться с «низким» приоритетом, если явно не указано иное):
const link = document.createElement("link"); link.rel = 'preload'; link.as = 'image'; link.href = "https://example.com/image.png"; // Если мы хотим, чтобы изображение загружалось с "высоким" приоритетом link.fetchPriority = "high"; // Добавляем тег в конец <head /> document.head.append(link);
Проверим это на нашем примере. Из-за заголовка Cache-Control: "no-store" изображение, которое мы загружаем, не должно сохраняться в HTTP-кэше.
<img src="" id="imageEl" style="display: none" /> <script> const imageEl = document.getElementById("imageEl"); const url = "http://localhost:3000/image.png"; // Предзагружаем изображение const link = document.createElement('link'); link.rel = 'preload'; link.as = 'image'; link.href = url; link.fetchPriority = 'high'; document.head.append(link); setTimeout(() => { // Загружается мгновенно благодаря кэшированию imageEl.src = url; imageEl.style.display = "block"; }, 2000); </script>
Тем не менее, мы видим, что отправлен всего один запрос изображения:

Смотрите-ка. Изображение отображается мгновенно, без задержек. То, что доктор прописал.

Этот прием позволяет обойти проблему “no-store” благодаря тому, где хранится ресурс после загрузки. HTTP-кэш игнорируется. Вместо него используется специальный «кэш предварительной загрузки» (preload cache). Когда приходит время рендеринга изображения, его данные возвращаются из этого кэша, что позволяет сразу же отобразить его на странице.
Допустим, у пользователя очень медленное интернет-соединение. Мы не хотим, чтобы произошла следующая последовательность событий:
Начинается предварительная загрузка, инициирующая HTTP-запрос.
Изображение запрашивается по тегу <img />, что инициирует еще один HTTP-запрос.
Запрос предзагрузки завершается, но теперь бесполезен.
К счастью, браузер достаточно умен, чтобы предотвратить возникновение такой ситуации. Если изображение требуется до завершения предварительной загрузки, он будет ждать завершения выполняющегося запроса, а не запускать новый. Я проверил это, эмулируя 3G-соединение. Отображение изображения тормозило, как и ожидалось. Просто для полной предзагрузки ресурса не хватило времени до того, как он понадобился:

Но запрос изображения по-прежнему был только один:

Спасибо умным людям, создающим умные браузеры
Предполагаю, что для большинства задач предварительной загрузки, которые у вас возникнут, двух вышеуказанных вариантов будет более чем достаточно. Тем не менее, я хочу быть обстоятельным. Один из указанных ниже подходов может оказаться именно тем, что потребуется вам в нестандартной ситуации.
Иногда встречается такой вариант — создать скрытый HTML-элемент div, установить ему фоновое изображение с помощью CSS и смонтировать его в DOM:
const div = document.createElement("div"); div.style.backgroundImage = "url('http://localhost:3000/image.png')"; div.style.visibility = "hidden"; div.style.position = "absolute"; document.body.appendChild(div);
Это инициирует запрос изображения сразу после монтирования элемента. Что интересно, запросу автоматически присваивается «высокий» приоритет:

Важный момент: установка свойства display элемента <div> в значение none предотвратит загрузку изображения.
const div = document.createElement("div"); div.style.backgroundImage = "url('http://localhost:3000/image.png')"; // Изображение не будет загружено div.style.display = "none"; document.body.appendChild(div);
Насколько я понимаю, это удаляет элемент из потока документа (document flow), поэтому браузер не расходует ресурсы впустую. Будьте осторожны, это может стать серьезной проблемой.
Для меня это совершенно новая функция. Современные браузеры предоставляют первоклассный Cache API для хранения ресурсов, полученных в результате запросов. Он позволяет кэшировать ответы, передав объект Request или даже просто URL-адрес:
const url = "http://localhost:3000/image.png"; const cache = await caches.open('images'); await cache.add(url); const cachedResponse = await cache.match(url);
Прим. пер.: этот API активно используется в сервис-воркерах.
Cache API основан на промисах, что позволяет обеспечить нужную последовательность событий. Рассмотрим следующий пример:
const url = "http://localhost:3000/image.png"; const cache = await caches.open('images'); // Таймер не запустится до полного кэширования ответа await cache.add(url); setTimeout( async () => { const response = await cache.match(url); const blob = await response.blob(); const fetchedUrl = URL.createObjectURL(blob); imageEl.src = fetchedUrl; imageEl.style.display = 'block'; }, 2000);
Даже если у пользователя медленное соединение, можно быть уверенным, что изображение не отобразится, пока не станет полностью доступным. Посмотрите, что происходит при переходе на 4G-соединение. Изображение больше не начинает отображаться ровно через две секунды. Браузер ждет, когда оно будет полностью готово.

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

Впрочем, это легко исправить:
setTimeout( async () => { const response = await cache.match(url); const blob = await response.blob(); const fetchedUrl = URL.createObjectURL(blob); imageEl.src = fetchedUrl; imageEl.style.display = 'block'; // Элемент больше не нужен. Удаляем его из кэша cache.delete(url); }, 2000);
Тем не менее, существует риск, что элементы будут помещены в кэш и впоследствии окажутся «осиротевшими» (особенно если задействованы таймеры). Это не конец света — браузер в конечном итоге все равно очистит кэш, но лучше не прибегать к этому методу без крайней необходимости.
Если вам нравится контроль, предоставляемый Cache API, но вы не хотите брать на себя ответственность за управление кэшем, то можно использовать Fetch API:
const res = await fetch("http://localhost:3000/image.png", { // Настройки }); const blob = await res.blob(); const fetchedUrl = URL.createObjectURL(blob); setTimeout( async () => { imageEl.src = fetchedUrl; imageEl.style.display = 'block'; }, 3000);
Преимущество fetch() и Cache API заключается в контроле. Мы можем передать все необходимые заголовки и получить прямой доступ к ответу до того, как с ним что-либо произойдет. Но с fetch() мы все равно зависим от заголовков сервера Cache-Control, поэтому будьте осторожны:
// Ответ содержит заголовок Cache-Control: 'no-store' const res = await fetch(url); const blob = await res.blob(); const fetchedUrl = URL.createObjectURL(blob); // Данные не кэшированы, поэтому выполняется новый ("холодный") запрос const res2 = await fetch(url);
В моем случае лучшим решение было использование тега <link rel="preload" />. Но не стоит забывать о других вариантах:
используйте new Image(), если хотите подключиться к процессу загрузки или вам нужна ссылка на изображение для последующего использования
используйте <link rel="preload" />, когда нужно гарантированно предварительно загрузить ресурс и продолжить заниматься своими делами
используйте <div> и backgroundImage, если вы странный человек)). Других причин я не могу придумать. Дайте мне знать, если я ошибаюсь
используйте Cache API, если нужен полный контроль над получением, хранением и очисткой предварительно загруженного ресурса. Или если вам необходимо, чтобы ресурс оставался доступным в течение некоторого времени (например, между загрузками страниц)
используйте fetch() по тем же причинам, что и Cache API, но когда нужно хранить данные в памяти лишь короткое время, и вы не хотите заниматься очисткой памяти
Это не входит в число моих главных пожеланий, но мне бы очень хотелось увидеть в будущем императивный API, предоставляемый браузерами, наподобие следующего:
navigator.preload({ href: '/critical.js', as: 'script', fetchPriority: 'high' // другие настройки }).then(() => { console.log('preloaded & cached'); }).catch(err => { console.error('oops, failed', err); });
Пока меня устраивает простое внедрение кода в документ (в любом случае, это легко можно обернуть в собственную утилиту). Самое главное, чтобы это работало надежно. Смотрите, как быстро отображается изображение, благодаря предзагрузке:

Как я уже говорил: предварительная загрузка изображений — это на удивление непростая задача. Надеюсь, вы узнали что-то полезное, что пригодится вам в будущих начинаниях.
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩
