---
title: "Ленивая подгрузка скриптов: IntersectionObserver"
description: "Типичная WP-тема с десятком секций на лендинге подключает и такое же количество скриптов и стилей — слайдер тут, модалка там, виджет отзывов, счётчик, карта. Даже…"
url: "https://rowan-web.ru/blog/lenivaya-podgruzka-skriptov-intersectionobserver/"
date_modified: "2026-08-23T18:24:13+03:00"
language: "ru-RU"
---
Типичная WP-тема с десятком секций на лендинге подключает и такое же количество скриптов и стилей — слайдер тут, модалка там, виджет отзывов, счётчик, карта. Даже если пользователь долистает страницу до середины и уйдёт, браузер уже скачал и распарсил всё до последней секции. Расскажу, как я в одном из проектов свёл это к одному входному JS-файлу. Все остальное — скрипты, стили, сторонние библиотеки стали подтягиваться по мере того, как пользователь реально долистывал до нужного блока.

Проблема: enqueue по шаблону не масштабируется
----------------------------------------------

Классический путь — вешать wp\_enqueue\_script/wp\_enqueue\_style на конкретный шаблон или секцию: is\_page\_template(), условные подключения в functions.php под каждый кусок разметки. Работает, пока секций мало. Как только один и тот же блок (слайдер отзывов, карточка с картой) начинает переиспользоваться на десятках разных шаблонов, эти условия расползаются по всему коду, приходится вручную копировать куски кода. И самое главное, что это всё равно уходит в head/footer сразу при загрузке страницы — независимо от того, дойдёт ли пользователь до этой секции вообще.

Идея: один входной файл, который сам решает, что грузить
--------------------------------------------------------

В итоге в wp\_enqueue\_scripts остался ровно один файл — main.min.js. Всё остальное он подгружает сам, во время выполнения, через два маленьких хелпера: loadScript и loadStyles. Оба — обёртки над созданием <script>/<link> с дедупликацией по Map: если два разных модуля почти одновременно запросят один и тот же src, вставится один тег и оба получат один и тот же промис, а не два конкурирующих запроса.

```
const _loadScriptPromises = new Map();
const loadScript = (src) => {
  if (_loadScriptPromises.has(src)) {
    return _loadScriptPromises.get(src);
  }
  const promise = new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = src;
    script.defer = true;
    script.onload = resolve;
    script.onerror = (err) => {
      _loadScriptPromises.delete(src);
      reject(err);
    };
    document.body.appendChild(script);
  });
  _loadScriptPromises.set(src, promise);
  return promise;
};

const _loadStylesPromises = new Map();
function loadStyles(href) {
  if (_loadStylesPromises.has(href)) {
    return _loadStylesPromises.get(href);
  }
  const promise = new Promise((resolve, reject) => {
    const link = document.createElement('link');
    link.rel = 'stylesheet';
    link.href = href;
    link.onload = resolve;
    link.onerror = (err) => {
      _loadStylesPromises.delete(href);
      reject(err);
    };
    document.head.appendChild(link);
  });
  _loadStylesPromises.set(href, promise);
  return promise;
}
```

Дедупликация тут не про красоту кода, а про конкретный баг: без неё при параллельном срабатывании двух секций (например, похожий слайдер снизу и модалка с таким же слайдером внутри) браузер вставляет два одинаковых <script>, бандл библиотеки парсится дважды, и второй экземпляр Swiper молча переопределяет window.Swiper поверх уже работающего — первый слайдер на странице перестаёт откликаться на клики.

Наблюдатель с двумя подстраховками
----------------------------------

За решение «пора грузить» отвечает обёртка над IntersectionObserver — observeElement. Идея простая: подписаться на элемент и вызвать callback, когда он входит во вьюпорт с запасом (rootMargin), чтобы контент успел подгрузиться до того, как пользователь до него доскроллит, а не в момент, когда блок уже на экране.

```
function observeElement(elementId, callback, options) {
  const element = document.getElementById(elementId);
  if (!element) return;

  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        callback();
        observer.unobserve(element);
      }
    });
  }, options);

  observer.observe(element);

  // элемент мог быть виден уже в момент observe() — IntersectionObserver
  // не всегда успевает отдать первый колбэк сразу (гонка с layout/paint),
  // а на быстром скролле элемент мог уже уйти выше экрана до того,
  // как до него дошла очередь наблюдения — тогда перехода
  // "не видно -> видно" уже не будет никогда
  const rect = element.getBoundingClientRect();
  const isVisibleNow = rect.top < window.innerHeight && rect.bottom > 0;
  const alreadyScrolledPast = rect.bottom < 0;
  if (isVisibleNow || alreadyScrolledPast) {
    callback();
    observer.unobserve(element);
  }
}
```

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

Разметка вместо PHP: data-module и data-css
-------------------------------------------

