Передать нужный код для каждого браузера – непростая задача.

В этой статье рассмотрим несколько вариантов, как эту задачу можно решить.



Передача современного кода современным браузером может очень сильно повысить производительность. Ваши JavaScript-пакеты смогут содержать более компактный или оптимизированный современный синтаксис и поддерживать старые браузеры.

Среди инструментов для разработчиков доминирует паттерн module/nomodule декларативной загрузки современного или legacy-кода, который предоставляет браузерам источники и позволяет решать, какие из них использовать:

<script type="module" src="/modern.js"></script>  
<script nomodule src="/legacy.js"></script> 

К сожалению, не всё так просто. Показанный выше подход на основе HTML инициирует перезагрузку скриптов в Edge и Safari.

Что можно сделать?


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

Во-первых, есть Safari Fix. Safari 10.1 поддерживает JS-модули, а не атрибут nomodule в скриптах, что позволяет ему исполнять и современный, и legacy-код. Однако нестандартное событие beforeload, поддерживаемое Safari 10 & 11, можно использовать для полифиллинга nomodule.

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


Можно обойти эти проблемы, реализовав маленький загрузчик скриптов. Аналогично тому, как работает LoadCSS. Вместо того, чтобы надеяться на реализацию в браузерах ES-модулей и атрибута nomodule, можно попытаться выполнить модульный скрипт в качестве «проверки лакмусовой бумажкой», а на основании результата выбрать загрузку современного или legacy-кода.

<!-- use a module script to detect modern browsers: -->  
<script type=module>  
  self.modern = true
</script>

<!-- now use that flag to load modern VS legacy code: -->  
<script>  
  addEventListener('load', function() {
    var s = document.createElement('script')
    if (self.modern) {
      s.src = '/modern.js'
      s.type = 'module'
    }
    else {
      s.src = '/legacy.js'
    }
    document.head.appendChild(s)
  })
</script>

Но при таком подходе необходимо дождаться выполнения «лакмусового» модульного скрипта, прежде чем внедрять правильный скрипт. Это происходит, потому что <sсript type=module> всегда работает асинхронно. Но есть способ получше!

Можно реализовать независимый вариант, проверяя, поддерживается ли nomodule в браузере. Это означает, что мы будем рассматривать браузеры вроде Safari 10.1 как устаревшие, даже если они поддерживают модули. Но это может быть к лучшему. Вот соответствующий код:

var s = document.createElement('script')  
if ('noModule' in s) {  // notice the casing  
  s.type = 'module'
  s.src = '/modern.js'
}
else  
  s.src = '/legacy.js'
}
document.head.appendChild(s)

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

<script>  
  $loadjs("/modern.js","/legacy.js")
  function $loadjs(src,fallback,s) {
    s = document.createElement('script')
    if ('noModule' in s) s.type = 'module', s.src = src
    else s.async = true, s.src = fallback
    document.head.appendChild(s)
  } 
</script> 

Какой же здесь компромисс?

Предварительная загрузка

Поскольку решение полностью динамическое, браузер не сможет обнаружить наши JavaScript-ресурсы до тех пор, пока не запустит загрузочный (bootstrapping) код, который мы написали для вставки современных или legacy-скриптов. Обычно браузер сканирует HTML в поисках ресурсов, которые он может загрузить заранее. Эта проблема решается, но не идеально: можно с помощью <link rеl=modulеpreload> предварительно загружать современную версию пакета в современных браузерах.

К сожалению, пока что только Chrome поддерживает modulepreload.

<link rel="modulepreload" href="/modern.js">  
<script type=module>self.modern=1</script>  
<!-- etc --> 

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

Вот как это решение может выглядеть в эксплуатации:

<link rel="modulepreload" href="/modern.js">  
<script type=module>self.modern=1</script>  
<script>  
  $loadjs("/modern.js","/legacy.js")
  function $loadjs(e,d,c){c=document.createElement("script"),self.modern?(c.src=e,c.type="module"):c.src=d,document.head.appendChild(c)}
</script>

Также нужно отметить, что список браузеров, поддерживающих JS-модули, почти аналогичен тем, которые поддерживают <link rеl=preload>. Для каких-то сайтов может быть целесообразным использование <link rеl=preload as=script crossorigin> вместо modulepreload. Производительность может ухудшиться, поскольку классическая предварительная загрузка скриптов не подразумевает равномерного выполнения парсинга с течением времени, как в случае с modulepreload.

Способ второй: отслеживание User Agent


У меня нет подходящего примера кода, поскольку отслеживание User Agent — задача нетривиальная. Но зато вы можете почитать прекрасную статью в Smashing Magazine.

По сути всё начинается с того же <scriрt src=bundle.js> в HTML для всех браузеров. Когда запрашивается bundle.js, сервер парсит строку User Agent запрашивающего браузера и выбирает, какой JavaScript возвращать — современный или legacy, в зависимости от того, как был распознан браузер.

Подход универсальный, но влечёт серьёзные последствия:

  • Поскольку требуются умные серверы, этот подход не будет работать в условиях статического развёртывания (генераторы статических сайтов, Netlify и т.д.).
  • Кэширование для этих JavaScript URL теперь зависит от User Agent, который очень изменчив.
  • Определение UA затруднено и может приводить к ложной классификации.
  • Строку User Agent легко спуфить, и каждый день появляются новые UA.

