feat: add event-based lazy loading

This commit is contained in:
tbitw2549 2024-06-15 01:06:47 +03:00
parent e3a0242048
commit 06096dcc0c
9 changed files with 104 additions and 6 deletions

View File

@ -0,0 +1,33 @@
---
title: 'createEventLoader'
description: A utility function to select the events used for event-based delayed hydration.
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/composables/hydrate.ts
size: xs
---
You can use this utility to set specific events that would trigger hydration in event-based delayed hydration components.
## Parameters
- `options`: An array of valid HTML events.
## Example
If you would like to trigger hydration when the element is either clicked or has the mouse over it:
```vue [pages/index.vue]
<template>
<div>
<LazyIdleMyComponent :loader="createEventLoader(['click','mouseover'])"/>
</div>
<template>
```
::read-more{to="/docs/guide/directory-structure/components#delayed-hydration"}
::
::read-more{to="https://developer.mozilla.org/en-US/docs/Web/API/Element#events"}
Read more on the possible events that can be used.
::

View File

@ -25,13 +25,19 @@ export const useHydration = <K extends keyof NuxtPayload, T = NuxtPayload[K]> (k
}
/**
* A `requestIdleCallback` options utility, used for determining custom timeout for idle-callback based delayed hydration.
* A `requestIdleCallback` options utility, used to determine custom timeout for idle-callback based delayed hydration.
* @param opts the options object, containing the wanted timeout
*/
export const createIdleLoader = (opts: IdleRequestOptions) => opts
/**
* An `IntersectionObserver` options utility, used for determining custom viewport-based delayed hydration.
* An `IntersectionObserver` options utility, used to determine custom viewport-based delayed hydration behavior.
* @param opts the options object, containing the wanted viewport options
*/
export const createVisibleLoader = (opts: IntersectionObserverInit) => opts
export const createVisibleLoader = (opts: Partial<IntersectionObserverInit>) => opts
/**
* A utility used to determine which events should trigger hydration in components with event-based delayed hydration.
* @param events an array of events that will be used to trigger the hydration
*/
export const createEventLoader = (events: Array<keyof HTMLElementEventMap>) => events

View File

@ -43,7 +43,7 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
const s = new MagicString(code)
const nuxt = tryUseNuxt()
// replace `_resolveComponent("...")` to direct import
s.replace(/(?<=[ (])_?resolveComponent\(\s*["'](lazy-|Lazy)?(Idle|Visible|idle-|visible-)?([^'"]*)["'][^)]*\)/g, (full: string, lazy: string, modifier: string, name: string) => {
s.replace(/(?<=[ (])_?resolveComponent\(\s*["'](lazy-|Lazy)?(Idle|Visible|idle-|visible-|Event|event-)?([^'"]*)["'][^)]*\)/g, (full: string, lazy: string, modifier: string, name: string) => {
const component = findComponent(components, name, options.mode)
if (component) {
// @ts-expect-error TODO: refactor to nuxi
@ -81,6 +81,12 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
identifier += '_delayedIO'
imports.add(`const ${identifier} = createLazyIOClientPage(__defineAsyncComponent(${genDynamicImport(component.filePath, { interopDefault: false })}.then(c => c.${component.export ?? 'default'} || c)))`)
break
case 'Event':
case 'event-':
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyEventClientPage' }]))
identifier += '_delayedEvent'
imports.add(`const ${identifier} = createLazyEventClientPage(__defineAsyncComponent(${genDynamicImport(component.filePath, { interopDefault: false })}.then(c => c.${component.export ?? 'default'} || c)))`)
break
case 'Idle':
case 'idle-':
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyNetworkClientPage' }]))

View File

@ -1,5 +1,5 @@
import { createStaticVNode, createVNode, defineComponent, getCurrentInstance, h, onBeforeUnmount, onMounted, ref } from 'vue'
import type { Component, Ref } from 'vue'
import type { Component, Ref, ComponentInternalInstance } from 'vue'
// import ClientOnly from '#app/components/client-only'
import { getFragmentHTML } from '#app/components/utils'
import { useNuxtApp } from '#app/nuxt'
@ -89,3 +89,39 @@ export const createLazyNetworkClientPage = (componentLoader: Component) => {
},
})
}
const eventsMapper = new WeakMap<ComponentInternalInstance,(() => void)[]>()
/* @__NO_SIDE_EFFECTS__ */
export const createLazyEventClientPage = (componentLoader: Component) => {
return defineComponent({
inheritAttrs: false,
setup (_, { attrs }) {
if (import.meta.server) {
return () => h(componentLoader, attrs)
}
const nuxt = useNuxtApp()
const instance = getCurrentInstance()!
const isTriggered = ref(false)
const events: string[] = attrs.loader ?? ['mouseover']
const registeredEvents: (() => void)[] = []
onMounted(() => {
events.forEach((event) => {
const handler = () => {
isTriggered.value = true
registeredEvents.forEach((remove) => remove())
eventsMapper.delete(instance)
}
instance.vnode.el.addEventListener(event, handler)
registeredEvents.push(() => instance.vnode.el.removeEventListener(event, handler))
})
eventsMapper.set(instance, registeredEvents)
})
onBeforeUnmount(() => {
registeredEvents?.forEach((remove) => remove())
eventsMapper.delete(instance)
})
return () => isTriggered.value ? h(componentLoader, attrs) : (instance.vnode.el && nuxt.isHydrating) ? createVNode(createStaticVNode(getFragmentHTML(instance.vnode.el ?? null, true)?.join('') || '', 1)) : null
}
})
}

View File

@ -42,7 +42,7 @@ const granularAppPresets: InlinePreset[] = [
from: '#app/composables/asyncData',
},
{
imports: ['useHydration', 'createVisibleLoader', 'createIdleLoader'],
imports: ['useHydration', 'createVisibleLoader', 'createIdleLoader', 'createEventLoader'],
from: '#app/composables/hydrate',
},
{

View File

@ -2675,6 +2675,12 @@ describe('lazy import components', () => {
await page.waitForLoadState('networkidle')
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 events!').all()).toHaveLength(1)
const component = await page.locator('#lazyevent');
const rect = component.boundingBox()
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2)
await page.waitForLoadState('networkidle')
expect(await page.locator('body').getByText('This shouldn\'t be visible at first with events!').all()).toHaveLength(1)
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
await page.waitForTimeout(1000) // attempt a hard-coded delay to ensure IO isn't triggered after network is idle
await page.waitForLoadState('networkidle')

View File

@ -0,0 +1,5 @@
<template>
<div>
This shouldn't be visible at first with events!
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div>
This should be visible at first with events!
</div>
</template>

View File

@ -3,6 +3,7 @@
<LazyNCompAll message="lazy-named-comp-all" />
<LazyNCompClient message="lazy-named-comp-client" />
<LazyNCompServer message="lazy-named-comp-server" />
<LazyEventDelayedEvent id="lazyevent"/>
<LazyIdleDelayedNetwork />
<div style="height:3000px">
This is a very tall div