Скорость сайта

Отложенная загрузка скриптов: defer, async и что реально ускоряет

Чем отличаются defer и async, какие скрипты можно грузить лениво, как отложить виджеты и счётчики и не сломать формы.

13 минут чтения 2 Дмитрий Акиндинов
Содержание
  1. Почему один тег script задерживает всю страницу
  2. defer против async: разница в порядке выполнения
  3. Что откладывать нельзя
  4. Ленивая загрузка тяжёлых виджетов
  5. Счётчики и пиксели: где отложенная загрузка съедает данные
  6. Что показывают замеры до и после
  7. Как это сделать в WordPress без плагина
  8. Когда плагин всё-таки удобнее
  9. Чек-лист перед выкаткой
  10. Что делать дальше
  11. Частые вопросы

Атрибут defer откладывает выполнение до разбора страницы и сохраняет порядок скриптов, async выполняет сразу по загрузке и порядок ломает. Отсюда правило: библиотекам и зависимому коду нужен defer, независимым счётчикам подходит async.

Отложенная загрузка скриптов это первое, что советуют в любом отчёте PageSpeed, и первое, чем ломают сайты владельцы, которые поставили галочку в плагине оптимизации. Страница ускорилась на 20 баллов, зато форма заявки перестала отправляться, а слайдер на главной прыгает при загрузке. Разберём, чем отличаются defer и async, какие скрипты трогать нельзя, как отложить чат и счётчики без потери данных, и что показывают замеры до и после.

Скрипты в очереди на таймлайне загрузки страницы

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

Почему один тег script задерживает всю страницу

Браузер читает HTML сверху вниз и строит DOM. Когда парсер встречает обычный <script src="..."> без атрибутов, он останавливается. Полностью. Не строит DOM дальше, не рисует то, что уже разобрал, ждёт: скачать файл, распарсить, выполнить. Только потом продолжает.

Это поведение из девяностых, и оно логично: скрипт мог вызвать document.write() и дописать HTML прямо в текущую точку. Браузер не знает заранее, что там внутри, поэтому обязан подождать.

Дальше простая арифметика. Файл на 80 КБ с российского хостинга по мобильному 4G это примерно 300-400 мс на скачивание плюс 60-150 мс на парсинг и выполнение на среднем Android. Три таких файла в <head> и вы отдали полторы секунды до первой отрисовки, даже если картинки и текст давно приехали.

Отдельно про jQuery. На типовом сайте малого бизнеса до сих пор живёт связка jquery.min.js плюс jquery-migrate.min.js плюс десяток плагинов, повешенных на $(document).ready(). Это 90-120 КБ блокирующего кода, который нужен ради двух вещей: аккордеона в FAQ и всплывающего меню на мобильном.

Проверить, кто именно блокирует, можно без всяких сервисов. Откройте вкладку Network в DevTools, включите фильтр JS и колонку «Initiator», отсортируйте по времени старта. Всё, что стартует до события DOMContentLoaded и не имеет атрибутов, и есть ваши блокираторы. Второй способ, если доступа к браузеру нет:

curl -s https://example.ru/ | grep -oP '<script[^>]*>' | grep -v 'defer|async|type="application/ld'

Команда выдаёт список тегов без отложенных атрибутов. На запущенном WordPress их обычно бывает от четырёх до пятнадцати.

defer против async: разница в порядке выполнения

Оба атрибута снимают блокировку парсера: файл качается параллельно с разбором HTML. Разница в том, когда он выполняется и в каком порядке относительно соседей.

async выполняет скрипт сразу, как только тот докачался. Парсер в этот момент прерывается. Порядок между несколькими async-скриптами не определён вообще: кто первым скачался, тот первым и стартовал. Файл на 5 КБ обгонит файл на 100 КБ, даже если в HTML стоит после него.

defer откладывает выполнение до момента, когда весь HTML разобран, но до события DOMContentLoaded. Порядок между defer-скриптами сохраняется тот, в котором они стоят в разметке. Это критично: если slider.js рассчитывает на уже загруженный jquery.js, только defer гарантирует, что jQuery выполнится первым.

