Всем привет, меня зовут Дима. Я Frontend Developer в Mayflower. А недавно я узнал, что выбор версии ES для сборки веб-приложения, как и организация самой этой сборки, может оказаться непростой задачей. Особенно, если вы хотите сделать этот выбор, основываясь исключительно на доказательствах. В этой статье я коснусь следующих моментов по этой теме:

  1. Как компиляция кода для ES5 влияет на производительность сайта?
  2. Какой инструмент генерирует наиболее эффективный код — компилятор TypeScript, Babel или SWC?
  3. Влияет ли современный синтаксис на скорость разбора кода JavaScript браузером?
  4. Можно ли добиться реального уменьшения размера бандла с учетом использования Brotli или GZIP, если компилировать код в более высокой версии ES?
  5. Так ли уж необходимо создавать сайты на ES5 в 2023 году?
  6. А также как мы реализовали переход на более высокую версию ES, и как изменились наши метрики.

Для ответа на вопросы 1-3 я даже создал полноценный бенчмарк, а четвертый вопрос решил протестировать на нашем реальном проекте с большой кодовой базой.

Компиляция в ES5 — это плохо?

Функции ECMAScript обновляются каждый год, и это действительно помогает разработчикам сократить кодовую базу проектов и повысить читабельность кода. А чтобы получить возможность использовать последнюю версию ES в исходном коде, разработчикам достаточно настроить процесс сборки — настроить компиляцию, а также добавить несколько полифайлов.

Небольшое напоминание для тех, кто забыл, зачем нужно настраивать сборку. Например, функция Array.prototype.at появилась только в ES2022, а Chrome версии ниже 92 не знает о существовании такой функции. Поэтому, если вы его используете, но не подумали об обеспечении обратной совместимости, все пользователи старых версий Chrome не смогут полноценно пользоваться вашим сайтом.

Позвольте мне привести вам пару коротких примеров по обратной совместимости. Во-первых, вы можете добавить полифиллы.

// After adding such polyfills
import "core-js/modules/es.array.at.js";
import "core-js/modules/es.array.find.js";

// You can safely you these functions
[1, 2, 3].at(-1);
[1, 2, 3].find(it => it > 2);

А во-вторых, вы можете использовать компилятор, который преобразует современный синтаксический код в код, поддерживаемый старыми браузерами:

// For example, this code
const sum = (a, b) => a + b;

// Using Babel or any other compiler can be converted into this code
var sum = function sum(a, b) {
  return a + b;
};

Ну, мне никогда не нравилась эта обратная совместимость. Ведь это подразумевает обязательную генерацию дополнительного кода, что в свою очередь приводит к увеличению размера бандла, засорению оперативной памяти и, возможно, снижению производительности приложения. И все это при условии, что у большинства (по крайней мере, в нашем случае) клиентов установлена ​​относительно свежая версия браузера, а значит, для них обратная совместимость может быть потенциально разрушительной.

Именно поэтому мне стало интересно ответить на вопросы, которые я указал в начале статьи. Я решил начать свое исследование с создания бенчмарка. Его назначение: изолированная оценка производительности фич в сборках, скомпилированных для ES5 разными инструментами (TypeScript, Babel, SWC), а также в сборке без компиляции.

Эксперимент проводился только с функциями, требующими компиляции, такими как классы или асинхронные функции. Я решил не тестировать фичи в зависимости от использования полифайлов, потому что если в браузерах уже есть реализация конкретной фичи, то полифайлы стараются не вставлять вместо нее собственную реализацию.

Описание бенчмарка: тест скорости парсинга и производительности

Как я писал выше, я буду оценивать каждый возможный компилятор отдельно, потому что результаты кодогенерации каждого компилятора могут отличаться. Поэтому в бенчмарке для проверки каждой фичи я создал бандлы, скомпилированные с помощью TypeScript, SWC и Babel. Вы можете возразить, что неплохо было бы проверить и ESBuild, но на момент написания статьи он не был способен генерировать код ES5, поэтому я его не рассматривал.

Пример разницы сгенерированного кода:

// Such code
const sum = (a = 0, b = 0) => a + b;

// Babel will compile into this
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;
};

// And TypeScript into this
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% медленнее. Судя по всему, в этих браузерах контролировать контекст, в котором была создана функция, сложнее, чем использовать свой контекст для каждой функции.

Проверить исходный код.

Классы. В этом тесте огромный разрыв в результатах разных компиляторов. Конфигурации Modern и TypeScript показали значительно более быстрые результаты. В принципе, современная конфигурация показала себя наиболее производительной, за исключением того, что Safari лучше работал с TypeScript. Babel и SWC генерировали код в 2–3 раза медленнее.

Проверить исходный код.

