Ваш пакет Next.js поблагодарит вас

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

Предисловие

В последний период мне пришлось взять в руки проект, созданный с использованием Next.js, и попросить улучшить его производительность, так как по неизвестным причинам все казалось чрезвычайно медленным.

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

Первоначальная проверка

Я провел несколько проверок, запустил пару отчетов Lighthouse и в итоге получил среднюю оценку производительности в 35 баллов как на мобильном, так и на настольном компьютере. По сути, они не ошиблись, некоторые проблемы были. После быстрой проверки отчета я перешел к другому виду тестирования, запустив производственную сборку, чтобы проверить красивый отчет, который предоставляет нам Next. Результат заставил меня вскочить со стула.

Чтобы пояснить суть, давайте начнем с того, что это вполне приемлемая сборка небольшого/среднего приложения Next. (На самом деле это мой веб-сайт).

Page                                                          Size     First Load JS
┌ ● /                                                         4.91 kB        88.2 kB
├   /_app                                                     0 B            83.3 kB
├ ○ /404                                                      194 B          83.5 kB
├ λ /api/auth/[...nextauth]                                   0 B            83.3 kB
├ λ /api/github                                               0 B            83.3 kB
├ λ /api/guestbook                                            0 B            83.3 kB
├ λ /api/newsletter                                           0 B            83.3 kB
├ λ /api/unsplash                                             0 B            83.3 kB
├ ● /articles                                                 2.63 kB        85.9 kB
├ ● /articles/[id] (323 ms)                                   47.2 kB         130 kB
├   ├ /articles/architecting-react-apps-like-its-2030
├   ├ /articles/learn-front-end-web-development-from-scratch
├   ├ /articles/the-reason-why-order-in-react-hooks-matters
├   └ [+3 more paths]
├ ● /guestbook (ISR: 10 Seconds) (1186 ms)                    8.71 kB          92 kB
├   └ css/d1d3e8f0a2ef53b6.css                                372 B
├ ○ /newsletter                                               3.85 kB        87.1 kB
└ ○ /testimonials                                             2.76 kB          86 kB
+ First Load JS shared by all                                 83.3 kB
  ├ chunks/framework-5f4595e5518b5600.js                      42 kB
  ├ chunks/main-a054bbf31fb90f6a.js                           27.6 kB
  ├ chunks/pages/_app-7cd69d02271692e8.js                     12.8 kB
  ├ chunks/webpack-9b312e20a4e32339.js                        836 B
  └ css/be09086502c4b867.css                                  6.83 kB
Вход в полноэкранный режим Выход из полноэкранного режима

Как вы можете видеть, размер First Load JS не превышает 100 кБ, поэтому ваш красивый терминал будет отображаться приятным зеленым цветом.

Однако в ситуации, о которой я вам рассказываю, вывод был совсем другим. Немного больше…

Анализ проблем

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

Вот вывод производственной сборки инкриминируемого приложения:

Route (pages)                              Size     First Load JS
┌ ○ / (454 ms)                             2.54 kB         303 kB
├   /_app                                  0 B             109 kB
├ ○ /404                                   186 B           110 kB
├ λ /api/hello                             0 B             109 kB
├ ○ /noop (435 ms)                         2.57 kB         303 kB
├ ○ /signin                                2.54 kB         303 kB
└ ○ /table (509 ms)                        2.54 kB         303 kB
+ First Load JS shared by all              109 kB
  ├ chunks/framework-9b5d6ec4444c80fa.js   45.7 kB
  ├ chunks/main-1ca307e6d442dee1.js        31.7 kB
  ├ chunks/pages/_app-ced22f7512a5e6d5.js  31 kB
  └ chunks/webpack-31dae04564131b7d.js     950 B
Вход в полноэкранный режим Выход из полноэкранного режима

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

Пара быстрых заметок, которые помогут вам лучше понять общие проблемы:

JS Shared By All Files

Как вы можете видеть, в нижней части показана часть, где указано, как весь базовый код наследуется каждым сгенерированным чанком как для API, так и для Pages.

Что это означает? Ну, например, страница /signin, имеет First Load JS 303kB, но общая часть весит 109kB, что означает, что фактический вес модулей, используемых на этой странице, составляет 194kB.

CSS не учитывается при расчете

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


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

