В поисках лучшей версии EcmaScript для сборки
Как оказалось, выбор версии ES для сборки веб-приложения, а также организация самой этой сборки, может оказаться весьма сложной задачей. Особенно, если вы собираетесь делать этот выбор, основываясь исключительно на доказательной базе. В этой статье я постараюсь ответить на следующие вопросы, возникшие в ходе моего расследования на эту тему:
Как влияет компиляция кода под ES5 на производительность сайта?
Какой инструмент генерирует самый производительный код — TypeScript Compiler, Babel или SWC?
Влияет ли современный синтаксис на скорость чтения браузером JavaScript кода?
Можно ли добиться реального уменьшения объёма бандла с учетом использования Brotli или GZIP, если компилировать код в более высокой версии ES?
Действительно ли нужно собирать сайты под ES5 в 2023 году?
А также как мы реализовали переход на более высокую версию ES, и как изменились наши метрики.
Для ответа на вопросы 1–3 я даже создал полноценный бенчмарк, а четвертый вопрос я решил проверить на нашем реальном проекте с большой кодовой базой.
Оглавление
Компилировать под ES5 плохо?
Итак, начнём. Ежегодно добавляемые в EcmaScript фичи помогают разработчикам все больше сокращать кодовую базу проектов и все сильнее повышать читаемость кода. Настроив процесс сборки своего продукта, настроив компиляцию, а также добавив полифилы, разработчики получают возможность использовать самую свежую версию ES в исходном коде.
А для тех, кто позабыл, почему необходимо настраивать сборку, я кратко напомню. Условная функция Array.prototype.at
появилась только в ES2022, и какой-нибудь Chrome версии ниже 92 о существовании такой функции не знает. Следовательно, если вы будете её использовать и об обеспечении обратной совместимости не подумаете, все пользователи старых версий Chrome не смогут в полной мере пользоваться вашим сайтом.
Пример организации обратной совместимости
Обеспечение обратной совместимости может достигаться двумя способами. Во-первых, вы можете добавить полифилы.
// После добавления этих импортов
import "core-js/modules/es.array.at.js";
import "core-js/modules/es.array.find.js";
// Вы можете без страха использовать эти функции
[1, 2, 3].at(-1);
[1, 2, 3].find(it => it > 2);
А во-вторых, вы можете использовать компилятор, который превратит код современного синтаксиса, в код, поддерживаемый старыми браузерами.
// Например, такой код
const sum = (a, b) => a + b;
// При помощи Babel или другого компилятора можно превратить в такой
var sum = function sum(a, b) {
return a + b;
};
Что ж, необходимость той самой организации обратной совместимости мне никогда особо не нравилась. Ведь она подразумевает обязательную генерацию дополнительного кода, что в свою очередь означает увеличение размера бандла, засорение оперативной памяти, а также, возможно, снижение производительности приложения. И все это при условии того, что большинство (по крайней мере в нашем случае) клиентов имеют относительно свежую версию браузера, а значит для них процесс организации обратной совместимости может быть потенциально деструктивным.
Потому мне и стало интересно ответить на вопросы, которые я указал еще в начале статьи. Свое исследование я решил начать с создания бенчмарка. Цель: изолированная оценка производительности фич в сборках, скомпилированных под ES5 разными инструментами (TypeScript, Babel, SWC), а также в сборке без компиляции.
Эксперимент ставился только над фичами, требующих компиляции, такие как классы или асинхронные функции. Фичи, завязанные на использовании полифилов я решил не тестировать, т.к. если в браузере уже есть реализация всё того же Array.prototype.at
, полифилы стараются не вставлять вместо нее собственную реализацию.
Описание бенчмарка: тест скорости парсинга и производительности
Как я и написал выше, я собираюсь оценить каждый возможный сборщик в отдельности, т.к. результаты генерации кода одного сборщика могут отличаться от результатов другого. Поэтому в бенчмарке для проверки каждой фичи я создал сборки, собранные при помощи TypeScript, SWC и Babel. Вы можете возразить, что неплохо было бы проверить ещё ESBuild, но на момент написания статьи он генерировать код стандарта ES5 был не способен, поэтому его я не рассматривал.
Пример разницы генерируемого кода
// Этот код
const sum = (a = 0, b = 0) => a + b;
// Babel скомпилирует в такой код
var sum = function sum() {
var a = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0;
var b = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0;
return a + b;
};
// А TypeScript в такой
var sum = function (a, b) {
if (a === void 0) { a = 0; }
if (b === void 0) { b = 0; }
return a + b;
};
Помимо трех указанных сборок, я создал еще одну, в которой код тестируемой фичи оставался нетронутым. Её я далее по тексту буду называть современной.
Мне так же было интересно проверить, как работают разные фичи в разных браузерах. Ведь браузеры могут иметь разные движки или хотя бы разный набор оптимизаций. А значит и результаты бенчмарка потенциально могут отличаться от одного браузера к другому. И как раз для автоматизации сбора метрик в разных браузерах я создал небольшой HTTP сервер на NodeJS.
Каждый тест подразумевает запуск сгенерированного HTML файла N раз с задержкой между запусками. Каждый запуск производился в новой вкладке браузера в приватном режиме. По открытию HTML файла браузер запускает JavaScript код, а после его выполнения отправляет в HTTP сервер запрос с результатом прогона итерации теста. Таким образом я пытался получить метрики, которые бы были максимально коррелированы с метриками First Paint, Last Visual Change и другими схожими.
Визуализация работы бенчмарка
По большей части бенчмарк я создавал для определения производительности фич, но посмотреть на влияние фич на скорость парсинга мне тоже было интересно. Поэтому для оценки скорости парсинга я создал 4 дополнительные сборки, в которых по большей части просто размножил код из сборок для измерения производительности. А далее я просто замерял, сколько нужно времени браузеру, чтобы прочитать содержимое элемента script
.
Результаты бенчмарка: не все так однозначно
Мы постепенно подошли к секции с результатами. В ней я для каждой версии стандарта ES, а также для каждой синтаксической фичи составил график. В каждом графике показывается скорость выполнения кода для каждой из сборок в каждом из браузеров. Самая длинная линия на графике означает, что сборка отработала быстрее всего.
Будьте осторожны — теста и графиков в этом блоке получилось много!
Оценка производительности ES фич
ES2015 (ES6)
Стрелочные функции. Как оказалось, разница в скорости вызова обычной и стрелочной функций действительно есть. Правда, наблюдается она только в Chrome, Opera и других V8 браузерах. В них стрелочные функции работают на 15% медленнее. По всей видимости в этих браузерах контролировать контекст, в котором функция была создана, сложнее, чем использовать собственный контекст для каждой функции.
Исходный код теста.
Классы. В этом тесте видна огромная пропасть в результатах у разных компиляторов. Использование современной и TypeScript конфигураций показали более быстрые результаты. В основном, современная конфигурация показывает себя производительнее всех, однако Safari лучше отработал с TypeScript. Babel и SWC же сгенерировали код в 2–3 раза медленнее.
Исходный код теста.
В тесте использования параметров по умолчанию итоги абсолютно противоположные. SWC и Babel показывают схожие результаты и отрабатывают быстрее всего. Самой медленной оказалась сборка от TypeScript. Современная же недалеко ушла от TypeScript, но все же показывает себя немножко эффективнее.
Исходный код теста.
Итерирование при помощи конструкции for … of. Снова все рекорды бьёт TypeScript. Далее идут современная сборка, SWC и в конце находится Babel.
Исходный код теста.
Генераторы. Среди сборщиков Babel показал самый быстрый результат. С современной сборкой не все так однозначно. В Safari она показала себя эффективнее, чем Babel. Но при этом в Firefox она же является самой медленной. По всей видимости, разработчики Firefox не особо думали об оптимизации работы генераторов. Но если не брать в расчет этот браузер, то я бы сказал, что современная сборка делит первое место с Babel, а SWC и TypeScript вместе стоят на втором.
Исходный код теста.
В тесте использования вычисляемых свойств объектов ситуация тоже неоднозначная. В целом, TypeScript и современная сборки являются самыми производительными, в Firefox и Safari первенство у TS, в V8 браузерах у современной. Судя по графику Babel оказался самым медленным, но, думаю, это произошло вследствие некоторого сайд эффекта, и в реальном проекте результаты SWC и Babel были бы одинаковы.
Исходный код теста.
Крайне однозначные итоги вышли в тесте использования rest параметра. Самая производительная конфигурация — современная, самая медленная — TypeScript.
Исходный код теста.
Spread оператор. Однозначно быстрее себя показала современная сборка. В Chrome и Opera разница составила аж 4 раза. Остальные же конфигурации показали себя примерно на одном уровне, однако в Firefox TypeScript отработал слегка медленнее.
Исходный код теста.
Шаблонные строки — опять же, однозначно производительнее себя показала современная сборка. Какой-либо разницы в сборках разными инструментами нет.
Исходный код теста.
ES2016
Оператор возведения в степень. Разница настолько невелика, что заметить её сложно. Все в пределах погрешности.
Исходный код теста.
ES2017
Асинхронные функции. Современная сборка снова на первом месте. Наибольший отрыв в Safari — до 20%. Небольшая разница между другими конфигурациями наблюдается, но однозначных выводов сделать не получится — в Chrome и Opera Babel является самой медленной сборкой, а в Firefox самой быстрой.
Исходный код теста.
ES2018
Формально говоря, в этом году появилось всего 2 синтаксических фичи — rest и spread операторы в объектах. Однако, я подумал, что 2х тестов может быть недостаточно. А все потому, что в зависимости, от того, как были использованы эти операторы, разные инструменты генерируют код по разному.
Вот ссылка на песочницы выбранных сборщиков, если вы желаете посмотреть на разнообразие генерируемого кода:
Начнем с простого. Для оценки rest оператора я создал два теста — в одном я просто копирую объект, а в другом я беру из объекта несколько пропертей.
В первом случае rest оператор показал довольно интересные итоги. Браузеры будто разделились на два лагеря: Chrome и Opera оптимизированы для работы с кодом от TypeScript, затем по скорости себя лучше всего показывает современная сборка, а Babel и SWC плетутся в конце;, но в Firefox и Safari ситуация абсолютно обратная — TypeScript работает медленнее всего, а результаты по остальным сборкам почти не отличаются.
Во втором случае во все тех же Safari и Firefox современная конфигурация всех разрывает. А вот в Opera и Chrome она является самой медленной. Из сборщиков TypeScript снова оказался немного медленнее остальных сборок.
Теперь по spread оператору. Я написал 4 теста, используя spread оператор в разных конфигурациях. Но независимо от того, как я применял оператор, результаты бенчмарка оказались схожи с итогами по rest оператору — современная и TS сборки шустро работают в Safari и Firefox, но настолько же медлительно в Chrome и Opera.
Во всех тестах наблюдается примерно такая картина. Но если вам интересно посмотреть на все результаты, можете их изучить в репозитории.
ES2018 Bonus
Забавный факт, который я обнаружил, пока писал бенчмарк. Если уже посмотрели на исходный код тестов, то заметили, как я в качестве ключей использовал значения 'a' + i
. И делал я это не случайно! Ведь если в качестве ключа в объекте использовать число, то по неведомой мне причине в Chrome и Opera современная сборка начинает отрабатывать невероятно быстро. Причем не просто быстрее других сборок в этих же браузерах, но даже быстрее, чем Firefox или Safari, хотя в тестах выше они показывали свое превосходство.
Исходный код теста.
ES2019
Приватные поля в классах. Снова безоговорочная победа за современной сборкой. А TypeScript показывает неплохие результаты, не считая тестов в Safari, однако полагаться на них не стоит. TypeScript в отличии от остальных сборщиков не способен компилировать приватные переменные в ES5.
Исходный код теста.
ES2020
Оператор нулевого слияния. Снова безоговорочная победа за современной конфигурацией. А Babel показал себя хуже всего.
Исходный код теста.
Оператор опциональной последовательности. TypeScript себя показал хуже остальных сборок, а в остальном разницы нет.
Исходный код теста.
ES2021
Логические операторы. Мне было интересно проверить по отдельности как они работают, когда присваивание выполняется и когда нет.
В первом случае современная сборка показывает себя чуть хуже других сборок, а разницы между сборщиками не наблюдается.
Исходный код теста.
А во втором случае современная сборка на пару с TypeScript показывают свое превосходство над другими сборками.
Исходный код теста.
ES2022
Приватные методы в классах. Результаты такие же, как и в тесте использования классов. А ещё TypeScript все так же не способен использовать приватные модификаторы в ES5. Но в ES6 соотношение результатов остается таким же.
Исходный код теста.
Оценки скорости парсинга
Вообще тренд на повышение скорости парсинга был популярен ещё в эпоху OptimizeJS. С тех пор прошло немало времени, сам разработчик той библиотеки пометил её устаревшей, а разработчики V8 описали практики, применяемые в ней, деструктивными. Потому, сейчас фронтэнд разработчики как-то и не гоняются особо за парой выигранных миллисекунд. И я не собирался, конечно. Но все же мне было интересно, может ли использование современного синтаксиса повлиять на скорость чтения браузером JavaScript кода.
Я запустил тест и получил таки парочку интересных результатов. Например, оказалось, что Safari считывает стрелочные функции медленнее, чем обычные, несмотря на то, что файл со стрелочными функциями имеет наименьший размер.
А Firefox довольно долго обрабатывает код с приватными полями в классе. Причем забавно, что приватные методы он считывает без особых сложностей.
На этом интересные факты заканчиваются. В остальных случаях в результатах бенчмарка прослеживается четкая зависимость времени от количества символов в сгенерированном коде, что означает, что в остальных случаях парсинг современной сборки показал себя эффективнее всего. Если желаете подробно ознакомиться с результатами, вот ссылка.
Краткое резюме по бенчмарку
Весь описанный выше текст можно резюмировать тремя основными идеями.
Во-первых, современная сборка абсолютного превосходства над ES5 не имеет и нередко даже отрабатывает медленнее. Однако, она является самой быстрой в большинстве случаев.
Во-вторых, идеального инструмента для сборки самого производительного кода в ES5 нет. Как минимум из-за того, что разные браузеры имеют разные оптимизации. Но вы можете подобрать для себя наилучшее соотношение плюсов и минусов. Например, если вдруг в вашем приложении генератор генератором погоняется, Babel будет весьма очевидным выбором, а если в нем очень много классов, стоит посмотреть в сторону TypeScript.
Я бы сказал, что TypeScript часто показывает себя лучше других инструментов. Однако, меня расстраивает, что в некоторых тестах, где он хорошо себя чувствует в Safari, в Chrome он способен показать наихудший результат. Особенно учитывая тот факт, что пользователей на Chrome большинство.
И в-третьих, мы можем сделать вывод о том, что не все браузеры уделили внимание оптимизации работы с современным синтаксисом. Firefox ужасно работает с генераторами, Chrome несовершенно организовал spread в объектах, и т.п. Однако, думается мне, что если браузеры и будут заниматься подкапотными оптимизациями, с большей вероятностью они будут внимание уделять современному синтаксису. Так что кто знает, может через пару лет современная сборка станет однозначно самой быстрой.
А что по объёму файлов?
Любимая фраза разработчиков, до сих пор компилирующих под ES5 звучит так:
«Ну так, а смысл гоняться за уменьшением размера бандла? Средства сжатия всю эту разницу все равно нивелируют.»
А правы ли они в своих рассуждениях, мы с вами сейчас и узнаем.
Этот пункт я решил проверить на своем рабочем проекте, т.к. сжатие является довольно комплексным процессом, а потому проводить оценку по отдельности для каждой фичи было бы не совсем честно.
На время тестов я убрал подключение полифилов из сборки. Затем я собрал наш проект каждым из указанных инструментов, сжал их при помощи GZip и Brotli, и посчитал суммарный объём созданных чанков приложения. И вот такие результаты у меня получились:
Raw | GZip | Brotli | |
Modern | 6.58 Мб | 1.79 Мб | 1.74 Мб |
TypeScript | 7.07 Мб | 1.82 Мб | 1.86 Мб |
Babel | 7.71 Мб | 1.92 Мб | 1.86 Мб |
SWC | 7.60 Мб | 1.94 Мб | 1.86 Мб |
Вы можете удивиться тому, что на TypeScript Brotli показал результат хуже, чем у GZip. Это произошло из-за того, что я запускал Brotli с уровнем сжатия 2 (максимальный — 11). Этот уровень сжатия я решил выбрать, т.к. он максимально близок к настройкам, применяемых в Cloudflare по умолчанию, и этот CDN мы используем в нашем продукте.
И что же мы видим? Размер проекта действительно уменьшился на 7–15%, что в сыром, что в сжатом состоянии. И тут уж как посмотреть — для кого-то такая разница будет незначительной, а кому-то, наоборот, покажется существенной. Для себя в компании мы решили, что это разница достаточно велика, чтобы попытаться прикрутить более современную сборку на прод.
Выходит, современная сборка получает ещё одну победу.
Ну и ещё вместе с этим, в таблице видно, как TypeScript показывает свое превосходство в плане объема генерируемого кода над другими библиотеками.
Так ли важны 4%?
Из всего описанного выше можно сделать простой вывод. Пользователи получат более приятный UX, если ваш продукт будет скомпилирован в более высокой версии ES. Ваше веб-приложение станет более производительным, а также станет меньше весить.
Однако, вместе с этим нужно понимать, что по данным Browserslist поддержка ES2015 на данный момент есть только у 96% пользователей по всему миру, ES2017 у 95%, а у более высоких версий поддержка ещё ниже.
Поэтому вывод можно сделать такой:
Всякие ситуации бывают, и если вам не так уж важны эти 4% пользователей с устаревшими браузерами, то логичнее будет собирать сайт в свежей версии ES. Например, в ES2018.
Если все же они важны, но у вас не очень большой проект, или вам не сильно важен прирост в качественных метриках, можете собираться под ES5. Производительность от этого не пострадает критическим образом.
Но если для вас важны и пользователи с устаревшими браузерами, и даже легкий прирост в производительности, вам стоит задуматься над созданием двух сборок — современной и ES5 — и продумать то, как доставлять пользователю нужную сборку. Именно так мы и поступили в нашей компании.
Наш опыт использования современной сборки
Вообще идея о разделении сбороĸ в нашем продуĸте появилась задолго до моего появлении в ĸомпании Mayflower, я просто немного её развил. Сейчас мы собираем наше приложение дважды — одна сборка у нас собирается в формате ES5 со всеми требуемыми полифилами, и ещё одна в формате ES2018 с весьма ограниченным набором полифилов.
К вопросу о том, почему остановились на ES2018. Чем выше мы рассматривали версию стандарта, тем меньше чувствовалась разница между сборками разных версий. ES2018 мы выбрали, как некую грань, при которой и 95% пользователей получат быстрый сайт, и при которой будут по максимуму использоваться преимущества современной сборки. Приватных полей в классе мы не держим, так что единственное, в чем будет разница между ES2018 и ES2022 — это потеря в производительности при использовании оператора нулевого слияния и, возможно, логического оператора. Но уж как-нибудь переживем эту потерю.
А теперь о том, как мы это реализовали. Специально для этой статьи я решил создать ещё один репозиторий, в котором показывается, как может быть организована сборка приложения с учетом разделения сборок. В нем я реализовал упрощенную реализацию нашей сборки. Однако, в ней все равно видно как можно организовать не только разделение сборок JavaScript кода, но так же и CSS. Если открыть инструменты разработчика в собранном сайте, видно, что даже на этом небольшом проекте, можно получить сокращение файлов на 120 Кб, что составило в моем случае 30%. Вы можете потрогать ручками деплой сборки из этого репозитория по этой ссылке.
А если вы не хотите смотреть в репозиторий, то я опишу вкратце, каким образом мы определяем на стороне клиента, какую же сборку нужно скачивать. Мы просто проверяем способность браузера к обработке асинхронных функций, а также наличие нескольких полифилов. А затем по флагу window.LEGACY
мы добавляем в head документа скрипт с нужным адресом.
try {
// Polyfills check
if (
!('IntersectionObserver' in window) ||
!('Promise' in window) ||
!('fetch' in window) ||
!('finally' in Promise.prototype)
) {
throw {};
}
// Syntax check
eval('const a = async ({ ...rest } = {}) => rest; let b = class {};');
window.LEGACY = false;
} catch (e) {
window.LEGACY = true;
}
Реальные метрики
Метрики в вакууме — это, конечно, хорошо, но что по реальным метрикам? В конечном итоге мы таки выкатили жесткое разграничение сборок на ES5 и ES2018 на прод. И вот такую разницу в метриках Sitespeed.io мы получили на разных сборках:
First Paint — на 13% быстрее
Page Load Time — на 13% быстрее
Last Visual Change — на 8% быстрее
Cumulative Layout Shift — на 42% меньше
Total blocking time — на 13% меньше
Speed Index — на 9% быстрее
По большей части эта разница была достигнута за счет меньшего размера скачиваемых файлов. Но в любом случае, переход на ES2018 смог немного повлиять на метрики в лучшую сторону. И самое приятное, что этот выигрыш был получен, почти не трогая исходный код.
Конец
Спасибо за уделенное время. Надеюсь вам, как и мне, было интересно узнать и про производительность, скорость парсинга, и полученные метрики.
Крайне рекомендую посмотреть на репозиторий бенчмарка, о котором я говорил в своей статье. Там помимо приложенных графиков в статье есть ещё «усатые» диаграммы. А ещё в статье я описал не все выводы, которые я получил в своем бенчмарке. Например, мне так же было интересно посмотреть, есть ли разница в производительности браузеров в зависимости от архитектуры и операционной системы. Поэтому я запустил его не только на MacOS, но так же на Windows и Android. И там же я, например, проверял заверения Microsoft об их самом быстром браузере, сравнивая Edge и Chrome.
Так же я ещё раз дам ссылку на репозиторий с примером по организации разделения сборок не только JavaScript, но и CSS кода. И в добавок к ней ссылку на GH pages с деплоем этой сборки.
И на этом все. Пишите свои мысли в комментариях, задавайте вопросы. Пока.