В тесте с использованием параметров по умолчанию результаты абсолютно противоположные. SWC и Babel показывают схожие результаты и работают быстрее всех. Самой медленной была сборка TypeScript. Современный не далеко ушел от TypeScript, но все же показывает себя чуть эффективнее.

Проверить исходный код.

Итерация с использованием for .. конструкции. TypeScript снова бьет все рекорды. Далее идет современная сборка, SWC и в конце Babel.

Проверить исходный код.

Генераторы. Babel показал самый быстрый результат среди компиляторов. С современной сборкой не все так однозначно. Safari оказался более эффективным, чем Babel. Но при этом в Firefox он еще и самый медленный. Судя по всему, разработчики Firefox не особо задумывались над оптимизацией генераторов. Но если не брать в расчет этот браузер, то я бы сказал, что современная сборка делит первое место с Babel, а SWC и TypeScript вместе стоят на втором.

Проверить исходный код.

В тесте использования расширенных литералов объектов ситуация также неоднозначна. В целом, TypeScript и современные сборки самые производительные, в Firefox и Safari TS — тот, кто имеет приоритет, в браузерах V8 — современный. Согласно графику, Babel оказался самым медленным, но я думаю, это было связано с каким-то побочным эффектом, и в реальном проекте результаты SWC и Babel были бы одинаковыми.

Проверить исходный код.

Крайне однозначные результаты получились в тесте с использованием остальных параметров. Самая производительная конфигурация — современная, самая медленная — TypeScript.

Проверить исходный код.

Оператор распространения. Однозначно современная сборка показала себя быстрее всех. В Хроме и Опере разница была аж в 4 раза. Остальные конфигурации показали себя примерно на том же уровне, но в Firefox TypeScript работал чуть медленнее.

Проверить исходный код.

Шаблонные строки — повторюсь, современная сборка однозначно показала себя более продуктивной. Разницы в сборках с разными инструментами нет.

Проверить исходный код.

ES2016

Оператор возведения в степень. Абсолютно никакой разницы, все в пределах погрешности.

Проверить исходный код.

ES2017

Асинхронные функции. Современная сборка снова на первом месте. Самая большая маржа в Safari — она до 20%. Небольшая разница между другими конфигурациями есть, но однозначных выводов сделать не получится — в Chrome и Opera Babel самая медленная сборка, а в Firefox самая быстрая.

Проверить исходный код.

ES2018

Формально говоря, в этом году появились только две синтаксические особенности — операторы rest и spread в объектах. Однако я подумал, что 2 тестов может быть недостаточно. А все потому, что в зависимости от того, как использовались эти операторы, разные инструменты генерируют код по-разному.

Вот ссылка на песочницы выбранных ассемблеров, если хотите посмотреть на разнообразие генерируемого кода:

Начнем с простого. Для оценки оператора rest я создал 2 теста — в одном я просто копирую объект, а в другом беру у объекта несколько свойств.

В первом случае оператор rest показал довольно интересные результаты. Браузеры как бы делятся на 2 лагеря: Chrome и Opera оптимизированы для работы с TypeScript-кодом, далее современная сборка показывает себя лучше всего по скорости, а Babel и SWC плетутся в конце; а вот в Firefox и Safari ситуация абсолютно обратная — TypeScript работает медленнее всех, а результаты по остальным билдам практически одинаковы.

Во втором случае, во всё тех же Safari и Firefox современная конфигурация выигрывает у всех. А вот в Опере и Хроме он самый медленный. Из компиляторов TypeScript опять оказался немного медленнее остальных сборок.

Теперь поговорим об операторе спреда. Я написал 4 теста с использованием оператора распространения в разных конфигурациях. Но независимо от того, как я использовал оператор, результаты бенчмарка оказались схожими с результатами для остальных операторов — современные и TS-сборки работают быстро в Safari и Firefox, но столь же медленно в Chrome и Opera.

Во всех тестах примерно такая картина. Но если вам интересно посмотреть на все результаты, вы можете изучить их в репозитории.

Бонус ES2018

Забавный факт, который я обнаружил при написании бенчмарка. Если вы уже смотрели исходный код тестов, то заметили, как я использовал значения 'a' + i в качестве ключей. И я сделал это нарочно! Потому что, как оказалось, если в качестве ключа в объекте использовать число, то по неизвестной мне причине в Хроме и Опере современная сборка начинает работать невероятно быстро. И не просто быстрее, чем другие сборки в тех же браузерах, а даже быстрее, чем Firefox или Safari, хотя они показали свое превосходство в тестах выше.

Проверить исходный код.

ES2019

Закрытые поля в классах. Опять безоговорочная победа современной сборки. И TypeScript показывает хорошие результаты, если не считать тестов в Safari. Но в любом случае полагаться на них не стоит — TypeScript, в отличие от других ассемблеров, не умеет компилировать приватные переменные в ES5.

Проверить исходный код.

