Асинхронные компоненты и Suspense

Урок про ленивую загрузку компонентов: как грузить код по требованию через defineAsyncComponent и как Suspense ждёт асинхронный setup, показывая заглушку загрузки.

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

По умолчанию все компоненты, которые вы импортируете, попадают в один JavaScript-бандл и грузятся при первой загрузке страницы. Для маленького приложения это нормально. Но когда оно растёт — десятки страниц, тяжёлый редактор, большой модальный визард, который открывают редко, — тащить весь код сразу расточительно: пользователь ждёт загрузки того, что, возможно, никогда не откроет. Решение — грузить такие куски лениво, только когда они действительно понадобятся.

Vue даёт для этого две связанные вещи. defineAsyncComponent превращает компонент в асинхронный: его код подгружается отдельным запросом в момент, когда компонент впервые понадобился. А <Suspense> — встроенная обёртка, которая умеет ждать асинхронную инициализацию (включая async setup) и на это время показывать заглушку. Разберём оба механизма и состояния загрузки/ошибки.

Зачем это на практике

Главная выгода — скорость первой загрузки. Сборщик (Vite) видит динамический import() и выносит этот компонент в отдельный файл-чанк; он скачается, только когда компонент впервые отрисуется. Так грузят страницы маршрутов (каждая — свой чанк), редко используемые модалки и диалоги, тяжёлые виджеты вроде графиков или WYSIWYG-редактора. Пользователь получает лёгкую стартовую страницу, а остальное подтягивается по мере навигации — это заметно улучшает метрики загрузки.

defineAsyncComponent и ленивая загрузка

В простейшем виде вы передаёте в defineAsyncComponent функцию-загрузчик, которая возвращает динамический import():

<script setup>
import { defineAsyncComponent } from 'vue'

// код HeavyChart.vue уедет в отдельный чанк и скачается при первом показе
const HeavyChart = defineAsyncComponent(() =>
  import('./HeavyChart.vue')
)
</script>

<template>
  <button @click="show = true">Показать график</button>
  <HeavyChart v-if="show" />
</template>

Пока show равно false, код HeavyChart.vue вообще не скачан. Как только v-if впервые отрисует компонент, Vue вызовет загрузчик, скачается чанк, и компонент появится. Второй раз сеть уже не дёргается — модуль закеширован.

Состояния загрузки и ошибки

У загрузки из сети есть фазы: она идёт какое-то время и может провалиться (нет сети, 404). Чтобы не показывать пустоту или не падать молча, defineAsyncComponent принимает расширенный объект опций:

<script setup>
import { defineAsyncComponent } from 'vue'
import Spinner from './Spinner.vue'
import ErrorBox from './ErrorBox.vue'

const Editor = defineAsyncComponent({
  loader: () => import('./Editor.vue'),
  loadingComponent: Spinner,   // показать, пока грузится
  errorComponent: ErrorBox,    // показать, если загрузка упала
  delay: 200,                  // не мигать спиннером, если успело за 200мс
  timeout: 8000                // через 8с считать загрузку проваленной
})
</script>

loadingComponent рисуется, пока идёт скачивание, но не сразу: delay (по умолчанию 200мс) откладывает его показ, чтобы при быстрой загрузке спиннер не мелькал. Если за timeout компонент не загрузился (или import() отклонился), показывается errorComponent. Так пользователь всегда видит осмысленное состояние, а не зависший экран.

Suspense и асинхронный setup

Второй механизм решает другую задачу. В <script setup> можно использовать await на верхнем уровне — тогда у компонента асинхронный setup: он не готов к показу, пока промис не разрешится (например, пока не загрузятся данные).

<!-- UserCard.vue — асинхронный setup -->
<script setup>
// await на верхнем уровне делает setup асинхронным
const res = await fetch('/api/user/1')
const user = await res.json()
</script>

<template>
  <div>{{ user.name }} — {{ user.email }}</div>
</template>

Сам по себе такой компонент нельзя просто вставить — кто-то должен подождать его готовности и показать заглушку. Эту роль играет <Suspense> с двумя слотами: #default (что показать, когда всё готово) и #fallback (что показать, пока ждём):

<template>
  <Suspense>
    <!-- основной контент: может содержать async-компоненты -->
    <template #default>
      <UserCard />
    </template>
    <!-- заглушка на время ожидания -->
    <template #fallback>
      <p>Загружаем профиль…</p>
    </template>
  </Suspense>
</template>

Пока setup у UserCard ждёт fetch, Suspense показывает fallback «Загружаем профиль…». Как только все асинхронные зависимости внутри default разрешились, он одним разом переключается на основной контент. Если внутри несколько async-компонентов, Suspense ждёт их все и покажет default, только когда готов последний, — поэтому пользователь не видит «дёрганого» появления частей по очереди.

Как это работает под капотом

За ленивой загрузкой стоит механизм сборщика, который называют code splitting. Синтаксис import('./X.vue') — это динамический импорт: он возвращает промис и является сигналом для Vite вынести модуль в отдельный чанк. defineAsyncComponent оборачивает загрузчик в компонент-обёртку: при первом рендере она вызывает loader(), дожидается промиса, кеширует результат и подставляет настоящий компонент. Состояния loading/error/timeout — это просто внутренний автомат этой обёртки, который решает, какой из переданных компонентов отрисовать в данный момент.

Suspense устроен иначе: это не про загрузку файла, а про ожидание промисов внутри setup. Когда Suspense монтирует свой default-слот, он перехватывает асинхронные setup-функции вложенных компонентов и собирает их промисы. Пока хотя бы один не разрешён, рисуется fallback; когда все разрешились — показывается default. Важно: эти два механизма ортогональны и часто работают вместе — асинхронный компонент (отдельный чанк) внутри которого ещё и async setup (загрузка данных) корректно проходит через Suspense.

Частые ошибки

Лениво грузить всё подряд. Дробление на чанки имеет цену: лишние сетевые запросы и задержка показа. Лениво грузите крупное и редкое (страницы, тяжёлые модалки, графики), а мелкие частые компоненты держите в основном бандле.

Динамический import() с переменным путём. Сборщик анализирует import() статически. import(somePath) с произвольной переменной он не сможет разбить на чанки правильно — путь должен быть достаточно явным (хотя бы с известным префиксом каталога).

Забыть про ошибку загрузки. Сеть ненадёжна: без errorComponent (или границы ошибок выше) упавший import() оставит пустое место и ошибку в консоли. Всегда предусматривайте состояние ошибки.

Думать, что Suspense заменяет обработку ошибок. Suspense отвечает только за ожидание (fallback). Если промис в setup отклонится, отлавливать это нужно отдельно — через onErrorCaptured на родителе (об этом — в уроке про границы ошибок). Сам Suspense ошибку не покажет.

Итоги

  • Асинхронный компонент через defineAsyncComponent(() => import('./X.vue')) грузится отдельным чанком по требованию — это уменьшает стартовый бандл.
  • Расширенные опции (loadingComponent, errorComponent, delay, timeout) дают осмысленные состояния загрузки и ошибки.
  • <Suspense> ждёт асинхронный setup (верхнеуровневый await) и на время ожидания показывает слот #fallback вместо #default.
  • Два механизма ортогональны: ленивая загрузка — про код (code splitting сборщика), Suspense — про ожидание промисов в setup; часто работают вместе.
  • Suspense не обрабатывает ошибки — отклонённый промис ловится отдельно через границу ошибок (onErrorCaptured).
Проверьте себя
1. Что делает defineAsyncComponent(() => import('./Heavy.vue'))?
AРендерит компонент дважды для надёжности
BВыносит компонент в отдельный чанк, который скачивается по требованию при первом показе
CЗапрещает компоненту иметь состояние
DЗагружает компонент на сервере и присылает готовый HTML
2. Для чего нужен <Suspense>?
AЧтобы кешировать отрисованные компоненты
BЧтобы дождаться асинхронного setup (верхнеуровневого await) и показать fallback на время ожидания
CЧтобы обрабатывать ошибки в дочерних компонентах
DЧтобы ускорить реактивность
3. Зачем у defineAsyncComponent параметр delay (по умолчанию 200мс)?
AИскусственно замедляет загрузку для тестов
BОткладывает показ loadingComponent, чтобы спиннер не мелькал при быстрой загрузке
CЗадаёт таймаут, после которого загрузка считается проваленной
DВремя кеширования чанка в браузере