Привет!
Каждый современный браузер сейчас позволяет работать с ES6 Modules.
На первый взгляд кажется, что это совершенно бесполезная вещь — ведь все мы пользуемся сборщиками, которые заменяют импорты на свои внутренние вызовы. Но если покопаться в спецификации, окажется, что благодаря ним можно подвезти отдельную сборку для современных браузеров.
Под катом рассказ о том, как я смог уменьшить размер приложения на 11% без ущерба для старых браузеров и своих нервов.
Особенности ES6 Modules
ES6 Modules — это всем уже известная и широко используемая модульная система:
/* someFile.js */
import { someFunc } from 'path/to/helpers.js'
/* helpers.js */
export function someFunc() {
/* ... */
}
Для использования этой модульной системы в браузерах необходимо добавить тип module к каждому скрипт-тегу. Старые браузеры увидят, что тип отличается от text/javascript, и не станут исполнять файл как JavaScript.
<!-- Будет выполнен только в браузерах с поддержкой ES6 Modules -->
<script type="module" src="/path/to/someFile.js"></script>
В спецификации еще есть атрибут nomodule для скрипт-тегов. Браузеры, поддерживающие ES6 Modules, проигнорируют этот скрипт, а старые браузеры скачают его и выполнят.
<!-- Будет загружен только в старых браузерах -->
<script nomodule src="/path/to/someFileFallback.js"></script>
Получается, можно просто сделать две сборки: первая с типом module для современных браузеров (Modern Build), а другая — с nomodule для старых (Fallback build):
<script type="module" src="/path/to/someFile.js"></script>
<script nomodule src="/path/to/someFileFallback.js"></script>
Зачем это нужно
Прежде чем отправить проект в production, мы должны:
- Добавить полифилы.
- Транспилировать современный код в более старый.
В своих проектах я стараюсь поддерживать максимальное количество браузеров, иногда даже IE 10. Поэтому мой список полифилов состоит в том числе и из таких базовых вещей, как es6.promise, es6.object.values и т.п. Но браузеры с поддержкой ES6 Modules имеют все ES6 методы, и им не нужны лишние килобайты полифилов.
Транспиляция тоже оставляет заметный след на размере файлов: для покрытия большинства браузеров babel/preset-env использует 25 трансформаторов, каждый из которых увеличивает размер кода. В это же время для браузеров с поддержкой ES6 Modules количество трансформаторов уменьшается до 9.
Значит, в сборке для современных браузеров мы можем убрать ненужные полифилы и уменьшить количество трансформаторов, что сильно скажется на размере итоговых файлов!
Как добавлять полифилы
Прежде чем идти готовить Modern Build для современных браузеров, стоит упомянуть, как я добавляю полифилы в проект.
Обычно, в проектах используют core-js для добавления всех возможных полифилов.
Конечно, вы не хотите все 88 Кбайт полифилов из этой библиотеки, а только те, которые нужны для вашего browserslist. Такая возможность доступна с помощью babel/preset-env и его опции useBuiltIns. Если установить ей значение entry, то импорт core-js заменится на импорты отдельных модулей, необходимых вашим браузерам:
/* .babelrc.js */
module.exports = {
presets: [
['@babel/preset-env', {
useBuiltIns: 'entry',
/* ... */
}]
],
/* ... */
};
/* Исходный файл */
import 'core-js';
/* Транспилированный файл */
import "core-js/modules/es6.array.copy-within";
import "core-js/modules/es6.array.fill";
import "core-js/modules/es6.array.find";
/* И еще много-много импортов */
Но такой трансформацией мы избавились лишь от части ненужных очень старых полифилов. У нас все еще присутствуют полифилы для TypedArray, WeakMap и других странных вещей, которые никогда в проекте не используются.
Чтобы полностью победить эту проблему, для опции useBuiltIns я ставлю значение usage. На этапе компиляции babel/preset-env проанализирует файлы на использование фич, которые отсутствуют в выбранных браузерах, и добавит полифилы к ним:
/* .babelrc.js */
module.exports = {
presets: [
['@babel/preset-env', {
useBuiltIns: 'usage',
/* ... */
}]
],
/* ... */
};
/* Исходный файл */
function sortStrings(strings) {
return strings.sort();
}
function createResolvedPromise() {
return Promise.resolve();
}
/* Транспилированный файл */
import "core-js/modules/es6.array.sort";
import "core-js/modules/es6.promise";
function sortStrings(strings) {
return strings.sort();
}
function createResolvedPromise() {
return Promise.resolve();
}
В примере выше babel/preset-env добавил полифил к функции sort. В JavaScript нельзя узнать, объект какого типа будет передан в функцию — будет это массив или объект класса с функцией sort, но babel/preset-env выбирает худший для себя сценарий и вставляет полифил.
Ситуации, когда babel/preset-env ошибается, случаются постоянно. Чтобы убирать ненужные полифилы, время от времени проверяйте, какие из них вы импортируете, и удаляйте лишние с помощью опции exclude:
/* .babelrc.js */
module.exports = {
presets: [
['@babel/preset-env', {
useBuiltIns: 'usage',
// Используйте эту опцию, чтобы узнать, какие полифилы вы используете
debug: true,
// Добавляйте в исключения ненужные полифилы
exclude: ['es6.regexp.to-string', 'es6.number.constructor'],
/* ... */
}]
],
/* ... */
};
Модуль regenerator-runtime я не рассматриваю, так как использую fast-async (и всем советую).
Создаем Modern Build
Приступим к настройке Modern Build.
Убедимся, что у нас в проекте есть файл browserslist, который описывает все необходимые браузеры:
/* .browserslistrc */
> 0.5%
IE 10
Добавим переменную окружения BROWSERS_ENV во время сборки, которая может принимать значения fallback (для Fallback Build) и modern (для Modern Build):
/* package.json */
{
"scripts": {
/* ... */
"build": "NODE_ENV=production webpack /.../",
"build:fallback": "BROWSERS_ENV=fallback npm run build",
"build:modern": "BROWSERS_ENV=modern npm run build"
},
/* ... */
}
Теперь изменим конфигурацию babel/preset-env. Для указания поддерживаемых браузеров в пресете есть опция targets. У нее существует специальное сокращение — esmodules. При его использовании babel/preset-env автоматически подставит браузеры, поддерживающие ES6 modules.
/* .babelrc.js */
const isModern = process.env.BROWSERS_ENV === 'modern';
module.exports = {
presets: [
['@babel/preset-env', {
useBuiltIns: 'usage',
// Для Modern Build выбираем браузеры с поддержкой ES6 modules,
// а для Fallback Build берем список браузеров из .browsersrc
targets: isModern ? { esmodules: true } : undefined,
/* ... */
}]
],
/* ... */
],
};
Babel/preset-env сделает дальше всю работу за нас: выберет только нужные полифилы и трансформации.
Теперь мы можем собрать проект для современных или старых браузеров просто командой из консоли!
Связываем Modern и Fallback Build
Последний шаг — это объединение Modern и Fallback Build'ов в одно целое.
Я планирую создать такую структуру проекта:
// Директория с собранными файлами
dist/
// Общий html-файл
index.html
// Директория с Modern Build'ом
modern/
...
// Директория с Fallback Build'ом
fallback/
...
В index.html будут ссылки на нужные javascript-файлы из обеих сборок:
/* index.html */
<html>
<head>
<!-- ... -->
</head>
<body>
<!-- ... -->
<script type="module" src="/modern/js/app.540601d23b6d03413d5b.js"></script>
<script nomodule src="/fallback/js/app.4d03e1af64f68111703e.js"></script>
</body>
</html>
Этот шаг можно разбить на три части:
- Сборка Modern и Fallback Build в разные директории.
- Получение информации о путях до необходимых javascript-файлов.
- Создание index.html со ссылками на все javascript-файлы.
Приступаем!
Сборка Modern и Fallback Build в разные директории
Для начала сделаем самый простой шаг — соберем Modern и Fallback Build в разные директории внутри директории dist.
Просто указать нужную директорию для output.path нельзя, так как нам необходимо, чтобы webpack имел пути до файлов относительно директории dist (index.html находится в этой директории, и все остальные зависимости будут выкачиваться относительно него).
Создадим специальную функцию для генерации путей файлов:
/* getFilePath.js */
/* Файл содержит функцию, которая поможет создавать пути для файлов */
const path = require('path');
const isModern = process.env.BROWSERS_ENV === 'modern';
const prefix = isModern ? 'modern' : 'fallback';
module.exports = relativePath => (
path.join(prefix, relativePath)
);
/* webpack.prod.config.js */
const getFilePath = require('path/to/getFilePath');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
mode: 'production',
output: {
path: 'dist',
filename: getFilePath('js/[name].[contenthash].js'),
},
plugins: [
new MiniCssExtractPlugin({
filename: getFilePath('css/[name].[contenthash].css'),
}),
/* ... */
],
/* ... */
}
Проект стал собираться в разные директории для Modern и Fallback Build'а.
Получение информации о путях до необходимых javascript-файлов
Чтобы получить информацию о собранных файлах, подключим webpack-manifest-plugin. В конце сборки он добавит файл manifest.json с данными о путях до файлов:
/* webpack.prod.config.js */
const getFilePath = require('path/to/getFilePath');
const WebpackManifestPlugin = require('webpack-manifest-plugin');
module.exports = {
mode: 'production',
plugins: [
new WebpackManifestPlugin({
fileName: getFilePath('manifest.json'),
}),
/* ... */
],
/* ... */
}
Теперь у нас есть информация о собранных файлах:
/* manifest.json */
{
"app.js": "/fallback/js/app.4d03e1af64f68111703e.js",
/* ... */
}
Создание index.html со ссылками на все javascript-файлы
Дело осталось за малым — добавить index.html и вставить в него пути до нужных файлов.
Для генерации html-файла я буду использовать html-webpack-plugin во время Modern Build'а. Пути до modern-файлов html-webpack-plugin вставит сам, а пути до fallback-файлов я получу из созданного на предыдущем шаге файла и вставлю их в HTML с помощью небольшого webpack-плагина:
/* webpack.prod.config.js */
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModernBuildPlugin = require('path/to/ModernBuildPlugin');
module.exports = {
mode: 'production',
plugins: [
...(isModern ? [
// Добавим html-страницу в Modern Build
new HtmlWebpackPlugin({
filename: 'index.html',
}),
new ModernBuildPlugin(),
] : []),
/* ... */
],
/* ... */
}
/* ModernBuildPlugin.js */
// Safari 10.1 не поддерживает атрибут nomodule.
// Эта переменная содержит фикс для Safari в виде строки.
// Найти фикс можно тут:
// https://gist.github.com/samthor/64b114e4a4f539915a95b91ffd340acc
const safariFix = '!function(){var e=document,t=e.createE/* ...И еще много кода... */';
class ModernBuildPlugin {
apply(compiler) {
const pluginName = 'modern-build-plugin';
// Получаем информацию о Fallback Build
const fallbackManifest = require('path/to/dist/fallback/manifest.json');
compiler.hooks.compilation.tap(pluginName, (compilation) => {
// Подписываемся на хук html-webpack-plugin,
// в котором можно менять данные HTML
compilation.hooks.htmlWebpackPluginAlterAssetTags.tapAsync(pluginName, (data, cb) => {
// Добавляем type="module" для modern-файлов
data.body.forEach((tag) => {
if (tag.tagName === 'script' && tag.attributes) {
tag.attributes.type = 'module';
}
});
// Вставляем фикс для Safari
data.body.push({
tagName: 'script',
closeTag: true,
innerHTML: safariFix,
});
// Вставляем fallback-файлы с атрибутом nomodule
const legacyAsset = {
tagName: 'script',
closeTag: true,
attributes: {
src: fallbackManifest['app.js'],
nomodule: true,
defer: true,
},
};
data.body.push(legacyAsset);
cb();
});
});
}
}
module.exports = ModernBuildPlugin;
Обновим package.json:
/* package.json */
{
"scripts": {
/* ... */
"build:full": "npm run build:fallback && npm run build:modern"
},
/* ... */
}
С помощью команды npm run build:full мы создадим один html-файл с Modern и Fallback Build. Любой браузер теперь получит тот JavaScript, который он в состоянии выполнить.
Добавляем Modern Build в свое приложение
Чтобы проверить на чем-то реальном свое решение, я подвез его в один из своих проектов. Настройка конфигурации заняла у меня менее часа, а размер JavaScript-файлов уменьшился на 11%. Отличный результат при простой реализации.
Спасибо, что прочитали статью до конца!
Использованные материалы
Автор: Андрей Аникин