СвойствоОбычный scriptdeferasynctype=»module»
Блокирует парсер при скачиванииданетнетнет
Когда выполняетсясразу на местепосле парсинга HTMLсразу после скачиванияпосле парсинга (как defer)
Порядок относительно другихпо разметкепо разметкеслучайныйпо разметке
Видит весь DOMнетдане гарантированнода
Работает с document.writeдаломаетсяломаетсяломается
Куда годитсяпочти никудакод темы, jQuery, UIнезависимые счётчикисовременные сборки

Практическое правило, к которому я пришёл: defer по умолчанию для всего своего кода, async только для полностью изолированных счётчиков, которые ни от чего не зависят и от которых не зависит ничего.

Ещё одна деталь, о которой забывают. Оба атрибута работают только на внешних скриптах с src. Инлайновый <script>alert(1)</script> с атрибутом defer выполнится немедленно, атрибут просто проигнорируется. Если у вас в шапке лежит инлайновая инициализация чего-нибудь на 30 строк, её сначала надо вынести в файл, и только потом откладывать.

И третье: defer-скрипт выполняется до DOMContentLoaded, а значит обработчики внутри вроде document.addEventListener('DOMContentLoaded', ...) продолжат работать нормально. А вот код, который вешается на window.onload внутри async-скрипта, может опоздать и не сработать, если onload уже отстрелялся.

Что откладывать нельзя

Есть категория скриптов, где отложенная загрузка ломает функциональность молча, без ошибок в консоли. Их надо знать в лицо.

Инлайновые зависимости. Классика WordPress: тема ставит <script src="main.js">, а сразу за ним инлайновый блок <script>MainApp.init({apiKey: "..."})</script>. Отложили внешний файл, инлайновый остался немедленным, MainApp на момент вызова не существует, консоль пишет «MainApp is not defined». Лечится либо переносом инициализации внутрь основного файла, либо оборачиванием в слушатель DOMContentLoaded.

Скрипты, которые правят вёрстку до отрисовки. Переключатель тёмной темы, скрипт скрытия элементов по A/B тесту, класс js-enabled на <html>. Отложите такой код и получите мигание: страница нарисовалась в одном виде, через 200 мс перекрасилась. Это ещё и портит CLS, за который Яндекс и Google снижают оценку.

Полифилы и загрузчики. Если в проекте есть RequireJS, SystemJS или сборка с динамическими импортами, лоадер обязан быть готов раньше остального. Тут порядок важнее скорости.

Платёжные и антифрод-скрипты. Виджеты ЮKassa, Тинькофф, СБП-кнопки, а также скрипты сбора отпечатка устройства. Они часто требуют исполнения в конкретный момент, а сломанная оплата стоит дороже, чем полсекунды LCP.

Скрипты, использующие document.write. Старые рекламные сети и партнёрские виджеты. С defer или async браузер просто выбросит запись в консоль («A parser-blocking script was invoked via document.write») и проигнорирует вставку.

Как понять до продакшена, что именно сломается. Порядок, который я использую на клиентских сайтах:

  1. Разворачиваем копию на поддомене или в staging, откладываем всё подряд, кроме платёжки.
  2. Прогоняем сценарии руками: отправка формы, открытие модалки, слайдер, фильтр каталога, мобильное меню, корзина.
  3. Смотрим консоль на каждой ключевой странице, не только на главной.
  4. Возвращаем немедленную загрузку тем скриптам, которые упали, и ищем причину точечно.
  5. Только после этого катим на боевой.

Один прогон занимает 40-60 минут на среднем сайте. Это дешевле, чем неделю не замечать, что заявки перестали приходить. И да, проверять надо руками, а не по скриншоту главной: половина поломок вылезает на внутренних страницах.

Ленивая загрузка тяжёлых виджетов

Отдельная история это сторонние виджеты: онлайн-чат, обратный звонок, карта, отзывы, календарь записи. Тут одного defer мало. Виджет чата тянет за собой 300-600 КБ ресурсов, открывает WebSocket и обычно грузит собственные шрифты. Даже отложенный, он съест процессор на мобильном ровно в тот момент, когда пользователь пытается скроллить.

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

