JS-приложения, сайты и другие ресурсы становятся сложнее и инструменты сборки — это реальность веб-разработки. Бандлеры помогают упаковывать, компилировать и организовывать библиотеки. Один из мощных и гибких инструментов с открытым исходным кодом, который можно идеально настроить для сборки клиентского приложения — Webpack.
Максим Соснов (crazymax11) — Frontend Lead в N1.RU внедрил Webpack в несколько больших проектов, на которых до этого была своя кастомная сборка, и контрибьютил с ним несколько проектов. Максим знает, как с Webpack собрать бандл мечты, сделать это быстро и конфигурировать так, чтобы конфиг оставался чистым, поддерживаемым и модульным.
Расшифровка отличается от доклада — это сильно усовершенствованная пруфлинками версия. По всей расшифровке рассыпаны пасхалочки на статьи, плагины, минификаторы, опции, транспайлеры и пруфы слов докладчика, ссылки на которые просто не поставить в выступление. Если собрать все, то откроется бонусный уровень в Webpack :-)
Обычно порядок внедрения такой: разработчик где-то прочитал статью про Webpack, решает его подключить, начинает встраивать, как-то это получается, все заводится, и какое-то время webpack-config работает — полгода, год, два. Локально все хорошо — солнце, радуга и бабочки. А потом приходят реальные пользователи:
— С мобильных устройств ваш сайт не загружается.
— У нас все работает. Локально все хорошо!
На всякий случай разработчик идет все профилировать и видит, что для мобильных устройств бандл весит 7 Мбайт и грузится 30 секунд. Это никого не устраивает и разработчик начинает искать, как решить проблему — может подключить лоадер или найти волшебный плагин, который решит все проблемы. Чудесным образом такой плагин находится. Наш разработчик идет в webpack-config, пытается установить, но мешает строчка кода:
Строчка переводится так: «Если config собирается для production, то возьми седьмое правило, и поставь там опцию
Для начала определим, что это такое. Прежде всего, у бандла мечты две основные характеристики:
А чтобы уменьшать размер бандла, нужно сначала оценить его размер.
Самое популярное решение — это плагин WebpackBundleAnalyzer. Он собирает статистику сборки приложения и рендерит интерактивную страничку, на которой можно посмотреть расположение и вес каждого модуля.
Если этого мало, можно построить граф зависимостей с помощью другого плагина.
Или круговую диаграмму.
Если и этого недостаточно, и вы хотите продать Webpack маркетологам, то можно построить целую вселенную, где каждая точка — это модуль, как звезда во Вселенной.
Инструментов, которые оценивают размер бандла и следят за ним, очень много. Есть опция в конфиге Webpack, которая рушит сборку, если бандл слишком много весит, например. Есть плагин duplicate-package-checker-webpack-plugin который не даст собрать бандл, если у вас 2 npm-пакета разных версий, например, Lodash 4.15 и Lodash 4.14.
Теперь поймем как выкинуть лишнее из бандла.
Рассмотрим это на популярном примере с moment.js:
Оказывается, когда вы добавляете в дате день, час или просто хотите поставить ссылку «через 15 минут» с помощью moment.js, вы подключаете целых 230 Кбайт кода! Почему так происходит и как это решается?
В moment.js есть функция, которая устанавливает локали:
Из кода видно, что локаль загружается по динамическому пути, т.е. вычисляется в рантайме. Webpack поступает умно и пытается сделать так, чтобы ваш бандл не упал во время выполнения кода: находит все возможные локали в проекте, и бандлит их. Поэтому приложение весит так много.
Решение очень простое — берем стандартный плагин из Webpack и говорим ему: «Если увидишь, что кто-то хочет загрузить много локалей, потому что не может определить какую, — возьми только русскую!»
Webpack возьмет только русскую, а WebpackBundleAnalyzer покажет 54 Кb, что уже на 200 Kb легче.
Следующая оптимизация, которая нас интересует — Dead code elimination. Рассмотрим следующий код.
Большинство строк из этого кода не нужны в финальном бандле — блок с условием не выполнится, функция после return — тоже. Все, что нужно оставить, это
Теперь перейдем к более продвинутому способу Dead code elimination — Tree shaking.
Допустим, у нас есть приложение, которое использует Lodash. Я сильно сомневаюсь, что кто-то применяет весь Lodash целиком. Скорее всего, эксплуатируется несколько функций типа get, IsEmpty, unionBy или подобных.
Когда мы делаем Tree shaking, мы хотим от Webpack, чтобы он «потряс» ненужные модули и выкинул их, а у нас остались только необходимые. Это и есть Tree shaking.
Допустим, у вас есть такой код:
Код очень простой: из какого-то модуля импортируете переменную a и выводите ее. Но в этом модуле есть две переменные: a и b. Переменная b нам не нужна, и мы хотим ее убрать.
Когда придет Webpack, он преобразует код с импортом в такой:
Наш
Зависимость Webpack преобразует в следующий код:
Webpack оставил экспорт переменной a, и убрал экспорт переменной b, но саму переменную оставил, пометив её специальным комментарием. В преобразованном коде переменная b не используется, и UglifyJS может ее удалить.
Рассмотрим случаи интереснее — когда Tree shaking не работает.
Кейс № 1. Вы пишете код:
Прогоняете код через Webpack, и он остается таким же. Все потому, что бандлер организует Tree shaking, только если вы используете ES6 модули. Если применяете CommonJS модули, то Tree shaking работать не будет.
Кейс № 2. Вы пишете код с ES6 модулями и именованными экспортами.
Если ваш код прогоняется через Babel и вы не выставили опцию modules в false, то Babel приведет ваши модули к CommonJS, и Webpack опять же не сможет выполнить Tree shaking, ведь он работает только с ES6 модулями.
Соответственно, нам нужно быть уверенными, что никто в нашем пайплане сборки не будет транспайлить ES6 модули.
Кейс № 3. Допустим, у нас есть такой бесполезный класс, который ничего не делает:
Вроде все должно быть хорошо, но если приглядимся, то увидим, что внутри этой функции есть глобальная переменная
Когда вы пишете классы и прогоняете их через Babel, они никогда не вырезаются. Как это исправляется? Есть стандартизованный хак — добавить коммент
Тогда UglifyJS поверит на слово, что следующая функция чистая. К счастью, сейчас это делает Babel 7, а в Babel 6 до сих пор ничего не удаляется.
Подведем итоги:
Мы разобрались, как уменьшить вес бандла, а теперь давайте научим его загружать только необходимый функционал.
Эту часть разобьем на две. В первой части загружается только код, который требует пользователь: если пользователь заходит на главную страницу вашего сайта, он не загружает страницы личного кабинета. Во второй, правки в коде приводят к минимально возможной перезагрузке ресурсов.
Рассмотрим структуру воображаемого приложения. В нем есть:
Первая проблема, которую мы хотим решить — это вынесение общего кода. Обозначим красным квадратиком общий код для всех страниц, зеленым кружком — для главной и страницы поиска. Остальные фигуры не особо важны.
Когда пользователь придет на поиск с главной страницы, он будет перезагружать и квадратик, и кружок второй раз, хотя они у него уже есть. В идеале мы хотели бы видеть примерно это.
Хорошо, что в Webpack 4 уже есть встроенный плагин, который это делает за нас — SplitChunksPlugin. Плагин выносит код приложения или код node modules, который используется несколькими чанками в отдельный чанк, при этом гарантирует, что чанк с общим кодом будет больше 30 Kb, а для загрузки страницы требуется загрузить не больше 5 чанков. Стратегия оптимальна: слишком маленькие чанки загружать невыгодно, а загрузка слишком большого количества чанков — долго и не так эффективно, как загрузка меньшего количества чанков даже на http2. Чтобы повторить такое поведение на 2 или 3 версии Webpack, приходилось писать 20–30 строк с не документированными фичами. Сейчас это решается одной строкой.
Было бы прекрасно, если бы мы еще вынесли CSS для каждого чанка в отдельный файл. Для этого есть готовое решение — Mini-Css-Extract-Plugin. Плагин появился только в 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 включается всего одной опцией:
В Webpack 3 и 2 мы бы написали 5-6 строк, вместо одной. Это не сильно больше, но все равно лишнее неудобство.
Все здорово, мы научились выносить ссылки и рантайм! Давайте напишем новый модуль в main, зарелизим, и — оп! — теперь вообще все перезагружается.
Почему так? Давайте разберемся, как работают модули в webpack.
Допустим, есть такой код, в котором вы добавляете модули a, b, d и e:
Webpack преобразует импорты в require: a, b, d и e заменились на require(0), require (1), require (2) и require (3).
Представим картину, которая очень часто случается: вы пишете новый модуль c
Когда Webpack будет все обрабатывать, то преобразует импорт нового модуля в require(2):
Модули d и e, которые были 2 и 3, получат цифры 3 и 4 — новые id. Из этого следует простой вывод: использовать порядковые номера как id немного глупо, но Webpack это делает.
Для исправления проблемы есть встроенное решение Webpack — HashedModuleIdsPlugin:
Этот плагин вместо цифровых id использует 4 символа md4-хэша от абсолютного пути до файла. С ним наши require превратятся в такие:
Вместо цифр появились буквы. Конечно, есть скрытая проблема — это коллизия хэшей. Мы на нее натыкались один раз и можем советовать использовать 8 символов, вместо 4. Настроив хэши правильно, все будет работать так, как мы изначально и хотели.
Мы теперь знаем, как собирать бандл мечты.
Собирать научились, а теперь поработаем над скоростью.
У нас в N1.RU самое большое приложение состоит из 10 000 модулей и без оптимизаций собирается 28 минут. Мы смогли ускорить сборку до двух минут! Как же мы это сделали? Существует 3 способа ускорения любых вычислений, и все три применимы к Webpack.
Первое, что мы сделали — распараллелили сборку. Для этого у нас есть:
Кэшировать результаты сборки — наиболее эффективный способ ускорения сборки Webpack.
Первое решение, которое у нас есть — cache-loader. Это лоадер, который встает в цепочку лоадеров и сохраняет на файловую систему результат сборки конкретного файла для конкретной цепочки лоадеров. При следующей сборке бандла, если этот файл есть на файловой системе и уже обрабатывался с этой цепочкой, cache-loader возьмет результаты и не будет вызывать те лоадеры, которые стоят за ними, например, Babel-loader или node-sass.
На графике представлено время сборки. Синий столбик — 100% время сборки, без cache-loader, а с ним — на 7% медленнее. Это происходит потому что cache-loader тратит дополнительное время на сохранение кэшей на файловую систему. Уже на а второй сборке мы получили ощутимый профит — сборка прошла в 2 раза быстрее.
Второе решение более навороченное — HardSourcePlugin. Основное отличие: cache-loader — это просто лоадер, который может оперировать только в цепочке лоадеров кодом или файлами, а HardSourcePlugin имеет почти полный доступ к экосистеме Webpack, умеет оперировать другими плагинами и лоадерами, и сам немного расширяет экосистему для кэширования. На графике выше видно, что на первом запуске время сборки увеличилось на 37%, но ко второму запуску со всеми кэшами мы ускорились в 5 раз.
Самое приятное, что можно использовать оба решения вместе, что мы в N1.RU и делаем. Будьте осторожны, потому что с кэшами есть проблемы, о которых я расскажу чуть позже.
В уже используемых вами плагинах/лоадерах могут быть встроенные механизмы кэширования. Например, в babel-loader очень эффективная система кэширования, но почему-то по умолчанию она выключена. Такой же функционал есть в awesome-typeScript-loader. В UglifyJS плагине тоже есть кэширование, которое замечательно работает. Нас он ускорил на несколько минут.
А теперь проблемы.
Последний способ ускорить какой-либо процесс — не делать какие-то части процесса. Давайте подумаем, на чем можно сэкономить в production? Что мы можем не делать? Ответ короткий — мы ничего не можем не делать! Мы не вправе отказаться от чего-то в production, но можем хорошо сэкономить в dev.
На чем экономить:
Мы разобрались, как собрать бандл мечты и как собрать его быстро, а теперь разберемся как сконфигурировать Webpack, чтобы при этом не стрелять себе в ногу каждый раз при изменении конфига.
Типичный путь webpack-конфиг в проекте начинается с простого конфига. Сначала вы просто вставляете Webpack, Babel-loader, sass-loader и все хорошо. Потом, неожиданно, появляются какие-то условия на process.env, и вы вставляете условия. Одно, второе, третье, все больше и больше, пока не добавляется условие с «магической» опцией. Вы понимаете, что все уже совсем плохо, и лучше просто продублировать конфиги для dev и production, и сделать правки два раза. Все будет понятнее. Если у вас мелькнула мысль: «Что-то здесь не так?», то единственный работающий совет — держать конфиг в порядке. Расскажу, как мы это делаем.
Мы используем пакет webpack-merge. Это npm-пакет, который создан, чтобы объединять несколько конфигов в один. Если вас не устраивает стратегия объединения по умолчанию, то можно кастомизировать.
У нас есть 4 основные папки:
Расскажу про каждую отдельно.
Это папки, которые содержат файлы для каждого лоадера и плагина, с подробной документацией и более человеческим API, чем тот, что предоставляют разработчики плагинов и лоадеров.
Выглядит это примерно так:
Есть модуль, он экспортирует функцию, которая имеет опции, и есть документация. На словах выглядит хорошо, а в реальности наши доки к url-loader выглядят так:
Мы рассказываем в простой форме, что он делает, как работает, описываем, какие параметры принимают функции, что создает лоадер, и даем ссылку на доки. Я надеюсь, что тот, кто сюда зайдет, точно поймет, как работает url-loader. Сама функция выглядит так:
Мы принимаем два параметра и возвращаем описание от лоадера. Не следует бояться того, что папка Loader будет громоздкой и на каждый лоадер будет по файлу.
Это набор опций webpack. Они отвечают за одну функциональность, при этом оперируют лоадерами и плагинами, которые мы уже описали, и настройками webpack, которые у него есть. Самый простой пример — это пресет, который говорит, как правильно загружать scss-файлы:
Он использует уже преподготовленные лоадеры.
Части — это то, что уже лежит в самом приложении. Они настраивают точку входа и выхода вашего приложения, и могут регулировать или подключать специфичные плагины, лоадеры и опции. Типичный пример, где мы объявляем точку входа и выхода:
В своей практике мы используем:
Webpack-merge просто выдает нам готовый конфиг. С этим подходом у нас всегда есть документация к конфигурации, в которой достаточно просто разобраться. С webpack-merge мы не лазим по 3-7 конфигам, чтобы поправить везде Babel-loader, потому что у нас есть консистентная конфигурация отдельных частей по всему проекту. А еще интуитивно понятно, где делать правку.
Подведем итоги. Используйте готовые инструменты, а не стройте велосипеды. Документируйте решения, потому что webpack конфиги правятся редко и разными людьми — поэтому документация там очень важна. Разделяйте и переиспользуйте то, что пишете.
Теперь вы знаете, как собирать бандл мечты!
Максим Соснов (crazymax11) — Frontend Lead в N1.RU внедрил Webpack в несколько больших проектов, на которых до этого была своя кастомная сборка, и контрибьютил с ним несколько проектов. Максим знает, как с 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
». Разработчик не знает, что с этим делать и как решать. Это ситуация, когда нужен бандл мечты.Как собрать бандл мечты?
Для начала определим, что это такое. Прежде всего, у бандла мечты две основные характеристики:
- Мало весит. Чем меньше вес — тем быстрее пользователь получит работающее приложение. Вы же не хотите, чтобы ваш сайт открывался 15 секунд.
- Пользователь загружает только то, что нужно загрузить для показа текущей страницы сайта, и ни байтом больше!
А чтобы уменьшать размер бандла, нужно сначала оценить его размер.
Оценить размер бандла
Самое популярное решение — это плагин WebpackBundleAnalyzer. Он собирает статистику сборки приложения и рендерит интерактивную страничку, на которой можно посмотреть расположение и вес каждого модуля.
Если этого мало, можно построить граф зависимостей с помощью другого плагина.
Или круговую диаграмму.
Если и этого недостаточно, и вы хотите продать Webpack маркетологам, то можно построить целую вселенную, где каждая точка — это модуль, как звезда во Вселенной.
Инструментов, которые оценивают размер бандла и следят за ним, очень много. Есть опция в конфиге Webpack, которая рушит сборку, если бандл слишком много весит, например. Есть плагин duplicate-package-checker-webpack-plugin который не даст собрать бандл, если у вас 2 npm-пакета разных версий, например, Lodash 4.15 и Lodash 4.14.
Как уменьшить бандл
- Самое очевидное — подключить UglifyJS, чтобы он заминифицировал JavaScript.
- Использовать специальные лоадеры и плагины, которые сжимают и оптимизируют определенный ресурс. Например, css-nano для css, или SVGO, который оптимизирует SVG.
- Сжимать все файлы прямо в Webpack через gzip/brotli плагины.
- Другие инструменты.
Теперь поймем как выкинуть лишнее из бандла.
Выкинуть лишнее
Рассмотрим это на популярном примере с moment.js:
import moment from 'moment'
. Если вы возьмете пустое приложение, импортируете в него moment.js и ReactDOM, и потом пропустите это через WebpackBundleAnalyzer, то увидите следующую картину.Оказывается, когда вы добавляете в дате день, час или просто хотите поставить ссылку «через 15 минут» с помощью moment.js, вы подключаете целых 230 Кбайт кода! Почему так происходит и как это решается?
Загрузка локали в moment
В moment.js есть функция, которая устанавливает локали:
function setLocale(locale) {
const localePath = ’locale/’ + locale + ’.js’;
this._currentLocale = require(localePath);
}
Из кода видно, что локаль загружается по динамическому пути, т.е. вычисляется в рантайме. Webpack поступает умно и пытается сделать так, чтобы ваш бандл не упал во время выполнения кода: находит все возможные локали в проекте, и бандлит их. Поэтому приложение весит так много.
Решение очень простое — берем стандартный плагин из Webpack и говорим ему: «Если увидишь, что кто-то хочет загрузить много локалей, потому что не может определить какую, — возьми только русскую!»
Webpack возьмет только русскую, а WebpackBundleAnalyzer покажет 54 Кb, что уже на 200 Kb легче.
Dead code elimination
Следующая оптимизация, которая нас интересует — 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.
Tree shaking
Допустим, у нас есть приложение, которое использует Lodash. Я сильно сомневаюсь, что кто-то применяет весь Lodash целиком. Скорее всего, эксплуатируется несколько функций типа get, IsEmpty, unionBy или подобных.
Когда мы делаем Tree shaking, мы хотим от Webpack, чтобы он «потряс» ненужные модули и выкинул их, а у нас остались только необходимые. Это и есть Tree shaking.
Как работает Tree shaking в Webpack
Допустим, у вас есть такой код:
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.
Рассмотрим случаи интереснее — когда Tree shaking не работает.
Когда 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, то 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, а в Babel 6 до сих пор ничего не удаляется.
Правило: если у вас где-то есть сайд-эффект, то UglifyJS ничего не сделает.
Подведем итоги:
- Tree shaking не работает для большинства библиотек из npm, потому что они все из CommonJS и собираются старым Babel’ем.
- Скорее всего, Tree shaking будет адекватно работать для тех библиотек, которые уже к этому подготовлены, например, Lodash-es, Date-fns и ваш код или библиотеки.
- В сборке участвует UglifyJS.
- Используются ES6-модули.
- Нет сайд-эффектов.
Мы разобрались, как уменьшить вес бандла, а теперь давайте научим его загружать только необходимый функционал.
Загружаем только нужный функционал
Эту часть разобьем на две. В первой части загружается только код, который требует пользователь: если пользователь заходит на главную страницу вашего сайта, он не загружает страницы личного кабинета. Во второй, правки в коде приводят к минимально возможной перезагрузке ресурсов.
Загружаем только необходимый код
Рассмотрим структуру воображаемого приложения. В нем есть:
- Entry-point — APP.
- Три страницы: главная, поиск и карточка.
Первая проблема, которую мы хотим решить — это вынесение общего кода. Обозначим красным квадратиком общий код для всех страниц, зеленым кружком — для главной и страницы поиска. Остальные фигуры не особо важны.
Когда пользователь придет на поиск с главной страницы, он будет перезагружать и квадратик, и кружок второй раз, хотя они у него уже есть. В идеале мы хотели бы видеть примерно это.
Хорошо, что в Webpack 4 уже есть встроенный плагин, который это делает за нас — SplitChunksPlugin. Плагин выносит код приложения или код node modules, который используется несколькими чанками в отдельный чанк, при этом гарантирует, что чанк с общим кодом будет больше 30 Kb, а для загрузки страницы требуется загрузить не больше 5 чанков. Стратегия оптимальна: слишком маленькие чанки загружать невыгодно, а загрузка слишком большого количества чанков — долго и не так эффективно, как загрузка меньшего количества чанков даже на http2. Чтобы повторить такое поведение на 2 или 3 версии Webpack, приходилось писать 20–30 строк с не документированными фичами. Сейчас это решается одной строкой.
Вынос CSS
Было бы прекрасно, если бы мы еще вынесли CSS для каждого чанка в отдельный файл. Для этого есть готовое решение — Mini-Css-Extract-Plugin. Плагин появился только в Webpack 4, а до него не было адекватных решений для такой задачи — только хаки, боль и простреленные ноги. Плагин выносит CSS из асинхронных чанков и создан специально для этой задачи, которую выполняет идеально.
Минимально возможная перезагрузка ресурсов
Разберемся, как бы нам сделать так, чтобы при релизе, например, нового промо-блока на главной странице пользователь перезагружал бы минимально возможную часть кода.
Если бы у нас было версионирование — всё было бы хорошо. Вот у нас главная страница версии N, а после релиза промо-блока — версии N+1. Webpack предоставляет подобный механизм прямо из коробки с помощью хэширования. После того, как Webpack соберет все ассеты, — в данном случае app.js, — то посчитает его контент-хэш, и добавит его к имени файла, чтобы получилось app.[hash].js. Это и есть версионирование, которое нам нужно.
Давайте теперь проверим как это работает. Включим хэши, внесем правки на главной странице, и посмотрим — действительно ли изменился код только главной страницы.Мы увидим, что изменились два файла: main и app.js.
Почему так произошло, ведь это нелогично? Чтобы понять почему, давайте разберем app.js. Он состоит из трех частей:
- код приложения;
- webpack runtime;
- ссылки на асинхронные чанки.
Когда мы меняем код в main, меняется его контент и хэш, а значит, в app меняется и ссылка на него. Сам app тоже поменяется и его нужно перезагрузить. Решение этой проблемы — разделить app.js на два чанка: код приложения и webpack runtime и ссылки на асинхронные чанки. Webpack 4 делает все за нас одной опцией runtimeChunk, которая весит очень мало — меньше 2 Кбайта в gzip. Перезагрузить его для пользователя — практически ничего не стоит. RuntimeChunk включается всего одной опцией:
optimization: {
runtimeChunk: true
}
В Webpack 3 и 2 мы бы написали 5-6 строк, вместо одной. Это не сильно больше, но все равно лишнее неудобство.
Все здорово, мы научились выносить ссылки и рантайм! Давайте напишем новый модуль в main, зарелизим, и — оп! — теперь вообще все перезагружается.
Почему так? Давайте разберемся, как работают модули в webpack.
Модули в 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.
Параллелизация сборки
Первое, что мы сделали — распараллелили сборку. Для этого у нас есть:
- HappyPackPlugin, который оборачивает ваши лоадеры в другие лоадеры, и выносит все вычисления, которые обернули, в отдельные процессы. Это позволяет, например, распараллелить Babel и node-sass.
- thread-loader. Выполняет примерно то же, что и HappyPackPlugin, только использует не процессы, а thread pool. Переключение на отдельный тред — затратная операция, используйте осторожно, и только если хотите обернуть ресурсоемкие и тяжелые операции, типа babel или node-sass. Для загрузки json, например, параллелизация не нужна, потому что он грузится быстро.
- В используемых вами плагинах и лоадерах, скорее всего, уже есть встроенные инструменты параллелизации — стоит только посмотреть. Например, эта опция есть в UglifyJS.
Кэширование результатов сборки
Кэшировать результаты сборки — наиболее эффективный способ ускорения сборки Webpack.
Первое решение, которое у нас есть — cache-loader. Это лоадер, который встает в цепочку лоадеров и сохраняет на файловую систему результат сборки конкретного файла для конкретной цепочки лоадеров. При следующей сборке бандла, если этот файл есть на файловой системе и уже обрабатывался с этой цепочкой, cache-loader возьмет результаты и не будет вызывать те лоадеры, которые стоят за ними, например, Babel-loader или node-sass.
На графике представлено время сборки. Синий столбик — 100% время сборки, без cache-loader, а с ним — на 7% медленнее. Это происходит потому что cache-loader тратит дополнительное время на сохранение кэшей на файловую систему. Уже на а второй сборке мы получили ощутимый профит — сборка прошла в 2 раза быстрее.
Второе решение более навороченное — HardSourcePlugin. Основное отличие: cache-loader — это просто лоадер, который может оперировать только в цепочке лоадеров кодом или файлами, а HardSourcePlugin имеет почти полный доступ к экосистеме Webpack, умеет оперировать другими плагинами и лоадерами, и сам немного расширяет экосистему для кэширования. На графике выше видно, что на первом запуске время сборки увеличилось на 37%, но ко второму запуску со всеми кэшами мы ускорились в 5 раз.
Самое приятное, что можно использовать оба решения вместе, что мы в N1.RU и делаем. Будьте осторожны, потому что с кэшами есть проблемы, о которых я расскажу чуть позже.
В уже используемых вами плагинах/лоадерах могут быть встроенные механизмы кэширования. Например, в babel-loader очень эффективная система кэширования, но почему-то по умолчанию она выключена. Такой же функционал есть в awesome-typeScript-loader. В UglifyJS плагине тоже есть кэширование, которое замечательно работает. Нас он ускорил на несколько минут.
А теперь проблемы.
Проблемы кэширования
- Кэш может неправильно валидироваться.
- Примененные решения могут не работать с подключенными плагинами, лоадерами, вашим кодом или друг с другом. В этом плане cache-loader — простое и беспроблемное решение. А вот с HardSourcePlugin нужно быть внимательнее.
- Сложно дебажить, если всё сломалось. Когда кэширование сработает неправильно и произойдет непонятная ошибка, будет очень сложно разобраться, в чем же проблема.
На чем сэкономить в production?
Последний способ ускорить какой-либо процесс — не делать какие-то части процесса. Давайте подумаем, на чем можно сэкономить в production? Что мы можем не делать? Ответ короткий — мы ничего не можем не делать! Мы не вправе отказаться от чего-то в production, но можем хорошо сэкономить в dev.
На чем экономить:
- Не собирать source map, пока они нам не понадобятся.
- Использовать style-loader вместо крутой схемы с выносом css и с обработкой через css-лоадеры. Style-loader сам по себе очень быстрый, потому что он берет строчку css и загоняет ее в функцию, которая вставляет эту строчку в тэг style.
- Можно оставить в browserlist только используемый конкретно вами браузер — скорее всего это last chrome. Это позволит сильно ускориться.
- Полностью отказаться от какой-либо оптимизации ресурсов: от UglifyJS, css-nano, gzip/brotli.
Ускорение сборки — это параллелизация, кэширование и отказ от вычислений. Выполнив эти три простых шага, вы можете ускориться очень сильно.
Как конфигурировать Webpack?
Мы разобрались, как собрать бандл мечты и как собрать его быстро, а теперь разберемся как сконфигурировать Webpack, чтобы при этом не стрелять себе в ногу каждый раз при изменении конфига.
Эволюция конфига в проекте
Типичный путь webpack-конфиг в проекте начинается с простого конфига. Сначала вы просто вставляете Webpack, Babel-loader, sass-loader и все хорошо. Потом, неожиданно, появляются какие-то условия на process.env, и вы вставляете условия. Одно, второе, третье, все больше и больше, пока не добавляется условие с «магической» опцией. Вы понимаете, что все уже совсем плохо, и лучше просто продублировать конфиги для dev и production, и сделать правки два раза. Все будет понятнее. Если у вас мелькнула мысль: «Что-то здесь не так?», то единственный работающий совет — держать конфиг в порядке. Расскажу, как мы это делаем.
Держать конфиг в порядке
Мы используем пакет webpack-merge. Это npm-пакет, который создан, чтобы объединять несколько конфигов в один. Если вас не устраивает стратегия объединения по умолчанию, то можно кастомизировать.
Структура проекта с конфигом
У нас есть 4 основные папки:
- Loaders.
- Plugins.
- Presets.
- Parts.
Расскажу про каждую отдельно.
Plugin/Loader
Это папки, которые содержат файлы для каждого лоадера и плагина, с подробной документацией и более человеческим 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 будет громоздкой и на каждый лоадер будет по файлу.
Preset
Это набор опций webpack. Они отвечают за одну функциональность, при этом оперируют лоадерами и плагинами, которые мы уже описали, и настройками webpack, которые у него есть. Самый простой пример — это пресет, который говорит, как правильно загружать scss-файлы:
{
test: /\.scss$/,
use: [cssLoader, postCssLoader, scssLoader]
}
Он использует уже преподготовленные лоадеры.
Part
Части — это то, что уже лежит в самом приложении. Они настраивают точку входа и выхода вашего приложения, и могут регулировать или подключать специфичные плагины, лоадеры и опции. Типичный пример, где мы объявляем точку входа и выхода:
entry: {
app: ’./src/Frontend/app.js’
},
output: {
publicPath: ’/static/cabinet/app/’,
path: path.resolve(’www/static/app’)
},
В своей практике мы используем:
- Базовый пресет, в котором описываем, как загружать шаблоны, json, какие плагины нужно использовать всегда, например, splitChunks.
- Пресет для dev, где описано, как правильно загружать js/css и плагины на оптимизацию
- Part, который описывает output, publicPath, entry-point и некоторые специфичные правила, например, как отдельно переписываются source map.
Webpack-merge просто выдает нам готовый конфиг. С этим подходом у нас всегда есть документация к конфигурации, в которой достаточно просто разобраться. С webpack-merge мы не лазим по 3-7 конфигам, чтобы поправить везде Babel-loader, потому что у нас есть консистентная конфигурация отдельных частей по всему проекту. А еще интуитивно понятно, где делать правку.
Управление конфигом
Подведем итоги. Используйте готовые инструменты, а не стройте велосипеды. Документируйте решения, потому что webpack конфиги правятся редко и разными людьми — поэтому документация там очень важна. Разделяйте и переиспользуйте то, что пишете.
Теперь вы знаете, как собирать бандл мечты!
Это доклад — один из лучших на Frontend Conf. Понравилось, и хотите больше — подпишитесь на рассылку, в которой мы собираем новые материалы и даем доступ к видео, и приходите на Frontend Conf РИТ++ в мае.
Хотите рассказать миру что-то крутое по фронтенду из своего опыта? Подавайте доклады на FrontenConf РИТ++, который пройдет 27 и 28 мая в Сколково. Присылайте тезисы до 27 марта, а до 15 апреля ПК примет решение о включении доклада в программу конференции. Мы ждем ваш опыт — откликайтесь!