Lazy Loading
The point of lazy loading is a smaller first download.
Most of it is a build setting; the rest is one declaration in the component that needs it.
Split the build by route
Name groups of routes in config/build.js and each becomes its own
chunk, fetched the first time someone visits a page in it:
// config/build.js
export default {
routeGroups: {
docs: ['docs/**'],
admin: ['admin/**'],
},
// One chunk for components several groups share
sharedComponentsChunk: true,
// Named vendor chunks
vendors: {
charting: ['chart.js'],
},
}
Routes that match no group land in main.
With
router.prefetch on - it is on by default - the remaining chunks
are fetched in the background once the first page has rendered, so the visit
that pays for a group is usually already warm. See
Configuration.
lazy()
For one heavy component inside a page most visitors never open, declare it
with lazy().
The declaration claims the tag: <Chart
/> in this file now resolves through that declaration and is fetched
on first use, instead of being loaded with the rest of the page.
<script>
// A component NAME: resolved the way <Chart /> normally is,
// but only once something asks for it.
const Chart = lazy('Chart')
</script>
<suspense fallback="<p>Loading the chart...</p>">
<Chart />
</suspense>
The declaration and the tag have to be in the same component.
<Chart /> in another file, with no declaration of its own,
resolves by filename as usual.
What lazy() accepts
| Form | Means |
|---|---|
lazy('Chart') |
A component name, resolved by the same lookup a plain
<Chart /> uses. The one to reach for.
|
lazy('/components/Chart.html') |
A path. A build error: a path is resolved against the declaring file's location, so two files could answer to one name and the build cannot say which module to split out. |
lazy(() => import('./chart.js')) |
A dynamic import. A build error for the same reason: there is no compiled component behind it to import. |
Only the name form compiles. Deferring a component is a code split, and a code split needs a module to split - so the declaration has to name a component this build compiled. A path or a dynamic import stops the build naming the declaration.
Changed in 0.1. The alpha accepted all three and resolved them through the component loader while the app ran. If you were using a path to disambiguate two components with the same name, rename the file instead - a build with two components claiming one name says so.
lazy needs no import. Like query(), it is bound in
any script that calls it - and only in those, so a component with its own
lazy keeps it.
Suspense
<suspense> shows a fallback while the lazy tags
under it are in flight, then swaps in the real children:
<script>
const Chart = lazy('Chart')
const Map = lazy('Map')
</script>
<suspense fallback="<p>Loading...</p>">
<Chart />
<Map />
</suspense>
It waits for all of them, so the swap is one step rather than a sequence of pops.
When every child is already loaded - a second boundary over the same component, a return visit - the children render immediately and no fallback appears at all. See Suspense for the rest of its behaviour.
A live one
The chart below is not resolved, loaded or mounted until you ask for it.
Click, and the boundary shows its fallback while the component is loaded, then swaps it in:
<script>
const LazyChart = lazy('LazyChart')
let showing = false
</script>
<button class="lazy-demo-button" onclick="showing = true" disabled={showing}>
Load the chart
</button>
<show when={showing}>
<suspense fallback="<p class="lazy-demo-loading">Loading the chart...</p>">
<LazyChart />
</suspense>
</show>
<style>
.lazy-demo-button {
padding: 0.4rem 0.9rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm, 0.375rem);
background: var(--bg-secondary, transparent);
cursor: pointer;
margin-bottom: 0.75rem;
}
.lazy-demo-button:disabled {
opacity: 0.5;
cursor: default;
}
.lazy-demo-loading {
color: var(--text-tertiary);
font-style: italic;
}
</style>Preloading by hand
A lower-case name claims no tag, which is what you want for a wrapper you drive yourself - loading on intent, before the click that needs it:
<script>
// Lower case: this one claims no tag. It is a loader you drive yourself.
const heavy = lazy('HeavyPanel')
let showing = false
</script>
<button onmouseenter="heavy.load()" onclick="showing = true">
Show panel
</button>
| Method | What it does |
|---|---|
load() |
Start the fetch, and return a promise for it. Calling it repeatedly shares one promise, so nothing is fetched twice - by you, or by a boundary. |
getComponent() |
The loaded source, or null if it has not arrived. |
Worth knowing
- A claim needs a PascalCase name and a top-level declaration. Inside a function, or lower case, it claims nothing.
-
Two components that write
lazy('Chart')share one fetch and one cached result. A failed load is not cached, so the next boundary retries. - A lazy tag is never prefetched - the pass that warms the rest of a page skips it.
-
How much network it saves is your chunking's business.
Deferring the resolve and the mount is what
lazy()guarantees; whether the source arrives over the wire at that moment depends on which chunk it is in. In dev every component is served from one registry, so the demo above loads instantly - the saving shows in a production build where the component sits in a route group nothing has pulled yet. -
A lazy tag with no
<suspense>above it is fine: it mounts when it is ready, with no loading state. -
A namespaced tag wins: it resolves against its namespace's pool and keeps that resolver,
so it is not lazy even if the name was claimed.
file="..."on a tag is itself a build error - it names a component the build cannot resolve until it runs. -
A
<suspense>fallbackis a literal HTML string. An expression in it, or a component tag in it, is a build error naming the fallback. -
Declaring your own
lazytakes the name back, and no tag is claimed.