Один из способов обхода этих ограничений заключается в комбинировании паттерна module/nomodule с дифференцированием User Agent, чтобы избежать отправки по одному адресу нескольких версий пакета. Этот подход снижает кэшируемость страницы, но обеспечивает эффективную предварительную загрузку: сервер, генерирующий HTML, знает, когда использовать modulepreload, а когда preload.

function renderPage(request, response) {  
  let html = `<html><head>...`;

  const agent = request.headers.userAgent;
  const isModern = userAgent.isModern(agent);
  if (isModern) {
    html += `
      <link rel=modulepreload href=modern.mjs>
      <script type=module src=modern.mjs></script>
    `;
  } else {
    html += `
      <link rel=preload as=script href=legacy.js>
      <script src=legacy.js></script>
    `;
  }

  response.end(html);
}

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

Способ третий: штрафуем старые браузеры


Негативный эффект паттерна module/nomodule виден в старых версиях Chrome, Firefox и Safari — их количество очень невелико, поскольку браузеры обновляются автоматически. С Edge 16-18 ситуация иная, но есть надежда: новые версии Edge будут использовать движок отрисовки на основе Chromium, который не имеет таких проблем.

Для некоторых приложений это было бы идеальным компромиссом: загружать современную версию кода в 90 % браузеров, а в старые — отдавать legacy-код. Нагрузка в старых браузерах повысится.

К слову, ни один из User Agent’ов, для которых такая перезагрузка является проблемой, не занимают значимую долю мобильного рынка. Так что источником всех этих лишних байтов вряд ли будут мобильные устройства или устройства со слабым процессором.

Если вы создаёте сайт, к которому обращаются в основном мобильные или свежие браузеры, то для большинства этих пользователей подойдёт простейший вид паттерна module/nomodule. Только удостоверьтесь, что вы добавили фикс Safari 10.1, если к вам заходят более старые iOS-устройства.

заходят более старые iOS-устройства.
<!-- polyfill `nomodule` in Safari 10.1: -->  
<script type=module>  
!function(e,t,n){!("noModule"in(t=e.createElement("script")))&&"onbeforeload"in t&&(n=!1,e.addEventListener("beforeload",function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()},!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove())}(document)
</script>

<!-- 90+% of browsers: -->  
<script src=modern.js type=module></script>

<!-- IE, Edge <16, Safari <10.1, old desktop: -->  
<script src=legacy.js nomodule async defer></script>

Способ четвёртый: применяйте условия использования пакетов


Хорошим решением будет использовать nomodule для условной загрузки пакетов с кодом, который не нужен в современных браузерах, например, с полифиллами. При таком подходе в худшем случае полифиллы будут загружены или даже выполнены (в Safari 10.1), но эффект от этого будет ограничен «переполифиллингом». Учитывая, что сегодня преобладает подход с загрузкой и выполнением полифиллов во всех браузерах, это может быть достойным улучшением.

<!-- newer browsers will not load this bundle: -->  
<script nomodule src="polyfills.js"></script>

<!-- all browsers load this one: -->  
<script src="/bundle.js"></script> 

Можно сконфигурировать Angular CLI для использования этого подхода с полифиллами, как продемонстрировал Минко Гечев. Узнав об этом подходе, я понял, что можно включить автоматическую инъекцию полифиллов в preact-cli — этот PR демонстрирует, насколько легко можно внедрить эту методику.

А если вы используете WebPack, то есть удобный плагин для html-webpack-plugin, который облегчает добавление nomodule в пакеты с полифиллами.

Так что же выбрать?


Ответ зависит от вашей ситуации. Если вы создаёте клиентское приложение, и ваш HTML содержит чуть больше, чем <sсript>, то вам может потребоваться первый способ.

Если вы создаёте сайт, который отрисовывается на сервере, и можете позволить себе кэширование, то вам может подойти второй способ.

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

Лично я выбираю, ориентируясь на длительность парсинга на мобильных устройствах, а не на стоимость загрузки в десктопных версиях. Мобильные пользователи воспринимают парсинг и расходы на передачу данных как фактические расходы (расход заряда батареи и плату за передачу данных), тогда как пользователи десктопа не имеют таких ограничений. Также я исхожу из оптимизации под 90% пользователей — основная аудитория моих проектов пользуется современными и/или мобильными браузерами.

Что почитать


Хотите изучить эту тему подробнее? Можете начать отсюда:

Комментарии (4)


  1. polearnik
    06.11.2019 17:00

    довольно иронично в статье про современную загрузку изображений вставить до ката изображение в 2,2 мегабайта.


    1. GCU
      06.11.2019 17:14

      Видимо уже оперативно ужали до 417 KБ. Но почему PNG — это же фото?
      Тем более в оригинальной статье это был JPG в 119 КБ.


    1. A11oW Автор
      06.11.2019 17:58

      Спасибо, что обратили внимание на магию конвертации.
      Теперь картинка весит меньше :)


  1. a1ex322
    07.11.2019 18:19

    Не фронтендер, поэтому вот бредовая идея:
    Представим что сервер в Австралии и пинг 1 секунда. Тогда что получается, сервер сначала отдал html, браузер прочитал директиву и давай тянуть этот js через океан. В результате только через 2 секунды юзер получит что-то рабочее. Почему бы не придумать некий «компилятор», который будет брать все зависимости вообще, минифицировать и получившийся текст в этот html вставлять в тег script. Помимо сокращения времени полной загрузки появится дополнительный профит от full page cache. Ну и проблема описываемая в статье отпадет сама собой.