(function () {
  var loaded = false;

  function loadChat() {
    if (loaded) return;
    loaded = true;

    var s = document.createElement('script');
    s.src = 'https://code.jivo.ru/widget/YOUR_ID';
    s.async = true;
    document.body.appendChild(s);
  }

  ['scroll', 'mousemove', 'touchstart', 'keydown'].forEach(function (evt) {
    window.addEventListener(evt, loadChat, { once: true, passive: true });
  });

  document.querySelectorAll('[data-open-chat]').forEach(function (el) {
    el.addEventListener('click', function (e) {
      e.preventDefault();
      loadChat();
    });
  });

  setTimeout(loadChat, 10000);
})();

Что здесь важно. Флаг loaded защищает от двойной вставки, если пользователь одновременно скроллит и двигает мышь. Опция once: true снимает слушатель после первого срабатывания. passive: true говорит браузеру, что обработчик не будет вызывать preventDefault, и скролл не подтормаживает. Таймер на 10 секунд нужен для тех, кто открыл страницу и читает не шевелясь: чат всё-таки появится.

Такая схема на клиентском сайте с Jivo убрала 480 КБ из начальной загрузки и подняла мобильный PageSpeed с 41 до 68 баллов. Конверсия в обращения через чат при этом не просела: люди, которым нужен чат, всё равно двигают мышь к иконке. Подробный разбор того, сколько именно весят популярные виджеты, есть в статье про виджеты Jivosite и Битрикс24 и скорость.

Карта Яндекса откладывается похожим образом, только по появлению блока в зоне видимости. Вместо интерактивной карты в разметке лежит статичная картинка с координатами, а настоящий API подгружается, когда пользователь до неё доскроллил, через IntersectionObserver. На странице контактов это экономит порядка 700 КБ для тех, кто до карты не дошёл, а таких обычно две трети.

Счётчики и пиксели: где отложенная загрузка съедает данные

Тут начинается зона, где техническая правильность конфликтует с бизнес-задачей, и решать должен владелец, а не оптимизатор.

Яндекс Метрика официально ставится с атрибутом async и своей очередью команд. Отложить её на условный setTimeout(3000) технически можно, но вы потеряете часть визитов: люди, закрывшие страницу за первые три секунды, в статистику не попадут. По моим наблюдениям на сайтах услуг это 8-15 процентов трафика. Хуже того, у вас поедет показатель отказов и время на сайте, потому что короткие визиты просто исчезнут из выборки.

Мой вывод простой: Метрику не откладывать дальше, чем async. Она сама по себе не так тяжела, как принято думать, а вот испорченная аналитика лечится месяцами. Отдельно я разбирал, что именно в ней тормозит и что можно отключить, в статье про Метрику, которая замедляет сайт.

Разложу по типам, что можно и чего нельзя:

СкриптЧто делатьРиск при отложении
Яндекс Метрика (базовый счётчик)async, как в официальном кодепотеря коротких визитов
Вебвизор и карта кликовотложить на interactionпочти нет, запись начнётся позже
Пиксель ВК, myTargetотложить на 2-3 секундынебольшая потеря охвата ретаргетинга
Коллтрекинг с подменой номеране откладыватьпокажется старый номер, звонок не атрибутируется
Сквозная аналитика (Roistat, Calltouch)не откладыватьрвётся связка визит-заявка
Виджеты соцсетей и лайковгрузить по кликунет
A/B тесты и персонализацияне откладыватьмигание контента, испорченный тест

Отдельно про коллтрекинг. Скрипт подмены номера должен отработать до того, как пользователь увидит телефон в шапке. Отложили его, человек увидел статичный номер, позвонил по нему, и рекламная кампания получила ноль звонков в отчёте. Такое я разбирал у клиента дважды, и оба раза причиной был плагин оптимизации с включённой опцией «отложить весь JavaScript».

Что показывают замеры до и после

Цифры имеют смысл только с конкретной методикой, поэтому опишу свою. Замер делается через Lighthouse в режиме мобильной эмуляции с троттлингом 4G, по три прогона, берётся медиана. Локальный запуск честнее, чем публичный PageSpeed, потому что там сеть до сервера с российского хостинга измеряется криво.

