背景
参考 React v18.0 – React Blog,React 18 已经发布,而流式渲染是 React 18 的一个非常重要的特性。
本文来探讨在 React 18 下,有什么方式可以实现 SSR 和流式渲染。
React 18 支持的流式渲染能力和 api
首先,我们需要知道 React 18 到底什么 api 支持了流式渲染?
具体来说,就是这几个 api 发生了变化:
Suspense 和 lazy
原本 Suspense 和 lazy 配合,只能支持一个场景:客户端的 js 代码拆分和懒加载。
React 18 扩展了这两个 api 的能力,可以直接在服务端使用他们组合使用,类似在客户端的代码拆分和懒加载,服务端直接使用这种方式的话就可以做到服务端数据的流式渲染。
一个简单的使用 Suspense 把页面分割开实现不同组件流式渲染的例子:
import { Suspense, lazy } from "react";
// 如果想强行实现流式的话,可以延迟 import 组件
const Header = lazy(() => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(import('./components/'));
}, 5000);
});
});
// 如果没有其他因素,SideBar 和 Main 是同步渲染的
const SideBar = lazy(() => import("./components/SideBar"));
const Main = lazy(() => import("./components/Main"));
function Homepage() {
return (
<div className="App">
<Suspense fallback={<div className="Header">Loading header...</div>}>
<Header></Header>
</Suspense>
<div className="Content">
<Suspense fallback={<div className="SideBar">Loading Sidebar...</div>}>
<SideBar></SideBar>
</Suspense>
<Suspense fallback={<div className="Main">Loading main...</div>}>
<Main></Main>
</Suspense>
</div>
</div>
);
}
export default Homepage;
如果只是把不同的组件用 Suspense 和 lazy 分割开的话并不会实现流式的数据返回,React 会自动把它们同步返回,我们需要保证这里的各个组件的加载时延迟的,才能正确实现流式的数据返回。
renderToPipeableStream
服务端为了配合 Suspense 和 Lazy 拆分的部分流式返回给浏览器,需要使用renderToPipeableStream 来流式返回数据
let didError = false;
const stream = renderToPipeableStream(
<App assets={assets} />,
{
onShellReady() {
res.statusCode = didError ? 500 : 200;
res.setHeader("Content-type", "text/html");
stream.pipe(res); // 通过 React 的 stream 流式把数据返回
},
onError(x) {
didError = true;
console.error(x);
},
}
);
hydrateRoot
为了配合流式返回的数据在服务端动态注水组件,使用这个新的 api 来进行注水,它可以在 React 组件流式返回后就开始注水,无需等待所有的内容都返回后才进行注水。
服务端流式组件数据请求方式
从前面 React 提供的 API 可以看出,React api 只定义了流式渲染组件的格式、如何流式返回数据以及如果动态给流式返回的内容注水。
但是不涉及一个比较重要的能力:如何分模块加载不同的组件和并请求它们的数据。
由于服务端的 React 组件不会执行 didMount 之后的生命周期,因此不能用类似常规的客户端开发的方式在 didMount 或者 useEffect 中去发送组件的请求,而需要通过某种方式提前发送请求,让组件渲染的时候可以直接拿到请求的内容开始渲染。
把请求封装 lazy 方法中
基于现有的 api,可以发现等待组件加载的逻辑在 lazy 方法中,因此,我们可以封装和拓展 lazy 方法,在等待了请求返回之后,再正常 import 组件。
import { Suspense, lazy } from "react";
// 如果想强行实现流式的话,可以延迟 import 组件
const Header = lazy(() => {
return new Promise((resolve) => {
fetchData().then(() => {
resolve(import('./components/'));
});
});
});
function Homepage() {
return (
<div className="App">
<Suspense fallback={<div className="Header">Loading header...</div>}>
<Header></Header>
</Suspense>
</div>
);
}
export default Homepage;
这样,服务端在渲染组件时,会先通过 Suspense 渲染这个组件的 loading,等到这个组件依赖的 fetch 请求返回后,再流式渲染这个组件的内容。
优点
- 无需额外的 api 就能支持懒加载组件和它的数据
缺点
- 服务端和客户端代码异构:服务端需要深度和请求和 lazy 封装起来,但是客户端不能同同样的封装,否则代码拆分的文件也会等到 fetch 之后才会加载,有明显的性能问题。
使用 Suspense 和 data fetching 结合
data fetching 是一个 React 中实验性质的 api,它和 Suspense 结合起来,可以使用一种新的设计模式来编写 React 组件。
一个例子:
const resource = fetchProfileData(); // 1. 提前发送请求
function ProfilePage() {
return (
// 3. 使用 Suspense 来维护 loading 状态
<Suspense fallback={<h1>Loading profile...</h1>}>
<ProfileDetails />
<Suspense fallback={<h1>Loading posts...</h1>}>
<ProfileTimeline />
</Suspense>
</Suspense>
);
}
function ProfileDetails() {
const user = resource.user.read(); // 2. 使用 render 方法中直接读请求数据
return <h1>{user.name}</h1>;
}
function ProfileTimeline() {
const posts = resource.posts.read();
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.text}</li>
))}
</ul>
);
}
data fetching 有三个关键点:
- 尽量直接提前发送请求
- 对于依赖请求的组件,在 render 方法中,去读取请求的结果。
- 使用 Suspense 来维护请求的 loading 状态:如果请求没有返回,该组件的外层 Suspense 会展示 loading 的 fallback,如果请求已经返回了,Suspense 会展示这个组件的内容。
优点
- 拆分组件的关注点:一个依赖请求的组件,可以直接把请求的内容作为数据内容进行渲染,无需关心请求的过程中 loading 状态如何维护,loading 由 Suspense 来处理
- 提前发送请求:很低成本地保证请求可以尽早的发出,而不是在 useEffect 等生命周期中才开始发请求,如果要发多个请求,也不需要复杂的 Promise.all 等封装
- 自动解决竞速问题:Suspense 只会处理最后一个请求的数据并渲染对应组件,无需开发者关心竞速
- React 的未来发展方向
缺点
- 试验性质的 api,除了基于 GraphQL 的 Relay,没有其他成熟的框架支持 data fetching
自研支持流式渲染需要解决问题
考虑到 data fetching 可能是 React 未来的发展方向,并且要保证服务端、客户端的同构以减少开发维护成本。
因此自研解决服务端流式组件的数据请求时,推荐使用 Suspense 和 data fetching 结合的方式。在服务端和客户端使用相同的方式进行数据请求。
目前 React 官方没有也没有计划提供官方的 data fetching 框架或者库,在社区提供完整的 data fetching 框架解决方案之前,需要我们自研 data fetching 相关的 SSR 逻辑
我们需要解决的问题有:
把普通请求 promise 转换成 data fetching api
一个用于 demo 的 wrapPromise 方法的例子如下:
function wrapPromise(promise) {
let status = "pending";
let result;
let suspender = promise.then(
(r) => {
status = "success";
result = r;
},
(e) => {
status = "error";
result = e;
}
);
return {
read() {
if (status === "pending") {
throw suspender;
} else if (status === "error") {
throw result;
} else if (status === "success") {
return result;
}
}
};
}
const fetchData() {
return wrapPromise(fetchUser()); // fetchUser 是一个普通的 fetch 方法
}
const user = fetchData(); // 提前发送 fetch 请求
function ProfileDetails() {
const user = user.read(); // render 中直接 read 数据
return <h1>{user.name}</h1>;
}
Suspense 识别 data fetching 的 loading 状态方式是根据 read 方法的返回值来的:
- 如果 read 方法 throw 了一个 promise,那它就还在 loading
- 如果 read 方法返回了数据,那它就 loading 完成了,并返回只是 read 的返回值
实际产品中,这个 wrapPromise 方法会复杂很多,需要处理更多的情况,比如报错等等。
服务端、客户端同构使用 data fetching 发送请求
为了兼容使用 data fetching 和现有的 React 组件使用方式,封装一个高阶组件来处理 data fetching 的逻辑,原本的 React 组件自身不处理请求相关的逻辑。
例子:
// 一个高阶组件来结合组件和 fetching 的逻辑
const withDataFetching = (InnerComponent) => {
const WrappedComponent = (props) => {
const resource = useResource();
const data = InnerComponent.getFetchingProps(resource);
return <InnerComponent {...props} {..data} />;
};
return WrappedComponent;
};
// 这是一个纯组件,无需关心 fetching
const Header = ({ header = {} }) => {
return (
<div className="Header">
{header.text}
</div>
);
};
// 约定组件上挂载一个获取 fetching 数据的逻辑
Header.getFetchingProps = (resource) => {
const header = resource.header.read();
return {
header,
};
};
// 对外暴露出被高阶组件包裹的组件,可以直接放到 Suspense 中使用
export default withDataFetching(Header);
服务端、客户端请求同构
如果页面在 SSR 的时候依赖一个其他服务的 RPC 或者别的服务端接口,那需要一层 BFF 吧这个依赖的 RPC 映射出一个 http api,保证客户端用相同的逻辑可以访问到这个接口,只是客户端访问接口的链路要长一些。
服务端、客户端分别处理 data fetching 的发请求的逻辑
服务端和客户端发请求的时机是不同的:
- 服务端:在接收到 SSR 页面请求时就开始发请求
- 客户端:在 SPA 应用内部切换到指定页面时,发起请求
这部分发请求的时间是没法同构的,因此需要在服务端和客户端单独开发维护不同的发请求逻辑。
万幸是这部分逻辑比较收敛,一个页面支持的请求可以全部维护在同一个地方。
服务端
get('/', () => {
const resources = fetchDataForUrl(url);
const stream = renderToPipeableStream(
<StaticRouter location={url}>
<DataProvider resources={resources}>
<App assets={assets} />
</DataProvider>
</StaticRouter>,
{
...
}
);
});
在 renderToPipeableStream 之前根据当天页面的 url 与发送请求。并作为 Context 传递给 React 组件。
不在组件中发请求是为了最早开始请求,节省请求时间。
客户端
function Homepage() {
const clientSideResources = fetchDataFromClientSide();
const content = (
<div className="App">
...
</div>
);
return isClientSide ? (
<DataProvider resources={clientSideResources}>{content}</DataProvider>
) : (
content
);
}
在需要发请求的页面 Router 根节点中,发送请求,并设置 Context。
由于组件是在服务端和客户端都会渲染,因此这里是否要设置请求的 Context 就根据自身在服务端还是客户端判断
保证注水时客户端组件能够拿到正确的 props
按照现在的设计思路,依赖请求数据的组件都是流式渲染的,因此需要在流式渲染的过程中把请求数据一起流式返回,并设置成组件的 props。
解决思路是,在 data fetching 的高阶组件中封装逻辑处理:
- 服务端渲染时,把请求的数据挂载在一个元素上,和流式渲染的组件一起返回
- 客户端渲染时:判断流式组件的数据是否在挂载的元素上
- 数据已经挂载了,直接用对应数据渲染组件
- 数据未挂载,在客户端 fetching 数据,在渲染组件
对应的代码示例:
- 扩展 withDataFetching 这个高阶组件处理服务端逻辑服务端逻辑:
- 通过 useId 生成数据挂载节点的 id
- 把数据挂载在一个 这个空的数据节点上
const withDataFetching = (InnerComponent) => {
const WrappedComponent = (props) => {
// useId 会保证在服务端和客户端渲染同一个组件拿到的 ID 是相同的
const id = useId();
const resource = useResource();
const data = InnerComponent.getFetchingProps(resource);
return (
<>
<div id={id} data-server-data={data}></div>
<InnerComponent {...props} {..data} />
</>
)
};
return WrappedComponent;
};
- 继续扩展这个高阶组件处理客户端逻辑:
- 获取挂载节点的数据,如果有数据,直接使用该数据作为 props
- 如果没有数据,使用和服务端同构的 data fetching 逻辑获取数据
const withDataFetching = (InnerComponent) => {
const WrappedComponent = (props) => {
// useId 会保证在服务端和客户端渲染同一个组件拿到的 ID 是相同的
const id = useId();
const resource = useResource();
let data;
if (isClientSide) {
// 客户端上,如果挂载数据节点上存在数据,则直接使用该数据作为 props
const dataElement = document.querySelector(`#${id}`);
const serverData = JSON.parse(dataElement.dataset.serverData);
if (serverData) {
data = serverData;
}
}
// 其他 case 下使用 data fetching 获取数据(服务端、客户端是同构的)
if (!data) {
data = InnerComponent.getFetchingProps(resource);
}
return (
<>
<div id={id} data-server-data={data}></div>
<InnerComponent {...props} {..data} />
</>
)
};
return WrappedComponent;
};
参考
- React 流式渲染的官方 demo
- Suspense for Data Fetching (Experimental)
- Relay
- data fetching demo
- next js 支持的发送请求方式
- next js 支持 streaming
加入我们
我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。
扫码发现职位&投递简历