Data Prefetch
什么是 Data Prefetch
Data Prefetch 可以将远程模块接口请求提前到 remoteEntry
加载完成后立即发出,无需等到组件渲染,提升首屏速度。
适用场景
项目首屏前置流程较长(例如需要鉴权等操作)或次屏希望数据直出的场景
Host HTML
(消费者 HTML) -> Host main.js
(消费者入口 js) -> Host fetch
(消费者鉴权等前置动作) -> Provider main.js
(生产者入口 js) -> Provider fetch
(生产者发送请求)
- 使用 Prefetch 后的加载流程
- 在前置流程中提前调用
loadRemote
的加载流程(loadRemote 会将有 prefetch 需求的生产者接口请求同步发送)
可以看到生产者的请求被提前到跟部分 js 并行了,目前对于首屏的优化效果取决于项目加载流程,次屏理论上可以通过提前调用 loadRemote
做到直出,在前置流程长的情况下可以大大提升模块整体的渲染速度
使用方法
- 给
生产者
和消费者
安装 @module-federation/enhanced
包
npm install @module-federation/enhanced
- 给生产者的 expose 模块目录同级增加
.prefetch.ts(js)
文件,假如有如下 exposes
rsbuild(webpack).config.ts
new ModuleFederationPlugin({
exposes: {
'.': './src/index.tsx',
'./Button': './src/Button.tsx',
},
// ...
})
此时生产者项目有 .
和 ./Button
两个 exposes
,
那么我们可以在 src
下新建 index.prefetch.ts
和 Button.prefetch.ts
两个 prefetch 文件,拿 Button
举例
注意 export 的函数必须以 default 导出或 Prefetch 结尾才会被识别成 Prefetch 函数(不区分大小写)
Button.prefetch.ts
// 这里通过使用 react-router-dom 提供的 defer API 举例,是否要使用这个 API 可以根据需求决定,参考问题解答「为什么要使用 defer、Suspense、Await 组件」
// 用户可以通过 npm install react-router-dom 来安装这个包
import { defer } from 'react-router-dom';
const defaultVal = {
data: {
id: 1,
title: 'A Prefetch Title',
}
};
// 注意 export 的函数必须以 default 导出或 Prefetch 结尾才会被识别成 Prefetch 函数(不区分大小写)
export default (params = defaultVal) => defer({
userInfo: new Promise(resolve => {
setTimeout(() => {
resolve(params);
}, 2000);
})
})
在 Button
中使用
Button.tsx
import { Suspense } from 'react';
import { usePrefetch } from '@module-federation/enhanced/prefetch';
import { Await } from 'react-router-dom';
interface UserInfo {
id: number;
title: string;
};
const reFetchParams = {
data: {
id: 2,
title: 'Another Prefetch Title',
}
}
export default function Button () {
const [prefetchResult, reFetchUserInfo] = usePrefetch<UserInfo>({
// 对应生产者 ModuleFederationPlugin 中的 (name + expose),例如 `app2/Button` 用于消费 `Button.prefetch.ts`
id: 'app2/Button',
// 可选参数,使用 defer 后必填
deferId: 'userInfo',
// default 导出默认可以不传 functionId,此处为举例说明,如果非 default 导出则需要填函数名,
// functionId: 'default',
});
return (
<>
<button onClick={() => reFetchUserInfo(reFetchParams)}>重新发送带参数的请求</button>
<Suspense fallback={<p>Loading...</p>}>
<Await
resolve={prefetchResult}
children={userInfo => (
<div>
<div>{userInfo.data.id}</div>
<div>{userInfo.data.title}</div>
</div>
)}
></Await>
</Suspense>
</>
)
};
- 生产者的 ModuleFederationPlugin 配置处设置
dataPrefetch: true
new ModuleFederationPlugin({
// ...
dataPrefetch: true
}),
- 消费者的 ModuleFederationPlugin 配置处设置
dataPrefetch: true
new ModuleFederationPlugin({
// ...
dataPrefetch: true
}),
这样就完成了接口预请求,在 Button
被消费者使用后会提前将接口请求发送出去(加载 js 资源时就会发出,正常项目需要等到组件渲染时),
在上面的例子中 Button
首先会渲染 loading...
, 然后在 2s 后展示数据
点击重新发送带参数的请求
可以重新触发请求并且添加参数,用于更新组件
查看优化效果
在浏览器控制台中打开日志模式查看输出(最好使用浏览器缓存模式模拟用户场景,否则数据可能不准确)
默认优化效果是数据3减去数据1(模拟用户在 useEffect
中发送请求),如果你的请求不是在 useEffect
中发送,那么可以在接口执行处手动调用 performance.now()
来减去数据1
localStorage.setItem('FEDERATION_DEBUG', 1)
API
usePrefetch
功能
类型
type Options: <T>{
id: string; // 必填,对应生产者 MF 配置中的 (name + expose),例如 `app2/Button` 用于消费 `Button.prefetch.ts`。
functionId?: string; // 可选(默认是 default),用于获取 .prefetch.ts 文件中 export 的函数名称,函数需要以 Prefetch 结尾(不区分大小写)
deferId?: string; // 可选(使用 defer 后必填),使用 defer 后函数返回值为对象(对象中可以有多个 key 对应多个请求),deferId 为对象中的某个 key,用于获取具体请求
cacheStrategy?: () => boolean; // 可选,一般不用手动管理,由框架管理,用于控制是否更新请求结果缓存,目前在组件卸载后或手动执行 reFetch 函数会刷新缓存
} => [
Promise<T>,
reFetch: (refetchParams?: refetchParams) => void, // 用于重新发起请求,常用于组件内部状态更新后接口需要重新请求获取数据的场景,调用此函数会重新发起请求并更新请求结果缓存
];
type refetchParams: any; // 用于组件内重新发起请求携带参数
使用方法
import { Suspense } from 'react';
import { usePrefetch } from '@module-federation/enhanced/prefetch';
import { Await } from 'react-router-dom';
export const Button = () => {
const [userInfoPrefetch, reFetchUserInfo] = usePrefetch<UserInfo>({
// 对应生产者 MF 配置中的 (name + expose),例如 `app2/Button` 用于消费 `Button.prefetch.ts`
id: 'app2/Button',
// 可选参数,使用 defer 后必填
deferId: 'userInfo'
// default 导出默认可以不传 functionId,此处为举例说明,如果非 default 导出则需要填函数名,
// functionId: 'default',
});
return (
<>
<button onClick={() => reFetchUserInfo(reFetchParams)}>重新发送带参数的请求</button>
<Suspense fallback={<p>Loading...</p>}>
<Await
resolve={prefetchResult}
children={userInfo => (
<div>
<div>{userInfo.data.id}</div>
<div>{userInfo.data.title}</div>
</div>
)}
></Await>
</Suspense>
<>
)
}
loadRemote
功能
用户如果在消费者项目中手动调用 loadRemote API,那么会认为消费者不仅希望加载生产者的静态资源,同时希望将接口请求也提前发出,这可以让项目获得更快的渲染速度,
这在首屏有前置请求场景或者希望次屏直出这些场景中尤其有效,适用于在当前页面提前加载次屏模块的场景
使用方法
import { loadRemote } from '@module-federation/enhanced/runtime';
loadRemote('app2/Button');
注意
注意这可能会导致数据缓存问题,即生产者会优先使用预请求的接口结果(用户可能通过交互操作已经修改了服务端的数据),这种情况下会导致渲染使用到过时的数据,请按照项目情况合理使用
问题解答
1. 和 React Router v6 的 Data Loader 有区别吗
React Router 的 Data Loader 只能给单体项目使用,即跨项目无法复用,同时 Data Loader 是按路由绑定的,而非按模块(expose)绑定的,Data Prefetch 更加适合远程加载的场景
2. 为什么要使用 defer、Suspense、Await 组件?参考链接
defer 和 Await 组件是 React Router v6 提供的用于数据加载和渲染 loading 时使用的 API 和组件,二者通常配合 React 的 Suspense
来完成:渲染 loading -> 渲染内容 的过程。可以看到 defer 返回的是一个对象,在执行 Prefetch 函数时这个对象中所有 key 对应的请求(也就是 value)
会一次性全部发送出去,而 defer 会追踪这些 Promise 的状态,配合 Suspense 和 Await 完成渲染,同时这些请求不会阻塞组件的渲染(在组件渲染完成前会展示 loading...)
3. 能不能不使用 defer、Suspense 和 Await 这一套?
可以,但是如果 export 的函数中有阻塞函数执行操作的话(例如有 await 或返回是 Promise),会让组件等待函数执行完成后再渲染,即使组件内容早已加载出来,但组件仍然可能会等待接口完成再渲染,例如
export default (params) => (
new Promise(resolve => {
setTimeout(() => {
resolve(params);
}, 2000);
})
)
4. 为什么不默认全部 defer?
让开发者场景更加可控,在某些场景下开发者更希望用户一次性看到完整的页面,而非渲染 loading,这可以让开发者更好的权衡场景,参考
5. Prefetch 能不能携带参数?
首次请求由于请求时间和 js 资源加载属于并行,所以不支持从组件内部传递参数,可以手动设置默认值。而 usePrefetch 会返回 reFetch 函数,此函数用于在组件内部重新发送请求更新数据,此时可以携带参数
6. 业务想最小成本改造如何操作?
- 将需要 prefetch 的接口放到 .prefetch.ts 中
- prefetch 函数用
defer
包裹返回一个对象(也可以直接返回一个对象,如果返回一个值的话会和组件 js 的加载互相 await 阻塞)
- 业务组件中一般是在
useEffect
中发请求:
import { useState } from 'react';
import { usePrefetch } from '@module-federation/enhanced/prefetch';
export const Button = () => {
const [state, setState] = useState(defaultVal);
const [userInfoPrefetch, reFetchUserInfo] = usePrefetch<UserInfo>({
// 对应生产者 MF 配置中的 (name + expose),例如 `app2/Button` 用于消费 `Button.prefetch.ts`
id: 'app2/Button',
// 可选参数,使用 defer 后必填
deferId: 'userInfo',
// default 导出默认可以不传 functionId,此处为举例说明,如果非 default 导出则需要填函数名,
// functionId: 'default',
});
useEffect(() => {
// 常规场景一般在这里发请求
userInfoPrefetch
.then(data => (
// 更新数据
setState(data)
));
}, []);
return (
<>{state.defaultVal}<>
)
}
7. 为什么我定义了 Prefetch 函数但是不生效?
注意 export 的函数必须以 default 导出或 Prefetch 结尾才会被识别成 Prefetch 函数(不区分大小写)
8. 模块在次屏可以优化性能吗
可以,并且效果比较明显,由于 Data Prefetch 是针对所有 expose 模块的,所以次屏模块也可以优化性能
import { loadRemote } from '@module-federation/enhanced/runtime';
loadRemote('app2/Button');
9. Vue 或其他框架想用怎么办
我们提供了通用的 Web API,只是未在 Vue 等其他框架中提供类似 usePrefetch
这种 hook,后续会进行支持