npx lighthouse https://example.ru/ 
  --only-categories=performance 
  --form-factor=mobile --throttling-method=simulate 
  --output=json --output-path=./before.json

Реальный пример с сайта услуг на WordPress, тема с jQuery, слайдер, чат Jivo, Метрика с Вебвизором, пиксель ВК. Хостинг shared, PHP 8.2, кэш на LiteSpeed.

МетрикаДоПослеЧто дало эффект
Performance (mobile)3871суммарно
LCP4,9 с2,3 сdefer на jQuery и коде темы
FCP2,7 с1,4 сснятие блокировки в head
TBT1 240 мс310 мсленивый чат и Вебвизор
INP (полевые данные, месяц)340 мс180 мсменьше работы в основном потоке
Вес JS до первой отрисовки612 КБ94 КБленивые виджеты

Обратите внимание на распределение вкладов. Атрибут defer на своих файлах дал основную часть улучшения LCP и FCP, потому что снял блокировку рендеринга. А вот TBT и INP просели вниз в основном за счёт ленивых виджетов: они не блокировали отрисовку, но сжигали процессор мобильника и держали основной поток занятым, из-за чего страница не отвечала на касания.

Именно поэтому одного defer часто мало. Отложенный скрипт всё равно выполняется, просто позже. Если он тяжёлый, он ударит по отзывчивости, а INP считается по худшему взаимодействию за сессию. Что изменилось в этой метрике и почему старый FID больше не показатель, я расписывал в статье про INP вместо FID.

Чего ждать не стоит. Отложенная загрузка почти не влияет на TTFB: там виноват сервер, а не браузер. И она не спасёт сайт, где главная весит 8 МБ из-за неоптимизированных фотографий. Порядок работ такой: сначала картинки и сервер, потом скрипты, потом уже тонкая настройка.

Как это сделать в WordPress без плагина

В WordPress все корректно подключённые скрипты проходят через wp_enqueue_script, а значит их HTML можно перехватить фильтром. Код кладётся в functions.php темы или в mu-plugin.

add_filter('script_loader_tag', function ($tag, $handle, $src) {
    // Что откладываем через defer, порядок между ними сохранится
    $defer = ['jquery-core', 'jquery-migrate', 'theme-main', 'slider', 'wp-embed'];

    // Что грузим асинхронно, зависимостей нет ни в одну сторону
    $async = ['yandex-metrika'];

    if (in_array($handle, $async, true)) {
        return str_replace(' src=', ' async src=', $tag);
    }

    if (in_array($handle, $defer, true) && strpos($tag, ' defer') === false) {
        return str_replace(' src=', ' defer src=', $tag);
    }

    return $tag;
}, 10, 3);

Белый список вместо «отложить всё» это принципиальный момент. Плагины делают наоборот: откладывают всё, а вы потом добавляете исключения по мере обнаружения поломок. Обнаружение обычно происходит через жалобу клиента, что заявки не приходят.

Узнать реальные handle можно одной строкой в консоли WP-CLI или временным дампом:

wp eval 'global $wp_scripts; wp_head(); print_r(array_keys($wp_scripts->registered));' --url=https://example.ru

Ещё одна полезная мелочь: wp-embed.min.js подключается на каждой странице и нужен только для встраивания чужих постов WordPress. На 95 процентах сайтов его можно просто выключить через wp_deregister_script('wp-embed'). Минус один запрос и полтора килобайта, зато бесплатно и без риска.

В Битриксе логика похожая, но проще: BitrixMainPageAsset::getInstance()->addString() позволяет добавить тег с нужными атрибутами вручную, а в настройках главного модуля есть опция перемещения JS в конец страницы. У Tilda своих ручек мало, там отложить получится только чужие вставки в блоке T123, а собственный tilda-blocks-*.js трогать бессмысленно и рискованно.

Когда плагин всё-таки удобнее

Не буду делать вид, что руками всегда лучше. Плагин выигрывает в трёх ситуациях.

Первая: сайт на shared-хостинге с LiteSpeed, где уже стоит LiteSpeed Cache. У него есть готовые переключатели «Load JS Deferred» и «Delay JS» с полем исключений, и он бесплатный. Настроить его аккуратно быстрее, чем писать фильтры.

