Оптимизация загрузки Nuxt 3 на клиенте: снижаем bundle size и ускоряем hydration

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

Lazy loading компонентов

Nuxt автоматически делает компоненты с префиксом Lazy асинхронными. Используйте это везде, где компонент не нужен при первом рендере:

<template>
  <!-- Загрузится только когда попадёт в DOM -->
  <LazyModalFeedback v-if="showModal" />
  <LazyTheFooter />
  
  <!-- Для тяжёлых виджетов — отличный вариант -->
  <LazyChartDashboard v-if="dataLoaded" />
</template>

Правило простое: всё что ниже fold, все модалки, все вкладки которые не активны по умолчанию — делайте Lazy.

Отключаем ненужные модули и плагины

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

// nuxt.config.ts
export default defineNuxtConfig({
  plugins: [
    // Аналитика нужна только на клиенте
    { src: '~/plugins/analytics.client.ts' },
    // Этот плагин только для SSR
    { src: '~/plugins/cache.server.ts' },
  ]
})

Суффиксы .client.ts и .server.ts — ваши лучшие друзья. Аналитика, работа с localStorage, сторонние виджеты — всё это .client.

Оптимизация hydration: <ClientOnly>

Hydration — процесс когда Vue на клиенте «оживляет» HTML пришедший с сервера. Чем меньше компонентов нужно гидрировать — тем быстрее страница становится интерактивной.

<template>
<!— Этот блок не будет гидрироваться вообще —>
<ClientOnly>
<UserDashboard />
<template #fallback>
<!— Покажем скелетон пока JS не загрузился —>
<DashboardSkeleton />
</template>
</ClientOnly>
</template>

Кандидаты на <ClientOnly>: виджеты с персонализацией, блоки зависящие от localStorage/cookies, тяжёлые интерактивные компоненты которые не влияют на SEO.

Разбиваем бандл через динамические импорты

Для тяжёлых библиотек (графики, редакторы, карты) — только динамический импорт:

// composables/useChart.ts
export const useChart = async () => {
  // Библиотека загрузится только когда вызовем функцию
  const { Chart } = await import('chart.js')
  return Chart
}
<script setup>
// Компонент с тяжёлой зависимостью — грузим лениво
const HeavyEditor = defineAsyncComponent(() => 
  import('~/components/HeavyEditor.vue')
)
</script>

Анализируем что весит в бандле

Прежде чем оптимизировать — смотрим что именно тяжёлое:

# Установить анализатор
npm i -D rollup-plugin-visualizer

# nuxt.config.ts
export default defineNuxtConfig({
  vite: {
    plugins: [
      visualizer({ 
        open: true,        // откроет браузер автоматически
        gzipSize: true,    // показывает размер после gzip
        filename: 'stats.html' 
      })
    ]
  }
})

# Запустить сборку с анализом
nuxt build

После этого увидите интерактивную карту бандла — сразу понятно что занимает место.

Настройка chunk splitting

// nuxt.config.ts
export default defineNuxtConfig({
  vite: {
    build: {
      rollupOptions: {
        output: {
          // Выносим вендоры в отдельные чанки
          manualChunks: {
            'vue-vendor': ['vue', 'vue-router'],
            'ui-vendor': ['@headlessui/vue'],
          }
        }
      }
    }
  }
})

Это позволяет браузеру кешировать вендорные чанки отдельно — при обновлении вашего кода пользователи не перекачивают весь бандл заново.

Итог

Быстрый чеклист для старта: расставьте Lazy-префиксы на компоненты ниже fold, оберните персонализированные блоки в <ClientOnly>, проставьте суффиксы .client плагинам с аналитикой и сторонними виджетами. Уже эти три шага дадут ощутимый результат без глубокого рефакторинга. Для следующего уровня — запустите visualizer и разберитесь что именно тянет бандл вниз в вашем конкретном проекте.