Ускорение проектов на JavaScript

Картинка сгенерирована нейросетью
Картинка сгенерирована нейросетью

Многие проекты заполнены файлами, которые просто переэкспортируют другие файлы. Эти так называемые "barrel files" являются одной из основных причин замедления работы инструментов для JavaScript в больших проектах.
Представьте, что вы работаете над большим проектом с множеством файлов. Вы добавляете новый файл для работы над новой функцией и импортируете функцию из другой директории в свой код.

import { foo } from "./some/other-file"; export const someFn = () => { const result = foo(); return result; }

Воодушевленный завершением своей функциональности, вы запускаете код и понимаете, что он занимает ужасно много времени для завершения. Код, который вы написали, достаточно прост и не должен занимать столько времени. Обеспокоенный этим, вы добавляете немного кода для измерения времени выполнения вашей функции.

import { foo } from "./some/other-file"; export const someFn = () => { console.time(); const result = foo(); console.timeEnd(); return result; }

Вы снова запускаете код и, к вашему удивлению, вставленные измерения показывают, что он работает молниеносно. Вы повторяете измерительные шаги, но на этот раз вставляете операторы console.time() в основной файл вашего проекта и снова запускаете код. Но ничего не меняется - записанные измерения только подтверждают, что сам код работает очень быстро. Что происходит? Если хотите узнать, то готовьтесь, вас ожидает история о разрушительном влиянии файлов с реэкспортом на ваш код.

Сбор дополнительной информации

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

У вас возникла идея: вы помните, что некоторые пакеты npm предварительно связывают свой код для повышения производительности. Возможно, это поможет и здесь? Вы решаете проверить эту теорию и связываете свой код с помощью esbuild в один файл. Вы специально отключаете любые виды минимизации, потому что хотите, чтобы ваш код был максимально близким к исходному исходнику.

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

И тут до вас доходит: главная задача пакетизатора - сглаживание и объединение графа модулей. То, что ранее состояло из тысяч файлов, объединено в один файл благодаря esbuild. Это является сильным указателем на то, что проблема заключается в размере графа модулей. И файлы с реэкспортом являются главной причиной этого.

Barrel files - это файлы, которые только экспортируют другие файлы и сами по себе не содержат кода. Во времена, когда редакторы не предлагали автоматический импорт и другие удобства, многие разработчики старались минимизировать количество импортов, которые им приходилось писать вручную.

import { foo } from "../foo"; import { bar } from "../bar"; import { baz } from "../baz";

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

// feature/index.js export * from "./foo"; export * from "./bar"; export * from "./baz";

Предыдущие показанные импортные инструкции теперь могут быть сведены в одну строку.

import { foo, bar, baz } from "../feature";

Со временем этот паттерн распространяется по всему кодовой базе, и в каждой папке вашего проекта появляется файл index.js. Вроде бы неплохо, не так ли? Но на самом деле нет.

Всё далеко не так гладко

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

Задайте себе вопрос: что будет быстрее? Загрузка 30 тысяч файлов или только 10? Вероятно, загрузка всего 10 файлов будет быстрее.

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

// a.js globalThis.foo = 123; // b.js console.log(globalThis.foo); // => 123 // index.js import "./a"; import "./b";

Если движок не загрузит первый импорт './a', то код неожиданно выведет undefined вместо 123.

Влияние файлов с реэкспоротом на производительность

Ситуация ухудшается, когда учитываются такие инструменты, как тестовые фреймворки. В популярном тестовом фреймворке jest каждый тестовый файл выполняется в своем отдельном процессе. Это означает, что каждый тестовый файл создает граф модулей заново и должен заплатить за это время. Если построение графа модулей в проекте занимает 6 секунд, и у вас, допустим, есть всего 100 тестовых файлов, то общая потеря времени составляет 10 минут, чтобы постоянно создавать граф модулей. В то время никакие тесты или другой код не выполняются. Это просто время, которое движок требуется для подготовки исходного кода перед его выполнением.

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

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

Как видно из данных, загрузка меньшего количества модулей очень ценна. Представим, что эти цифры применяются к проекту с 100 тестовыми файлами, где используется тестовый фреймворк, который создает новый дочерний процесс для каждого тестового файла. Будем щедрыми и предположим, что наш тестовый фреймворк может выполнять 4 теста параллельно:

500 модулей: 0.15 сек * 100 / 4 = 3.75 сек сверху

1000 модулей: 0.31сек * 100 / 4 = 7.75 сек сверху

10000 модулей: 3.12 сек * 100 / 4 = 1:18 мин сверху

25000 модулей: 16.81 сек * 100 / 4 = ~7:00 мин сверху

50000 модулей: 48.44 сек * 100 / 4 = ~20:00 мин сверху

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

Что же делать?

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

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

Понравилась статья. Подпишитесь на мой канал в Telegram:

Ссылки:

11
1 комментарий

Тема интересная, и интересно было бы посмотреть на циферки. Жаль, что такой халтурный перевод "в лоб".

В статье упоминается esbuild, но любопытно, актуально ли для вебпака, вити и роллапа. Может, там и нет этой проблемы, а автор почему-то обобщил проблему на весь JavaScript.