Одна вещь, которую мы можем сделать, это попытаться проанализировать наш производственный пакет и посмотреть, что он нам скажет, есть очень хороший инструмент, который мы можем использовать для анализа пакета, называется Next.js Bundle Analyzer, его очень легко установить (поэтому я пропущу эту часть), и он даст вам хорошую интерактивную тепловую карту о размерах всех ваших пакетов.

Вот тепловая карта зависимостей для сборки, если вы скачали исходный код, вы можете сделать это также, используя ANALYZE=true npm run build:

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

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

Первую проблему может быть трудно обнаружить человеку, который обычно не занимается подобными вещами, но вскоре она станет очень очевидной: есть один БОЛЬШОЙ кусок, содержащий все зависимости!

Да, я говорю о чанке слева, и знаете что? Он будет общим для всех страниц, которые будут импортировать хотя бы одну из этих зависимостей, даже если последняя будет крошечной! Мысль может быть полезной подсказкой о том, почему все страницы имеют одинаковый размер!

Второе, вместо этого, может быть легче обнаружить, это приложение использует некоторые огромные зависимости, первые, которые выскакивают:

  • @mui/x-data-grid

  • ajv

  • react-phone-input-labelled

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

  • @mui/x-data-grid используется в компоненте random-table.js, который в свою очередь используется только на странице table.js.

  • Пакет «ajv» очень похож, он используется в компоненте auth-form.js, который в свою очередь используется только на странице signin.js.

  • Последний вместо этого используется в PhoneInput, но последний не используется ни на одной из страниц!!!

Теперь, зная, в чем причина проблемы, оглянемся на мгновение назад на результат сборки, сделанной ранее. WTF здесь происходит!!!?

Волшебное безумие бочковых файлов

Чтобы было понятно, что такое Barrel File? Ну, вы знаете, когда вы помещаете все ваши экспорты в файл index.js, чтобы иметь более простые пути импорта? Это и есть Barrel File. (Знаете ли вы, что создатель Node.js сожалеет о том, что создал его?)

Итак, я хочу поэкспериментировать, глядя на измерения связки, мы точно знаем, что ajv является тяжелой зависимостью, поэтому я собираюсь открыть страницу signin.js и закомментировать компонент AuthForm.

import { Box } from "@mui/material";
import { Navbar } from "../components";

export default function Home() {
  return (
    <div>
      <Navbar />
      <Box padding="32px">{/* <AuthForm /> */}</Box>
    </div>
  );
}
Вход в полноэкранный режим Выход из полноэкранного режима

Отлично, не терпится переделать сборку и посмотреть, сколько веса я сэкономил! Итак, я перезапускаю сборку и… «Happy Music Stops», ничего не изменилось. Страница signin.js по-прежнему занимает 303 кБ…

В приступе истерии я решил попробовать все и поэтому я также комментирую компонент Navbar, что-то ведь получится?

import { Box } from "@mui/material";

export default function Home() {
  return (
    <div>
      {/* <Navbar /> */}
      <Box padding="32px">{/* <AuthForm /> */}</Box>
    </div>
  );
}
Вход в полноэкранный режим Выход из полноэкранного режима

И при следующей сборке происходит нечто волшебное:

Route (pages)                              Size     First Load JS
┌ ○ / (418 ms)                             2.05 kB         303 kB
├   /_app                                  0 B             109 kB
├ ○ /404                                   186 B           110 kB
├ λ /api/hello                             0 B             109 kB
├ ○ /noop (410 ms)                         2.07 kB         303 kB
├ ○ /signin (382 ms)                       289 B           116 kB
└ ○ /table                                 2.05 kB         303 kB
+ First Load JS shared by all              109 kB
  ├ chunks/framework-9b5d6ec4444c80fa.js   45.7 kB
  ├ chunks/main-1ca307e6d442dee1.js        31.7 kB
  ├ chunks/pages/_app-ced22f7512a5e6d5.js  31 kB
  └ chunks/webpack-31dae04564131b7d.js     950 B
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь страницы потеряли весь свой вес! Как такое возможно? Неужели все дело в компоненте Navbar? Попробуйте вернуть все на прежнее место и на этот раз удалите только компонент Navbar. Изменилось ли что-нибудь?

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

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

import { Box } from "@mui/material";
import { AuthForm } from "../components/auth-form";
import { Navbar } from "../components/navbar";

export default function Home() {
  return (
    <div>
      <Navbar />
      <Box padding="32px">
        <AuthForm />
      </Box>
    </div>
  );
}
Войти в полноэкранный режим Выйти из полноэкранного режима

И давайте запустим еще одну сборку, что произошло?

