Асинхронні компоненти та затримка в Vue.js
Асинхронні компоненти та Suspense вирішують різні задачі, але добре працюють разом. defineAsyncComponent розбиває JS-бандл і завантажує код компонента лише тоді, коли він потрібен. <Suspense> керує тим, що бачить користувач під час цього завантаження.
Теорія
TL;DR
- Асинхронні компоненти використовують динамічний
import()для створення окремих JS-чанків, зменшуючи початковий бандл на 50-90% у великих застосунках <Suspense>відстежує проміси від асинхронних компонентів таawaitу<script setup>, показує#fallbackпоки всі не виконаються- Головна різниця: async = розбивка коду (мережа); Suspense = координація завантаження (UI)
- Використовуй async для компонентів понад 10KB, яких немає на першому екрані; додавай Suspense якщо очікування перевищує 500ms
- Suspense чекає на найповільніший проміс. Часткового відображення всередині одного boundary немає
Швидкий приклад
<script setup>
import { defineAsyncComponent } from 'vue'
// Vite/webpack виносить Chart.vue в окремий JS-чанк
const AsyncChart = defineAsyncComponent(() => import('./Chart.vue'))
</script>
<template>
<Suspense>
<template #default>
<AsyncChart /> <!-- чанк завантажується при монтуванні -->
</template>
<template #fallback>
<div>Завантаження графіка...</div> <!-- рендериться одразу -->
</template>
</Suspense>
</template>"Завантаження графіка..." з'являється відразу. Chart.vue завантажується у фоні та підміняється після готовності.
Ключова різниця
defineAsyncComponent про мережу. Він каже Vite або webpack покласти компонент в окремий JS-файл і завантажити його через динамічний import() при першому рендері. Suspense нічого не знає про розбивку коду. Він сканує дочірнє дерево в пошуку незавершених промісів, від асинхронних компонентів або від await у <script setup>, і тримає слот #fallback поки всі не виконаються. Один зменшує бандл. Другий не дає екрану залишатись пустим.
defineAsyncComponent з параметрами
Проста форма огортає один import(). Форма з параметрами дає контроль над станами завантаження та помилок:
<script setup>
import { defineAsyncComponent } from 'vue'
const AsyncUsersTable = defineAsyncComponent({
loader: () => import('./UsersTable.vue'),
loadingComponent: LoadingSpinner, // fallback для конкретного компонента
errorComponent: ErrorDisplay, // показується якщо чанк не завантажився
delay: 300, // показувати loadingComponent тільки після 300мс
timeout: 8000 // показати errorComponent якщо не завершено за 8 секунд
})
</script>Параметр delay вирішує реальну UX-проблему. На швидкому з'єднанні чанк завантажується менш ніж за 300мс, тому користувачі спінер взагалі не бачать. Він з'являється тільки на повільних мережах. Без delay спінер блимає при кожному завантаженні сторінки, навіть у тих хто сидить на гарному інтернеті. Більшість команд дізнаються про це вже в продакшені.
Як Suspense відстежує асинхронні компоненти
Коли Suspense монтується, Vue реєструє незавершені проміси від кожного асинхронного нащадка: компонентів через defineAsyncComponent і будь-яких await у <script setup>. Слот fallback рендериться одразу. Як тільки всі зареєстровані проміси виконуються, Vue підміняє DOM актуальним контентом.
Якщо компонент використовує await в setup без батьківського Suspense, Vue виводить попередження в режимі розробки. Це не просто шум. Компонент зависає в невирішеному стані і нічого не показує.
<!-- UserProfile.vue: асинхронний через await в setup -->
<script setup>
const response = await fetch('/api/user/1')
const user = await response.json()
</script>
<template>
<div>{{ user.name }}</div>
</template><!-- Parent.vue: Suspense перехоплює проміси від UserProfile -->
<template>
<Suspense>
<UserProfile />
<template #fallback>
<p>Завантаження користувача...</p>
</template>
</Suspense>
</template>Suspense та Vue Router
Ліниве завантаження маршрутів це найпоширеніший продакшн-патерн. Кожен маршрут завантажується як окремий чанк, а Suspense не дає екрану залишатись пустим під час навігації:
<template>
<RouterView v-slot="{ Component }">
<Suspense>
<template #default>
<component :is="Component" />
</template>
<template #fallback>
<PageLoading />
</template>
</Suspense>
</RouterView>
</template>Обробка помилок
Suspense сам по собі помилки не перехоплює. Два варіанти: onErrorCaptured в батьківському компоненті або errorComponent в defineAsyncComponent.
<script setup>
import { onErrorCaptured, ref } from 'vue'
const error = ref(null)
onErrorCaptured((err) => {
error.value = err
return false // зупинити поширення помилки далі
})
</script>
<template>
<div v-if="error">{{ error.message }}</div>
<Suspense v-else>
<AsyncComponent />
<template #fallback><Loading /></template>
</Suspense>
</template>Завжди визначай errorComponent в defineAsyncComponent. Деплойменти, що змінюють хеші в іменах чанків, призводять до 404 для користувачів зі старим закешованим HTML. Без стану помилки вони бачать порожню сторінку без жодного пояснення.
Типові помилки
v-if напряму на <Suspense> призводить до демонтування і перезапуску всіх відстежуваних промісів при кожній зміні умови:
<!-- Неправильно: Suspense демонтується при false, перезапускає проміси при true -->
<Suspense v-if="isReady">
<AsyncComponent />
</Suspense>
<!-- Правильно: v-if ставимо на внутрішній контент -->
<Suspense>
<AsyncComponent v-if="isReady" />
<template #fallback><Loading /></template>
</Suspense>Відсутність timeout на повільних мережах. За замовчуванням таймауту немає взагалі. На 3G користувач може чекати нескінченно. Встанови timeout в defineAsyncComponent.
Огортання синхронних компонентів в Suspense. Suspense завжди рендерить #fallback першим, навіть для миттєвих нащадків. Якщо в дереві немає асинхронного, ти додаєш накладні витрати без причини.
Відсутній errorComponent. Невдале завантаження чанку дає тиху порожню сторінку. Завжди передбачай стан помилки з можливістю повторної спроби.
Каскадні вкладені Suspense без чіткого плану. Внутрішні boundary вирішуються незалежно від зовнішніх. Нагромади забагато і користувач бачить послідовність станів завантаження, яка виглядає як баг. Один Suspense на маршрут, як правило, правильний рівень.
Де використовується
- Nuxt 3:
<NuxtPage>автоматично огортає асинхронні лейаути в Suspense для island architecture - Vue Router застосунки: компоненти маршрутів як
() => import('./View.vue')з патерномRouterViewзі слотом - Великі адмінки: важкі таблиці з
delay: 300, щоб уникнути блимання спінера на швидких з'єднаннях - Quasar Framework:
QPageінтегрує Suspense для асинхронного контенту панелей
Питання на співбесіді
Q: Як Suspense обробляє кілька асинхронних нащадків?
A: Він збирає всі незавершені проміси в чергу і тримає fallback поки кожен не виконається. Найшвидший компонент чекає на найповільніший. Часткового відображення всередині одного boundary немає.
Q: Яка різниця між loadingComponent в defineAsyncComponent і слотом #fallback в Suspense?
A: loadingComponent діє на рівні конкретного компонента і враховує delay та timeout. Слот #fallback покриває весь boundary і не має параметрів таймінгу. Використовуй loadingComponent коли кожен компонент потребує власної поведінки завантаження; #fallback коли потрібен один стан завантаження для групи.
Q: Що станеться якщо асинхронний компонент кинув помилку після того як Suspense вже розрішився?
A: Помилка спливає до найближчого onErrorCaptured або до errorComponent в defineAsyncComponent. Suspense не повертається в режим fallback після розрішення.
Q: Як асинхронні компоненти працюють з SSR?
A: Динамічні імпорти пропускаються на сервері. Компонент рендериться на клієнті, а Suspense показує fallback під час гідратації.
Q: У Vue 3.4+: що відбувається коли async setup охоплює teleport boundary?
A: Проміс setup підвішує teleport-джерело. Boundary Suspense має огортати компонент-відправник, а не teleport-ціль. Огортання цілі приховує підвішене дерево вузлів від boundary, і fallback ніколи не показується.
Приклади
Базовий: лінива модалка за дією користувача
<script setup>
import { defineAsyncComponent, ref } from 'vue'
// Чанк HeavyModal завантажується тільки коли користувач відкриває модалку
const AsyncModal = defineAsyncComponent({
loader: () => import('./HeavyModal.vue'),
errorComponent: ErrorDisplay,
timeout: 5000
})
const showModal = ref(false)
</script>
<template>
<button @click="showModal = true">Відкрити налаштування</button>
<Suspense>
<template #default>
<AsyncModal v-if="showModal" @close="showModal = false" />
</template>
<template #fallback>
<div v-if="showModal">Завантаження налаштувань...</div>
</template>
</Suspense>
</template>Чанк модалки не потрапляє в початковий бандл. Перший клік запускає завантаження. Suspense показує повідомлення під час отримання чанку, потім рендерить модалку. Ні порожнього екрану, ні зайвого початкового навантаження.
Середній: панель дашборда з async-отриманням даних
<!-- UserDashboard.vue: асинхронний через await в setup -->
<script setup>
const res = await fetch('/api/dashboard')
const stats = await res.json()
</script>
<template>
<div class="dashboard">
<h2>Замовлення: {{ stats.orders }}</h2>
<p>Виручка: {{ stats.revenue }}</p>
</div>
</template><!-- App.vue: Suspense перехоплює async setup від UserDashboard -->
<script setup>
import { onErrorCaptured, ref } from 'vue'
const error = ref(null)
onErrorCaptured((err) => { error.value = err; return false })
</script>
<template>
<div v-if="error">Помилка завантаження: {{ error.message }}</div>
<Suspense v-else>
<template #default>
<UserDashboard />
</template>
<template #fallback>
<DashboardSkeleton />
</template>
</Suspense>
</template><DashboardSkeleton /> показується поки API-запит виконується. Якщо запит впав, onErrorCaptured перехоплює і показує повідомлення про помилку. Користувач завжди бачить зворотній зв'язок.
Просунутий: кілька асинхронних нащадків паралельно
<script setup>
import { defineAsyncComponent } from 'vue'
// Три окремих чанки, всі завантажуються паралельно
const AsyncChart = defineAsyncComponent(() => import('./RevenueChart.vue'))
const AsyncTable = defineAsyncComponent(() => import('./OrdersTable.vue'))
const AsyncMap = defineAsyncComponent(() => import('./DeliveryMap.vue'))
</script>
<template>
<Suspense>
<template #default>
<!-- Всі три паралельно; fallback тримається до завершення найповільнішого -->
<AsyncChart />
<AsyncTable />
<AsyncMap />
</template>
<template #fallback>
<div>Завантаження панелей дашборда...</div>
</template>
</Suspense>
</template>Всі три чанки отримуються паралельно. Єдиний Suspense boundary чекає на всі три перед підміною fallback. Якщо потрібно щоб кожна панель з'являлась одразу по готовності, огорни кожну в окремий <Suspense>.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.