Дальше — контракт между PHP-шаблоном и JS-загрузчиком, который не требует ничего менять в functions.php при добавлении новой секции. На корневом теге блока просто указывается путь к его скрипту и/или стилю:

```
<section id="section" class="section" data-module="/assets/js/section.js?v=1.2"></section>
```

А единственный подключённый через wp\_enqueue\_script файл при загрузке страницы сканирует документ на такие атрибуты и вешает на каждый найденный элемент observeElement:

```
function observeModules() {
  document.querySelectorAll('[data-module], [data-css]').forEach(element => {
    if (!element.id) {
      element.id = `module-${Math.random().toString(36).slice(2, 11)}`;
    }

    if (element.dataset.module) {
      const path = `${myThemeVars.themeDir}${element.dataset.module}`;
      observeElement(element.id, () => loadScript(path), { rootMargin: '700px', threshold: 0.1 });
    }

    if (element.dataset.css) {
      const path = `${myThemeVars.themeDir}${element.dataset.css}`;
      observeElement(element.id, () => loadStyles(path), { rootMargin: '900px', threshold: 0.1 });
    }
  });
}

document.addEventListener('DOMContentLoaded', () => {
  // модальные окна грузятся первыми: часть data-module скриптов
  loadScript(`${myThemeVars.themeDir}/assets/js/modalManajer.js`)
    .then(observeModules);
});
```

myThemeVars — это объект, который тема передаёт в JS через стандартный wp\_localize\_script, с путём до темы и версией для кэш-бастинга. Порядок в последнем блоке важен: сначала грузится модалка, и только потом запускается сканирование. Тут стоит немного пояснить, почему именно так.   
У большинства сайтов, в том числе и тот, на котором это все работает, — в шапке имеет кнопку вызова модального окна. Таким образом, я именно поэтому, и ставлю в приоритет загрузку [файла управления модальными окнами, ](https://rowan-web.ru/klass-upravleniya-modalnymi-oknami/)чтобы не было момента, когда пользователь нажал на кнопку, а отклика нет.

Библиотека грузится там же, где используется
--------------------------------------------

Второй слой того же приёма — сам файл, на который ссылается data-module, может при необходимости подтянуть внешнюю библиотеку. Например, секция со слайдером сначала проверяет, нет ли Swiper в window уже (кто-то выше на странице мог его подгрузить раньше), и только если нет — тянет CSS и JS библиотеки перед тем, как инициализировать слайдер:

```
if (typeof Swiper === 'function') {
  initSlider();
} else {
  loadStyles(`${myThemeVars.themeDir}/assets/css/vendor/swiper-bundle.min.css`)
    .then(() => loadScript(`${myThemeVars.themeDir}/assets/js/modules/swiper-bundle.min.js`))
    .then(initSlider);
}

function initSlider() {
  new Swiper('.swiper', {
    slidesPerView: 1,
    spaceBetween: 24,
    navigation: {
      nextEl: '.swiper-next',
      prevEl: '.swiper-prev',
    },
  });
}
```

Если на странице пять разных секций со своим слайдером, все пять модулей выполнят одну и ту же проверку, но благодаря дедупликации в loadScript/loadStyles бандл Swiper скачается и распарсится ровно один раз — для той секции, что первой попала во вьюпорт. Остальные четыре просто дождутся уже летящего промиса и заберут готовый window.Swiper.

Что это даёт
------------

- Первая загрузка страницы почти не зависит от количества секций — блокирует рендер один небольшой файл, а не сумма enqueue со всей страницы.
- За тяжёлые библиотеки — слайдеры, карты, графики — платит только тот, кто реально долистал до соответствующей секции, а не каждый посетитель первого экрана.
- Новая секция не требует правок в functions.php — путь к её скрипту и стилю живёт прямо в разметке шаблона, рядом с самой секцией.
- Дедупликация на уровне loadScript/loadStyles закрывает типичный для тем баг: одна и та же библиотека, подключённая с двух независимых секций, не приезжает на страницу дважды и не ломает уже работающий экземпляр.
- Если говорить о честных данных, например из Google Console, то после внедрения данного механизма, «потребление» js трафика снизилось с 34% до 12%. То есть простым вычислением мы получаем, что примерно 22 процента всех загруженных ранее js скриптов грузилось просто впустую.

Приём не требует сборки или фреймворка — это обычный vanilla JS поверх стандартного wp\_enqueue\_script на единственный файл. Из нюансов, на которые стоит закладываться сразу: подбирать rootMargin с запасом под скорость сети (иначе на быстром скролле мелькнёт пустой блок) и явно продумывать порядок загрузки для модулей, которые от чего-то зависят — как в примере с модалкой выше. Помните, что каждый случай индивидуален, а я лишь делюсь идеей….

[Полный список страниц сайта, читаемых AI](https://rowan-web.ru/llms.txt)