ES2020

Нулевой оператор объединения. И снова безоговорочная победа за современной конфигурацией. И Бабель оказался худшим.

Проверить исходный код.

Необязательный оператор цепочки. TypeScript показал себя хуже других сборок, но в остальном разницы нет.

Проверить исходный код.

ES2021

Логические операторы. Мне было интересно проверить по отдельности, как они работают, когда применяется присваивание, а когда нет.

В первом случае современная сборка показывает себя чуть менее производительно в Chrome, но более продуктивно в Safari. Разницы между коллекторами нет.

Проверить исходный код.

А во втором случае современная сборка в паре с TypeScript показывает свое превосходство над другими сборками.

Проверить исходный код.

ES2022

Закрытые методы в классах. Результаты такие же, как и в тесте использования класса. А TypeScript по-прежнему не умеет использовать приватные модификаторы в ES5. Но в ES6 соотношение результатов осталось прежним.

Проверить исходный код.

Оценка скорости парсинга

Вообще тренд на увеличение скорости парсинга был популярен еще во времена OptimizeJS. С тех пор прошло много времени, сам разработчик этой библиотеки пометил ее как устаревшую, а разработчики V8 охарактеризовали применяемые в ней наработки как деструктивные. Поэтому сейчас фронтенд-разработчики как-то не гонятся, особенно за выигранной парой миллисекунд. И я тоже не собирался, конечно. Но все же мне было интересно, может ли использование современного синтаксиса повлиять на скорость чтения кода JavaScript браузером.

Я провел тест и получил несколько интересных результатов. Например, оказалось, что Safari медленнее читает стрелочные функции, чем обычные, несмотря на то, что файл со стрелочными функциями имеет наименьший размер.

А Firefox довольно долго обрабатывает код с приватными полями в классе. И забавно, что он без особого труда читает приватные методы.

На этом интересные факты заканчиваются. В других случаях результаты бенчмарка показывают четкую корреляцию времени и количества символов в сгенерированном коде, а значит, в остальных случаях парсинг современной сборки оказался наиболее эффективным. Если вы хотите подробно ознакомиться с результатами, вот ссылка.

Кратко о тесте

Весь текст, описанный выше, можно свести к трем основным идеям.

1. Современная сборка не имеет абсолютного превосходства над ES5 и часто даже работает медленнее. Однако в большинстве случаев он является самым быстрым.

2. Идеального инструмента для сборки максимально производительного кода в ES5 не существует. Хотя бы потому, что разные браузеры имеют разную оптимизацию. Но вы можете подобрать для себя оптимальное соотношение плюсов и минусов. Например, если вдруг в вашем приложении огромное количество генераторов, то очень очевидным выбором будет Babel, а если много классов, то стоит смотреть в сторону TypeScript.

Я бы сказал, что TypeScript часто работает лучше, чем другие инструменты. Однако меня огорчает, что в некоторых местах, где в Safari он чувствует себя хорошо, в Chrome он способен показать худший результат. Особенно с учетом того, что у большинства пользователей Chrome.

3. Можно сделать вывод, что не все браузеры уделили внимание оптимизации работы с современным синтаксисом. Firefox ужасно работает с генераторами, Chrome не до конца организовал работу распространения в объектах и ​​т.д. Однако мне кажется, что если браузеры занимаются подковерными оптимизациями, то они скорее обратят внимание на современный синтаксис. Так что кто знает, может через пару лет современная сборка точно будет самой быстрой.

А как насчет размера пакета?

Любимая фраза разработчиков, до сих пор компилирующих для ES5, звучит так:

"Ну и какой смысл гнаться за уменьшением размера бандла? Инструменты сжатия все равно нивелируют всю эту разницу».

А правы ли они в своих рассуждениях, мы сейчас и узнаем.

Я решил проверить этот момент на своем рабочем проекте, т. к. сжатие — достаточно сложный процесс, и поэтому проводить оценку отдельно по каждой фиче было бы не совсем справедливо.

Во время тестов я удалил полифайлы из сборки. Затем я скомпилировал наш проект каждым из этих инструментов, сжал их с помощью GZip и Brotli и подсчитал общий объем созданных чанков приложения. И вот какие результаты я получил.

           | Raw     | GZip    | Brotli
Modern     | 6.58 MB | 1.79 MB | 1.74 MB
TypeScript | 7.07 MB | 1.82 MB | 1.86 MB
Babel      | 7.71 MB | 1.92 MB | 1.86 MB
SWC        | 7.60 MB | 1.94 MB | 1.86 MB

Мы можем быть удивлены, что Brotli показал худшие результаты на TypeScript, чем GZip. Это произошло потому, что я запускал Brotli с уровнем сжатия 2 (максимум 11). Я решил выбрать этот уровень сжатия, потому что он максимально приближен к настройкам, используемым в Cloudflare по умолчанию.

