Большинство веб-разработчиков, с которыми я общаюсь сейчас, любят писать JavaScript со всеми новейшими функциями языка — async/await, классами, стрелочными функциями и т.д. Однако, несмотря на то, что все современные браузеры могут исполнять код ES2015+ и изначально поддерживают упомянутый мной функционал, большинство разработчиков по-прежнему транспилируют свой код на ES5 и связывают его с полифиллами, чтобы удовлетворить небольшой процент пользователей, все еще работающих в старых браузерах.
Это отвратительно. В идеальном мире мы не будем развертывать ненужный код.
При работе с новыми API-интерфейсами JavaScript и DOM мы можем условно загружать полифиллы, т.к. мы можем выявить поддержку этих интерфейсов во время выполнения программы. Но с новым синтаксисом JavaScript сделать это намного сложнее, поскольку любой неизвестный синтаксис вызовет ошибку синтаксического анализа (parse error), и тогда наш код вообще не будет запущен.
Хотя в настоящее время у нас нет подходящего решения для создания функционала выявления нового синтаксиса, у нас есть способ установить базовую поддержку синтаксиса ES2015 уже сегодня.
Решим это с помощью тега script type="module"
.
Большинство разработчиков думают о script type="module"
как о способе загрузки модулей ES (и, конечно же, это так), но script type="module"
также имеет более быстрый и практичный вариант использования — загружает обычные файлы JavaScript с функциями ES2015+, зная, что браузер может справиться с ними!
Другими словами, каждый браузер, поддерживающий script type="module"
также поддерживает большинство функций ES2015+, которые вы знаете и любите. Например:
- Каждый браузер, поддерживающий
script type="module"
, также поддерживает async/await - Каждый браузер, поддерживающий
script type="module"
, также поддерживает классы. - Каждый браузер, поддерживающий
script type="module"
, также поддерживает стрелочные функции. - Каждый браузер, поддерживающий
script type="module"
, также поддерживает fetch, Promises, Map, Set, и многое другое!
Осталось только предоставить резервную копию для браузеров, которые не поддерживают script type="module"
. К счастью, если вы в настоящее время генерируете ES5-версию своего кода, вы уже сделали эту работу. Все, что вам теперь нужно — создать версию ES2015+!
В остальной части этой статьи объясняется, как реализовать эту технику, и обсуждается, как возможность развертывания кода ES2015+ изменит способ создания модулей в будущем.
Реализация
Если вы уже используете сборщик модулей (module bundler), например webpack или rollup для генерации своего кода на JavaScript, продолжайте по-прежнему это делать.
Затем, в дополнение к вашему текущему набору (bundle), вы создадите второй набор, как и первый; единственное различие будет заключается в том, что вы не будете транспилировать код в ES5, и вам не нужно будет подключать устаревшие полифиллы (legacy polyfills).
Если вы уже используете babel-preset-env
(что должны), то второй шаг будет очень прост. Все, что вам нужно сделать, это изменить список браузеров только на те, которые поддерживают script type="module"
, и Babel автоматически не будет делать ненужные преобразования.
Иными словами, это будет вывод кода ES2015+ вместо ES5.
Например, если вы используете webpack, и вашей основной точкой входа является скрипт ./path/to/main.js
, тогда конфигурация вашей текущей версии ES5 может иметь следующий вид (обратите внимание, так как это ES5, я называю набор (bundle) main-legacy
):
module.exports = {
entry: {
'main-legacy': './path/to/main.js',
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'public'),
},
module: {
rules: [{
test: /.js$/,
use: {
loader: 'babel-loader',
options: {
presets: [
['env', {
modules: false,
useBuiltIns: true,
targets: {
browsers: [
'> 1%',
'last 2 versions',
'Firefox ESR',
],
},
}],
],
},
},
}],
},
};
Для того, чтобы сделать современную версию для ES2015+, все, что вам нужно — это создать вторую конфигурацию и настроить целевую среду только для браузеров, поддерживающих script type="module"
. Вот как это может выглядеть:
module.exports = {
entry: {
'main': './path/to/main.js',
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'public'),
},
module: {
rules: [{
test: /.js$/,
use: {
loader: 'babel-loader',
options: {
presets: [
['env', {
modules: false,
useBuiltIns: true,
targets: {
browsers: [
'Chrome >= 60',
'Safari >= 10.1',
'iOS >= 10.3',
'Firefox >= 54',
'Edge >= 15',
],
},
}],
],
},
},
}],
},
};
При запуске эти две конфигурации выведут на продакшн два JavaScript-файла:
main.js
(ES2015+ синтаксис)main-legacy.js
(ES5 синтаксис)
Следующим шагом будет обновление вашего HTML для условной загрузки ES2015+ пакета (bundle) в браузерах, поддерживающих модули. Вы можете сделать это, используя script type="module"
и script nomodule
:
<!-- Браузеры с поддержкой модулей ES загрузят этот файл. -->
<script type="module" src="main.js"></script>
<!-- Устаревшие браузеры загрузят этот файл (поддерживающие модули -->
<!-- браузеры знают, что этот файл загружать *не* нужно). -->
<script nomodule src="main-legacy.js"></script>
Внимание! Единственная засада (gotcha) здесь — браузер Safari 10, который не поддерживает атрибут nomodule
, но вы можете решить это, встроив JavaScript-сниппет в ваш HTML до использования любых тегов script nomodule
. (Примечание: это было исправлено в Safari 11).
Важные моменты
По большей части описанная выше техника «просто работает», но перед ее реализацией важно знать несколько деталей о том, как загружаются модули:
- Модули загружаются как
script defer
. Это означает, что они не выполняются до тех пор, пока документ не будет распарсен. Если какую-то часть вашего кода нужно запустить раньше, лучше разбить этот код и загрузить его отдельно. - Модули всегда запускают код в строгом режиме (strict mode), поэтому, если по какой-либо причине часть вашего кода должна быть запущена за пределами строгого режима, ее придется загружать отдельно.
- Модули обрабатывают объявления верхнего уровня переменных (
var
) и функций (function
) отлично от обычных сценариев. Например, кvar foo = 'bar'
иfunction foo() {…}
в скрипте можно получить доступ черезwindow.foo
, но в модуле это не будет работать. Убедитесь, что в своем коде вы не зависите от такого поведения.
Рабочий пример
Я создал webpack-esnext-boilerplate, чтобы разработчики могли увидеть реальное применение описанной здесь техники.
В этот пример я намеренно включил несколько продвинутых фич webpack, поскольку хотел показать, что описанная мною техника готова к применению и работает в реальных сценариях. К ним относятся хорошо известные передовые практики, такие как:
- Разделение кода (Code splitting)
- Динамический импорт (Dynamic imports, условная загрузка дополнительного кода во время выполнения программы)
- Asset fingerprinting (для эффективного длительного кэширования)
И так как я никогда не буду рекомендовать то, что не использую сам, я обновил свой блог, чтобы использовать эту технику. Вы можете проверить исходный код, если хотите увидеть больше.
Если для создания пакетов (bundles) продакшн вы используете инструмент, отличный от webpack, этот процесс более или менее одинаковый. Я решил продемонстрировать описанную мной технику с помощью webpack, поскольку в настоящее время он является самым популярным инструментом для сборки, а также и самым сложным. Я полагаю, что если описанная мной техника может работать с webpack, она может работать с чем угодно.
Игра стоит свеч?
По-моему, определенно! Экономия может быть значительной. Например, ниже приведено сравнение общих размеров файлов для двух версий кода из моего блога:
Версия | Размер (minified) | Размер (minified + gzipped) |
---|---|---|
ES2015+ (main.js) | 80K | 21K |
ES5 (main-legacy.js) | 175K | 43K |
Устаревшая ES5-версия кода более чем в два раза превышает размер (даже gzipped) версии ES2015+.
Большие файлы занимают больше времени для загрузки, но они также занимают больше времени для анализа и оценки. При сравнении двух версий моего блога, время, затраченное на parse/eval, также было стабильно вдвое дольше для устаревшей ES5-версии (эти тесты выполнялись на Moto G4 с использованием webpagetest.org):
Версия | Parse/eval time (по отдельности) | Parse/eval time (среднее) |
---|---|---|
ES2015+ (main.js) | 184ms, 164ms, 166ms | 172ms |
ES5 (main-legacy.js) | 389ms, 351ms, 360ms | 367ms |
Хотя эти абсолютные размеры файлов и время parse/eval не особенно большие, поймите, что это блог, и я не загружаю много скриптов. Но для большинства сайтов это не так. Чем больше у вас скриптов, тем больше будет выигрыш, который вы получите, развернув код на ES2015+ в своем проекте.
Если вы все еще настроены скептически, и считаете, что размер файла и различия во времени выполнения в первую очередь связаны с тем, что для поддержки устаревших сред требуется много полифиллов, вы не совсем ошибаетесь. Но, к лучшему или худшему, сегодня в Интернете это очень распространенная практика.
Быстрый запрос данных HTTPArchive показывает, что из лучших сайтов по рейтингу Alexa, 85 181 включают в себя babel-polyfill, core-js или regenerator-runtime в своих пакетах (bundles) продакшн. Шесть месяцев назад их число было 34 588!
Реальность транспилируется, и в том числе полифиллы быстро становятся новой нормой. К сожалению, это означает, что миллиарды пользователей получают триллионы байтов, без необходимости отправленные через сеть в браузеры, которые изначально были бы вполне способны запускать нетранспилированный код.
Пришло время собирать наши модули как ES2015
Главная засада (gotcha) для описанной здесь техники сейчас состоит в том, что большинство авторов модулей не публикуют ES2015+ версии исходного кода, а публикуют сразу транспилированную ES5-версию.
Теперь, когда развертывание кода на ES2015+ возможно, пришло время изменить это.
Я полностью осознаю, что такой шаг сопряжен с множеством проблем в ближайшем будущем. Сегодня большинство инструментов сборки публикуют документацию, рекомендующую конфигурацию, которая предполагает, что все модули написаны на ES5. Это означает, что если авторы модулей начнут публиковать исходный код на ES2015+ в npm, то они, вероятно, сломают некоторые сборки пользователей и просто вызовут путаницу.
Проблема заключается в том, что большинство использующих Babel разработчиков настраивают его так, чтобы код в node_modules
не транспилировался. Однако, если модули опубликованы с исходным кодом ES2015+, возникает проблема. К счастью, она легко исправима. Вам просто нужно удалить исключение node_modules
из конфигурации сборки:
rules: [
{
test: /.js$/,
exclude: /node_modules/, // удалите эту строку
use: {
loader: 'babel-loader',
options: {
presets: ['env']
}
}
}
]
Недостаток заключается в том, что если такие инструменты как Babel должны начать транспилировать зависимости (dependencies) из node_modules
, в дополнение к локальным зависимостям, это замедлит скорость сборки. К счастью, эту проблему можно отчасти решить на уровне инструментария с постоянным локальным кэшированием.
Несмотря на удары, мы, скорее всего, пройдем путь к тому, что ES2015+ станет новым стандартом публикации модулей. Я думаю, что борьба стоит своей цели. Если мы, как авторы модулей, публикуем в npm только ES5-версии нашего кода, мы навязываем пользователям раздутый (bloated) и медленный код.
Публикуя код в ES2015, мы даем разработчикам выбор, что в конечном счете приносит пользу всем.
Заключение
Хотя script type="module"
предназначен для загрузки модулей ES (и их зависимостей) в браузере, его не нужно использовать только для этой цели.
script type="module"
будет успешно загружать единственный файл Javascript, и это даст разработчикам столь необходимое средство для условной загрузки современного функционала в тех браузерах, которые могут его поддерживать.
Это, наряду с атрибутом nomodule
, дает нам возможность использовать код ES2015+ в продакшн, и наконец-то мы можем прекратить отправку транспилированного кода в браузеры, которые в нем не нуждаются.
Написание кода ES2015 — это победа для разработчиков, а внедрение кода ES2015 — победа для пользователей.
Автор: Олег Лазарев