Меня зовут Илья Гольдфарб, я разработчик интерфейсов Яндекса. Мне интересно следить за тем, как развиваются инструменты для сборки фронтенда, поэтому я стараюсь изучать изменения в каждом релизе популярных решений.
В преддверии выхода пятой версии webpack я хочу рассказать о его, казалось бы, минорном релизе 4.26.0 от 19 ноября 2018 года, где неожиданно и без объявления войны изменилась версия минификатора по умолчанию. Раньше это был пакет UglifyJS, теперь же используется Terser, форк UglifyES — ветки UglifyJS, которая может сжимать и ES5, и ES6 код. Terser появился, когда основной майнтейнер отказался поддерживать и развивать UglifyES. Впрочем, UglifyJS тоже прекратил свое развитие с августа 2018 года, когда был выпущен последний релиз. В новом форке исправили некоторые баги и немного отрефакторили код.
API этих минификаторов совместимый, но результат сжатия они выдают разный. Обычно изменения подобного уровня происходят лишь в мажорных, а не минорных обновлениях. Из-за этого многие разработчики могут не обратить внимания на нововведение. Конечно, в большинстве случаев всё будет работать, но никто не хочет стать тем, кто на продакшне своего проекта получит баги из-за системы сборки и минификации.
Вся эта история подвигла меня провести маленькое личное исследование сжатия. Вот вопросы, которые я задал:
- Что лучше сжимает ES5, Terser или UglifyJS?
- Что быстрее загружается: сжатая версия ES5 от Terser или от UglifyJS?
- Какая версия весит больше: ES5 или ES6? И как на это влияет TypeScript?
- Большая ли разница между настройками по умолчанию и ручной настройкой?
- А если не webpack? Кто выдаёт сборку меньшего размера, Rollup или webpack?
Для исследования я сделал небольшое приложение на React 16, которое рендерит приложение на Vue 2, которое рендерит приложение на Angular 7, в котором есть целая одна кнопка.
Итого вышло 3 529 695 байт неминифицированного кода (720 393 байта gzip).
Что лучше сжимает ES5, Terser или UglifyJS?
Я взял последний доступный UglifyJS и идущий вместе с вебпаком Terser с опцией ES5 и использовал одинаковые настройки сжатия.
Размер в байтах | Размер в байтах (gzip) | |
UglifyJS | 1 050 376 | 285 290 |
Terser | 1 089 282 | 292 678 |
Итог: UglifyJS сжимает лучше на 3,5% (2,5% gzip).
Что быстрее загружается: сжатая версия ES5 от Terser или от UglifyJS?
Я измерял производительность с помощью стандартных DevTools Яндекс.Браузера. Загрузил страницу 12 раз и взял значение Scripting (время исполнения скрипта), отбросив первые три измерения.
UglifyJS — 221 мс (погрешность 2,8%).
Terser — 226 мс (погрешность 2,7%).
Итог: значения слишком малы для такой погрешности, можно считать их одинаковыми. Также делаем вывод, что этот метод не подходит для измерения времени загрузки.
Я не стал измерять и сравнивать скорость работы кода, поскольку разный код работает по-разному. Разработчики каждого проекта должны самостоятельно исследовать этот вопрос.
Какая версия весит больше: ES6 или ES5? И как на это влияет TypeScript?
Чтобы сравнить две версии и ориентироваться исключительно на технологии, я взял плагины Babel и сделал четыре сборки:
- ES5: все плагины, отмеченные как es2016, + плагин для Object.assign + плагины для поздних версий + экспериментальные плагины, target в tsconfig установлен в ES5;
- ES5 (ts esnext): все плагины, отмеченные как es2016, + плагин для Object.assign + все плагины для поздних версий + экспериментальные плагины, target в tsconfig установлен в esnext;
- ES6: только плагины для es2017 и поздних версий + экспериментальные плагины, target в tsconfig установлен в ES6;
- ES6 (ts esnext): только плагины для es2017 и поздних версий + экспериментальные плагины, target в tsconfig установлен в esnext.
Размер в байтах | Размер в байтах (gzip) | |
ES5 | 1 186 520 | 322 071 |
ES5 (ts esnext) | 1 089 282 | 292 678 |
ES6 | 1 087 220 | 292 232 |
ES6 (ts esnext) | 1 087 220 | 292 232 |
Итог: версия, сжатая Babel с компиляцией тайпскриптом под esnext, весит на 97 238 байт (8,2%) меньше. Так неожиданно много получилось, потому что ангуляр написан на TypeScript, а Vue и React на JavaScript Terser, как и Uglify, при сборке вебпаком не может вырезать неиспользуемый кусок кода, поставляемый из ангуляра тайпскриптом. Это баг компиляции данного примера. В сборке на другом проекте его может не быть, и разница будет гораздо меньше.
Также видно, что объём ES6 кода меньше ES5 всего на 2062 байта. На пет-проекте я получил совершенно другой результат: ES6 код на 3–6% больше, чем ES5. Это объясняется несколькими факторами, из них два основных:
1. Хелпер Babel для наследования классов вставляется один раз и потом стоит четыре байта (e(a,b)), а в ES6 используется нативное наследование ценой 15 байт (class a extends b).
2. Метод объявления переменных. В ES5 это var’ы, и они отлично сжимаются. А вот в ES6 это let и const, которые сохраняют порядок инициализации и между собой не объединяются.
Небезопасная агрессивная минификация вроде принудительных стрелочных функций или использования настройки loose поможет снизить размер ES6 кода. Будьте осторожны и учитывайте тонкости. Например, в Firefox стрелочные функции в четыре раза медленнее, чем обычные, а вот в Chromium нет никакой разницы.
Поэтому невозможно однозначно ответить на вопрос: результат сильно зависит от кода и целевой среды выполнения.
Большая ли разница между настройками по умолчанию и ручной настройкой?
Сравним, можно ли получить меньший размер файла, если немного подкрутить настройки. Например, укажем, что минификацию надо повторить пять раз. По умолчанию она проходит только один раз.
Размер в байтах | Размер в байтах (gzip) | |
Terser (по умолчанию) ES5 | 1 097 141 | 294 306 |
Terser (passes 5) ES5 | 1 089 312 | 292 408 |
Uglify (по умолчанию) ES5 | 1 091 350 | 294 845 |
Uglify (passes 5) ES5 | 1 050 363 | 284 618 |
Итог: Uglify с пятикратной минификацией меньше Uglify по умолчанию на 3,7% (3,4% gzip). Поэтому необходимо всегда докручивать настройки сжатия. Кстати, пятикратная минификация не означает, что сборка будет идти в пять раз дольше. Например, в данном тестовом проекте однократная минификация занимает 18 секунд, пятикратная — 38, а десятикратная — 49. Рекомендую опытным путём найти для своего проекта идеальное значение, после которого минификация остановится и код изменяться не будет. Обычно оно от 5 до 10. Также есть куча других опций: comments: false вырезает все комментарии о лицензиях (хотя тут юридический вопрос), а host_funs: true группирует функции в одном месте, что позволяет лучше оптимизировать var’ы. В идеале надо пробежаться по всем настройкам.
Кто выдаёт сборку меньшего размера, Rollup или webpack?
Rollup — альтернативный сборщик со встроенным механизмом tree shaking. Для теста я сделал сборку на Rollup 0.67.4 с такими же настройками, как у вебпака.
Размер в байтах | Размер в байтах (gzip) | |
Rollup ES5 (Uglify) | 990 497 | 274 105 |
Rollup ES5 (Terser) | 995 318 | 272 532 |
webpack ES5 (Uglify) | 1 050 363 | 284 618 |
webpack ES5 (Terser) | 1 089 312 | 292 408 |
Итог: результат от Rollup и Uglify на 5,6% (3,6% gzip) меньше.
Так получилось по нескольким причинам:
1. Вебпак содержит костыли для пограничных случаев. Например, этот код оборачивает каждый вызов функции из другого модуля в Object(). Это сделано, чтобы предотвратить перенос контекста для модулей без use strict в модули с use strict. Хорошо написанным проектам без сторонних зависимостей обёртка не нужна, но иногда в сборке участвует не только хорошо написанный код. И в этом плане webpack выглядит надёжнее. Роллап, в свою очередь, считает, что все модули — ES6 модули, а они всегда выполняются в use strict, так что этой проблемы для него просто не существует.
Важный вопрос — как подобные костыли из вебпака влияют на производительность. Представим, что мы написали идеальный код, которому не нужны дополнительные обёртки, но всё равно каждый вызов функций будет проходит через них. Это добавляет небольшой оверхед при исполнении: примерно одну сотую наносекунды на каждый вызов функции в Chromium (одну десятую в Firefox).
2. В вебпаке маленький по размеру бутстрап, управляющий инициализацией и загрузкой модулей. Rollup не использует обёртки, а просто скидывает в единую область видимости код всех модулей. У вебпака есть похожая оптимизация, но она работает не со всеми модулями.
Итоги исследования
Я надеюсь, что многие, прочитав статью, проверят свои системы сборки и убедятся, что применяют все возможные приёмы для наилучшего сжатия. Это быстро и несложно.
Во-первых, правильно настройте связку TypeScript и Babel. Пусть каждый компонент сборки занимается своим делом: один проверяет типы, а второй отвечает за конвертацию под устаревающие стандарты.
Во-вторых, при использовании ES5 можно сменить минификатор обратно на UglifyJS, но надо помнить, что он уже не поддерживается.
В-третьих, для сборки предпочтительнее выбирать Rollup. Правда, не во всех случаях это возможно из-за отсутствия некоторых плагинов. После сборки не забывайте проверить работоспособность функциональными тестами. Если у вас их нет — самое время начать их писать.
Автор: dumistoklus