- PVSM.RU - https://www.pvsm.ru -
JS-приложения, сайты и другие ресурсы становятся сложнее и инструменты сборки — это реальность веб-разработки. Бандлеры помогают упаковывать, компилировать и организовывать библиотеки. Один из мощных и гибких инструментов с открытым исходным кодом, который можно идеально настроить для сборки клиентского приложения — Webpack.
Максим Соснов (crazymax11 [1]) — Frontend Lead в N1.RU внедрил Webpack в несколько больших проектов, на которых до этого была своя кастомная сборка, и контрибьютил с ним несколько проектов. Максим знает, как с Webpack собрать бандл мечты, сделать это быстро и конфигурировать так, чтобы конфиг оставался чистым, поддерживаемым и модульным.
Расшифровка отличается от доклада — это сильно усовершенствованная пруфлинками версия. По всей расшифровке рассыпаны пасхалочки на статьи, плагины, минификаторы, опции, транспайлеры и пруфы слов докладчика, ссылки на которые просто не поставить в выступление. Если собрать все, то откроется бонусный уровень в Webpack :-)
Обычно порядок внедрения такой: разработчик где-то прочитал статью про Webpack, решает его подключить, начинает встраивать, как-то это получается, все заводится, и какое-то время webpack-config работает — полгода, год, два. Локально все хорошо — солнце, радуга и бабочки. А потом приходят реальные пользователи:
— С мобильных устройств ваш сайт не загружается.
— У нас все работает. Локально все хорошо!
На всякий случай разработчик идет все профилировать и видит, что для мобильных устройств бандл весит 7 Мбайт и грузится 30 секунд. Это никого не устраивает и разработчик начинает искать, как решить проблему — может подключить лоадер или найти волшебный плагин, который решит все проблемы. Чудесным образом такой плагин находится. Наш разработчик идет в webpack-config, пытается установить, но мешает строчка кода:
if (process.env.NODE_ENV === ’production’) {
config.module.rules[7].options.magic = true;
}
Строчка переводится так: «Если config собирается для production, то возьми седьмое правило, и поставь там опцию magic = true
». Разработчик не знает, что с этим делать и как решать. Это ситуация, когда нужен бандл мечты.
Для начала определим, что это такое. Прежде всего, у бандла мечты две основные характеристики:
А чтобы уменьшать размер бандла, нужно сначала оценить его размер.
Самое популярное решение — это плагин WebpackBundleAnalyzer [2]. Он собирает статистику сборки приложения и рендерит интерактивную страничку, на которой можно посмотреть расположение и вес каждого модуля.
Если этого мало, можно построить граф зависимостей с помощью другого плагина [3].
Или круговую диаграмму [4].
Если и этого недостаточно, и вы хотите продать Webpack маркетологам, то можно построить целую вселенную [5], где каждая точка — это модуль, как звезда во Вселенной.
Инструментов, которые оценивают размер бандла и следят за ним, очень много. Есть опция в конфиге Webpack [6], которая рушит сборку, если бандл слишком много весит, например. Есть плагин duplicate-package-checker-webpack-plugin [7] который не даст собрать бандл, если у вас 2 npm-пакета разных версий, например, Lodash 4.15 и Lodash 4.14.
Теперь поймем как выкинуть лишнее из бандла.
Рассмотрим это на популярном примере с moment.js: import moment from 'moment'
. Если вы возьмете пустое приложение, импортируете в него moment.js и ReactDOM, и потом пропустите это через WebpackBundleAnalyzer, то увидите следующую картину.
Оказывается, когда вы добавляете в дате день, час или просто хотите поставить ссылку «через 15 минут» с помощью moment.js, вы подключаете целых 230 Кбайт кода! Почему так происходит и как это решается?
В moment.js есть функция, которая устанавливает локали:
function setLocale(locale) {
const localePath = ’locale/’ + locale + ’.js’;
this._currentLocale = require(localePath);
}
Из кода видно, что локаль загружается по динамическому пути, т.е. вычисляется в рантайме. Webpack поступает умно и пытается сделать так, чтобы ваш бандл не упал во время выполнения кода: находит все возможные локали в проекте, и бандлит их. Поэтому приложение весит так много.
Решение очень простое — берем стандартный плагин [10] из Webpack и говорим ему: «Если увидишь, что кто-то хочет загрузить много локалей, потому что не может определить какую, — возьми только русскую!»
Webpack возьмет только русскую, а WebpackBundleAnalyzer покажет 54 Кb, что уже на 200 Kb легче.
Следующая оптимизация, которая нас интересует — Dead code elimination. Рассмотрим следующий код.
const cond = true;
if (!cond) {
return false;
}
return true;
someFunction(42);
Большинство строк из этого кода не нужны в финальном бандле — блок с условием не выполнится, функция после return — тоже. Все, что нужно оставить, это return true
. Это как раз и есть Dead code elimination: инструмент сборки обнаруживает код, который не может быть выполнен, и вырезает его. Есть приятная особенность, что UglifyJS умеет это делать.
Теперь перейдем к более продвинутому способу Dead code elimination — Tree shaking.
Допустим, у нас есть приложение, которое использует Lodash. Я сильно сомневаюсь, что кто-то применяет весь Lodash целиком. Скорее всего, эксплуатируется несколько функций типа get, IsEmpty, unionBy или подобных.
Когда мы делаем Tree shaking, мы хотим от Webpack, чтобы он «потряс» ненужные модули и выкинул их, а у нас остались только необходимые. Это и есть Tree shaking.
Допустим, у вас есть такой код:
import { a } from ’./a.js’;
console.log(a);
Код очень простой: из какого-то модуля импортируете переменную a и выводите ее. Но в этом модуле есть две переменные: a и b. Переменная b нам не нужна, и мы хотим ее убрать.
export const a = 3
export const b = 4
Когда придет Webpack, он преобразует код с импортом в такой:
var d = require(0);
console.log(d["a"]);
Наш import
превратился в require
, а console.log
не изменился.
Зависимость Webpack преобразует в следующий код:
var a = 3;
module.exports["a«] = a;
/* unused harmony export b */
var b = 4;
Webpack оставил экспорт переменной a, и убрал экспорт переменной b, но саму переменную оставил, пометив её специальным комментарием. В преобразованном коде переменная b не используется, и UglifyJS может ее удалить.
Tree shaking в Webpack работает, только если у вас есть какой-нибудь минификатор кода, например, UglifyJS или babel-minify [11].
Рассмотрим случаи интереснее — когда Tree shaking не работает.
Кейс № 1. Вы пишете код:
module.exports.a = 3;
module.exports.b = 4;
Прогоняете код через Webpack, и он остается таким же. Все потому, что бандлер организует Tree shaking, только если вы используете ES6 модули. Если применяете CommonJS модули, то Tree shaking работать не будет.
Кейс № 2. Вы пишете код с ES6 модулями и именованными экспортами.
export const a = 3
export const b = 4
Если ваш код прогоняется через Babel и вы не выставили опцию modules в false [12], то Babel приведет ваши модули к CommonJS, и Webpack опять же не сможет выполнить Tree shaking, ведь он работает только с ES6 модулями.
module.exports.a = 3;
module.exports.b = 4;
Соответственно, нам нужно быть уверенными, что никто в нашем пайплане сборки не будет транспайлить ES6 модули.
Кейс № 3. Допустим, у нас есть такой бесполезный класс, который ничего не делает: export class ShakeMe {}
. Более того, мы его еще и не используем. Когда Webpack будет проходить по импортам и экспортам, Babel превратит класс в функцию, а бандлер пометит, что функция не используется:
/* unused harmony e[port b */
var ShakeMe = function () {
function ShakeMe() {
babelHelpers.classCallCheck(this, ShakeMe);
}
return ShakeMe;
}();
Вроде все должно быть хорошо, но если приглядимся, то увидим, что внутри этой функции есть глобальная переменная babelHelpers
, из которой вызывается какая-то функция. Это сайд-эффект: UglifyJS видит, что вызывается какая-то глобальная функция и не вырежет код, потому что боится, что что-то сломает.
Когда вы пишете классы и прогоняете их через Babel, они никогда не вырезаются. Как это исправляется? Есть стандартизованный хак — добавить коммент /*#__PURE__*/
перед функцией:
/* unused harmony export b */
var ShakeMe = /*#__PURE__*/ function () {
function ShakeMe() {
babelHelpers.classCallCheck(this, ShakeMe);
}
return ShakeMe;
}();
Тогда UglifyJS поверит на слово, что следующая функция чистая. К счастью, сейчас это делает Babel 7 [13], а в Babel 6 до сих пор ничего не удаляется.
Правило: если у вас где-то есть сайд-эффект, то UglifyJS ничего не сделает.
Подведем итоги:
Мы разобрались, как уменьшить вес бандла, а теперь давайте научим его загружать только необходимый функционал.
Эту часть разобьем на две. В первой части загружается только код, который требует пользователь: если пользователь заходит на главную страницу вашего сайта, он не загружает страницы личного кабинета. Во второй, правки в коде приводят к минимально возможной перезагрузке ресурсов.
Рассмотрим структуру воображаемого приложения. В нем есть:
Первая проблема, которую мы хотим решить — это вынесение общего кода. Обозначим красным квадратиком общий код для всех страниц, зеленым кружком — для главной и страницы поиска. Остальные фигуры не особо важны.
Когда пользователь придет на поиск с главной страницы, он будет перезагружать и квадратик, и кружок второй раз, хотя они у него уже есть. В идеале мы хотели бы видеть примерно это.
Хорошо, что в Webpack 4 уже есть встроенный плагин, который это делает за нас — SplitChunksPlugin [14]. Плагин выносит код приложения или код node modules, который используется несколькими чанками в отдельный чанк, при этом гарантирует, что чанк с общим кодом будет больше 30 Kb, а для загрузки страницы требуется загрузить не больше 5 чанков. Стратегия оптимальна: слишком маленькие чанки загружать невыгодно, а загрузка слишком большого количества чанков — долго и не так эффективно [15], как загрузка меньшего количества чанков даже на http2. Чтобы повторить такое поведение на 2 или 3 версии Webpack, приходилось писать 20–30 строк с не документированными фичами. Сейчас это решается одной строкой.
Было бы прекрасно, если бы мы еще вынесли CSS для каждого чанка в отдельный файл. Для этого есть готовое решение — Mini-Css-Extract-Plugin [16]. Плагин появился только в Webpack 4, а до него не было адекватных решений для такой задачи — только хаки, боль и простреленные ноги. Плагин выносит CSS из асинхронных чанков и создан специально для этой задачи, которую выполняет идеально.
Разберемся, как бы нам сделать так, чтобы при релизе, например, нового промо-блока на главной странице пользователь перезагружал бы минимально возможную часть кода.
Если бы у нас было версионирование — всё было бы хорошо. Вот у нас главная страница версии N, а после релиза промо-блока — версии N+1. Webpack предоставляет подобный механизм прямо из коробки с помощью хэширования. После того, как Webpack соберет все ассеты, — в данном случае app.js, — то посчитает его контент-хэш, и добавит его к имени файла, чтобы получилось app.[hash].js. Это и есть версионирование, которое нам нужно.
Давайте теперь проверим как это работает. Включим хэши, внесем правки на главной странице, и посмотрим — действительно ли изменился код только главной страницы.Мы увидим, что изменились два файла: main и app.js.
Почему так произошло, ведь это нелогично? Чтобы понять почему, давайте разберем app.js. Он состоит из трех частей:
Когда мы меняем код в main, меняется его контент и хэш, а значит, в app меняется и ссылка на него. Сам app тоже поменяется и его нужно перезагрузить. Решение этой проблемы — разделить app.js на два чанка: код приложения и webpack runtime и ссылки на асинхронные чанки. Webpack 4 делает все за нас одной опцией runtimeChunk, которая весит очень мало — меньше 2 Кбайта в gzip. Перезагрузить его для пользователя — практически ничего не стоит. RuntimeChunk включается всего одной опцией:
optimization: {
runtimeChunk: true
}
В Webpack 3 и 2 мы бы написали 5-6 строк, вместо одной. Это не сильно больше, но все равно лишнее неудобство.
Все здорово, мы научились выносить ссылки и рантайм! Давайте напишем новый модуль в main, зарелизим, и — оп! — теперь вообще все перезагружается.
Почему так? Давайте разберемся, как работают модули в webpack.
Допустим, есть такой код, в котором вы добавляете модули a, b, d и e:
import a from ’a’;
import b from ’b’;
import d from ’d’;
import e from ’e’;
Webpack преобразует импорты в require: a, b, d и e заменились на require(0), require (1), require (2) и require (3).
var a = require(0);
var b = require(1);
var d = require(2);
var e = require(3);
Представим картину, которая очень часто случается: вы пишете новый модуль c import c from 'c';
и вставляете его где-то посередине:
import a from ’a’;
import b from ’b’;
import c from ’c’;
import d from ’d’;
import e from ’e’;
Когда Webpack будет все обрабатывать, то преобразует импорт нового модуля в require(2):
var a = require(0);
var b = require(1);
var c = require(2);
var d = require(3);
var e = require(4);
Модули d и e, которые были 2 и 3, получат цифры 3 и 4 — новые id. Из этого следует простой вывод: использовать порядковые номера как id немного глупо, но Webpack это делает.
Не используйте порядковый номер как уникальный id
Для исправления проблемы есть встроенное решение Webpack — HashedModuleIdsPlugin:
new webpack.HashedModuleIdsPlugin({
hashFunction: ’md4′,
hashDigest:’base64′,
hashDigestLength: 4,
}),
Этот плагин вместо цифровых id использует 4 символа md4-хэша от абсолютного пути до файла. С ним наши require превратятся в такие:
var a = require(’YmRl’);
var b = require(’N2Fl’);
var c = require(’OWE4′);
var d = require(’NWQz’);
var e = require(’YWVj’);
Вместо цифр появились буквы. Конечно, есть скрытая проблема — это коллизия хэшей. Мы на нее натыкались один раз и можем советовать использовать 8 символов, вместо 4. Настроив хэши правильно, все будет работать так, как мы изначально и хотели.
Мы теперь знаем, как собирать бандл мечты.
Собирать научились, а теперь поработаем над скоростью.
У нас в N1.RU самое большое приложение состоит из 10 000 модулей и без оптимизаций собирается 28 минут. Мы смогли ускорить сборку до двух минут! Как же мы это сделали? Существует 3 способа ускорения любых вычислений, и все три применимы к Webpack.
Первое, что мы сделали — распараллелили сборку. Для этого у нас есть:
Кэшировать результаты сборки — наиболее эффективный способ ускорения сборки Webpack.
Первое решение, которое у нас есть — cache-loader [20]. Это лоадер, который встает в цепочку лоадеров и сохраняет на файловую систему результат сборки конкретного файла для конкретной цепочки лоадеров. При следующей сборке бандла, если этот файл есть на файловой системе и уже обрабатывался с этой цепочкой, cache-loader возьмет результаты и не будет вызывать те лоадеры, которые стоят за ними, например, Babel-loader или node-sass.
На графике представлено время сборки. Синий столбик — 100% время сборки, без cache-loader, а с ним — на 7% медленнее. Это происходит потому что cache-loader тратит дополнительное время на сохранение кэшей на файловую систему. Уже на а второй сборке мы получили ощутимый профит — сборка прошла в 2 раза быстрее.
Второе решение более навороченное — HardSourcePlugin [21]. Основное отличие: cache-loader — это просто лоадер, который может оперировать только в цепочке лоадеров кодом или файлами, а HardSourcePlugin имеет почти полный доступ к экосистеме Webpack, умеет оперировать другими плагинами и лоадерами, и сам немного расширяет экосистему для кэширования. На графике выше видно, что на первом запуске время сборки увеличилось на 37%, но ко второму запуску со всеми кэшами мы ускорились в 5 раз.
Самое приятное, что можно использовать оба решения вместе, что мы в N1.RU и делаем. Будьте осторожны, потому что с кэшами есть проблемы, о которых я расскажу чуть позже.
В уже используемых вами плагинах/лоадерах могут быть встроенные механизмы кэширования. Например, в babel-loader [22] очень эффективная система кэширования, но почему-то по умолчанию она выключена. Такой же функционал есть в awesome-typeScript-loader [23]. В UglifyJS [24] плагине тоже есть кэширование, которое замечательно работает. Нас он ускорил на несколько минут.
А теперь проблемы.
Последний способ ускорить какой-либо процесс — не делать какие-то части процесса. Давайте подумаем, на чем можно сэкономить в production? Что мы можем не делать? Ответ короткий — мы ничего не можем не делать! Мы не вправе отказаться от чего-то в production, но можем хорошо сэкономить в dev.
На чем экономить:
Ускорение сборки — это параллелизация, кэширование и отказ от вычислений. Выполнив эти три простых шага, вы можете ускориться очень сильно.
Мы разобрались, как собрать бандл мечты и как собрать его быстро, а теперь разберемся как сконфигурировать Webpack, чтобы при этом не стрелять себе в ногу каждый раз при изменении конфига.
Типичный путь webpack-конфиг в проекте начинается с простого конфига. Сначала вы просто вставляете Webpack, Babel-loader, sass-loader и все хорошо. Потом, неожиданно, появляются какие-то условия на process.env, и вы вставляете условия. Одно, второе, третье, все больше и больше, пока не добавляется условие с «магической» опцией. Вы понимаете, что все уже совсем плохо, и лучше просто продублировать конфиги для dev и production, и сделать правки два раза. Все будет понятнее. Если у вас мелькнула мысль: «Что-то здесь не так?», то единственный работающий совет — держать конфиг в порядке. Расскажу, как мы это делаем.
Мы используем пакет webpack-merge [26]. Это npm-пакет, который создан, чтобы объединять несколько конфигов в один. Если вас не устраивает стратегия объединения по умолчанию, то можно кастомизировать.
У нас есть 4 основные папки:
Расскажу про каждую отдельно.
Это папки, которые содержат файлы для каждого лоадера и плагина, с подробной документацией и более человеческим API, чем тот, что предоставляют разработчики плагинов и лоадеров.
Выглядит это примерно так:
/**
* Подробный JSdoc
* @param {Object} options
* @see ссылка на доки
*/
module.exports = function createPlugin(options) {
return new Plugin(options);
};
Есть модуль, он экспортирует функцию, которая имеет опции, и есть документация. На словах выглядит хорошо, а в реальности наши доки к url-loader выглядят так:
/**
* url-loader это надстройка над file-loader. Он позволяет учитывать ассеты во время бандлинга
*
* @example
* Какой-то ресурс запросил some-image.png. Если для загрузки нужен url-loader, то url-loader проверит размер файла
* 1. если он меньше лимита, то url-loader вернет ресурс как base64 строку
* 2. иначе, url-loader сложит файл в outputPath + name и вернёт вместо ресурса ссылку, по которой его можно загрузить.
* В случае с some-image.png, он может сохраниться в outputPath/images/some-image.12345678hash.png, а url-loader вернет
* publicPath/images/some-image.12345678hash.png
*
* @param {string} prefix префикс имён файлов
* @param {number} limit если ресурс меньше лимита, он будет заинлайнен
* @return {Object} loader конфиг лоадера
* @see https://www.npmjs.com/package/url-loader
*/
Мы рассказываем в простой форме, что он делает, как работает, описываем, какие параметры принимают функции, что создает лоадер, и даем ссылку на доки. Я надеюсь, что тот, кто сюда зайдет, точно поймет, как работает url-loader. Сама функция выглядит так:
function urlLoader(prefix = ’assets’, limit = 100) {
return {
loader: ’url-loader’,
options: {
limit,
name: `${prefix}/[name].[hash].[ext]`
}
};
};
Мы принимаем два параметра и возвращаем описание от лоадера. Не следует бояться того, что папка Loader будет громоздкой и на каждый лоадер будет по файлу.
Это набор опций webpack. Они отвечают за одну функциональность, при этом оперируют лоадерами и плагинами, которые мы уже описали, и настройками webpack, которые у него есть. Самый простой пример — это пресет, который говорит, как правильно загружать scss-файлы:
{
test: /.scss$/,
use: [cssLoader, postCssLoader, scssLoader]
}
Он использует уже преподготовленные лоадеры.
Части — это то, что уже лежит в самом приложении. Они настраивают точку входа и выхода вашего приложения, и могут регулировать или подключать специфичные плагины, лоадеры и опции. Типичный пример, где мы объявляем точку входа и выхода:
entry: {
app: ’./src/Frontend/app.js’
},
output: {
publicPath: ’/static/cabinet/app/’,
path: path.resolve(’www/static/app’)
},
В своей практике мы используем:
Webpack-merge просто выдает нам готовый конфиг. С этим подходом у нас всегда есть документация к конфигурации, в которой достаточно просто разобраться. С webpack-merge мы не лазим по 3-7 конфигам, чтобы поправить везде Babel-loader, потому что у нас есть консистентная конфигурация отдельных частей по всему проекту. А еще интуитивно понятно, где делать правку.
Подведем итоги. Используйте готовые инструменты, а не стройте велосипеды. Документируйте решения, потому что webpack конфиги правятся редко и разными людьми — поэтому документация там очень важна. Разделяйте и переиспользуйте то, что пишете.
Теперь вы знаете, как собирать бандл мечты!
Это доклад — один из лучших на Frontend Conf [27]. Понравилось, и хотите больше — подпишитесь на рассылку [28], в которой мы собираем новые материалы и даем доступ к видео, и приходите на Frontend Conf РИТ++ [29] в мае.
Хотите рассказать миру что-то крутое по фронтенду из своего опыта? Подавайте доклады на FrontenConf РИТ++ [29], который пройдет 27 и 28 мая в Сколково. Присылайте тезисы [30] до 27 марта, а до 15 апреля ПК примет решение о включении доклада в программу конференции. Мы ждем ваш опыт — откликайтесь!
Автор: Глеб Михеев
Источник [31]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/308830
Ссылки в тексте:
[1] crazymax11: https://habr.com/ru/users/crazymax11/
[2] WebpackBundleAnalyzer: https://www.npmjs.com/package/webpack-bundle-analyzer
[3] другого плагина: https://www.npmjs.com/package/webpack-runtime-analyzer
[4] круговую диаграмму: https://alexkuz.github.io/webpack-chart/
[5] построить целую вселенную: https://alexkuz.github.io/stellar-webpack/
[6] опция в конфиге Webpack: https://webpack.js.org/configuration/performance/#performance-hints
[7] duplicate-package-checker-webpack-plugin: https://www.npmjs.com/package/duplicate-package-checker-webpack-plugin
[8] SVGO: https://github.com/rpominov/svgo-loader
[9] gzip/brotli плагины: https://github.com/webpack-contrib/compression-webpack-plugin
[10] стандартный плагин: https://webpack.js.org/plugins/context-replacement-plugin/
[11] babel-minify: https://github.com/babel/minify
[12] modules в false: https://babeljs.io/docs/en/babel-preset-env#modules
[13] Babel 7: https://babeljs.io/blog/2018/08/27/7.0.0#pure-annotation-support
[14] SplitChunksPlugin: https://webpack.js.org/plugins/split-chunks-plugin/
[15] долго и не так эффективно: http://engineering.khanacademy.org/posts/js-packaging-http2.htm
[16] Mini-Css-Extract-Plugin: https://github.com/webpack-contrib/mini-css-extract-plugin
[17] HappyPackPlugin: https://github.com/amireh/happypack
[18] thread-loader: https://github.com/webpack-contrib/thread-loader
[19] UglifyJS: https://github.com/webpack-contrib/uglifyjs-webpack-plugin#parallel
[20] cache-loader: https://github.com/webpack-contrib/cache-loader
[21] HardSourcePlugin: https://github.com/mzgoddard/hard-source-webpack-plugin
[22] babel-loader: https://github.com/babel/babel-loader#options
[23] awesome-typeScript-loader: https://github.com/s-panferov/awesome-typescript-loader
[24] UglifyJS: https://github.com/webpack-contrib/uglifyjs-webpack-plugin#cache
[25] Это позволит сильно ускориться: https://www.smashingmagazine.com/2018/10/smart-bundling-legacy-code-browsers/
[26] webpack-merge: https://github.com/survivejs/webpack-merge
[27] Frontend Conf: https://frontendconf.ru/
[28] на рассылку: https://onticolist.us8.list-manage.com/subscribe?u=719c4e65585ea6013f361815e&id=b6209f5edf
[29] Frontend Conf РИТ++: https://frontendconf.ru/moscow-rit/2019
[30] Присылайте тезисы: https://conf.ontico.ru/users/login.html?url=/lectures/propose
[31] Источник: https://habr.com/ru/post/433324/?utm_source=habrahabr&utm_medium=rss&utm_campaign=433324
Нажмите здесь для печати.