•
阅读时长 3 分钟

Next.js 流式 SSR:页面内容如何分批返回

目录

一个页面可能同时展示基本信息、统计数据和评论列表。如果评论接口比较慢,服务端等所有接口返回后才生成 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。