Route (pages)                              Size     First Load JS
┌ ○ /                                      2.61 kB         305 kB
├   /_app                                  0 B             109 kB
├ ○ /404                                   186 B           110 kB
├ λ /api/hello                             0 B             109 kB
├ ○ /noop (417 ms)                         2.63 kB         305 kB
├ ○ /signin (405 ms)                       1.13 kB         188 kB
└ ○ /table (430 ms)                        2.61 kB         305 kB
+ First Load JS shared by all              109 kB
  ├ chunks/framework-9b5d6ec4444c80fa.js   45.7 kB
  ├ chunks/main-1ca307e6d442dee1.js        31.7 kB
  ├ chunks/pages/_app-ced22f7512a5e6d5.js  31 kB
  └ chunks/webpack-31dae04564131b7d.js     950 B
Вход в полноэкранный режим Выход из полноэкранного режима

Кажется, что вес довольно сильно уменьшился, конечно, он все еще большой, потому что, вспомним, что AuthForm использует тяжелую зависимость, но в чем существенная разница между раньше и сейчас?

Если вы заметили, внутри папки components есть безобидный на первый взгляд файл, который до сих пор не упоминался — index.js:

export * from "./auth-form";
export * from "./button";
export * from "./movie-autocomplete";
export * from "./navbar";
export * from "./phone-input";
export * from "./random-table";
export * from "./side-menu";
Вход в полноэкранный режим Выход из полноэкранного режима

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

import { Navbar } from "../components"; // Version 1
import { Navbar } from "../components/navbar"; // Version 2
Вход в полноэкранный режим Выход из полноэкранного режима

Итак, да, мы сохраняем подкаталог, но каковы последствия? Достаточно импортировать даже один крошечный модуль из этого index.js, чтобы вызвать массовый импорт всех остальных компонентов в пучке страницы.

Вот почему, когда мы удалили только один из двух компонентов между AuthForm и Navbar, результат не изменился, поскольку оба вызвали одинаковый эффект массивного импорта. Только при удалении обоих компонентов пропала ссылка на файл index.js и, следовательно, ни один компонент не был импортирован!

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

Route (pages)                              Size     First Load JS
┌ ○ /                                      2.01 kB         171 kB
├   /_app                                  0 B             109 kB
├ ○ /404                                   186 B           110 kB
├ λ /api/hello                             0 B             109 kB
├ ○ /noop (397 ms)                         587 B           128 kB
├ ○ /signin (314 ms)                       39.1 kB         190 kB
└ ○ /table                                 83.6 kB         252 kB
+ First Load JS shared by all              109 kB
  ├ chunks/framework-9b5d6ec4444c80fa.js   45.7 kB
  ├ chunks/main-1ca307e6d442dee1.js        31.7 kB
  ├ chunks/pages/_app-ced22f7512a5e6d5.js  31 kB
  └ chunks/webpack-31dae04564131b7d.js     950 B
Вход в полноэкранный режим Выход из полноэкранного режима

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

На самом деле, неудивительно, что страница table.js больше, потому что, глядя на тепловую карту, мы знаем, что @mui/x-data-grid определенно тяжелая.

Почему это происходит?

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

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

Теперь представьте, что вы трясете это дерево со всей силы, чтобы мертвые листья упали на землю и остались только здоровые. Это и есть встряхивание дерева, которое в нашем случае, как правило, производится модульным бандлером, таким как Webpack или Rollup.

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

Однако бывают ситуации, когда наш компоновщик (в данном случае webpack, поскольку он используется в Next) не может удалить некоторые модули автоматически. Это происходит потому, что Terser (модуль, который webpack использует для этой операции) не всегда может безопасно определить, используется ли экспорт модуля или нет. Как говорится в документации webpack:

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

Означает ли это, что файлы barrel больше нельзя использовать? Скорее всего, нет.

Альтернативное решение

Я уже слышу ваши голоса, которые бомбардируют мою голову фразами типа:

Да, все это очень хорошо, но разве нет способа получить тот же результат, сохранив index.js?

На самом деле, есть (в большинстве случаев) альтернативное решение для вас.

Как вы только что прочитали, Terser делает хорошую работу, но иногда она не идеальна. Чтобы заставить его работать лучше, мы можем дать webpack хорошую подсказку под названием sideEffects. Это значение может быть помещено в package.json и принимает в качестве значений RegEx, Strings и Boolean. Но что именно представляет собой побочный эффект?

