Асинхронная загрузка js-скриптов
25-12-2020Reading time ~ 7 min.Alpine.js, jQuery и JavaScript 5959
Думаю, что с проблемой загрузки js-скриптов сталкивался каждый вебмастер. В современных браузерах доступны варианты — обычной загрузки в HEAD, поздней (lazy) в BODY, а также async и defer. Масла в огонь подлил Гугл, который понижает рейтинг страниц, где используются загружаемые js-скрипты, мотивируя это тем, что браузер вынужден ждать их полной загрузки и отработки, прежде чем отрендерить страницу.
Но на самом деле, проблема ещё глубже, поскольку затрагивает вопрос не только последовательности загрузки, но и порядка выполнения скриптов. Я покажу это на примере Alpine.js и jQuery.
Обычная загрузка
Традиционно всегда было принято загружать js-скрипты в секции HEAD.
<html> <head> ... <script src="my.js"></script> </head> <body> текст <script> любые скрипты, которые могут использовать my.js </script> </body> </html>
Браузер скачает my.js
до того, как будет выполнять скрипты в BODY. Это позволяет в теле страницы использовать любые функции из my.js
, поскольку он уже гарантированно загружен.
Блокировка вывода страницы
Такой вариант очень удобен для разработчика, но проблема в том, что браузер должен потратить время на загрузку js-скрипта, прежде чем отобразить (точнее скомпоновать в окончательном варианте) страницу. Если скриптов несколько, то это приводит к задержке вывода, что по мнению Гугла, является критически важным для мобильных пользователей.
Именно такой вариант и понижает рейтинг страницы в PageSpeed Insights.
Чтобы этого избежать чаще всего используют отложенную (lazy) загрузку.
Lazy-загрузка
В этом случае загрузка скрипта переносится из секции HEAD в конец секции BODY. Проблема здесь в том, что не получится использовать функции из my.js
в теле страницы, поскольку файл ещё не загрузился.
<html> <head> ... </head> <body> текст <script> здесь уже нельзя использовать my.js </script> <script src="my.js"></script> </body> </html>
Здесь как раз время вспомнить jQuery. Если библиотека загружена в HEAD, то в теле страницы мы всегда можем использовать функции jQuery. Но если перенести загрузку в конец BODY, то вызов функций, до того, как был загружен основной файл, приведёт к ошибке.
Обычно код jQuery заключают в такую конструкцию:
$(function () { // используем функции jQuery }); // или так: $(document).ready(function(){ // используем функции jQuery });
Этот код означает, что его нужно будет выполнить только после того, как страница будет полностью загружена. Казалось бы это то что нужно, но есть подвох — функция ready()
(и $
) является частью jQuery. То есть мы не можем её использовать, пока не будет загружена основная библиотека.
На самом деле проблема решается достаточно просто. Для определения загрузки станицы можно использовать нативный js-код:
document.addEventListener('DOMContentLoaded', () => { тут любой код, который сработает после загрузки страницы });
В данном случае мы цепляемся к событию DOMContentLoaded
, но можно использовать и load
, то есть смысл в том, чтобы выполнить jQuery-код только после того, как всё будет загружено. В многих случаях этого уже достаточно, чтобы решить проблему загрузки js-скриптов.
<html> <head> ... </head> <body> <div class="my">Click my</div> <script> document.addEventListener('DOMContentLoaded', () => { // используем jQuery $('.my').click(function() { $(this).toggleClass('t-red') }); }); </script> <script src="https://code.jquery.com/jquery-1.12.4.min.js"></script> </body> </html>
Загрузка в конце BODY можно сказать является псевдо-асинхронной, поскольку браузер, получив адреса файлов, сам решит как именно организовать их очередь загрузки, но при этом гарантируется их очередь выполнения, как указано в теле страницы. Но главное, что достигается lazy-загрузкой, это то, что не происходит блокировки контента.
Загрузка defer
В HTML 5 появилась загрузка js-скриптов с атрибутом defer
и async
. Асинхронная загрузка async делает так, что скрипт будет загружен и сразу выполнен без оглядки на загрузку страницы. Поэтому async используется редко.
Намного чаще используется defer
: он указывает браузеру скачать файл, но дождаться события DOMContentLoaded
(то есть загрузки всей страницы и скриптов). Важный момент в том, что defer-скрипт сработает до события DOMContentLoaded — это что-то вроде его инициализации.
Теперь поговорим об Alpine.js, поскольку для неё как раз и используется defer-загрузка в секции HEAD.
<html> <head> ... <script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.x.x/dist/alpine.min.js" defer></script> </head> <body> <div x-data @click="...">Click my</div> </body> </html>
Директивы Alpine — по сути псевдокод, то есть это не нативный js-код, который выполняет браузер, а код, который выполняется через Alpine. Поэтому defer-загрузка прекрасно работает — здесь просто нет ни очереди скриптов, ни завязки на функции Alpine, как это было с jQuery.
Ну а что если всё-таки завязаться на функции Alpine?
Магические свойства Alpine
В Alpine есть «магические свойства»: $el, $refs, $event
и т.д. Мы можем добавить свои варианты, например свойство $hello
— функция, которая будет выводить сообщение «Hello!». Делается это довольно просто:
Alpine.addMagicProperty('hello', function ($el) { alert('Hello!'); });
И используется так:
<div x-data @click="$hello">Click my</div>
Обратите внимание, что для создания своего «магического свойства» используется объект Alpine
, то есть на момент добавления свойства, Альпина должна быть загружена. Проверим через defer.
<html> <head> ... <script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.x.x/dist/alpine.min.js" defer></script> </head> <body> <div x-data @click="$hello">Click my</div> <script> Alpine.addMagicProperty('hello', function ($el) { alert('Hello!'); }); </script> </body> </html>
Этот вариант приведёт к ошибке Uncaught ReferenceError: Alpine is not defined
. Происходит это из-за того, что наш код сработает до загрузки Альпины (которая ждет DOMContentLoaded).
Если убрать defer, то всё прекрасно работает, но тогда мы теряем возможность асинхронной загрузки...
Lazy-загрузка Alpine.js
Проверим, можно ли перенести загрузку Альпины в конец BODY. Здесь очевидно, что файл должен загружаться до нашего кода:
<html> <head> ... </head> <body> <div x-data @click="$hello">Click my</div> <script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.x.x/dist/alpine.min.js"></script> <script> Alpine.addMagicProperty('hello', function ($el) { alert('Hello!'); }); </script> </body> </html>
Если здесь добавить defer, то мы опять получим ошибку Uncaught ReferenceError: Alpine is not defined
. То есть проблема такая же как и с ready()
из jQuery.
Используем DOMContentLoaded
Тут уже понятно — нужно прицепиться к DOMContentLoaded.
<html> <head> ... </head> <body> <div x-data @click="$hello">Click my</div> <script> document.addEventListener('DOMContentLoaded', () => { Alpine.addMagicProperty('hello', function ($el) {alert('Hello');}); }); </script> <script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.x.x/dist/alpine.min.js"></script> </body> </html>
Всё прекрасно работает, мы в общем-то нашли оптимальный и универсальный вариант загрузки js-скриптов. Хотя есть одно «но» — поскольку скрипты загружаются в конце html-страницы, то мы потеряли возможность явно указать браузеру, чтобы он загрузил файлы асинхронно. Если же указывать defer, то возникает ошибка.
Но на самом деле проблема только в том, чтобы загрузить js-скрипт как можно быстрее — в идеале вообще отдельным потоком, а использовать скрипт ровно там, где мы его указали (в конце BODY).
Предзагрузка контента
На помощь приходит другая возможность HTML 5 — возможность указывать браузеру контент, который нужно предварительно загрузить, но не использовать. Делается это в секции HEAD кодом link rel="preload"
(см. документацию).
<html> <head> ... <link rel="preload" href="адрес" as="script"> </head> <body> ... <script src="адрес"></script> </body> </html>
Атрибут preload
указывает браузеру создать отдельный поток для загрузки скрипта намного раньше, чем он встретится в html-коде страницы. Файл попадает в кэш и как только будет востребован, мгновенно выполнится. Это ровно то, что нам и нужно.
Вот итоговый пример для jQuery и Alphine.js, чтобы было понятно:
<html> <head> ... <link rel="preload" href="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.x.x/dist/alpine.min.js" as="script"> <link rel="preload" href="https://code.jquery.com/jquery-1.12.4.min.js" as="script"> </head> <body> <div x-data @click="$hello">Click my</div> <div class="my">Click my</div> <script> document.addEventListener('DOMContentLoaded', () => { Alpine.addMagicProperty('hello', function ($el) {alert('Hello');}); $('.my').click(function() { $(this).toggleClass('t-red') }); }); </script> <script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.x.x/dist/alpine.min.js"></script> <script src="https://code.jquery.com/jquery-1.12.4.min.js"></script> </body> </html>
Итого
Подключение js-скриптов оптимально разделять на предзагрузку и непосредственное использование:
- В секции HEAD указываются preload-адреса файлов.
- Сами файлы подключаются как обычно в конце BODY.
- Код, использующий функции библиотек, следует оборачивать в слушателя
DOMContentLoaded
.