Everything in your src folder ends up in one JavaScript bundle unless you tell the
build otherwise. This lesson is about telling it otherwise, and about the two components that go with
that.
Route-level code splitting
The highest-value split, and one line per route:
component: () => import("../views/public/ExploreView.vue"),A function returning a dynamic import() instead of the component. Vite sees the
import(), gives that module its own chunk, and the chunk is fetched the first time someone
navigates there.
The numbers from the demo application's build make the case:
dist/assets/FeedView-DYKBp3Im.js 3.20 kB
dist/assets/ExploreView-BO33kbQP.js 3.79 kB
dist/assets/ReelListView-BZ8crJCl.js 7.74 kB
dist/assets/ReelEditView-DLPQMmBp.js 14.38 kB
dist/assets/DashboardView-BMckYpIh.js 197.49 kB <- Chart.js lives here
dist/assets/index-DYUHRy06.js 138.03 kB <- the shared bundleThe dashboard is bigger than the entire rest of the application, because it pulls in Chart.js. Nobody visiting the public feed downloads a byte of it.
Except the landing route
// The feed is eager: it is the landing route, and a lazy chunk there just adds
// a round trip before the first pixel.
component: FeedView,The rule is lazy-load everything except the route people arrive on. Splitting the first screen makes the browser fetch the app shell, parse it, discover it needs another file, and fetch that too — a guaranteed extra round trip before anything renders.
defineAsyncComponent
The same idea for a component that is not a route:
import { defineAsyncComponent } from "vue";
// The chunk is fetched the first time <ReelAnalytics> actually renders.
const ReelAnalytics = defineAsyncComponent(() =>
import("../components/admin/ReelAnalytics.vue")
);Worth it for something heavy behind a condition — a chart panel on a tab, a rich text editor in a modal, a map. Not worth it for a 2 kB card component, where the extra request costs more than the bytes saved.
The long form takes loading and error states, which is what you want for anything the user waits on:
const ReelAnalytics = defineAsyncComponent({
loader: () => import("../components/admin/ReelAnalytics.vue"),
loadingComponent: LoadingSpinner,
errorComponent: LoadFailed,
// Do not flash a spinner for a chunk that arrives in 40ms.
delay: 200,
timeout: 10000,
});delay is the detail worth copying. Without it, a fast connection gets a spinner that
appears and vanishes within one frame, which reads as a flicker rather than as loading.
Also note the errorComponent. A dynamic import fails more often than you would
think — most commonly when you deploy while someone has the old page open, and the hashed chunk they ask
for no longer exists. Handle it, and consider reloading the page on that specific failure.
Suspense
Still marked experimental. The API has changed before and may again, so read this as orientation rather than a recommendation.
<Suspense> lets a component have an async setup(), and renders a
fallback until it resolves:
<Suspense>
<ReelDetail :slug="slug" />
<template #fallback>
<LoadingSpinner label="Loading reel…" />
</template>
</Suspense><script setup>
// Top-level await -- allowed only inside a component under <Suspense>.
const reel = await api.reelBySlug(props.slug);
</script>The appeal is that a tree of components can await in parallel and produce one coordinated loading state, instead of six spinners appearing and vanishing independently.
The catch, beyond the experimental label: an error in that await needs an
onErrorCaptured in an ancestor, and a component that has already resolved does not go back
to the fallback when its props change — so re-fetching still needs a watcher.
The demo application uses the explicit loading / error / empty pattern from lesson 22
instead. It is more code and it is entirely stable.
KeepAlive
By default, navigating away from a component destroys it. Come back and it is created from scratch: state gone, scroll position lost, data refetched.
<KeepAlive> caches the instance instead:
<RouterView v-slot="{ Component }">
<KeepAlive :include="['ExploreView']">
<component :is="Component" />
</KeepAlive>
</RouterView>Now going from a search result into a reel and back returns to the same results, at the same scroll position, with no request — which is exactly the behaviour people expect from a browser back button and almost never get from a single-page app.
Cached components get two extra lifecycle hooks, because they are no longer being mounted and unmounted:
import { onActivated, onDeactivated } from "vue";
onActivated(() => {
// Entered from the cache. onMounted does NOT run again.
refreshIfStale();
});
onDeactivated(() => {
// Navigated away, but still alive. Pause things here.
player.value?.pause();
});This is the part that bites. A cached component's onMounted runs once,
ever. Any refresh-on-return logic that lives there silently stops happening, and
onBeforeUnmount never fires — so a timer or a subscription started at mount keeps running
for the rest of the session, for every cached component.
Use :include or :max to bound it. <KeepAlive> with no
props caches every component that passes through it, and the memory only goes up.
Next: Performance.