Frontender Magazine

Глубокое погружение в мутные воды загрузки скриптов

Введение

В этой статье я научу вас, как загружать и исполнять JavaScript внутри браузера.

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

Для начала, вот как в спецификации определяются различные способы, которыми можно скачать и исполнить код:

СкриншотWHATWG о загрузке скриптов

Как и все спецификации WHATWG (Web Hypertext Application Technology Working Group, прим. ред.), с первого взгляда эта больше всего напоминает последствия попадания кластерной бомбы в фабрику по производству игры «Эрудит», но когда вы прочитаете ее в пятый раз и утрёте кровь с глаз, все становится на самом деле довольно интересно:

Как я подключал скрипт в первый раз

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

О, святая простота! Итак, в этом примере браузер скачает оба скрипта параллельно и исполнит их как можно быстрее, сохранив при этом их порядок. Скрипт 2.js не станет исполняться, пока не выполнится (или пока не выдаст ошибку) 1.js. 1.js, в свою очередь, не начнет исполняться, пока не будет выполнен предыдущий скрипт (или не обработана таблица стилей).

К сожалению, пока все это происходит, браузер блокирует дальнейший рендеринг страницы. Происходит это из-за доставшегося нам в наследство из эпохи «Веб 1.0» DOM API. API позволяют прибавлять данные к содержимому, которое в настоящий момент прожевывает браузер, такие, например, как document.write. Современные браузеры будут продолжать сканировать и парсить документ в фоновом режиме, а также начинать загрузку внешнего содержимого (JS, CSS, изображения и т.п.), которое требуется документу, но рендеринг страницы все равно будет блокирован.

Именно по этой причине лучшие спецы по производительности советуют ставить элементы script в самом конце документа — в таком случае выполнение скрипта блокирует как можно меньше контента. К сожалению, это означает, что браузер не увидит ваш скрипт до тех пор, пока не скачает весь HTML, а к этому моменту он уже начал загружать остальной контент: CSS, изображения, блоки iframe. Современные браузеры достаточно умные и дают JavaScript-файлам приоритет в загрузке выше, чем картинками, но можно сделать еще лучше.

Спасибо, IE! (и никакого сарказма)

<script src="//other-domain.com/1.js" defer></script>
<script src="2.js" defer></script>

Microsoft обратила внимание на эти проблемы с производительностью, и, в Internet Explorer 4 был введен атрибут defer. Означает он, более-менее, следующее: «Я обещаю не вставлять палки в колеса парсеру и не буду использовать такие вещи, как document.write. Если я нарушу это обещание, можете наказывать меня на ваше усмотрение». Этот атрибут вошел в спецификацию HTML4 и появился в других браузерах.

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

Атрибут defer, как взорвавшаяся кластерная бомба на фабрике по производству шерсти, внезапно, превратил все в жутко запутанный клубок. Итого, с атрибутами src и defer, тэгами script и динамически добавляемыми скриптами, всего у нас получалось шесть способов добавить скрипт в документ. Неудивительно, что разработчики браузеров не смогли договориться о том, в каком порядке они должны исполняться. На сайте, посвященном хакам Mozilla, есть отличная статья с описанием проблемы, в том виде, в котором она была в 2009 году.

WHATWG стандартизировала поведение, объявив, что defer не должен иметь никакого эффекта на динамически добавленные скрипты или скрипты без src. В остальных случаях скрипты должны выполняться после того, как закончился парсинг документа, в том порядке, в котором они были добавлены.

Вот спасибо, IE! (и теперь это сарказм)

Бог дал, Бог взял. К сожалению, в семействе IE с версии 4 по 9 присутствует очень противный баг, который может заставить скрипты выполняться в самом неожиданном порядке.

Вот что происходит:

1.js

console.log('1');
document.getElementsByTagName('p')[0].innerHTML = 'Изменяем какой-нибудь контент';
console.log('2');

2.js

console.log('3');

Если на странице присутствует хотя бы один абзац, то мы можем ожидать, что порядок записей в лог будет идти так — [1, 2, 3], а в IE9 и ниже получается так — [1, 3, 2]. Некоторые манипуляции с DOM заставляют IE приостановить выполнение текущего скрипта и исполнить другие, ожидающие выполнения скрипты, перед тем, как продолжить.

Даже в реализациях без этого бага (IE10 и других браузерах) выполнение скрипта будет отложено до того момента, как браузер скачает и распарсит весь документ. Не так уж плохо, если вы в любом случае собираетесь ждать выполнения DOMContentLoaded. Но если вы хотите действительно агрессивно подойти к оптимизации производительности, то подготовку кода к обработке событий можно начать пораньше.

