作者简介
19 组清风,携程资深前端开发工程师,负责商旅前端公共基础平台建设,关注 NodeJs、研究效能领域;
ZZR,携程商旅资深前端开发工程师,负责商旅公共平台基础平台建设,致力于高效率、高性能开发。
一、引言
眨眼之间,距离 React 18.2.0 发布已过了一年多的时间,越来越多的开发者从当初的观望心态,逐步已经将 React18 的新特性投入开发/生产中了,当然,笔者所在的团队也不例外。
今天这篇文章就和大家简单聊聊 React 18 中的 Streaming 。
二、Streaming
所谓的 Streaming(流式渲染) 的概念,简单来说就是将一整个 HTML 脚本文件通过切成一小段一小段的方式返回给客户端,客户端收到每一段内容时进行分批渲染。
这样的方式相较于传统的服务端一次性渲染完成整个 HTML 内容进行返回,在视觉上大大减少了 TTFB 以及 FP 的时间,在用户体验上更好。
在 HTTP/1.1 中可以利用的分块传输编码(Chunked transfer encoding)机制实现这一过程。
在 HTTP/2.0 中由于传输内容是基于数据帧的,自然默认内容总是“分块”的。
接下来,我们首先会在 NextJs、Remix 中体验这一特性。
同时在文章的第三个部分,我们会不借助任何框架尝试实现这一过程从而让你更好的理解它。
三、NextJs
这里,我使用 npx create-next-app@13.4.6 创建了一个初始项目做了简单的修改。
在新版本中,NextJs 引入了一个新的基于服务端组件(RSF)构建的 app 目录,该目录下所有的组件默认为 React Server Compnent。
简单来将,RSF 在 React18 中的出现赋予了我们在服务端获取组件数据并在服务端进行渲染组件的能力。
上边的代码中,我将 app/page.tsx 中的原始模版代码修改成为了一段商品展示的业务代码:
// 获取商品评论信息(延迟3s)
function getComments(): Promise<string[]> {
return new Promise((resolve) =>
setTimeout(() => {
resolve(['This is Great.', 'Worthy of recommendation!']);
}, 3000)
);
}
export default async function Home() {
// 获取评论数据
const comments = await getComments();
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div>
<div>商品</div>
<p>价格</p>
<div>
<p>评论</p>
<input />
<div>
{comments.map((comment) => {
return <p key={comment}>{comment}</p>;
})}
</div>
</div>
</div>
</main>
);
}
当我们启动项目打开页面时,延迟 3 秒之后页面会展示出所有的内容。
对于商品评论这些非关键性数据来说,打开页面需要因为获取评论数据从而导致页面存在 3 秒白屏时间这无疑是比较糟糕的体验。
在 NextJs 中,我们只要稍作修改就可以非常方便的利用内置的 Server Component 和 Streaming 特性来完美解决这一问题:
// components/Comment.tsx
function getComments(): Promise<string[]> {
return new Promise((resolve) =>
setTimeout(() => {
resolve(['This is Great.', 'Worthy of recommendation!']);
}, 3000)
);
}
export default async function Comments() {
const comments = await getComments();
return (
<div>
<p>评论</p>
<input />
{comments.map((comment) => {
return <p key={comment}>{comment}</p>;
})}
</div>
);
}
// app/page.tsx
import Comment from '@/components/Comments';
import { Suspense } from 'react';
export default async function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div>
<div>商品</div>
<p>价格</p>
<div>
{/* Suspense 包裹携带数据请求的 Comment Server 组件 */}
<Suspense fallback={<div>Loading...</div>}>
<Comment />
</Suspense>
</div>
</div>
</main>
);
}
将评论内容抽离为携带数据请求的服务端组件,同时在父组件中通过 <Suspense /> 进行包裹,即可利用 RSF 和 Streaming 的特性来解决获取评论数据阻塞页面渲染的问题:
你可以点击这里查看代码仓库地址。
打开网页地址时,整个页面除了评论部分使用 Loading... 进行占位其余部分会立即进行渲染。
3s 之后,评论组件的内容会替换页面中的 Loading 内容展示给用户,这看来就非常酷,对吧。
接下来,我们尝试在代码中在额外添加一些交互的内容,允许用户在 <input /> 中输入内容并进行提交:
// components/Comment.tsx
import { useRef } from 'react';
function getComments(): Promise<string[]> {
return new Promise((resolve) =>
setTimeout(() => {
resolve(['This is Great.', 'Worthy of recommendation!']);
}, 3000)
);
}
export default async function Comments() {
const comments = await getComments();
const inputRef = useRef<HTMLInputElement>(null);
const onSubmit = () => {
alert(`您提交的评论内容:${inputRef.current?.value}`);
};
return (
<div>
<p>评论</p>
<input ref={inputRef} />
<button onClick={onSubmit}>提交评论</button>
{comments.map((comment) => {
return <p key={comment}>{comment}</p>;
})}
</div>
);
}
在此刷新页面,不出意外的话你会得到这样的错误:
这是因为 React 服务端组件是完全在服务器上进行的渲染,你无法使用任何 hooks Api 以及使用任何浏览器 Api 、事件绑定等。
同样在 Next 中提供了解决方案嵌套组件的方式来为我们来解决这个问题。
我们需要让各个组件各司其职,在服务端组件中配合 Suspense 动态获取数据同时将数据传递给具有交互逻辑的客户端组件,之后在 RSF 中将客户端组件作为子组件进行包裹即可。
// components/Comment.tsx
import EditableComments from './EditableComments';
function getComments(): Promise<string[]> {
return new Promise((resolve) =>
setTimeout(() => {
resolve(['This is Great.', 'Worthy of recommendation!']);
}, 3000)
);
}
export default async function Comments() {
const comments = await getComments();
return (
<div>
<p>评论</p>
{/* RFC 中包裹客户端组件 */}
<EditableComments comments={comments} />
{comments.map((comment) => {
return <p key={comment}>{comment}</p>;
})}
</div>
);
}
// components/EditableComments.tsx
'use client';
import { useRef } from 'react';
export default function EditableComments(props: { comments: string[] }) {
const inputRef = useRef<HTMLInputElement>(null);
const onSubmit = () => {
// 限制评论内容
if (props.comments.length < 10) {
alert(`您提交的评论内容为:${inputRef.current?.value}`);
}
};
return (
<>
<input ref={inputRef} />
<button onClick={onSubmit}>提交评论</button>
</>
);
}
完整代码在这里。
上述的代码可以看到,我们将存在客户端交互逻辑部分抽离成为 EditableComments.tsx 组件。
通过在原有的 Comment.tsx 服务端组件中进行数据获取,当获取完成数据后会将数据传递给客户端组件进行展示。
一起看起来都完美无误,在 NextJs 中默认 app 目录下的组件都是服务端组件。
当你需要添加客户端逻辑时,需要在该文件的顶层使用 'use client' 显式声明这是一个客户端组件才能添加交互逻辑以及使用浏览器 API。
同时不要忘记服务端组件和客户端组件只能通过嵌套的关系进行相互存在(客户端组件需要服务端数据时,只能通过外层服务端组件获取传入)。
上面这张图是 NextJs 中总结的一些客户端组件和服务端组件的不同用例。
四、Remix
了解完 NextJs 中如何利用服务端组件配合 Streaming 特性后,我们再来看看 Remix 中是如何处理这一过程的。
Remix 中规定在每个路由页面中可以导出一个名为 loader 的函数用来为渲染时提供数据。
比如:
import type { Loade