Lazy-load Яндекс.Карт через IntersectionObserver
- производительность
- IntersectionObserver
- Core Web Vitals
- Яндекс.Карты
Виджет Яндекс.Карт — это около 600 КБ JS и 200 КБ CSS на каждую страницу, где она встроена. На типовой странице «Контакты» с одной картой PSI Mobile падает с 90+ до 30-40, TBT превышает 1500 мс. И всё это — ещё до того как пользователь увидел карту глазами. Решение — IntersectionObserver и ленивая инициализация. На ozsm.ru дало Mobile PSI 44 → 99 на главной.
Проблема
Стандартный код подключения Яндекс.Карт у большинства сайтов выглядит так:
<script src="https://api-maps.yandex.ru/2.1/?lang=ru_RU&apikey=YOUR_KEY"></script>
<script>
ymaps.ready(function () {
var map = new ymaps.Map('map', {
center: [53.2415, 50.2212],
zoom: 14
});
});
</script>
<div id="map" style="height: 400px;"></div>
Что не так: скрипт api-maps.yandex.ru/2.1/?lang=ru_RU грузится сразу при загрузке страницы. Это:
- ~250 КБ основного бандла + ещё ~350 КБ зависимых модулей
- ~1 секунда CPU-времени на парсинг и инициализацию
- TBT прыгает на 800-1500 мс
- LCP сдвигается на 1.5-3 секунды
Если карта показывается в подвале страницы или на отдельной вкладке — этот вес лежит на каждой странице, где есть <script src="api-maps">. Пользователь до карты может вообще не доскроллить.
Решение: IntersectionObserver + ленивая инициализация
Не подгружаем api-maps.yandex.ru пока пользователь не подскроллил до карты. IntersectionObserver с rootMargin: 200px начинает загрузку за экран до появления — пользователь не видит «пустого места».
<!-- На странице — placeholder без подключения API -->
<div id="ymap-placeholder"
class="ymap-placeholder"
style="height: 400px; background: #1a1a1a; position: relative;"
data-coords="53.2415,50.2212"
data-zoom="14">
<noscript>
<a href="https://yandex.ru/maps/?ll=50.2212,53.2415&z=14">
Открыть карту в Яндексе
</a>
</noscript>
</div>
<script>
(function () {
var mapEl = document.getElementById('ymap-placeholder');
if (!mapEl) return;
var observer = new IntersectionObserver(function (entries) {
if (!entries[0].isIntersecting) return;
observer.disconnect();
loadYandexMaps();
}, { rootMargin: '200px' });
observer.observe(mapEl);
function loadYandexMaps() {
var s = document.createElement('script');
s.src = 'https://api-maps.yandex.ru/2.1/?lang=ru_RU&apikey=YOUR_KEY';
s.async = true;
s.onload = function () {
ymaps.ready(function () {
var coords = mapEl.dataset.coords.split(',').map(parseFloat);
var zoom = parseInt(mapEl.dataset.zoom, 10);
mapEl.id = 'ymap';
mapEl.style.background = '';
var map = new ymaps.Map('ymap', {
center: coords,
zoom: zoom,
controls: ['zoomControl', 'fullscreenControl']
});
map.geoObjects.add(
new ymaps.Placemark(coords, {}, { preset: 'islands#redIcon' })
);
});
};
document.head.appendChild(s);
}
})();
</script>
Тридцать строк vanilla JavaScript. Никаких зависимостей.
Что даёт rootMargin: 200px
Опция rootMargin: '200px' говорит IntersectionObserver: «начни загружать карту, когда до её появления остаётся 200 пикселей скролла». На типовой мобильной странице это около половины экрана — пользователь не успевает заметить, как карта появилась.
Без rootMargin карта начинает загружаться в момент попадания в viewport — есть шанс увидеть «пустой блок 1-2 секунды», пока скрипт парсится.
Дополнительный fix: prefetch DNS
Можно ещё ускорить — за момент до подгрузки сделать DNS-prefetch для api-maps.yandex.ru:
<!-- В <head> — без блокировки -->
<link rel="dns-prefetch" href="https://api-maps.yandex.ru">
<link rel="preconnect" href="https://api-maps.yandex.ru" crossorigin>
DNS-prefetch — это уже разрешённый адрес в кэше браузера, а preconnect — открытое TCP-соединение. Когда IntersectionObserver сработает, скрипт начнёт скачиваться сразу, без 100-300 мс на handshake.
Результаты на ozsm.ru
На главной ozsm.ru карта стояла в самом подвале (контактный блок). Цифры до и после:
| Метрика на главной | До | После |
|---|---|---|
| PSI Mobile Performance | 44 | 99 |
| TBT | 1 108 мс | 89 мс (-92%) |
| Bundle JS на старте | ~520 КБ | ~50 КБ (gzip) |
| Bootup time | 2,4 с | 0,6 с |
| Время до карты при скролле | мгновенно | +300 мс (незаметно) |
Прибавка к PSI на 55 пунктов — не одна эта правка, конечно. Но lazy-карты — самый дешёвый из десятка фиксов спринта (полтора часа кода против десяти дней на критический CSS, дедуп jQuery, WebP и прочее).
Гочи
Несколько вещей, на которые потратили время и которые стоит знать заранее:
- Если карта в табе/аккордеоне, первый IntersectionObserver не сработает (карта изначально
display: none). Нужен второй обсервер на открытие таба, или подгрузка прямо при клике. - Apikey должен быть вашим — берётся в developer.tech.yandex.ru. Без него карта работает, но в логах browser console будут warnings.
<noscript>обязателен — для пользователей с отключённым JS должна быть хотя бы ссылка на yandex.ru/maps.- Skin/theme карты — стандартный белый фон выглядит не очень на тёмной теме сайта. Меняется через
mapType: 'yandex#dark'или CSS-фильтры.
Что мы из этого вынесли
- Не грузите то, до чего пользователь не доскроллит. Это базовое правило для всех тяжёлых виджетов: карты, видео-плееры, чаты, embed’ы соцсетей.
- IntersectionObserver работает в проде. Поддержка >97% браузеров, никаких полифиллов не требуется.
- Один маленький фикс может дать 30-50 пунктов PSI — если виджет действительно тяжёлый, а место в DOM подходит для отложенной загрузки.
Ссылки
- MDN — IntersectionObserver API — официальная документация
- Яндекс.Карты — JavaScript API — документация виджета
- web.dev — Optimize LCP — почему вес сторонних виджетов важен для LCP
- PageSpeed Insights — основной инструмент замера до/после