HTML5 спешит на помощь

<script src="//other-domain.com/1.js" async></script>
<script src="2.js" async></script>

В HTML5 появился новый атрибут, async. Его использование подразумевает, что вы не будете пользоваться document.write, и браузер не будет ждать, пока документ полностью распарсится, перед тем, как начать выполнение обозначенного атрибутом скрипта. Браузер скачает оба скрипта параллельно и выполнит их как только сможет.

К сожалению, в силу того факта, что скрипты будут загружаться насколько возможно быстро, может выйти так, что 2.js загрузится перед 1.js. В общем то нет проблем, если наши скрипты независимы друг от друга (1.js — скрипт аналитики, и не имеет ничего общего со скриптом 2.js). Но если, например, 1.js — CDN-копия jQuery, от которой зависит выполнение 2.js, тогда ваша страница покроется ошибками, как взорвавшаяся кластерная бомба в…
в… ой, даже не могу придумать метафору.

Я знаю, что спасет нас! JavaScript-библиотека!

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

Эту проблему пытались несколько раз решить на уровне JavaScript. Одни решения предлагали вам внести изменения в ваш JavaScript-код, обернув его в колбэк, который библиотека вызовет в правильном порядке (например, RequireJS). Другие используют XHR для параллельной загрузки, а потом исполняют через eval() в правильном порядке. Но этот метод не работает для скриптов находящихся на других доменах, если у них нет CORS-заголовка (а у браузера нет соответствующей поддержки).

Некоторые, как покойный LabJS, вообще использовали супер-магические хаки. Общий принцип этих хаков выглядел так: заставить браузер скачать ресурс так, что по окончанию загрузки будет вызвано событие, но, при этом, не исполнять ресурс.

В LabJS скрипт добавлялся с неправильным MIME-типом, например: <script type="script/cache" src="...">. После того, как все скрипты скачались, они добавлялись снова, но, уже с правильным типом, в надежде, что браузер загрузит их из кэша и исполнит сразу и по порядку. Это опиралось на распространенное, но не соответствующее спецификации поведение.  И все сломалось, когда в HTML5 было объявлено, что браузеры не должны загружать скрипты с неизвестным типом.

У этих приемов есть парочка вполне четких проблем в сфере производительности. Например, придется подождать, пока загрузится и исполнится код JavaScript- библиотеки перед тем, как хоть какой-то из скриптов, который ею управляется, начнет загружаться. Кроме того, как мы собираемся загружать загрузчик скрипта? Как мы будем загружать скрипт, который говорит загрузчику скриптов, что ему загружать? Кто будет хранить «Хранителей»? Почему я голый? Это все очень сложные вопросы.

DOM спешит на помощь

На самом деле, ответ находится внутри спецификации HTML5, хотя он и спрятан в самом низу секции, посвященной загрузке скриптов.

Вот он:

Атрибут async контролирует, будет ли элемент исполняться асинхронно. Если на элементе установлен флаг force-async, то при чтении атрибут async должен возвращать true. При записи флаг force-async должен быть снят.

А теперь, давайте переведем это на человеческий язык:

[
  '//other-domain.com/1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  document.head.appendChild(script);
});

Скрипты, которые создаются и добавляются к документу динамически, асинхронны по умолчанию, не блокируют рендеринг и выполняются, как только загружены.  Но, это так же означает, что они могут выполниться в неправильном порядке. Однако, мы можем явно указать на них, как на не-асинхронные:

[
  '//other-domain.com/1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  script.async = false;
  document.head.appendChild(script);
});

Это дает нашим скриптам поведение, которого нельзя добиться только манипуляциями с HTML. Скрипты явно не-асинхронные, они добавляются в очередь выполнения (та самая очередь, в которую они добавляются в нашем самом первом примере, где был только HTML). Однако из-за того, что они создаются динамически, они выполняются независимо от парсинга документа, так что рендеринг не блокируется, пока они загружаются (не путайте не-асинхронную загрузку скриптов с синхронным XHR, в котором нет ничего хорошего).

Приведенный скрипт нужно включать прямо в заголовке страницы, чтобы загрузка скриптов начиналась как можно быстрее, не мешая прогрессивному рендерингу, и выполнялась том порядке, который мы установили. 2.js легко может скачаться перед 1.js, но он не будет запущен, пока 1.js не скачается и не выполнится (или пока не произойдет ошибка на том или ином этапе). Ура! Асинхронная загрузка и исполнение по порядку!

Загружать скрипты таким образом можно во всех браузерах, которые поддерживают атрибут async, за исключением Safari 5.0 (в 5.1 все окей). Кроме того, этот метод поддерживают все версии Firefox и Opera, поскольку те версии, которые не поддерживают атрибут async, все равно исполняют динамически добавленные скрипты в том порядке, в котором они добавлены в документ.

