Хабрахабр

Делаем 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>

Этот шаг можно разбить на три части:

  1. Сборка Modern и Fallback Build в разные директории.
  2. Получение информации о путях до необходимых javascript-файлов.
  3. Создание 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%. Отличный результат при простой реализации.

Спасибо, что прочитали статью до конца!

Использованные материалы

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»