一个页面可能同时展示基本信息、统计数据和评论列表。如果评论接口比较慢,服务端等所有接口返回后才生成 HTML,基本信息也会跟着等。
流式 SSR 可以让已经准备好的内容提前返回。浏览器收到后显示页面的一部分,慢数据回来后再补上对应内容。下面用 Next.js 的 App Router 和 Suspense 做一个页面,观察这个过程。
普通的等待方式
如果在页面组件中等待数据,再返回 JSX,整个页面都会等这个 Promise。下面是写法示意,getComments 代表取评论的异步函数:
export default async function Page() {
const comments = await getComments();
return <main>
<h1>文章详情</h1>
<Comments comments={comments} />
</main>;
}
即使标题是固定内容,也要等 getComments() 完成才能进入返回这一步。在返回的 JSX 中再给 Comments 加 Suspense,不能改变前面已经发生的等待。
需要把取数据的操作放到子组件中,让标题所在的外层可以直接返回。
使用 Suspense 包住慢组件
在 Next.js App Router 项目中,将 app/page.jsx 写成下面这样:
import { Suspense } from 'react';
import Counter from './Counter';
export const dynamic = 'force-dynamic';
async function Comments() {
// 模拟评论接口等待,不是接口实际耗时
await new Promise(resolve => setTimeout(resolve, 650));
return <p data-testid="comments">评论内容已经返回</p>;
}
export default function Page() {
return <main>
<h1>文章详情</h1>
<p>文章正文可以直接显示。</p>
<Suspense fallback={<p data-testid="loading">评论加载中……</p>}>
<Comments />
</Suspense>
<Counter />
</main>;
}
Comments 等待期间,Suspense 显示 fallback。边界之外的标题、正文不需要等待评论。
force-dynamic 让这个示例在请求时渲染,避免页面在构建阶段已经生成,观察不到请求中的等待。
页面中的 Counter 是客户端组件,创建 app/Counter.jsx:
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(value => value + 1)}>
点击次数:{count}
</button>;
}
页面需要的两个组件都在上面。放入已有的 App Router 项目后,运行 npm run build、npm start,打开页面就可以观察。使用生产模式,是为了避免把开发环境首次编译的等待也算进去。
响应中先出现了什么
这个页面的返回过程大致是:
请求页面
↓
标题、正文、评论加载中的占位内容
↓
Comments 等待结束
↓
评论内容,以及框架用于更新对应区域的信息
这些内容属于同一个页面请求,不是浏览器又请求了一次评论接口。Next.js 会处理后续内容如何更新到页面中。
为了观察响应顺序,把下面代码保存为 inspect.mjs,在页面服务运行时执行 node inspect.mjs。默认 Next.js 服务地址是 http://localhost:3000:
const start = performance.now();
const response = await fetch('http://localhost:3000');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let html = '';
let loadingSeen = false;
let commentsSeen = false;
try {
while (true) {
const { done, value } = await reader.read();
html += done ? decoder.decode() : decoder.decode(value, { stream: true });
const elapsed = Math.round(performance.now() - start);
if (!loadingSeen && html.includes('data-testid="loading"')) {
loadingSeen = true;
console.log(elapsed, 'ms:收到评论占位内容');
}
if (!commentsSeen && html.includes('data-testid="comments"')) {
commentsSeen = true;
console.log(elapsed, 'ms:收到评论正文');
}
if (done) break;
}
} finally {
reader.releaseLock();
}
要检查的是占位内容能否在评论正文之前到达,而不是固定会收到几个 chunk。网络分块不一定和组件一一对应。直接调用 response.text() 会等整个响应收完,看不到中间过程。
响应顺序和浏览器画面的变化也不完全一样。浏览器可能缓冲少量内容,代理也可能缓冲响应,所以不能把 650ms 当成所有环境下两次绘制的间隔。
页面显示了,按钮为什么还可能点不动
Counter 的初始 HTML 可以由服务端生成,浏览器收到后就能显示“点击次数:0”。但点击事件需要 JavaScript 执行,并由 React 接管这部分页面,也就是 hydration。
因此有两个不同的时间点:
- 收到 HTML:浏览器可以显示按钮和文字。
- 客户端代码完成接管:点击按钮可以更新计数。
流式 SSR 能改变内容的输出顺序,不代表页面不再需要客户端 JavaScript,也不保证所有组件同时完成 hydration。
它和聊天接口的流式响应也有区别。聊天接口是在用户点击后返回一段段数据,由前端代码更新页面;这里是在服务端渲染页面时,分批返回渲染结果。
页面中怎么安排这些等待
比较适合放进 Suspense 的内容,是允许稍晚显示的独立区域,例如评论或推荐列表。页面主体如果也依赖这份数据,仍然可能需要等待,不能只靠增加几个 Suspense 就消除所有等待。
占位区域最好和实际内容的布局接近。如果占位只有一行,实际内容却很长,替换时页面仍然会明显跳动。边界拆得太细,也可能让用户看到很多区域分别加载。
这个例子能确认占位内容先于慢组件正文出现在响应中,以及客户端按钮接管后能工作。首屏时间、布局变化和部署后的代理行为,需要在实际页面里继续检查。
相关机制可参考 React 的流式服务端渲染文档。在 Next.js 中,框架负责调用渲染接口和发送响应,这里不需要手动调用 renderToPipeableStream。