Это же самый быстрый способ загружать скрипты, правда? ПРАВДА?

Ну, если вы динамически решаете, какие скрипты загружать, тогда да, в противном случае, пожалуй, нет. С примером выше браузер должен распарсить и выполнить скрипт для того, чтобы определить, какие скрипты скачивать. Это значит, что ваши скрипты остаются скрытыми от сканеров предзагрузки. Браузеры используют эти сканеры для того, чтобы найти на странице те ресурсы, которые браузеру, скорее всего, скоро понадобятся. Или, найти ресурсы страницы, пока браузер блокирован загрузкой другого ресурса.

Можно добавить видимость этих скриптов для браузера, поставив в заголовке документа:

<link rel="subresource" href="//other-domain.com/1.js">
<link rel="subresource" href="2.js">

Эти директивы говорят браузеру, что странице нужны 1.js и 2.js, и они видны модулю предзагрузки. link[rel=subresource] очень похож на link[rel=prefetch], но у него немного другая семантика. К сожалению, сейчас он поддерживается только в Chrome, и вам нужно дважды декларировать, какие скрипты загружать: один раз в ссылках, один раз в вашем скрипте.

У меня развивается депрессия от этой статьи.

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

С HTTP2/SPDY можно уменьшить избыточность запроса до такой степени, что самым быстрым способом загрузить JavaScript будет передача скриптов отдельными маленькими файлами (каждый из которых может индивидуально кэшироваться).

Представьте такую ситуацию:

<script src="dependencies.js"></script>
<script src="enhancement-1.js"></script>
<script src="enhancement-2.js"></script>
<script src="enhancement-3.js"></script>
…
<script src="enhancement-10.js"></script>

Каждый скрипт улучшения работает с конкретным компонентом на странице, но ему требуются сервисные функции из dependencies.js. В идеале, мы всё скачали асинхронно, затем, как можно быстрее загрузили скрипты enhancement, в любом порядке, но после dependencies.js. Такое вот прогрессивное улучшение!

К сожалению, никакого декларативного способа добиться такой ситуации нет, если только сами скрипты не возьмутся отслеживать состояние загрузки dependencies.js. Даже объявление async=false не решает эту проблему, поскольку выполнение enhancement-10.js будет блокироваться, пока не выполнятся скрипты с 1 по 9. Существует только один браузер, в котором это возможно сделать без хаков.

У IE есть идея!

IE загружает скрипты не так, как другие браузеры.

var script = document.createElement('script');
script.src = 'whatever.js';

IE начинает скачивать whatever.js сразу, в то время как другие браузеры не начинают скачивать файл, пока скрипт не добавлен к домену. У IE также есть событие readystatechange и свойство readystate, которые позволяют нам отследить процесс загрузки. На самом деле, это довольно полезно, поскольку позволяет нам независимо контролировать загрузку и исполнение скриптов.

var script = document.createElement('script');

script.onreadystatechange = function() {
  if (script.readyState == 'loaded') {
    // Наш скрипт загрузился, но не исполнился.
    // И не исполнится, пока мы не скажем ему:
    document.body.appendChild(script);
  }
};

script.src = 'whatever.js';

Выбирая, когда добавить скрипты к документу, можно строить достаточно сложные модели зависимости. IE поддерживает эту модель начиная с 6 версии. Довольно интересно, но здесь есть такая же проблема, что и с async=false — наши скрипты невидны для модуля предзагрузки.

Короче! Скажите уже, как мне загружать скрипты!

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

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

Да, именно так. И в конце элемента body. Понимаете, бытие веб-разработчика чем-то похоже на бытие Сизифа (бум! +100 очков к хипстерству за ссылку на древнегреческую мифологию). Ограничения в HTML и браузерах не дают нам сделать что-то, что будет значительно лучше.

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

Ммм, то есть ничего лучше сейчас нет, да?

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

Во-первых, мы добавляем определение subresource, для тех браузеров, которые занимаются предзагрузкой:

<link rel="subresource" href="//other-domain.com/1.js">
<link rel="subresource" href="2.js">

После этого сразу в заголовке документа мы загружаем наши скрипты через JavaScript с помощью async=false, с фолбэком на загрузку скриптов через IE-событие readystate, ну, и на всякий случай, если это не сработает, то с фолбэком на defer.

var scripts = [
  '1.js',
  '2.js'
];
var src;
var script;
var pendingScripts = [];
var firstScript = document.scripts[0];