И что мы видим? Размер проекта действительно уменьшился на 7–15%, как в сыром, так и в сжатом варианте. И здесь решение за вами. Для кого-то такая разница будет незначительной, а кому-то, наоборот, покажется существенной. Для себя мы решили, что эта разница достаточно велика, чтобы попробовать использовать современную сборку в производстве.

Получается, что современная сборка одерживает очередную победу.

Ну и вместе с этим таблица показывает, насколько TypeScript показывает свое превосходство по объему генерируемого кода над другими библиотеками.

Так ли важны 4%?

Из всего вышеописанного можно сделать простой вывод. Пользователи получат более приятный UX, если ваш продукт скомпилирован в более высокой версии ES. Ваше веб-приложение станет более производительным, а ваш пакет будет меньше.

Однако в то же время нужно понимать, что по данным Browserslist только 96% пользователей во всем мире на данный момент имеют поддержку ES2015, 95% — ES2017, а более высокие версии имеют еще меньшую поддержку.

Таким образом, вывод можно сделать следующий:

  • Если эти 4% пользователей с устаревшими браузерами для вас не так важны, то логичнее будет построить сайт на свежей версии ES. Например, в ЕС2018.
  • Если они по-прежнему важны, но у вас не очень большой проект, или вам не очень важен прирост показателей качества, можно собрать под ES5. Производительность от этого критически не пострадает.
  • Но если вам также важны пользователи с устаревшими браузерами, да еще и небольшой прирост производительности, стоит задуматься о создании двух сборок — современной и ES5 — и подумать, как доставить пользователю нужную сборку. Именно так мы и поступили в нашей компании.

Наш опыт использования современной сборки

В общем, идея разделения узлов в нашем изделии появилась задолго до моего появления в компании Mayflower, я лишь немного ее доработал. Сейчас мы дважды собираем наше приложение — одна сборка компилируется в формате ES5 со всеми необходимыми полифайлами, а другая — в формате ES2018 с очень ограниченным набором полифайлов.

На вопрос, почему мы остановились на ES2018. Чем выше мы смотрели версию стандарта, тем меньше ощущалась разница между сборками разных версий. Мы выбрали ES2018 как некую грань, при которой 95% пользователей получат быстрый сайт, и при которой будут максимально использованы преимущества современной сборки. Мы не храним приватные поля в классе, поэтому единственная разница между ES2018 и ES2022 — небольшая потеря производительности при использовании нулевого оператора объединения и, возможно, логического оператора. Мы обязательно переживем эту потерю.

А теперь о том, как мы это реализовали. Специально для этой статьи я решил создать еще один репозиторий, просто чтобы показать, как можно организовать сборку приложения с учетом разделения сборок. Там я реализовал упрощенную реализацию нашего рабочего варианта. Однако он все же показывает, как можно организовать не только разделение сборок кода JavaScript, но и CSS. Если открыть инструменты разработчика в собранном сайте, то можно увидеть, что даже на этом небольшом проекте можно получить уменьшение файлов на 120 КБ, что в моем случае составило 30%. Вы можете использовать развернутую сборку из этого репозитория по этой ссылке.

А если не хотите смотреть на репозиторий, то я вкратце опишу, как мы на стороне клиента определяем, какую сборку нужно скачать. Мы просто проверяем способность браузера обрабатывать асинхронные функции, а также наличие нескольких полифилов. А затем с помощью флага window.LEGACYдобавляем скрипт с нужным адресом в шапку документа.

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 мы получили на разных билдах:

  • Первая покраска — на 13% быстрее
  • Время загрузки страницы — на 13 % быстрее, чем
  • Последнее визуальное изменение — на 8% быстрее
  • Общее время блокировки — на 13% меньше
  • Индекс скорости — на 9% быстрее

По большей части эта разница была достигнута за счет меньшего размера загружаемых файлов. Но в любом случае переход на ES2018 может немного повлиять на метрики в лучшую сторону. И самое приятное, что этот прирост был получен почти не касаясь исходного кода.

Конец

Спасибо за ваше время. Надеюсь, вам, как и мне, было интересно узнать о производительности, скорости парсинга и полученных метриках.

Очень рекомендую заглянуть в репозиторий бенчмарков. В статье я описал выводы только по производительности сборок, но в бенчмарке хотел еще посмотреть на разницу производительности браузеров в разных ОС и архитектурах. Например, вы можете узнать, верны ли заверения Microsoft, что Edge быстрее Chrome.

Так же еще раз дам ссылку на репозиторий с примером организации разделения сборок не только JavaScript но и CSS кода. И в дополнение к ней ссылка на GH страницы с развёртыванием этой сборки.

Вот и все. Пишите свои мысли в комментариях, задавайте вопросы. Пока!