Делаем Modern Build
Привет, Хабр!
Каждый современный браузер сейчас позволяет работать с ES6 Modules.
Но если покопаться в спецификации, окажется, что благодаря ним можно подвезти отдельную сборку для современных браузеров. На первый взгляд кажется, что это совершенно бесполезная вещь — ведь все мы пользуемся сборщиками, которые заменяют импорты на свои внутренние вызовы.
Под катом рассказ о том, как я смог уменьшить размер приложения на 11% без ущерба для старых браузеров и своих нервов.
Особенности ES6 Modules
ES6 Modules — это всем уже известная и широко используемая модульная система:
/* someFile.js */ import 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 методы, и им не нужны лишние килобайты полифилов.
В это же время для браузеров с поддержкой ES6 Modules количество трансформаторов уменьшается до 9. Транспиляция тоже оставляет заметный след на размере файлов: для покрытия большинства браузеров babel/preset-env использует 25 трансформаторов, каждый из которых увеличивает размер кода.
Значит, в сборке для современных браузеров мы можем убрать ненужные полифилы и уменьшить количество трансформаторов, что сильно скажется на размере итоговых файлов!
Как добавлять полифилы
Прежде чем идти готовить Modern Build для современных браузеров, стоит упомянуть, как я добавляю полифилы в проект.
Обычно, в проектах используют core-js для добавления всех возможных полифилов.
Такая возможность доступна с помощью babel/preset-env и его опции useBuiltIns. Конечно, вы не хотите все 88 Кбайт полифилов из этой библиотеки, а только те, которые нужны для вашего browserslist. Если установить ей значение 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 и других странных вещей, которые никогда в проекте не используются.
На этапе компиляции babel/preset-env проанализирует файлы на использование фич, которые отсутствуют в выбранных браузерах, и добавит полифилы к ним: Чтобы полностью победить эту проблему, для опции useBuiltIns я ставлю значение usage.
/* .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 выбирает худший для себя сценарий и вставляет полифил.
Чтобы убирать ненужные полифилы, время от времени проверяйте, какие из них вы импортируете, и удаляйте лишние с помощью опции exclude: Ситуации, когда babel/preset-env ошибается, случаются постоянно.
/* .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 и вставить в него пути до нужных файлов.
Пути до modern-файлов html-webpack-plugin вставит сам, а пути до fallback-файлов я получу из созданного на предыдущем шаге файла и вставлю их в HTML с помощью небольшого webpack-плагина: Для генерации html-файла я буду использовать html-webpack-plugin во время Modern Build'а.
/* 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%. Отличный результат при простой реализации.
Спасибо, что прочитали статью до конца!