Вторая: у владельца нет разработчика на постоянной основе, а тема обновляется. Правки в functions.php при обновлении темы улетают. Мера защиты это дочерняя тема или mu-plugin, но если этого нет, плагин переживёт обновление.

Третья: нужен не просто defer, а полноценная отложенная загрузка всего JS до первого взаимодействия. Руками это писать долго, а WP Rocket (порядка 5 500 рублей в год за одну лицензию) или Flying Scripts (бесплатный) делают это из коробки.

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

Компромисс, который я обычно предлагаю клиентам: базовый defer прописан в mu-plugin руками (это надёжно и не ломается), а Delay JS для сторонних виджетов делается плагином, потому что там логика сложнее и правится чаще.

Чек-лист перед выкаткой

Прогоняйте по нему каждый раз, он экономит вечера.

  • Список всех скриптов страницы собран, у каждого понятно назначение и зависимости.
  • Инлайновые блоки, зависящие от внешних файлов, найдены и обёрнуты в DOMContentLoaded или перенесены.
  • Платёжка, коллтрекинг и A/B тесты в белом списке исключений.
  • Метрика оставлена на async, дальше не откладывается.
  • Проверены руками: главная, страница услуги, карточка товара, корзина, страница контактов, форма заявки на всех трёх.
  • Консоль браузера чистая на каждой проверенной странице, включая мобильную ширину.
  • Замер Lighthouse сделан до и после в одинаковых условиях, цифры записаны.
  • Тестовая заявка реально дошла до почты и Telegram.
  • Метрика зафиксировала визит и цель, проверено в отчёте по времени.
  • Есть бэкап или git-коммит, к которому можно откатиться за минуту.

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

Что делать дальше

Если у вас в PageSpeed висит красное «Устраните ресурсы, блокирующие отображение», начните с малого: соберите список блокирующих файлов, отложите через defer свой код темы и jQuery, замерьте. На типовом сайте малого бизнеса это полчаса работы и 15-25 баллов мобильного PageSpeed. Ленивые виджеты и счётчики это второй заход, там нужен разговор с владельцем про то, чем можно жертвовать в аналитике.

Если разбираться самому некогда, я делаю такую работу отдельной задачей: инвентаризация скриптов, белый список, правки в mu-plugin, ленивый чат и карта, замеры до и после в отчёте. На среднем сайте это 2-4 часа. У клиентов на абонентке (годовой тариф 35 000 рублей, помесячный от 15 000) такие правки входят в часы обслуживания, отдельно платить не нужно, условия есть в тарифах.

Пишите через форму на главной, если сайт тормозит на мобильных и нужно понять, что именно из этого лечится за один вечер, а что тянет за собой переделку темы. Остальные разборы по скорости лежат в рубрике про скорость.

Частые вопросы

Чем defer отличается от async

Оба не блокируют разбор страницы. Defer выполняет скрипты после разбора и в том порядке, в каком они стоят. Async выполняет сразу по загрузке, в произвольном порядке. Зависимый код с async ломается.

Что откладывать нельзя

Код, который влияет на первый экран: скрипт установки темы оформления, инлайновую логику, от которой зависит вёрстка. Отложите такое, и получите мигание страницы через 200 мс после отрисовки.

Как отложить тяжёлые виджеты

Грузить их по прокрутке или по клику. Чат, карта и видео весят сотни килобайт и нужны единицам посетителей, а замедляют они всех.

Съедает ли отложенная загрузка данные счётчиков

Может, если посетитель уходит раньше, чем счётчик загрузился. Для аналитики выбирают компромисс: не откладывать надолго, но и не грузить синхронно в шапке.

Передайте техническую часть в надёжные руки.

Слежу за хостингом, бэкапами и обновлениями. Когда что-то пойдёт не так – звоните мне, не своему программисту, не в спортзал и не маме.

Короткие вопросы по теме

Все вопросы по теме "Скорость сайта"

Заявка

Обсудим задачу

Напишите имя и телефон, выберите удобный канал. Перезвоню или напишу в течение часа в рабочее время.

Куда удобнее ответить?