В поисках лучшей версии EcmaScript для сборки сайта
Всем привет, меня зовут Дима! Я — Frontend Developer в Mayflower. И недавно я выяснил, что выбор версии 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 с деплоем этой сборки.
И на этом все. Пишите свои мысли в комментариях, задавайте вопросы. Пока.