Skip to main content

Асинхронні компоненти та затримка в 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 немає

Швидкий приклад

vue
<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(). Форма з параметрами дає контроль над станами завантаження та помилок:

vue
<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 виводить попередження в режимі розробки. Це не просто шум. Компонент зависає в невирішеному стані і нічого не показує.

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>
vue
<!-- Parent.vue: Suspense перехоплює проміси від UserProfile --> <template> <Suspense> <UserProfile /> <template #fallback> <p>Завантаження користувача...</p> </template> </Suspense> </template>

Suspense та Vue Router

Ліниве завантаження маршрутів це найпоширеніший продакшн-патерн. Кожен маршрут завантажується як окремий чанк, а Suspense не дає екрану залишатись пустим під час навігації:

vue
<template> <RouterView v-slot="{ Component }"> <Suspense> <template #default> <component :is="Component" /> </template> <template #fallback> <PageLoading /> </template> </Suspense> </RouterView> </template>

Обробка помилок

Suspense сам по собі помилки не перехоплює. Два варіанти: onErrorCaptured в батьківському компоненті або errorComponent в defineAsyncComponent.

vue
<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> призводить до демонтування і перезапуску всіх відстежуваних промісів при кожній зміні умови:

vue
<!-- Неправильно: 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 ніколи не показується.

Приклади

Базовий: лінива модалка за дією користувача

vue
<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-отриманням даних

vue
<!-- 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>
vue
<!-- 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 перехоплює і показує повідомлення про помилку. Користувач завжди бачить зворотній зв'язок.

Просунутий: кілька асинхронних нащадків паралельно

vue
<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>.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?