Оптимизация next.js monorepo приложения

от автора

Дисклеймер: Данный кейс основан на архитектуре нашего проекта (~2600 файлов). В проектах другого масштаба или с другой структурой зависимости результаты могут отличаться. Это не «серебряная пуля», а мой личный опыт оптимизации конкретной инфраструктуры.

Буду краток, на проекте, где я сейчас работаю, мы с командой заметили огромную проблему со скоростью сборки и весом проекта после билда. Стек у нас React, Next.js, FSD Monorepo.

Изначальный набор инструментов был такой:

Билд

Turbopack

Линтинг

Eslint

Проверка типов

TypeScript Compiler

Форматирование

Prettier

Мертвый код

Knip

Как видите, вполне стандартный набор инструментов для проекта на React+Next.js, к которым все привыкли и многих они устраивают. Но в какой‑то момент я устал ждать всё то время, пока все проверки пре‑пуш‑хука пройдут. Тем более, что из‑за кривой настройки проверка проходила не на изменённые файлы, а на весь проект: несколько минут ждать только чтобы узнать, что ты не можешь сделать пуш из‑за другого человека, и так по кругу. Так что я сделал полноценную миграцию на нативные инструменты и настроил их.

Начал я с линтера: стандартный Eslint, как оказалось, даже не был способен дойти до конца проекта на базовых настройках. Он просто падал в OOM(Out‑of‑Memory), но даже в таких условиях линтинг этой малой части занимал 35.2с на не полную проверку проекта. После увеличения лимитов оперативной памяти до 8 гигабайт, проверка всего проекта наконец‑то смогла пройти, но заняла 146.3с, то есть, на каждый пуш приходилось ждать 2.5 минуты просто на один этап из двух.

Заменой ему стала связка из oxlint и ast‑grep, поскольку один лишь oxlint не поддерживал все правила линтинга, что нам были необходимы. Такая, казалось бы, минорная и простая замена на два нативных инструмента позволила срезать время линтинга с 146.3с до жалких 14.5с. Таким образом, каждый пуш стал на 2 минуты короче для каждого члена команды, что даже при 5 пушах от разработчика в день сэкономило 20 минут времени каждому и часы, если считать общее время всех разработчиков. Дополнительно срезалось потребление оперативной памяти с 6.91GB на Eslint до 2.79GB на oxlint+ast‑grep.

Eslint

oxlint+ast‑grep

Разница

Время

146.3с

14.5с

10.08х

Потребление RAM

6.91GB

2.79GB

2.47х


Следующим на разделочный стол попал tsc. Он был просто медленным, проходил весь проект за 101.9 с, что тоже достаточно долго, но хотя бы без OOM. Его я заменил на tsgo, по памяти он остался почти таким же, из‑за специфики проверки типов в коде, но вот время упало с 101.9с на tsc до 9с на tsgo, при той же стабильности.

tsc

tsgo

Разница

Время

101.9с

11.3х

Потребление RAM

1.55GB

1.54GB


А вот дальше пошел в ход самый неоднозначный результат в этом замере: билд. В базе, как я писал ранее, у нас был Turbo с его 8.9MB после всех процедур(JS+css) и скоростью билда в 18.6с. Это очень быстро, этим результатом он даже обогнал базовый Webpack+Terser в 3 прохода с его 9.2MB и билдом в 147.5с, что в 8 раз дольше чем Turbo так еще и проигрывает по весу. Но у Webpack есть огромное преимущество — контроль над ним. Мы можем сами контролировать, как он будет резать и собирать чанки. После пары часов настроек я смог добиться общего веса проекта в 5.8MB, что уже почти пик, но ещё не полный.

Дальше в ход пошли уже относительно нестандартные техники вроде:

  • стабы next,

  • пре билд и пост билд скриптов,

  • обрезки ядра React,

  • сжатие css после билда.

Дали они очень интересный результат: общий вес они порезали относительно мало (660кб), если сравнить с корректной настройкой самого билдера, но вот максимальный вес чанка они смогли очень сильно срезать, более чем в 2 раза.

Важно: эти манипуляции требуют глубокого понимания графа зависимостей проекта и могут привести к регрессиям, если не покрыты тестами. Также отмечу, что я использовал стабы не для уменьшения общего веса проекта, а для уменьшения веса edger чанков, что в разы критичнее, чем общий вес, так что даже срез 50кб raw веса с главной стоит в разы больше в моем случае, чем 200кб суммарного веса проекта.

Пределом же оказался Rspack, который позволил достичь времени билда в 61.2с и при этом веса в 5.78MB. Я назвал этот инструмент самым неоднозначным по одной простой причине: в базе, без настроек он проигрывает в весе всем и очень сильно, как и видно из бенчмарка.

Тип сборки

Время сборки

потребление RAM

Суммарный вес

rspack база

69.0с

1.53 GB

13 881 KB

webpack база

147.5с

2.30 GB

9 379 KB

turbopack

18.6с

2.56 GB

9 125 KB

rspack настройка

61.2с

3.46 GB

5 780 KB

webpack настройка

136.6с

3.83 GB

5 798 KB

rspack трюки

104.0с

1.66 GB

5 088 KB

webpack трюки

106.9с

2.28 GB

5 145 KB

