From a59824a139656458c6a4ffd6f7402cab5962aeb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?chencheng=20=28=E4=BA=91=E8=B0=A6=29?= Date: Thu, 12 Dec 2024 15:31:59 +0800 Subject: [PATCH] feat: improve link with prefetch (#12844) * feat: improve link with prefetch * feat: support preload=render * feat: support preload=viewport * chore: throw error when prefetch props is invalid * chore: docs and default prefetch config * ci: fix --- docs/docs/docs/api/api.en-US.md | 5 +- docs/docs/docs/api/api.md | 5 +- docs/docs/docs/api/config.en-US.md | 2 +- docs/docs/docs/api/config.md | 2 +- .../features/routePrefetch/routePrefetch.ts | 18 ++- packages/renderer-react/src/link.tsx | 130 ++++++++++++++---- .../src/useIntersectionObserver.ts | 33 +++++ 7 files changed, 164 insertions(+), 31 deletions(-) create mode 100644 packages/renderer-react/src/useIntersectionObserver.ts diff --git a/docs/docs/docs/api/api.en-US.md b/docs/docs/docs/api/api.en-US.md index b3b99b46e91b..789ec30069fc 100644 --- a/docs/docs/docs/api/api.en-US.md +++ b/docs/docs/docs/api/api.en-US.md @@ -187,7 +187,8 @@ Type definition is as follows: ```ts declare function Link(props: { - prefetch?: boolean; + prefetch?: boolean | 'intent' | 'render' | 'viewport' | 'none'; + prefetchTimeout?: number; to: string | Partial<{ pathname: string; search: string; hash: string }>; replace?: boolean; state?: any; @@ -207,7 +208,7 @@ function IndexPage({ user }) { `` supports relative path navigation; `` does not do routing navigation and is equivalent to the jump behavior of ``. -If `prefetch` is enabled, then when the user hovers over the component, Umi will automatically start preloading the component js files and data for the routing jump. (Note: Use this feature when `routePrefetch` and `manifest` are enabled) +If `prefetch` is enabled, then when the user hovers over the component, Umi will automatically start preloading the component js files and data for the routing jump. (Note: Use this feature when `routePrefetch` is enabled) ### matchPath diff --git a/docs/docs/docs/api/api.md b/docs/docs/docs/api/api.md index 4139c76bbb32..dfe941a6a055 100644 --- a/docs/docs/docs/api/api.md +++ b/docs/docs/docs/api/api.md @@ -186,7 +186,8 @@ unlisten(); ```ts declare function Link(props: { - prefetch?: boolean; + prefetch?: boolean | 'intent' | 'render' | 'viewport' | 'none'; + prefetchTimeout?: number; to: string | Partial<{ pathname: string; search: string; hash: string }>; replace?: boolean; state?: any; @@ -206,7 +207,7 @@ function IndexPage({ user }) { `` 支持相对路径跳转;`` 不做路由跳转,等同于 `` 的跳转行为。 -若开启了 `prefetch` 则当用户将鼠标放到该组件上方时,Umi 就会自动开始进行跳转路由的组件 js 文件和数据预加载。(注:使用此功能请同时开启 `routePrefetch` 和 `manifest` 配置) +若开启了 `prefetch` 则当用户将鼠标放到该组件上方时,Umi 就会自动开始进行跳转路由的组件 js 文件和数据预加载。(注:使用此功能请开启 `routePrefetch` 配置) ### matchPath diff --git a/docs/docs/docs/api/config.en-US.md b/docs/docs/docs/api/config.en-US.md index 8b844153553c..2166ae697874 100644 --- a/docs/docs/docs/api/config.en-US.md +++ b/docs/docs/docs/api/config.en-US.md @@ -1304,7 +1304,7 @@ Configure how routes are loaded. Setting moduleType to 'cjs' will load route com ## routePrefetch -- Type: `boolean` +- Type: `{ defaultPrefetch: 'none' | 'intent' | 'render' | 'viewport', defaultPrefetchTimeout: number } | false` - Default: `false` Enable route preloading functionality. diff --git a/docs/docs/docs/api/config.md b/docs/docs/docs/api/config.md index fce4a49dc6a1..05b2ce19610a 100644 --- a/docs/docs/docs/api/config.md +++ b/docs/docs/docs/api/config.md @@ -1311,7 +1311,7 @@ proxy: { ## routePrefetch -- 类型:`boolean` +- 类型:`{ defaultPrefetch: 'none' | 'intent' | 'render' | 'viewport', defaultPrefetchTimeout: number } | false` - 默认值:`false` 启用路由预加载功能。 diff --git a/packages/preset-umi/src/features/routePrefetch/routePrefetch.ts b/packages/preset-umi/src/features/routePrefetch/routePrefetch.ts index dffb8f526368..173e21f45e30 100644 --- a/packages/preset-umi/src/features/routePrefetch/routePrefetch.ts +++ b/packages/preset-umi/src/features/routePrefetch/routePrefetch.ts @@ -4,13 +4,27 @@ export default (api: IApi) => { api.describe({ config: { schema({ zod }) { - return zod.object({}); + return zod.object({ + defaultPrefetch: zod + .enum(['none', 'intent', 'render', 'viewport']) + .optional(), + defaultPrefetchTimeout: zod.number().optional(), + }); }, }, enableBy: api.EnableBy.config, }); api.addEntryCodeAhead(() => { - return `if(typeof window !== 'undefined') window.__umi_route_prefetch__ = true;`; + return `if(typeof window !== 'undefined') window.__umi_route_prefetch__ = + { + defaultPrefetch: ${JSON.stringify( + api.config.routePrefetch.defaultPrefetch || 'none', + )}, + defaultPrefetchTimeout: ${JSON.stringify( + api.config.routePrefetch.defaultPrefetchTimeout || 50, + )}, + }; + `; }); }; diff --git a/packages/renderer-react/src/link.tsx b/packages/renderer-react/src/link.tsx index c518fc978d15..3a44e6ab8c23 100644 --- a/packages/renderer-react/src/link.tsx +++ b/packages/renderer-react/src/link.tsx @@ -1,27 +1,111 @@ -import React, { PropsWithChildren } from 'react'; +import React, { PropsWithChildren, useLayoutEffect } from 'react'; import { Link, LinkProps } from 'react-router-dom'; import { useAppData } from './appContext'; +import { useIntersectionObserver } from './useIntersectionObserver'; -export function LinkWithPrefetch( - props: PropsWithChildren< - { - prefetch?: boolean; - } & LinkProps & - React.RefAttributes - >, -) { - const { prefetch, ...linkProps } = props; - const appData = useAppData(); - const to = typeof props.to === 'string' ? props.to : props.to?.pathname; - // compatible with old code - // which to might be undefined - if (!to) return null; - return ( - prefetch && to && appData.preloadRoute?.(to)} - {...linkProps} - > - {props.children} - - ); +function useForwardedRef(ref?: React.ForwardedRef) { + const innerRef = React.useRef(null); + React.useEffect(() => { + if (!ref) return; + if (typeof ref === 'function') { + ref(innerRef.current); + } else { + ref.current = innerRef.current; + } + }); + return innerRef; } + +export const LinkWithPrefetch = React.forwardRef( + ( + props: PropsWithChildren< + { + prefetch?: boolean | 'intent' | 'render' | 'viewport' | 'none'; + prefetchTimeout?: number; + } & LinkProps & + React.RefAttributes + >, + forwardedRef, + ) => { + const { prefetch: prefetchProp, ...linkProps } = props; + const { defaultPrefetch, defaultPrefetchTimeout } = (typeof window !== + 'undefined' && // @ts-ignore + window.__umi_route_prefetch__) || { + defaultPrefetch: 'none', + defaultPrefetchTimeout: 50, + }; + + const prefetch = + (prefetchProp === true + ? 'intent' + : prefetchProp === false + ? 'none' + : prefetchProp) || defaultPrefetch; + if (!['intent', 'render', 'viewport', 'none'].includes(prefetch)) { + throw new Error( + `Invalid prefetch value ${prefetch} found in Link component`, + ); + } + const appData = useAppData(); + const to = typeof props.to === 'string' ? props.to : props.to?.pathname; + const hasRenderFetched = React.useRef(false); + const ref = useForwardedRef(forwardedRef); + // prefetch intent + const handleMouseEnter = (e: React.MouseEvent) => { + if (prefetch !== 'intent') return; + const eventTarget = (e.target || {}) as HTMLElement & { + preloadTimeout?: NodeJS.Timeout | null; + }; + if (eventTarget.preloadTimeout) return; + eventTarget.preloadTimeout = setTimeout(() => { + eventTarget.preloadTimeout = null; + appData.preloadRoute?.(to!); + }, props.prefetchTimeout || defaultPrefetchTimeout); + }; + const handleMouseLeave = (e: React.MouseEvent) => { + if (prefetch !== 'intent') return; + const eventTarget = (e.target || {}) as HTMLElement & { + preloadTimeout?: NodeJS.Timeout | null; + }; + if (eventTarget.preloadTimeout) { + clearTimeout(eventTarget.preloadTimeout); + eventTarget.preloadTimeout = null; + } + }; + + // prefetch render + useLayoutEffect(() => { + if (prefetch === 'render' && !hasRenderFetched.current) { + appData.preloadRoute?.(to!); + hasRenderFetched.current = true; + } + }, [prefetch, to]); + + // prefetch viewport + useIntersectionObserver( + ref as React.RefObject, + (entry) => { + if (entry?.isIntersecting) { + appData.preloadRoute?.(to!); + } + }, + { rootMargin: '100px' }, + { disabled: prefetch !== 'viewport' }, + ); + + // compatible with old code + // which to might be undefined + if (!to) return null; + + return ( + } + {...linkProps} + > + {props.children} + + ); + }, +); diff --git a/packages/renderer-react/src/useIntersectionObserver.ts b/packages/renderer-react/src/useIntersectionObserver.ts new file mode 100644 index 000000000000..978f565a1be5 --- /dev/null +++ b/packages/renderer-react/src/useIntersectionObserver.ts @@ -0,0 +1,33 @@ +import React from 'react'; + +export function useIntersectionObserver( + ref: React.RefObject, + callback: (entry: IntersectionObserverEntry | undefined) => void, + intersectionObserverOptions: IntersectionObserverInit = {}, + options: { disabled?: boolean } = {}, +): IntersectionObserver | null { + // check if IntersectionObserver is available + if (typeof IntersectionObserver !== 'function') return null; + + const isIntersectionObserverAvailable = React.useRef( + typeof IntersectionObserver === 'function', + ); + const observerRef = React.useRef(null); + React.useEffect(() => { + if ( + !ref.current || + !isIntersectionObserverAvailable.current || + options.disabled + ) { + return; + } + observerRef.current = new IntersectionObserver(([entry]) => { + callback(entry); + }, intersectionObserverOptions); + observerRef.current.observe(ref.current); + return () => { + observerRef.current?.disconnect(); + }; + }, [callback, intersectionObserverOptions, options.disabled, ref]); + return observerRef.current; +}