// наблюдаем за тем, как IE загружает скрипты.
function stateChange() {
  // выполняем по порядку столько скриптов, сколько можем.
  var pendingScript;
  while (pendingScripts[0] && pendingScripts[0].readyState == 'loaded') {
    pendingScript = pendingScripts.shift();
    // больше не будем загружать события от этого скрипта (например, если меняется src)
    pendingScript.onreadystatechange = null;
    // нельзя просто выполнить appendChild — старый баг в IE, если элемент не закрыт.
    firstScript.parentNode.insertBefore(pendingScript, firstScript);
  }
}

// проходимся через наши ссылки на скрипты
while (src = scripts.shift()) {
  if ('async' in firstScript) { // современные браузеры
    script = document.createElement('script');
    script.async = false;
    script.src = src;
    document.head.appendChild(script);
  }
  else if (firstScript.readyState) { // IE<10
    // создаем скрипт и добавляем его в наш список
    script = document.createElement('script');
    pendingScripts.push(script);
    // смотрим за изменением состояния
    script.onreadystatechange = stateChange;
    // устанавливать src нужно только после того, как добавляем обработчик на onreadystatechange
    // иначе не поймаем событие загрузки для кэшированных скриптов
    script.src = src;
  }
  else { // ничего не получилось, пишем код через defer
    document.write('<script src="' + src + '" defer></'+'script>');
  }
}

Пара оптимизаций, минификация и, вуаля: 362 байта + ссылки на ваши скрипты:

!function(e,t,r){function n(){for(;d[0]&&"loaded"==d[0][f];)c=d.shift(),c[o]=!i.parentNode.insertBefore(c,i)}for(var s,a,c,d=[],i=e.scripts[0],o="onreadystatechange",f="readyState";s=r.shift();)a=e.createElement(t),"async"in i?(a.async=!1,e.head.appendChild(a)):i[f]?(d.push(a),a[o]=n):e.write("<"+t+' src="'+s+'" defer></'+t+">"),a.src=s}(document,"script",[
  "//other-domain.com/1.js",
  "2.js"
])

Стоит ли все это лишних байтов по сравнению с обычным включением скрипта? Если вы уже используете JavaScript для условной загрузки скриптов (как BBC), то начать их загрузку раньше выглядит вполне оправданным. В остальных случаях, скорее всего — нет, просто помещайте скрипты в конец body.

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

Быстрая справка

Простые элементы script

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

Спецификация: Скачивать вместе, выполнять по порядку после загрузки CSS, блокировать рендеринг до завершения.

Браузеры: Да, сэр!

Defer

<script src="//other-domain.com/1.js" defer></script>
<script src="2.js" defer></script>

Спецификация: Скачивать вместе, выполнять по порядку непосредственно перед событием DOMContentLoaded. Игнорировать defer для скриптов, у которых нет src.

IE < 10: Ну, может быть, я выполню 2.js где-то посередине исполнения 1.js. Это же забавно, согласитесь?

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

Другие браузеры: Окей, но вообще-то, я могу и не игнорировать defer на скриптах, у которых нет src.

Async

<script src="//other-domain.com/1.js" async></script>
<script src="2.js" async></script>

Спецификация: Скачивать вместе, выполнять в том порядке, в котором они будут загружены.

Браузеры в красном: Что это за async? Буду загружать скрипты, как если бы его не было.

Остальные браузеры: Оу, ну окей.

Async false

[
  '1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  script.async = false;
  document.head.appendChild(script);
});

Спецификация: Скачивать вместе, выполнять по порядку тогда, когда все загрузятся.

Firefox < 3.6, Opera: Понятия не имею, что это за async, но так уж вышло, что я выполняю скрипты, которые добавляются через JS, в том порядке, в котором они были добавлены.

Safari 5.0: Так, я вроде знаю, что такое async, но не понимаю, как можно устанавливать его значение в false через JS. Знаете что? Я выполню ваши скрипты в таком порядке, в котором они загрузятся, а там уж как пойдет.

IE < 10: Ничего не знаю про async, но меня можно убедить с помощью onreadystatechange.

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

Все остальные: Я твой друг, сделаю все по инструкции!

Если вы заметили ошибку, вы всегда можете отредактировать статью, создать issue или просто написать об этом Антону Немцеву в skype ravencry.

Jake Archibald
Vlad Andersen

Комментарии (2 комментария, если быть точным)

Автар пользователя
Apuox

Сокращенная 362-байтная версия в ие9 и ие8 не работает, полностью не добавляет скрипты к дереву. Полная версия этой же вариации скрипта - корректно добавляет и отрабатывает.

Автар пользователя
Ma3oBblu

очень занятная статья. оставлю все как есть ;)