Всем привет!
Некоторое время назад думали с командой, как оптимизировать наш бандл. Но когда ты поддерживаешь 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-файлов объединены в один (возможно сжаты), и программист забыл поставить точку с запятой перед файлом с библиотекой, то будет ошибка. Так как последняя строка кода «склеится» с модулем.
Зачем скобки вокруг функции?
В начале и в конце стоят скобки, так как иначе была бы ошибка. Она произойдет потому, что браузер, видя ключевое слово 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, для создания двух бандлов:
- Бандл с преобразованием до es3-5, polyfills.
Для старых браузеров - Такой же бандл, но в es6
Для новых браузеров
Чтобы корректно подключить бандлы с тегом script и разными атрибутами, можно использовать плагины для webpack: html-webpack-multi-build-plugin, webpack-module-nomodule-plugin и т.д.
Как это работает?
С атрибутом module / nomodule мы даем браузеру возможность выбрать, какой бандл для своей работы взять.
И вроде все идет хорошо, пытаемся сделать пробный вариант:
Рис.2. Пример для Safari 10.1
В примере можно увидеть, что некоторые «старые» браузеры ведут себя некорректно и могут загрузить сразу два бандла. А если посмотреть тестовые примеры, то оказывается, что подобных ошибок в браузерах не так уж и мало.
Но если копнуть еще глубже, то вот все виды ошибок в браузерах:
- загружает оба бандла и выполняет их;
- загружает оба бандла;
- загружает «устаревший» бандл и новый бандл — дважды.
Метод был таким многообещающим, но в итоге подкачал с реализацией.
Может есть способ как-то это поправить?
Хак
Можно воспользоваться старым-добрым хаком. Довольно топорный способ, прямо скажем «в лоб», но многие разработчики на английских форумах советуют именно его.
Для нас тут главное, чтобы бандл загружался и исполнялся только один раз.
<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();
}
Кажется, что в этом методе больше контроля, так как можно указать, какая версия браузера какой бандл будет использовать. Вместо списка браузеров можно указать supports es6-module.
Однако есть довольно весомое «НО». Скоро 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, так как пользователей, использующих его уже мало, а поддержки слишком много)
Используемые и полезные ссылки:
- Модули через замыкания
- Module в es6
- Исследование по differential Serving с примерами браузеров
- Интересный подкаст
- Differential Serving Pattern
- Differential serving vs. polyfill service: How to best serve modern and legacy browsers
- Differential Serving — Serve legacy code to old browsers and ES6 code to modern browsers
- Differential Serving