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

Введение

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

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

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

WHATWG по загрузке скрипта
WHATWG по загрузке скрипта

Как и все спецификации WHATWG, поначалу это выглядит как последствия взрыва кассетной бомбы на фабрике скрэббл, но как только вы прочтете это в пятый раз и вытерете кровь с глаз, это на самом деле довольно интересно:

Мой первый сценарий включает в себя

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

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

К сожалению, браузер блокирует дальнейший рендеринг страницы, пока все это происходит. Это связано с API-интерфейсами DOM из «первой эпохи Интернета», которые позволяют добавлять строки к содержимому, обрабатываемому парсером, например document.write . Новые браузеры будут продолжать сканировать или анализировать документ в фоновом режиме и запускать загрузку внешнего контента, который может ему понадобиться (js, изображения, css и т. д.), но рендеринг по-прежнему блокируется.

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

Спасибо, ИЕ! (нет, я не иронизирую)

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

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

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

Подобно кассетной бомбе на овцефабрике, «отсрочка» превратилась в беспорядочную путаницу. Между атрибутами «src» и «defer», а также тегами скриптов и динамически добавляемыми скриптами у нас есть 6 шаблонов добавления скрипта. Конечно, браузеры не пришли к согласию относительно порядка выполнения. Mozilla написала отличную статью об этой проблеме в том виде, в котором она стояла еще в 2009 году.

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

Спасибо, ИЕ! (ок, теперь я саркастичен)

Оно дает, оно забирает. К сожалению, в IE4-9 есть неприятная ошибка, из-за которой сценарии могут выполняться в неожиданном порядке . Вот что происходит:

1.js

console.log('1');
document.getElementsByTagName('p')[0].innerHTML = 'Changing some content';
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 объявил, что браузеры не должны загружать скрипты неизвестного типа. Стоит отметить, что LabJS адаптировался к этим изменениям и теперь использует комбинацию методов из этой статьи.

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

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

ДОМ спешит на помощь

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

Переведем это на «Землянин»:

[
  '//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 вы можете сократить накладные расходы на запросы до такой степени, что доставка сценариев в нескольких небольших индивидуально кешируемых файлах может оказаться самым быстрым способом. Представлять себе:

<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>

Каждый сценарий улучшения работает с определенным компонентом страницы, но требует служебных функций в файле dependency.js. В идеале мы хотим загрузить все асинхронно, а затем как можно скорее выполнить сценарии улучшения в любом порядке, но после dependency.js. Это прогрессивное прогрессивное улучшение! К сожалению, не существует декларативного способа добиться этого, если только сами сценарии не будут изменены для отслеживания состояния загрузки dependency.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') {
    // Our script has download, but hasn't executed.
    // It won't execute until we do:
    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 спасут нас, предоставив декларативный неблокирующий способ загрузки скриптов и контроль над порядком выполнения, хотя для этого скрипты должны быть написаны как модули.

Иуу, должно быть что-то получше, что мы можем использовать сейчас?

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

Сначала мы добавляем объявление подресурса для предзагрузчиков:

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

Затем, в заголовке документа, мы загружаем наши сценарии с помощью JavaScript, используя async=false , возвращаясь к загрузке сценариев IE на основе Readystate и возвращаясь к отсрочке.

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

// Watch scripts load in IE
function stateChange() {
  // Execute as many scripts in order as we can
  var pendingScript;
  while (pendingScripts[0] && pendingScripts[0].readyState == 'loaded') {
    pendingScript = pendingScripts.shift();
    // avoid future loading events from this script (eg, if src changes)
    pendingScript.onreadystatechange = null;
    // can't just appendChild, old IE bug if element isn't closed
    firstScript.parentNode.insertBefore(pendingScript, firstScript);
  }
}

// loop through our script urls
while (src = scripts.shift()) {
  if ('async' in firstScript) { // modern browsers
    script = document.createElement('script');
    script.async = false;
    script.src = src;
    document.head.appendChild(script);
  }
  else if (firstScript.readyState) { // IE<10
    // create a script and add it to our todo pile
    script = document.createElement('script');
    pendingScripts.push(script);
    // listen for state changes
    script.onreadystatechange = stateChange;
    // must set src AFTER adding onreadystatechange listener
    // else we'll miss the loaded event for cached scripts
    script.src = src;
  }
  else { // fall back to defer
    document.write('<script src="' + src + '" defer></'+'script>');
  }
}

Спустя несколько трюков и минификации, это 362 байта + URL-адреса ваших скриптов:

!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 , вы также можете получить выгоду от запуска этих загрузок раньше. В противном случае, возможно, нет, придерживайтесь простого метода конца тела.

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

Краткая справка

Простые элементы сценария

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

Спецификация гласит: загружайте вместе, выполняйте по порядку после любого ожидающего CSS, блокируйте рендеринг до завершения. Браузеры говорят: Да, сэр!

Отложить

<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» в скриптах без «src».

Асинхронный

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

Спецификация гласит: загружайте вместе, выполняйте в том порядке, в котором они загружаются. Браузеры, отмеченные красным, говорят: что такое «асинхронный»? Я собираюсь загрузить скрипты, как будто их там нет. Другие браузеры говорят: Да, ок.

Асинхронный ложь

[
  '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 говорит: Я понятия не имею, что такое «асинхронность», но так получилось, что я выполняю скрипты, добавленные через JS, в том порядке, в котором они добавлены. Safari 5.0 говорит: Я понимаю «асинхронность», но не понимаю, как установить для него значение «false» с помощью JS. Я выполню ваши сценарии, как только они прибудут, в любом порядке. IE < 10 говорит: понятия не имею об «асинхронности», но есть обходной путь с использованием «onreadystatechange». Другие браузеры, отмеченные красным, говорят: я не понимаю эту «асинхронность», я выполню ваши скрипты, как только они приземлятся, в любом порядке. Все остальное говорит: я твой друг, мы сделаем это по правилам.