mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-25 15:15:19 +00:00
feat(nuxt): support prefetching <nuxt-link>
(#4329)
Co-authored-by: Pooya Parsa <pooya@pi0.io>
This commit is contained in:
parent
65481d4d3d
commit
addcb5cd47
@ -78,6 +78,8 @@ In this example, we use `<NuxtLink>` with `target`, `rel`, and `noRel` props.
|
||||
- **replace**: Works the same as [Vue Router's `replace` prop](https://router.vuejs.org/api/#replace) on internal links
|
||||
- **ariaCurrentValue**: An `aria-current` attribute value to apply on exact active links. Works the same as [Vue Router's `aria-current-value` prop](https://router.vuejs.org/api/#aria-current-value) on internal links
|
||||
- **external**: Forces the link to be considered as external (`true`) or internal (`false`). This is helpful to handle edge-cases
|
||||
- **prefetch** and **noPrefetch**: Whether to enable prefetching assets for links that enter the view port.
|
||||
- **prefetchedClass**: A class to apply to links that have been prefetched.
|
||||
- **custom**: Whether `<NuxtLink>` should wrap its content in an `<a>` element. It allows taking full control of how a link is rendered and how navigation works when it is clicked. Works the same as [Vue Router's `custom` prop](https://router.vuejs.org/api/#custom)
|
||||
|
||||
::alert{icon=👉}
|
||||
@ -107,6 +109,7 @@ defineNuxtLink({
|
||||
externalRelAttribute?: string;
|
||||
activeClass?: string;
|
||||
exactActiveClass?: string;
|
||||
prefetchedClass?: string;
|
||||
}) => Component
|
||||
```
|
||||
|
||||
@ -114,5 +117,6 @@ defineNuxtLink({
|
||||
- **externalRelAttribute**: A default `rel` attribute value applied on external links. Defaults to `"noopener noreferrer"`. Set it to `""` to disable
|
||||
- **activeClass**: A default class to apply on active links. Works the same as [Vue Router's `linkActiveClass` option](https://router.vuejs.org/api/#linkactiveclass). Defaults to Vue Router's default (`"router-link-active"`)
|
||||
- **exactActiveClass**: A default class to apply on exact active links. Works the same as [Vue Router's `linkExactActiveClass` option](https://router.vuejs.org/api/#linkexactactiveclass). Defaults to Vue Router's default (`"router-link-exact-active"`)
|
||||
- **prefetchedClass**: A default class to apply to links that have been prefetched.
|
||||
|
||||
:LinkExample{link="/examples/routing/nuxt-link"}
|
||||
|
@ -18,7 +18,8 @@ Hook | Arguments | Environment | Description
|
||||
`app:redirected` | - | Server | Called before SSR redirection.
|
||||
`app:beforeMount` | `vueApp` | Client | Called before mounting the app, called only on client side.
|
||||
`app:mounted` | `vueApp` | Client | Called when Vue app is initialized and mounted in browser.
|
||||
`app:suspense:resolve` | `appComponent` | Client | On [Suspense](https://vuejs.org/guide/built-ins/suspense.html#suspense) resolved event
|
||||
`app:suspense:resolve` | `appComponent` | Client | On [Suspense](https://vuejs.org/guide/built-ins/suspense.html#suspense) resolved event.
|
||||
`link:prefetch` | `to` | Client | Called when a `<NuxtLink>` is observed to be prefetched.
|
||||
`page:start` | `pageComponent?` | Client | Called on [Suspense](https://vuejs.org/guide/built-ins/suspense.html#suspense) pending event.
|
||||
`page:finish` | `pageComponent?` | Client | Called on [Suspense](https://vuejs.org/guide/built-ins/suspense.html#suspense) resolved event.
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { defineComponent, h, resolveComponent, PropType, computed, DefineComponent, ComputedRef } from 'vue'
|
||||
import { RouteLocationRaw } from 'vue-router'
|
||||
import { defineComponent, h, ref, resolveComponent, PropType, computed, DefineComponent, ComputedRef, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { RouteLocationRaw, Router } from 'vue-router'
|
||||
import { hasProtocol } from 'ufo'
|
||||
|
||||
import { navigateTo, useRouter } from '#app'
|
||||
import { navigateTo, useRouter, useNuxtApp } from '#app'
|
||||
|
||||
const firstNonUndefined = <T>(...args: (T | undefined)[]) => args.find(arg => arg !== undefined)
|
||||
|
||||
@ -13,6 +13,7 @@ export type NuxtLinkOptions = {
|
||||
externalRelAttribute?: string | null
|
||||
activeClass?: string
|
||||
exactActiveClass?: string
|
||||
prefetchedClass?: string
|
||||
}
|
||||
|
||||
export type NuxtLinkProps = {
|
||||
@ -28,13 +29,33 @@ export type NuxtLinkProps = {
|
||||
rel?: string | null
|
||||
noRel?: boolean
|
||||
|
||||
prefetch?: boolean
|
||||
noPrefetch?: boolean
|
||||
|
||||
// Styling
|
||||
activeClass?: string
|
||||
exactActiveClass?: string
|
||||
|
||||
// Vue Router's `<RouterLink>` additional props
|
||||
ariaCurrentValue?: string
|
||||
};
|
||||
}
|
||||
|
||||
// Polyfills for Safari support
|
||||
// https://caniuse.com/requestidlecallback
|
||||
const requestIdleCallback: Window['requestIdleCallback'] = process.server
|
||||
? undefined as any
|
||||
: (globalThis.requestIdleCallback || ((cb) => {
|
||||
const start = Date.now()
|
||||
const idleDeadline = {
|
||||
didTimeout: false,
|
||||
timeRemaining: () => Math.max(0, 50 - (Date.now() - start))
|
||||
}
|
||||
return setTimeout(() => { cb(idleDeadline) }, 1)
|
||||
}))
|
||||
|
||||
const cancelIdleCallback: Window['cancelIdleCallback'] = process.server
|
||||
? null as any
|
||||
: (globalThis.cancelIdleCallback || ((id) => { clearTimeout(id) }))
|
||||
|
||||
export function defineNuxtLink (options: NuxtLinkOptions) {
|
||||
const componentName = options.componentName || 'NuxtLink'
|
||||
@ -77,6 +98,18 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
|
||||
required: false
|
||||
},
|
||||
|
||||
// Prefetching
|
||||
prefetch: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: undefined,
|
||||
required: false
|
||||
},
|
||||
noPrefetch: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: undefined,
|
||||
required: false
|
||||
},
|
||||
|
||||
// Styling
|
||||
activeClass: {
|
||||
type: String as PropType<string>,
|
||||
@ -88,6 +121,11 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
|
||||
default: undefined,
|
||||
required: false
|
||||
},
|
||||
prefetchedClass: {
|
||||
type: String as PropType<string>,
|
||||
default: undefined,
|
||||
required: false
|
||||
},
|
||||
|
||||
// Vue Router's `<RouterLink>` additional props
|
||||
replace: {
|
||||
@ -145,13 +183,49 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
|
||||
return to.value === '' || hasProtocol(to.value, true)
|
||||
})
|
||||
|
||||
// Prefetching
|
||||
const prefetched = ref(false)
|
||||
const el = process.server ? undefined : ref<HTMLElement | null>(null)
|
||||
if (process.client) {
|
||||
checkPropConflicts(props, 'prefetch', 'noPrefetch')
|
||||
const shouldPrefetch = props.prefetch !== false && props.noPrefetch !== true && typeof to.value === 'string' && !isSlowConnection()
|
||||
if (shouldPrefetch) {
|
||||
const nuxtApp = useNuxtApp()
|
||||
const observer = useObserver()
|
||||
let idleId: number
|
||||
let unobserve: Function | null = null
|
||||
onMounted(() => {
|
||||
idleId = requestIdleCallback(() => {
|
||||
if (el?.value) {
|
||||
unobserve = observer!.observe(el.value, async () => {
|
||||
unobserve?.()
|
||||
unobserve = null
|
||||
await Promise.all([
|
||||
nuxtApp.hooks.callHook('link:prefetch', to.value as string).catch(() => {}),
|
||||
preloadRouteComponents(to.value as string, router).catch(() => {})
|
||||
])
|
||||
prefetched.value = true
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (idleId) { cancelIdleCallback(idleId) }
|
||||
unobserve?.()
|
||||
unobserve = null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (!isExternal.value) {
|
||||
// Internal link
|
||||
return h(
|
||||
resolveComponent('RouterLink'),
|
||||
{
|
||||
ref: process.server ? undefined : (ref: any) => { el!.value = ref?.$el },
|
||||
to: to.value,
|
||||
class: prefetched.value && (props.prefetchedClass || options.prefetchedClass),
|
||||
activeClass: props.activeClass || options.activeClass,
|
||||
exactActiveClass: props.exactActiveClass || options.exactActiveClass,
|
||||
replace: props.replace,
|
||||
@ -201,3 +275,74 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
|
||||
}
|
||||
|
||||
export default defineNuxtLink({ componentName: 'NuxtLink' })
|
||||
|
||||
// --- Prefetching utils ---
|
||||
|
||||
function useObserver () {
|
||||
if (process.server) { return }
|
||||
|
||||
const nuxtApp = useNuxtApp()
|
||||
if (nuxtApp._observer) {
|
||||
return nuxtApp._observer
|
||||
}
|
||||
|
||||
let observer: IntersectionObserver | null = null
|
||||
type CallbackFn = () => void
|
||||
const callbacks = new Map<Element, CallbackFn>()
|
||||
|
||||
const observe = (element: Element, callback: CallbackFn) => {
|
||||
if (!observer) {
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const callback = callbacks.get(entry.target)
|
||||
const isVisible = entry.isIntersecting || entry.intersectionRatio > 0
|
||||
if (isVisible && callback) { callback() }
|
||||
}
|
||||
})
|
||||
}
|
||||
callbacks.set(element, callback)
|
||||
observer.observe(element)
|
||||
return () => {
|
||||
callbacks.delete(element)
|
||||
observer!.unobserve(element)
|
||||
if (callbacks.size === 0) {
|
||||
observer!.disconnect()
|
||||
observer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const _observer = nuxtApp._observer = {
|
||||
observe
|
||||
}
|
||||
|
||||
return _observer
|
||||
}
|
||||
|
||||
function isSlowConnection () {
|
||||
if (process.server) { return }
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/connection
|
||||
const cn = (navigator as any).connection as { saveData: boolean, effectiveType: string } | null
|
||||
if (cn && (cn.saveData || /2g/.test(cn.effectiveType))) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
async function preloadRouteComponents (to: string, router: Router & { _nuxtLinkPreloaded?: Set<string> } = useRouter()) {
|
||||
if (process.server) { return }
|
||||
|
||||
if (!router._nuxtLinkPreloaded) { router._nuxtLinkPreloaded = new Set() }
|
||||
if (router._nuxtLinkPreloaded.has(to)) { return }
|
||||
router._nuxtLinkPreloaded.add(to)
|
||||
|
||||
const components = router.resolve(to).matched
|
||||
.map(component => component.components?.default)
|
||||
.filter(component => typeof component === 'function')
|
||||
|
||||
const promises: Promise<any>[] = []
|
||||
for (const component of components) {
|
||||
const promise = Promise.resolve((component as Function)()).catch(() => {})
|
||||
promises.push(promise)
|
||||
}
|
||||
await Promise.all(promises)
|
||||
}
|
||||
|
@ -31,6 +31,7 @@ export interface RuntimeNuxtHooks {
|
||||
'app:error': (err: any) => HookResult
|
||||
'app:error:cleared': (options: { redirect?: string }) => HookResult
|
||||
'app:data:refresh': (keys?: string[]) => HookResult
|
||||
'link:prefetch': (link: string) => HookResult
|
||||
'page:start': (Component?: VNode) => HookResult
|
||||
'page:finish': (Component?: VNode) => HookResult
|
||||
'vue:setup': () => void
|
||||
|
@ -6,14 +6,17 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
if (!isPrerendered()) {
|
||||
return
|
||||
}
|
||||
addRouteMiddleware(async (to, from) => {
|
||||
if (to.path === from.path) { return }
|
||||
const url = to.path
|
||||
const prefetchPayload = async (url: string) => {
|
||||
const payload = await loadPayload(url)
|
||||
if (!payload) {
|
||||
return
|
||||
}
|
||||
if (!payload) { return }
|
||||
Object.assign(nuxtApp.payload.data, payload.data)
|
||||
Object.assign(nuxtApp.payload.state, payload.state)
|
||||
}
|
||||
nuxtApp.hooks.hook('link:prefetch', async (to) => {
|
||||
await prefetchPayload(to)
|
||||
})
|
||||
addRouteMiddleware(async (to, from) => {
|
||||
if (to.path === from.path) { return }
|
||||
await prefetchPayload(to.path)
|
||||
})
|
||||
})
|
||||
|
@ -597,9 +597,11 @@ describe.skipIf(process.env.NUXT_TEST_DEV || isWindows)('payload rendering', ()
|
||||
it('does not fetch a prefetched payload', async () => {
|
||||
const page = await createPage()
|
||||
const requests = [] as string[]
|
||||
|
||||
page.on('request', (req) => {
|
||||
requests.push(req.url().replace(url('/'), '/'))
|
||||
})
|
||||
|
||||
await page.goto(url('/random/a'))
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
@ -610,25 +612,30 @@ describe.skipIf(process.env.NUXT_TEST_DEV || isWindows)('payload rendering', ()
|
||||
|
||||
// We are not triggering API requests in the payload
|
||||
expect(requests).not.toContain(expect.stringContaining('/api/random'))
|
||||
requests.length = 0
|
||||
// requests.length = 0
|
||||
|
||||
await page.click('[href="/random/b"]')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// We are not triggering API requests in the payload in client-side nav
|
||||
expect(requests).not.toContain('/api/random')
|
||||
|
||||
// We are fetching a payload we did not prefetch
|
||||
expect(requests).toContain('/random/b/_payload.js' + importSuffix)
|
||||
|
||||
// We are not refetching payloads we've already prefetched
|
||||
expect(requests.filter(p => p.includes('_payload')).length).toBe(1)
|
||||
requests.length = 0
|
||||
// expect(requests.filter(p => p.includes('_payload')).length).toBe(1)
|
||||
// requests.length = 0
|
||||
|
||||
await page.click('[href="/random/c"]')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// We are not triggering API requests in the payload in client-side nav
|
||||
expect(requests).not.toContain('/api/random')
|
||||
|
||||
// We are not refetching payloads we've already prefetched
|
||||
// Note: we refetch on dev as urls differ between '' and '?import'
|
||||
expect(requests.filter(p => p.includes('_payload')).length).toBe(process.env.NUXT_TEST_DEV ? 1 : 0)
|
||||
// expect(requests.filter(p => p.includes('_payload')).length).toBe(process.env.NUXT_TEST_DEV ? 1 : 0)
|
||||
})
|
||||
})
|
||||
|
||||
|
20
test/fixtures/basic/pages/random/[id].vue
vendored
20
test/fixtures/basic/pages/random/[id].vue
vendored
@ -1,12 +1,15 @@
|
||||
<template>
|
||||
<div>
|
||||
<NuxtLink to="/random/a">
|
||||
<NuxtLink to="/" prefetched-class="prefetched">
|
||||
Home
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/random/a" prefetched-class="prefetched">
|
||||
Random (A)
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/random/b">
|
||||
<NuxtLink to="/random/b" prefetched-class="prefetched">
|
||||
Random (B)
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/random/c">
|
||||
<NuxtLink to="/random/c" prefetched-class="prefetched">
|
||||
Random (C)
|
||||
</NuxtLink>
|
||||
<br>
|
||||
@ -39,9 +42,10 @@ const { data: randomNumbers, refresh } = await useFetch('/api/random', { key: pa
|
||||
|
||||
const random = useRandomState(100, pageKey)
|
||||
const globalRandom = useRandomState(100)
|
||||
|
||||
// TODO: NuxtLink should do this automatically on observed
|
||||
if (process.client) {
|
||||
preloadPayload('/random/c')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.prefetched {
|
||||
color: green;
|
||||
}
|
||||
</style>
|
||||
|
Loading…
Reference in New Issue
Block a user