← Курс/async setup() и Suspense#218 из 257+30 XP

async setup() и Suspense

async setup()

В Vue 3 функция setup() может быть **асинхронной**. Это позволяет использовать await прямо на верхнем уровне:

<!-- UserProfile.vue -->
<script setup>
// Компонент с async setup — await прямо на верхнем уровне
const user = await fetchUser(userId)
const posts = await fetchUserPosts(userId)
// Компонент будет ждать всех промисов перед рендером
</script>

<template>
  <div>
    <h1>{{ user.name }}</h1>
    <PostList :posts="posts" />
  </div>
</template>

Компонент с async setup не будет рендериться, пока все промисы не разрешатся.

Проблема: нужен родительский <Suspense>

Async-компоненты **нельзя использовать без <Suspense>** родительского компонента. Иначе они просто не отобразятся. Компонент <Suspense> — это встроенный контейнер, который управляет ожиданием асинхронных дочерних компонентов:

<!-- ParentComponent.vue -->
<template>
  <Suspense>
    <!-- Слот #default: контент который загружается -->
    <template #default>
      <UserProfile :userId="42" />
    </template>

    <!-- Слот #fallback: что показать пока грузится -->
    <template #fallback>
      <div class="skeleton">
        <div class="skeleton-avatar"></div>
        <div class="skeleton-text"></div>
      </div>
    </template>
  </Suspense>
</template>

Обработка ошибок: onErrorCaptured

Хук onErrorCaptured перехватывает ошибки из дочерних компонентов, включая ошибки в async setup:

<script setup>
import { ref, onErrorCaptured } from 'vue'

const error = ref(null)

onErrorCaptured((err, instance, info) => {
  error.value = err.message
  return false  // false = не распространять ошибку выше
})
</script>

<template>
  <div v-if="error" class="error-banner">
    Ошибка загрузки: {{ error }}
  </div>

  <Suspense v-else>
    <template #default>
      <AsyncComponent />
    </template>
    <template #fallback>
      <LoadingSpinner />
    </template>
  </Suspense>
</template>

defineAsyncComponent

Для ленивой загрузки компонентов используется defineAsyncComponent():

import { defineAsyncComponent } from 'vue'

// Простой вариант
const UserDashboard = defineAsyncComponent(
  () => import('./UserDashboard.vue')
)

// Расширенный вариант с обработкой состояний
const HeavyChart = defineAsyncComponent({
  loader: () => import('./HeavyChart.vue'),
  loadingComponent: LoadingSpinner,  // показывается пока грузится
  errorComponent: ErrorDisplay,      // показывается при ошибке
  delay: 200,                        // задержка перед показом loadingComponent
  timeout: 10000,                    // таймаут (показывает errorComponent)
})

Вложенный Suspense

Suspense-компоненты можно вкладывать. Каждый управляет своими async-дочерними:

<Suspense>
  <template #default>
    <UserProfile />  <!-- своя загрузка -->

    <Suspense>       <!-- вложенный — независимая загрузка -->
      <template #default>
        <RecommendedPosts />
      </template>
      <template #fallback>
        <PostsSkeleton />
      </template>
    </Suspense>
  </template>
  <template #fallback>
    <FullPageLoader />
  </template>
</Suspense>

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

  • Загрузка данных необходимых для первого рендера
  • Инициализация сторонних библиотек с async API
  • Получение конфигурации перед рендером
  • Не используй async setup для:

  • Данных, которые не нужны немедленно — используй onMounted + реактивное состояние
  • Запросов, которые могут провалиться без ущерба для UX — лучше загрузить после рендера
  • Примеры

    Эмуляция механизма Suspense: отслеживание async-компонентов и управление fallback-состоянием

    // Эмулируем поведение <Suspense>: управление async-загрузкой компонентов
    
    // Симуляция API запросов с задержкой
    function fakeApiCall(data, delay, shouldFail = false) {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          if (shouldFail) {
            reject(new Error(`API Error: не удалось загрузить данные`))
          } else {
            resolve(data)
          }
        }, delay)
      })
    }
    
    // Async-компонент: имеет async setup
    class AsyncComponent {
      constructor(name, setupFn) {
        this.name = name
        this._setupFn = setupFn
        this.data = null
        this.error = null
        this.isReady = false
      }
    
      async setup() {
        try {
          this.data = await this._setupFn()
          this.isReady = true
          return this.data
        } catch (err) {
          this.error = err
          throw err
        }
      }
    
      render() {
        if (!this.isReady) return `[${this.name}: не готов]`
        return `[${this.name}: ${JSON.stringify(this.data)}]`
      }
    }
    
    // Suspense-контейнер
    class SuspenseContainer {
      constructor(defaultSlot, fallbackSlot, onError) {
        this._components = defaultSlot  // async-компоненты
        this._fallback = fallbackSlot
        this._onError = onError
        this._state = 'pending'  // pending | resolved | error
      }
    
      async mount() {
        console.log(`  [Suspense] Начало загрузки. Показываем fallback: "${this._fallback}"`)
    
        // Запускаем все async setup параллельно
        const promises = this._components.map(comp =>
          comp.setup().catch(err => {
            console.log(`  [Suspense] Ошибка в компоненте "${comp.name}": ${err.message}`)
            if (this._onError) {
              this._onError(err, comp)
            }
            throw err
          })
        )
    
        try {
          await Promise.all(promises)
          this._state = 'resolved'
          console.log(`  [Suspense] Все компоненты загружены. Убираем fallback.`)
          this._render()
        } catch (err) {
          this._state = 'error'
          console.log(`  [Suspense] Ошибка загрузки. Состояние: error`)
        }
      }
    
      _render() {
        console.log('\n  [Suspense] Рендер default-слота:')
        this._components.forEach(comp => {
          console.log('   ', comp.render())
        })
      }
    }
    
    // === Сценарий 1: успешная загрузка ===
    async function scenario1() {
      console.log('=== Сценарий 1: Успешная загрузка ===')
    
      const userComp = new AsyncComponent('UserProfile', async () => {
        console.log('  [UserProfile] async setup: загружаем пользователя...')
        return await fakeApiCall({ name: 'Алексей', id: 1 }, 100)
      })
    
      const postsComp = new AsyncComponent('PostList', async () => {
        console.log('  [PostList] async setup: загружаем посты...')
        return await fakeApiCall([{ id: 1 }, { id: 2 }], 150)
      })
    
      const suspense = new SuspenseContainer(
        [userComp, postsComp],
        'Skeleton-загрузчик...',
        null
      )
    
      await suspense.mount()
    }
    
    // === Сценарий 2: с обработкой ошибки через onErrorCaptured ===
    async function scenario2() {
      console.log('\n=== Сценарий 2: Ошибка + onErrorCaptured ===')
    
      let capturedError = null
    
      const brokenComp = new AsyncComponent('BrokenComponent', async () => {
        return await fakeApiCall(null, 100, true)  // Симулируем ошибку
      })
    
      const suspense = new SuspenseContainer(
        [brokenComp],
        'Загрузка...',
        (err, comp) => {
          capturedError = err.message
          console.log(`  [onErrorCaptured] Перехвачена ошибка: ${err.message}`)
          console.log(`  [onErrorCaptured] error.value = "${err.message}" — показываем error UI`)
        }
      )
    
      await suspense.mount()
      console.log(`  Итог: capturedError = "${capturedError}"`)
    }
    
    // Запуск
    scenario1().then(() => scenario2())