Официальная документация может нам помочь:

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

Например, в нашем случае мы не используем никаких побочных эффектов, поэтому мы можем напрямую установить false, помогая webpack обрезать неиспользуемые модули:

{
  "sideEffects": false
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Теперь, если мы попытаемся создать производственную сборку, сохранив наши старые импорты из файла barrel (index.js), посмотрим, что произойдет:

Route (pages)                              Size     First Load JS
┌ ○ / (338 ms)                             2 kB            171 kB
├   /_app                                  0 B             109 kB
├ ○ /404                                   186 B           110 kB
├ λ /api/hello                             0 B             109 kB
├ ○ /noop (336 ms)                         587 B           128 kB
├ ○ /signin (397 ms)                       39.1 kB         190 kB
└ ○ /table                                 83.6 kB         252 kB
+ First Load JS shared by all              109 kB
  ├ chunks/framework-9b5d6ec4444c80fa.js   45.7 kB
  ├ chunks/main-1ca307e6d442dee1.js        31.7 kB
  ├ chunks/pages/_app-ced22f7512a5e6d5.js  31 kB
  └ chunks/webpack-31dae04564131b7d.js     950 B
Войти в полноэкранный режим Выход из полноэкранного режима

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

Посмотрите, что теперь есть разные куски (разных размеров), и у нас больше нет больших огромных кусков, общих для всех страниц!

Умный вопрос

Помните, как мы удалили AuthForm и компонент Navbar со страницы signin.js? Изначально мы решили проблему, но я ошибаюсь, или импортированная зависимость все еще присутствовала?

import { Box } from "@mui/material"
Вход в полноэкранный режим Выход из полноэкранного режима

Почему эта зависимость не продолжила вызывать проблему двух других? Однако здесь снова используется бочковой файл для экспорта всех компонентов, и ответ снова можно найти здесь. И если вам интересно, ChakraUI использует то же самое для всех компонентов, MantineUI тоже, даже Lodash (в версии ESM) использует эту технику.

Общие советы по улучшению дрожания деревьев

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

Используйте библиотеку с возможностью встряхивания деревьев

Опять же, это может быть клише, но очень часто можно встретить проекты с тоннами нетривиальных зависимостей, как узнать, является ли одна из них тривиальной или нет? С помощью таких инструментов, как BundlePhobia.

Избегайте транспиляции в CommonJS

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

export default {
  presets: [
    [
      "@babel/preset-env",
      {
        modules: false,
      },
    ],
  ],
};
Войти в полноэкранный режим Выйти из полноэкранного режима

Избегайте «звездных» импортов

Вы должны импортировать только те модули, которые вам нужны, избегайте импорта * из модуля, иначе все будет включено в ваш кусок кода, даже если оно не используется!

Работа с огромными зависимостями

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

  • Необходима ли эта библиотека или ее можно заменить чем-то другим?

  • Если нам нужны эти функции, есть ли более легкая альтернатива, которая делает то же самое? (Подумайте о lodash и lodash-es).

Давайте выберем одну за другой эти зависимости и посмотрим, можно ли их заменить, начиная с самой большой @mui/x-data-grid.

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

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

Давайте продолжим говорить об ajv, даже в этом случае, требованием является валидация простой формы, настолько простой, что мы даже можем сделать это вручную. В этом случае мало что можно сказать, если нет никаких препятствий, то хорошо выбрать другое решение, отличное от этого. Это подчеркивается тем, что данная библиотека не является tree-shakeable. Одна другая библиотека для этого конкретного случая? Superstruct может быть круче (и более легковесной).

Последнее — самое простое, фактически не используемое react-phone-input-labelled. Это можно легко удалить, так как его единственная задача — разжиреть, но почему я хотел включить его? Просто потому, что очень часто, если кодовая база не поддерживается постоянно, может случиться так, что между различными сменами рук и изменениями требований что-то остается в исходном коде, даже не будучи использованным. Следовательно, иногда полезно проверять зависимости, а затем посмотреть, можно ли что-то удалить, чтобы сэкономить байты и время сборки.

И последнее, но не менее важное!

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

Также загляните и подпишите мою гостевую книгу, чтобы я знал, что вы думаете об этой статье!

Ниже я оставлю несколько ссылок, которые могут вам помочь!

  • Документы по встряхиванию деревьев (Webpack)

  • BundlePhobia

  • Стоимость импорта для VSCode

Оцените статью
devanswers.ru
Добавить комментарий