mirror of
https://github.com/nuxt/nuxt.git
synced 2025-02-16 21:58:19 +00:00
feat: support condition, media query, and never
This adds 3 more types of hydration to cover most of the use cases.
This commit is contained in:
parent
7fc29e1a56
commit
e304d6c367
@ -165,6 +165,36 @@ If you would like the component to load after certain events occur, like a click
|
|||||||
</template>
|
</template>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you would like to load the component when the window matches a media query, you can use the `LazyMedia` prefix:
|
||||||
|
|
||||||
|
```vue [pages/index.vue]
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<LazyMediaMyComponent />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
For general purpose conditional hydration, you can use the `LazyIf` prefix:
|
||||||
|
|
||||||
|
```vue [pages/index.vue]
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<LazyIfMyComponent />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
If you would like to never hydrate a component, use the `LazyNever` prefix:
|
||||||
|
|
||||||
|
```vue [pages/index.vue]
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<LazyNeverMyComponent />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
### Custom hydration triggers
|
### Custom hydration triggers
|
||||||
|
|
||||||
If you would like to override the default hydration triggers when dealing with delayed hydration, like changing the timeout, the options for the intersection observer, or the events to trigger the hydration, you can do so by supplying a `hydrate` prop to your lazy components.
|
If you would like to override the default hydration triggers when dealing with delayed hydration, like changing the timeout, the options for the intersection observer, or the events to trigger the hydration, you can do so by supplying a `hydrate` prop to your lazy components.
|
||||||
@ -172,9 +202,11 @@ If you would like to override the default hydration triggers when dealing with d
|
|||||||
```vue [pages/index.vue]
|
```vue [pages/index.vue]
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<LazyIdleMyComponent :hydrate="createIdleLoader(3000)"/>
|
<LazyIdleMyComponent :hydrate="createIdleLoader(3000)" />
|
||||||
<LazyVisibleMyComponent :hydrate="createVisibleLoader({threshold: 0.2})"/>
|
<LazyVisibleMyComponent :hydrate="createVisibleLoader({threshold: 0.2})" />
|
||||||
<LazyEventMyComponent :hydrate="createEventLoader(['click','mouseover'])"/>
|
<LazyEventMyComponent :hydrate="createEventLoader(['click','mouseover'])" />
|
||||||
|
<LazyMediaMyComponent hydrate="(max-width: 500px)" />
|
||||||
|
<LazyIfMyComponent :hydrate="someCondition" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
```
|
```
|
||||||
@ -188,6 +220,10 @@ If you would like to override the default hydration triggers when dealing with d
|
|||||||
::read-more{to="/docs/api/utils/create-event-loader"}
|
::read-more{to="/docs/api/utils/create-event-loader"}
|
||||||
::
|
::
|
||||||
|
|
||||||
|
::read-more{to="https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia"}
|
||||||
|
Read more about using `LazyMedia` components and the accepted values.
|
||||||
|
::
|
||||||
|
|
||||||
::important
|
::important
|
||||||
Nuxt will respect your component names, which means even if your components begin with a reserved prefix like Visible/Idle/Event they will not have delayed hydration. This is made to ensure you have full control over all your components and prevent breaking dynamic imports for those components. This also means you would need to explicitly add the prefix to those components. For example, if you have a component named `IdleBar`, you would need to use it like `<LazyIdleIdleBar>` and not `<LazyIdleBar>` to make it a delayed hydration component, otherwise it would be treated as a regular [dynamic import](/docs/guide/directory-structure/components#dynamic-imports)
|
Nuxt will respect your component names, which means even if your components begin with a reserved prefix like Visible/Idle/Event they will not have delayed hydration. This is made to ensure you have full control over all your components and prevent breaking dynamic imports for those components. This also means you would need to explicitly add the prefix to those components. For example, if you have a component named `IdleBar`, you would need to use it like `<LazyIdleIdleBar>` and not `<LazyIdleBar>` to make it a delayed hydration component, otherwise it would be treated as a regular [dynamic import](/docs/guide/directory-structure/components#dynamic-imports)
|
||||||
::
|
::
|
||||||
|
@ -43,7 +43,7 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
|
|||||||
const s = new MagicString(code)
|
const s = new MagicString(code)
|
||||||
const nuxt = tryUseNuxt()
|
const nuxt = tryUseNuxt()
|
||||||
// replace `_resolveComponent("...")` to direct import
|
// replace `_resolveComponent("...")` to direct import
|
||||||
s.replace(/(?<=[ (])_?resolveComponent\(\s*["'](lazy-|Lazy(?=[A-Z]))?(Idle|Visible|idle-|visible-|Event|event-)?([^'"]*)["'][^)]*\)/g, (full: string, lazy: string, modifier: string, name: string) => {
|
s.replace(/(?<=[ (])_?resolveComponent\(\s*["'](lazy-|Lazy(?=[A-Z]))?(Idle|Visible|idle-|visible-|Event|event-|Media|media-|If|if-|Never|never-)?([^'"]*)["'][^)]*\)/g, (full: string, lazy: string, modifier: string, name: string) => {
|
||||||
const normalComponent = findComponent(components, name, options.mode)
|
const normalComponent = findComponent(components, name, options.mode)
|
||||||
const modifierComponent = !normalComponent && modifier ? findComponent(components, modifier + name, options.mode) : null
|
const modifierComponent = !normalComponent && modifier ? findComponent(components, modifier + name, options.mode) : null
|
||||||
const component = normalComponent || modifierComponent
|
const component = normalComponent || modifierComponent
|
||||||
@ -75,31 +75,50 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (lazy) {
|
if (lazy) {
|
||||||
|
const dynamicImport = `${genDynamicImport(component.filePath, { interopDefault: false })}.then(c => c.${component.export ?? 'default'} || c)`
|
||||||
if (modifier && normalComponent && nuxt?.options.experimental.delayedHydration === true) {
|
if (modifier && normalComponent && nuxt?.options.experimental.delayedHydration === true) {
|
||||||
switch (modifier) {
|
switch (modifier) {
|
||||||
case 'Visible':
|
case 'Visible':
|
||||||
case 'visible-':
|
case 'visible-':
|
||||||
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyIOComponent' }]))
|
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyIOComponent' }]))
|
||||||
identifier += '_delayedIO'
|
identifier += '_delayedIO'
|
||||||
imports.add(`const ${identifier} = createLazyIOComponent(${genDynamicImport(component.filePath, { interopDefault: false })}.then(c => c.${component.export ?? 'default'} || c))`)
|
imports.add(`const ${identifier} = createLazyIOComponent(${dynamicImport})`)
|
||||||
break
|
break
|
||||||
case 'Event':
|
case 'Event':
|
||||||
case 'event-':
|
case 'event-':
|
||||||
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyEventComponent' }]))
|
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyEventComponent' }]))
|
||||||
identifier += '_delayedEvent'
|
identifier += '_delayedEvent'
|
||||||
imports.add(`const ${identifier} = createLazyEventComponent(${genDynamicImport(component.filePath, { interopDefault: false })}.then(c => c.${component.export ?? 'default'} || c))`)
|
imports.add(`const ${identifier} = createLazyEventComponent(${dynamicImport})`)
|
||||||
break
|
break
|
||||||
case 'Idle':
|
case 'Idle':
|
||||||
case 'idle-':
|
case 'idle-':
|
||||||
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyNetworkComponent' }]))
|
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyNetworkComponent' }]))
|
||||||
identifier += '_delayedNetwork'
|
identifier += '_delayedNetwork'
|
||||||
imports.add(`const ${identifier} = createLazyNetworkComponent(${genDynamicImport(component.filePath, { interopDefault: false })}.then(c => c.${component.export ?? 'default'} || c))`)
|
imports.add(`const ${identifier} = createLazyNetworkComponent(${dynamicImport})`)
|
||||||
|
break
|
||||||
|
case 'Media':
|
||||||
|
case 'media-':
|
||||||
|
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyMediaComponent' }]))
|
||||||
|
identifier += '_delayedMedia'
|
||||||
|
imports.add(`const ${identifier} = createLazyMediaComponent(${dynamicImport})`)
|
||||||
|
break
|
||||||
|
case 'If':
|
||||||
|
case 'if-':
|
||||||
|
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyIfComponent' }]))
|
||||||
|
identifier += '_delayedIf'
|
||||||
|
imports.add(`const ${identifier} = createLazyIfComponent(${dynamicImport})`)
|
||||||
|
break
|
||||||
|
case 'Never':
|
||||||
|
case 'never-':
|
||||||
|
imports.add(genImport('vue', [{ name: 'defineAsyncComponent', as: '__defineAsyncComponent' }]))
|
||||||
|
identifier += '_delayedNever'
|
||||||
|
imports.add(`const ${identifier} = __defineAsyncComponent({loader: ${dynamicImport}, hydrate: () => {})`)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
imports.add(genImport('vue', [{ name: 'defineAsyncComponent', as: '__defineAsyncComponent' }]))
|
imports.add(genImport('vue', [{ name: 'defineAsyncComponent', as: '__defineAsyncComponent' }]))
|
||||||
identifier += '_lazy'
|
identifier += '_lazy'
|
||||||
imports.add(`const ${identifier} = __defineAsyncComponent(${genDynamicImport(component.filePath, { interopDefault: false })}.then(c => c.${component.export ?? 'default'} || c)${isClientOnly ? '.then(c => createClientOnly(c))' : ''})`)
|
imports.add(`const ${identifier} = __defineAsyncComponent(${dynamicImport}${isClientOnly ? '.then(c => createClientOnly(c))' : ''})`)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
imports.add(genImport(component.filePath, [{ name: component._raw ? 'default' : component.export, as: identifier }]))
|
imports.add(genImport(component.filePath, [{ name: component._raw ? 'default' : component.export, as: identifier }]))
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { defineAsyncComponent, defineComponent, getCurrentInstance, h, hydrateOnIdle, hydrateOnInteraction, hydrateOnVisible } from 'vue'
|
import { defineAsyncComponent, defineComponent, getCurrentInstance, h, hydrateOnIdle, hydrateOnInteraction, hydrateOnMediaQuery, hydrateOnVisible, ref, watch } from 'vue'
|
||||||
import type { AsyncComponentLoader, HydrationStrategy } from 'vue'
|
import type { AsyncComponentLoader, HydrationStrategy } from 'vue'
|
||||||
import { useNuxtApp } from '#app/nuxt'
|
import { useNuxtApp } from '#app/nuxt'
|
||||||
|
|
||||||
@ -64,3 +64,38 @@ export const createLazyEventComponent = (componentLoader: AsyncComponentLoader)
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* @__NO_SIDE_EFFECTS__ */
|
||||||
|
export const createLazyMediaComponent = (componentLoader: AsyncComponentLoader) => {
|
||||||
|
return defineComponent({
|
||||||
|
inheritAttrs: false,
|
||||||
|
setup (_, { attrs }) {
|
||||||
|
if (import.meta.server) {
|
||||||
|
return () => h(defineAsyncComponent(componentLoader), attrs)
|
||||||
|
}
|
||||||
|
return () => h(delayedHydrationComponent(componentLoader, hydrateOnMediaQuery(attrs.hydrate as string | undefined)))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* @__NO_SIDE_EFFECTS__ */
|
||||||
|
export const createLazyIfComponent = (componentLoader: AsyncComponentLoader) => {
|
||||||
|
return defineComponent({
|
||||||
|
inheritAttrs: false,
|
||||||
|
setup (_, { attrs }) {
|
||||||
|
if (import.meta.server) {
|
||||||
|
return () => h(defineAsyncComponent(componentLoader), attrs)
|
||||||
|
}
|
||||||
|
const shouldHydrate = ref(!!(attrs.hydrate ?? true))
|
||||||
|
if (shouldHydrate.value) {
|
||||||
|
return () => h(defineAsyncComponent(componentLoader), attrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const strategy: HydrationStrategy = (hydrate) => {
|
||||||
|
const unwatch = watch(shouldHydrate, () => hydrate(), { once: true })
|
||||||
|
return () => unwatch()
|
||||||
|
}
|
||||||
|
return () => h(delayedHydrationComponent(componentLoader, strategy))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
@ -2697,21 +2697,27 @@ describe('lazy import components', () => {
|
|||||||
expect(await page.locator('body').getByText('This shouldn\'t be visible at first with network!').all()).toHaveLength(1)
|
expect(await page.locator('body').getByText('This shouldn\'t be visible at first with network!').all()).toHaveLength(1)
|
||||||
expect(await page.locator('body').getByText('This should be visible at first with viewport!').all()).toHaveLength(1)
|
expect(await page.locator('body').getByText('This should be visible at first with viewport!').all()).toHaveLength(1)
|
||||||
expect(await page.locator('body').getByText('This should be visible at first with events!').all()).toHaveLength(2)
|
expect(await page.locator('body').getByText('This should be visible at first with events!').all()).toHaveLength(2)
|
||||||
const component = await page.locator('#lazyevent')
|
// The default value is immediately truthy
|
||||||
|
expect(await page.locator('body').getByText('This shouldn\'t be visible at first with conditions!').all()).toHaveLength(1)
|
||||||
|
const component = page.locator('#lazyevent')
|
||||||
const rect = (await component.boundingBox())!
|
const rect = (await component.boundingBox())!
|
||||||
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2)
|
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2)
|
||||||
await page.waitForLoadState('networkidle')
|
await page.waitForLoadState('networkidle')
|
||||||
expect(await page.locator('body').getByText('This shouldn\'t be visible at first with events!').all()).toHaveLength(1)
|
expect(await page.locator('body').getByText('This shouldn\'t be visible at first with events!').all()).toHaveLength(1)
|
||||||
|
await page.locator('#conditionbutton').click()
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
expect(await page.locator('body').getByText('This shouldn\'t be visible at first with conditions!').all()).toHaveLength(2)
|
||||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
|
||||||
await page.waitForLoadState('networkidle')
|
await page.waitForLoadState('networkidle')
|
||||||
expect(await page.locator('body').getByText('This shouldn\'t be visible at first with viewport!').all()).toHaveLength(1)
|
expect(await page.locator('body').getByText('This shouldn\'t be visible at first with viewport!').all()).toHaveLength(1)
|
||||||
|
expect(await page.locator('body').getByText('This should always be visible!').all()).toHaveLength(1)
|
||||||
await page.close()
|
await page.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('respects custom delayed hydration triggers and overrides defaults', async () => {
|
it('respects custom delayed hydration triggers and overrides defaults', async () => {
|
||||||
const { page } = await renderPage('/lazy-import-components')
|
const { page } = await renderPage('/lazy-import-components')
|
||||||
await page.waitForLoadState('networkidle')
|
await page.waitForLoadState('networkidle')
|
||||||
const component = await page.locator('#lazyevent2')
|
const component = page.locator('#lazyevent2')
|
||||||
const rect = (await component.boundingBox())!
|
const rect = (await component.boundingBox())!
|
||||||
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2)
|
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2)
|
||||||
await page.waitForTimeout(500)
|
await page.waitForTimeout(500)
|
||||||
@ -2723,9 +2729,9 @@ describe('lazy import components', () => {
|
|||||||
expect(await page.locator('body').getByText('This shouldn\'t be visible at first with events!').all()).toHaveLength(1)
|
expect(await page.locator('body').getByText('This shouldn\'t be visible at first with events!').all()).toHaveLength(1)
|
||||||
await page.close()
|
await page.close()
|
||||||
})
|
})
|
||||||
it('does not delay hydration of components named after modifiers', async () => {
|
it('does not delay hydration of components named after modifiers', () => {
|
||||||
const { page } = await renderPage('/lazy-import-components')
|
expect(html).toContain('This fake lazy event should be visible!')
|
||||||
await page.waitForFunction(() => window.useNuxtApp?.() && !window.useNuxtApp?.().isHydrating)
|
expect(html).not.toContain('This fake lazy event shouldn\'t be visible!')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
5
test/fixtures/basic/components/DelayedCondition.client.vue
vendored
Normal file
5
test/fixtures/basic/components/DelayedCondition.client.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
This shouldn't be visible at first with conditions!
|
||||||
|
</div>
|
||||||
|
</template>
|
5
test/fixtures/basic/components/DelayedCondition.server.vue
vendored
Normal file
5
test/fixtures/basic/components/DelayedCondition.server.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
This should be visible at first with conditions!
|
||||||
|
</div>
|
||||||
|
</template>
|
5
test/fixtures/basic/components/DelayedNever.client.vue
vendored
Normal file
5
test/fixtures/basic/components/DelayedNever.client.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
This should never be visible!
|
||||||
|
</div>
|
||||||
|
</template>
|
5
test/fixtures/basic/components/DelayedNever.server.vue
vendored
Normal file
5
test/fixtures/basic/components/DelayedNever.server.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
This should always be visible!
|
||||||
|
</div>
|
||||||
|
</template>
|
@ -5,10 +5,20 @@
|
|||||||
<LazyNCompServer message="lazy-named-comp-server" />
|
<LazyNCompServer message="lazy-named-comp-server" />
|
||||||
<LazyEventDelayedEvent id="lazyevent" />
|
<LazyEventDelayedEvent id="lazyevent" />
|
||||||
<LazyEventView />
|
<LazyEventView />
|
||||||
|
<LazyNeverDelayedNever />
|
||||||
<LazyEventDelayedEvent
|
<LazyEventDelayedEvent
|
||||||
id="lazyevent2"
|
id="lazyevent2"
|
||||||
:hydrate="createEventLoader(['click'])"
|
:hydrate="createEventLoader(['click'])"
|
||||||
/>
|
/>
|
||||||
|
<LazyIfDelayedCondition id="lazycondition" />
|
||||||
|
<button
|
||||||
|
id="conditionbutton"
|
||||||
|
@click="state++"
|
||||||
|
/>
|
||||||
|
<LazyIfDelayedCondition
|
||||||
|
id="lazycondition2"
|
||||||
|
:hydrate="state > 1"
|
||||||
|
/>
|
||||||
<LazyIdleDelayedNetwork />
|
<LazyIdleDelayedNetwork />
|
||||||
<div style="height:3000px">
|
<div style="height:3000px">
|
||||||
This is a very tall div
|
This is a very tall div
|
||||||
@ -16,3 +26,7 @@
|
|||||||
<LazyVisibleDelayedVisible />
|
<LazyVisibleDelayedVisible />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const state = useState('delayedHydrationCondition', () => 1)
|
||||||
|
</script>
|
||||||
|
Loading…
Reference in New Issue
Block a user