Но его пик оказался на нашем проекте лучшим, за счет гибкой настройки самого Rspack удалось достичь аномальных результатов в весе огромного проекта. Еще хочу сказать кое‑что интересное про сам turbo: у него есть огромная проблема, а именно то, как он режет чанки. Если сейчас зайти на наш проект или же на ремангу, после чего открыть вкладку «сеть», можно увидеть крайне неприятную картинку: количество запросов js чанков+css+html стремится к сотне, а каждый запрос — это дополнительный оверхэд на установку, tcp‑соединение и так далее.

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


Теперь про Prettier. Сам по себе инструмент не так плох, со своей работой он справляется, но у меня есть личная неприязнь к инструментам на Js, так что я решил заменить его тоже. Замена нашлась достаточно быстро — Biome, тут расписывать в принципе нечего, просто форматер кода, в моем случае. Хотя в себе он имеет также: линтер, сортировку импортов, поиск по коду, авто миграцию конфига, lsp‑proxy и hot daemon. Использовать это всё не стал, так‑как настроил на тот момент уже oxlint. Вот сама таблица времени прогонов на всем репозитории.

Холодный прогон

Горячий прогон

RAM

Biome

3.7с

1.5с

162MB

Prettier

41.1с

34.7с

399MB

Как видно из прогона, выигрыш огромен, особенно в горячем прогоне, там он в 24 раза.


Также расскажу про полную замену всеми любимого Knip. Он получился тоже достаточно интересным, тестировал я его и его замену Fallow на дефолтных конфигах и на реальных продакшен конфигах. Получилась интересная ситуация. Fallow быстрее, что логично, но разница уже небольшая — всего 2.7 раза на холодном прогоне, 4.7с у fallow против 12.7c у Knip, и 6 раз на горячем прогоне — 1.7с у Fallow против 10.2c у Knip. Насчет работы обоих инструментов сложно что‑либо сказать, поскольку оба очень сильно зависят от настройки, но на базовых настройках Fallow нашел на 19% больше файлов, но это абсолютно не показатель.

Холодный прогон

Горячий прогон

RAM

Fallow

4.7с

1.7с

211MB

Knip

12.5с

10.2с

531MB

Тут, как видно, выигрыш в скорости скорее минорен, особенно учитывая, что инструмент используется относительно редко, но всё же выигрыш есть.


Также, думаю, стоит затронуть достаточно базовую, в моём понимании, тему: различие между npm и pnpm, и в чем pnpm превосходит уже ставший стандартом npm.

Для начала стоит разобраться, как вообще работают обе команды.

Начнем с npm, он ведет себя максимально просто: открывает ваш package.json и качает все зависимости и иногда зависимости зависимостей прямо в ваш проект в папку node_modules.

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

Скачивание, при каждом npm install вы копируете его из кэша, что достаточно долгая и тяжелая операция.

Из этого вытекает вторая очевидная проблема, это вес: если работать с одним проектом, то проблем почти нет, но как только вы начинаете работать с двумя или более проектами, вес становится заметным, особенно, если стек похож, поскольку npm заботливо скачивает вам для каждого проекта свои зависимости.

Далее идет не очень очевидная проблема: в случае конфликта версий, npm прямо в папке node_modules создает еще одну папку node_modules и скидывает уже в нее файл с конфликтом версий. И тут происходит страшное: если у этой зависимости есть свои транзитивные зависимости, то их тоже качает и начинает раздувать вес node_modules еще больше.

Ну а теперь самое страшное: это фантомные зависимости. Вы скачали, например, библиотеку, которой нужен lodash, и она появилась в вашем node_modules, после чего вы или другой разработчик в любом файле пишет import lodash from 'lodash'; а за счет того, что npm имеет плоскую структуру и каждая зависимость пытается встать в корневой node_modules, у него всё запускается и работает, при том, что в package.json lodash не указан. Казалось бы, не указан и не указан, что такого, но тут та библиотека, которую вы поставили, становится не нужна или обновляется, и ей становится не нужен lodash → следовательно, этот самый lodash удаляется из node_modules, и билд начинает падать из‑за ошибки импорта.

Это были все основные проблемы npm, теперь — как эти проблемы решает pnpm.

Проблема 1 и 2 решается одновременно: при скачивании библиотеки через pnpm install, она ставится не в node_modules, а в специальную папку (в базе ~/.local/share/pnpm/*), и прокидывает в вашу папку node_modules хард линк. Когда вы пишете pnpm install повторно, сначала идет проверка, есть ли у вас уже эта зависимость в базовой папке. Если есть, то pnpm вместо долгого и тяжёлого копирования просто прокидывает дешевый хард линк и в другую папку node_modules в другом проекте. Таким образом, скачав один раз зависимость, вы получаете ее бесплатное использование во всех проектах.

Проблема 3 и 4 решается за счет симлинка: в pnpm зависимость не пытается вылезти в корневой node_modules, и ситуация, когда две зависимости конфликтуют по версиям, почти невозможна. А фантомные зависимости в принципе становятся невозможны.


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

Вот сам бенчмарк, кому интересно можете использовать и протестировать на своем next.js monorepo https://github.com/BezSaharaD/Benchmark

Все замеры были произведены на Ryzen 5 3600 и 16GB оперативной памяти 3733MHz с таймингами 16–18-18-36-86

ссылка на оригинал статьи https://habr.com/ru/articles/1055360/