Всем привет!

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

Бандл весит много, грузится долго. Но почему пользователь, например, последней версии хрома, должен мучиться с долгой загрузкой приложения?

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

Differential Serving на русский примерно переводится как «условная загрузка ресурсов», но мне кажется, английское название более благозвучное и понятное, поэтому дальше буду использовать его.


Видео
Если неохота читать, то можете посмотреть видео моего доклада на HolyJS



Прежде чем начать разговор про Differential Serving и понять принцип его работы, для полного погружения нужно узнать, что такое «модуль» в js. Или вы можете отправиться дальше.

Модуль old-school


Давайте перемотаем на 7-8 лет назад…
И вспомним, какие раньше были конструкции. Если вы смотрели исходный код библиотек или сами их когда-то писали, то вам будет знаком такой код.

; (function() {}())

Эта конструкция называется «модулем» или самовызывающейся функцией. В качестве примера можете посмотреть библиотеку Lodash.
Напомню, что данный метод сделали для создания собственной области видимости, и чтобы код выполнился только один раз при запуске.

Узнать подробнее о методе

Зачем скобки вокруг функции?


В начале и в конце стоят скобки, так как иначе была бы ошибка. Она произойдет потому, что браузер, видя ключевое слово function в основном потоке кода, попытается прочитать Function Declaration, но вызывать «на месте» разрешено только Function Expression.
Но если function идет в составе более сложного выражения, то браузер считает, что это Function Expression, для этого и нужны скобки.

Точка с запятой в начале


В начале кода находится точка с запятой — это не опечатка, а «защита от дураков». Если получится, что несколько JS-файлов объединены в один (возможно сжаты), и программист забыл поставить точку с запятой перед файлом с библиотекой, то будет ошибка. Так как последняя строка кода «склеится» с модулем.

Модуль в es6


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

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

// sayHi.js
export function sayHi(user) {
  alert(`Hello, ${user}!`);
}

// main.js
import {sayHi} from './sayHi.js';

sayHi('John'); // Hello, John!

В объекте import.meta содержится информация о текущем модуле.

Вкратце выделю основные возможности:

  • каждый модуль имеет свою собственную область видимости;
  • код в нем выполняется только один раз при импорте;
  • в модуле всегда используется режим use strict;
  • код в нем выполняется в отложенном (deferred) режиме;
  • this не определен;
  • async работает во встроенных скриптах.

Для использования модуля необходимо явно указать браузеру, что скрипт является модулем, при помощи атрибута type='module'.

Совместимость, «nomodule»


А вот это, на мой взгляд, самая занимательная особенность модулей.
Старые браузеры не понимают атрибут type='module', а скрипты с неизвестным атрибутом type просто игнорируются.



Рис.1. Поддержка браузерами атрибута type='module'.

Но мы можем сделать для старых браузеров «резервный» скрипт при помощи атрибута nomodule.

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


Differential Serving


И вот мы плавно подошли к теме Differential Serving. Его основная идея состоит в том, чтобы использовать атрибуты module / nomodule, для создания двух бандлов:

  1. Бандл с преобразованием до es3-5, polyfills.
    Для старых браузеров
  2. Такой же бандл, но в es6
    Для новых браузеров

Чтобы корректно подключить бандлы с тегом script и разными атрибутами, можно использовать плагины для webpack: html-webpack-multi-build-plugin, webpack-module-nomodule-plugin и т.д.

Как это работает?


С атрибутом module / nomodule мы даем браузеру возможность выбрать, какой бандл для своей работы взять.

И вроде все идет хорошо, пытаемся сделать пробный вариант:



Рис.2. Пример для Safari 10.1

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



Но если копнуть еще глубже, то вот все виды ошибок в браузерах:

  1. загружает оба бандла и выполняет их;
  2. загружает оба бандла;
  3. загружает «устаревший» бандл и новый бандл — дважды.

Метод был таким многообещающим, но в итоге подкачал с реализацией.
Может есть способ как-то это поправить?

Хак


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

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

<script>
  const scriptEl = document.createElement('script');

  if ('noModule' in scriptEl) {
    scriptEl.src = 'js/main.js';
    scriptEl.type = 'module';
  } else {
    scriptEl.src = 'js/legacy';
    scriptEl.defer = true;
  } 
  document.body.appendChild(scriptEl)
</script>

Проверить, что браузер поддерживает nomodule, можно определив, поддерживает ли он атрибут type='module', так что в условии можно использовать любой атрибут.

Альтернативный подход


Также есть и альтернативный подход — использовать пакет browserslist-useragent.

Выглядеть файл будет примерно так

// .browserslistrc file

const express = require('express');
const { matchesUA } = require('browserslist-useragent');
const exphbs = require('express-handlebars');
…
app.use((req, res, next) => {
  try {
    const ESM_BROWSERS = [
      'Edge >= 16',
      'Firefox >= 60',
      'Chrome >= 61',
      'Safari >= 11',
      'Opera >= 48',
    ];
    const isModuleCompatible = matchesUA(
      req.headers['user-agent'],
      {browsers: ESM_BROWSERS, allowHigherVersions: true}
    );

    res.locals.isModuleCompatible = isModuleCompatible;
  } catch (error) {
    …
  }
  next();
}

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

Однако есть довольно весомое «НО». Скоро Google уберет из браузера Chrome строку 'user-agent', а вслед за ним последуют и остальные браузеры.
Поэтому есть подозрения, что browserslist-useragent проживет недолго, а ему на смену придет Client Hints API.

Differential serving vs. polyfill service


Первый вопрос, который возникает при знакомстве с Differential Serving — есть ли аналоги?

Более-менее похожий метод — polyfill service. Также есть различные npm-пакеты, которые частично похожи на polyfill service.

Polyfill service — сервис, который принимает запрос на набор функций браузера и возвращает только те полифиллы, которые необходимы запрашивающему браузеру.

<script src='https://polyfill.io/v3/polyfill.min.js'/>

Кратко разберем его плюсы и минусы.
Плюсы:

кэширование полифиллов

доступно для всех браузеров

контроль (user-agent)

Минусы:

не предлагает решения для es6+

дополнительный запрос

содержит ошибки реализации

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

Но и у Differential Serving есть свои плюсы и минусы.
Плюсы:

оптимизирует транспилирование, полифиллинг

минимум полифиллов

минимум проблем в обслуживании

Минусы:

время кэширования

настройка webpack, babel

увеличивается время сборки

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

Результирующее сравнение можно посмотреть в таблице ниже или в статье.



Итог


Как известно, Microsoft в следующем году перестанет поддерживать IE, но это не значит, что разработчики перестанут поддерживать свои приложения под IE. Мем смешной — ситуация страшная :(

Differential Serving кажется многообещающим методом, хоть и со своей спецификой и некоторыми недостатками. Зато он позволяет уменьшить бандл на ~ 20%.

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



Используемые и полезные ссылки: