← Курс/Асинхронные компоненты: defineAsyncComponent#227 из 257+25 XP

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

Зачем нужны асинхронные компоненты

При сборке большого приложения весь код попадает в один бандл. Тяжёлые компоненты (графики, редакторы, карты) загружаются даже если пользователь их никогда не откроет. **Асинхронные компоненты** решают эту проблему: компонент загружается только когда он действительно нужен.

Базовый синтаксис

import { defineAsyncComponent } from 'vue'

const HeavyChart = defineAsyncComponent(() =>
  import('./components/HeavyChart.vue')
)

Динамический import() возвращает Promise — Vite/Webpack автоматически выделяют этот модуль в отдельный чанк. Компонент скачивается с сервера только при первом рендере.

Полные опции

const AsyncModal = defineAsyncComponent({
  // Функция-загрузчик
  loader: () => import('./Modal.vue'),

  // Компонент, отображаемый пока идёт загрузка
  loadingComponent: LoadingSpinner,

  // Компонент, отображаемый при ошибке загрузки
  errorComponent: ErrorBoundary,

  // Задержка перед показом loadingComponent (мс)
  // Предотвращает мигание при быстрой загрузке
  delay: 200,

  // Максимальное время ожидания загрузки (мс)
  // После истечения показывается errorComponent
  timeout: 10000,

  // Вызывается при ошибке (Vue 3.3+)
  onError(error, retry, fail, attempts) {
    if (attempts <= 3) retry()  // Повторить попытку
    else fail()                  // Показать errorComponent
  },
})

Совместная работа с Suspense

<Suspense> — экспериментальный компонент Vue, который управляет состоянием загрузки дерева async-компонентов:

<Suspense>
  <!-- Основной контент — может содержать async компоненты -->
  <template #default>
    <HeavyChart :data="chartData" />
  </template>

  <!-- Показывается пока default слот загружается -->
  <template #fallback>
    <LoadingSpinner message="Загружаем график..." />
  </template>
</Suspense>

Отличие от loadingComponent: Suspense работает на уровне дерева компонентов и поддерживает async setup().

Async setup() с Suspense

// AsyncUserProfile.vue
const { data: user } = await useFetch('/api/user')
// ^ await в setup() работает только внутри <Suspense>

Стратегия ленивой загрузки

// router/index.js — каждый маршрут в отдельном чанке
const routes = [
  {
    path: '/dashboard',
    component: () => import('./views/Dashboard.vue'),
  },
  {
    path: '/reports',
    component: defineAsyncComponent({
      loader: () => import('./views/Reports.vue'),
      loadingComponent: PageSkeleton,
      delay: 300,
    }),
  },
]

Когда использовать

  • Большие компоненты (> 20кб минифицированного кода)
  • Компоненты, видимые только при определённых действиях (модалки, дропдауны)
  • Маршруты — всегда лениво загружайте страницы
  • Не стоит оборачивать маленькие компоненты — накладные расходы не оправданы
  • Примеры

    Асинхронная загрузка модулей с состояниями loading/error/success — аналог defineAsyncComponent

    // Реализуем аналог defineAsyncComponent:
    // асинхронная загрузка с состояниями и retry-логикой.
    
    function defineAsyncComponent(options) {
      // Нормализуем: функция → объект с loader
      if (typeof options === 'function') {
        options = { loader: options }
      }
    
      const {
        loader,
        delay = 200,
        timeout = null,
        onError = null,
      } = options
    
      let cachedComponent = null
      let status = 'idle'  // idle | loading | loaded | error
      let attempts = 0
    
      async function load() {
        if (cachedComponent) return cachedComponent
    
        status = 'loading'
        attempts++
    
        const loadPromise = loader()
        let timedOut = false
    
        // Таймаут
        const timeoutPromise = timeout
          ? new Promise((_, reject) =>
              setTimeout(() => {
                timedOut = true
                reject(new Error(`Timeout: компонент не загружен за ${timeout}мс`))
              }, timeout)
            )
          : null
    
        try {
          const result = await (timeoutPromise
            ? Promise.race([loadPromise, timeoutPromise])
            : loadPromise)
    
          // Динамический import возвращает { default: Component }
          cachedComponent = result.default ?? result
          status = 'loaded'
          console.log(`[AsyncComp] Загружен за попытку #${attempts}`)
          return cachedComponent
        } catch (err) {
          status = 'error'
    
          if (onError) {
            let resolved = false
            await new Promise((resolve, reject) => {
              onError(
                err,
                () => { resolved = true; resolve() },  // retry
                () => reject(err),                       // fail
                attempts
              )
              if (!resolved) reject(err)
            }).catch(() => {})
    
            if (resolved) {
              console.log(`[AsyncComp] Повторная попытка #${attempts + 1}`)
              return load()  // рекурсия — retry
            }
          }
    
          throw err
        }
      }
    
      return { load, getStatus: () => status, getAttempts: () => attempts }
    }
    
    // --- Симуляция загрузки модуля ---
    function mockImport(name, { failTimes = 0, delay: ms = 100 } = {}) {
      let calls = 0
      return async () => {
        calls++
        await new Promise(r => setTimeout(r, ms))
        if (calls <= failTimes) {
          throw new Error(`Сетевая ошибка при загрузке ${name} (попытка ${calls})`)
        }
        return { default: { name, template: `<div>${name}</div>` } }
      }
    }
    
    // === Тест 1: Успешная загрузка ===
    async function test1() {
      console.log('=== Тест 1: Простая загрузка ===')
      const AsyncComp = defineAsyncComponent(mockImport('HeavyChart'))
      const comp = await AsyncComp.load()
      console.log('Компонент:', comp.name, '| Статус:', AsyncComp.getStatus())
      // Кэш — второй вызов без запроса
      await AsyncComp.load()
      console.log('Попыток загрузки (всего 1 реальная):', AsyncComp.getAttempts())
    }
    
    // === Тест 2: Retry при ошибке ===
    async function test2() {
      console.log('\n=== Тест 2: Retry (провал 2 раза) ===')
      const AsyncComp = defineAsyncComponent({
        loader: mockImport('DataGrid', { failTimes: 2, delay: 50 }),
        onError(err, retry, fail, attempts) {
          console.log(`Ошибка #${attempts}:`, err.message)
          if (attempts < 3) retry()
          else fail()
        }
      })
    
      try {
        const comp = await AsyncComp.load()
        console.log('Успех после', AsyncComp.getAttempts(), 'попыток:', comp.name)
      } catch (e) {
        console.log('Финальная ошибка:', e.message)
      }
    }
    
    // === Тест 3: Таймаут ===
    async function test3() {
      console.log('\n=== Тест 3: Таймаут ===')
      const AsyncComp = defineAsyncComponent({
        loader: mockImport('SlowMap', { delay: 500 }),
        timeout: 200,
      })
      try {
        await AsyncComp.load()
      } catch (e) {
        console.log('Поймали:', e.message, '| Статус:', AsyncComp.getStatus())
      }
    }
    
    test1().then(() => test2()).then(() => test3())