在 Gemini、DeepSeek 的帮助下,构建了一个简单的 Next.js 项目,用来追踪 Next.js 的服务端渲染和客户端渲染流程,从而加深对 Next.js 服务端渲染(Server-Side Rendering,SSR)流程的理解,也进一步学习掌握客户端渲染与服务端渲染的区别。
项目结构
这个项目设计了两个对比页面,分别展示不同的渲染策略:
-
SSR 页面 (app/ssr-page/page.tsx)
- 服务器组件,在 Node.js 环境执行
- 数据请求从 Node 服务发出
- 在该页面中,也包含一个
客户端组件,用来加强对比
-
CSR 页面 (app/csr-page/page.tsx)
- 只包含
客户端组件 - 浏览器触发数据的异步获取
- 只包含
-
共享的
客户端组件(app/csr-page/client-component.tsx)- 使用
use client标记,表示这是一个客户端组件(Client Component) - 包含
useEffect进行数据获取
- 使用
-
Python API 服务
- 用 FastAPI 搭建一个简单的 API 服务,用来模型后端服务(或者说真正意义上的后端服务)
- 提供随机数生成 API
创建项目
1 | # cd /root/code/private/web/learn/nextjs |
app/ssr-page/page.tsx
1 | import ClientComponent from '../csr-page/client-component'; |
app/csr-page/page.tsx
1 | // 这个文件定义了页面的路由,但主体内容由客户端组件渲染 |
app/csr-page/client-component.tsx
1 | 'use client'; // 👈 标记为客户端组件 |
python API 服务
1 | from fastapi import FastAPI |
启动服务
在我的 Linux 服务器(10.9.33.133)上分别启动如下服务:
- 启动
Next服务:本质上是启动一个 Node.js 服务,加载 Next.js 框架及应用程序代码
1 | npm run dev |
- 启动 python 服务
1 | # uvicorn main:app --reload --port 8000 |
访问 ssr-page
现象
在浏览器中访问 http://10.9.33.133:3002/ssr-page
- Next 服务日志
1 | GET /ssr-page 200 in 39ms (compile: 1875µs, render: 37ms) |
- Python 服务日志
1 | INFO: 127.0.0.1:33324 - "GET /api/random-number HTTP/1.1" 200 OK |
- 在 Chrome 的开发攻击中,可以看到 SSR 页面的返回结果
1 | ...... |
结果分析
在 Next.js 的 App Router 中,默认的 page.tsx 是 Server Component。当它包含一个 Client Component 时,会发生以下流程:
阶段一:Node.js 服务器处理 SSR (仅渲染结构)
- 服务器组件运行
ssr-page/page.tsx: Node.js 服务器开始执行SSRPage函数 - 服务器获取数据:
SSRPage内部的getSSRData()函数直接向 Python API 发起请求,获取 SSR 页面本身所需的数据 - 遇到
Client Component(<ClientComponent>):当服务器遇到一个标记了use client的组件时,它不会执行这个组件内部的某些 JavaScript 逻辑(如 useState 或 useEffect)- 跳过逻辑,生成占位符: 服务器会跳过
ClientComponent的所有 Hooks 和事件处理函数,只执行它的 return 语句,并将组件的初始 HTML 结构渲染成一个占位符 - 在这个例子中,服务器生成的初始 HTML 结构包含
loading ? (<p>加载中...</p>)部分 - 因此,
加载中...等内容是在 Node.js 服务器上生成的,并包含在发送给浏览器的初始 HTML 种
- 跳过逻辑,生成占位符: 服务器会跳过
- 发送给浏览器:服务器将所渲染出来的 HTML(包含
SSRPage的数据和<ClientComponent>所对应的加载中...)发送给浏览器
阶段二:浏览器接管 (执行逻辑和数据获取)
- 浏览器显示:浏览器立即显示来自服务器的 HTML,用户看到了完整的 SSR 数据和
<ClientComponent>中的加载中...。当然这个例子因为请求时间太短,导致看不到页面上的加载中...,如果在 Python 服务的 API 实现中加入time.sleep(10),则可以观察到加载中...:
-
Hydration(水合作用):
Next.js客户端代码开始水合作用,它发现<ClientComponent>是一个客户端组件,于是开始执行它的 JavaScript 逻辑- Hooks 启动
- useState(null) 被初始化。
- useEffect 被执行(因为它在组件挂载后运行)
-
浏览器发出 ajax 请求:
useEffect内部的fetch(http://localhost:8000/api/random-number)函数被执行,这个 HTTP 请求是从用户的浏览器中发出的- 通过浏览器的开发者工具,我们能看到这个 ajax 请求
1 | GET http://localhost:8000/api/random-number |
- 状态更新和客户端渲染 (CSR):
- 请求完成后,setData 被调用
ClientComponent在浏览器中重新渲染,用真实的随机结果数据替换了之前的加载中...文本
整个流程如下所示:
总结一下
- 对于
SSRPage内的Server Component:初始 HTML 在 Node.js 服务器上进行渲染,对于其中的动态数据,由 Node.js 服务向 Python API 服务发出请求 - 对于
SSRPage内的ClientComponent:初始 HTML 在 Node.js 服务器上生成,构建出加载中HTML 结构
这揭示了 Next.js App Router 的一个重要特性:服务器组件可以包含客户端组件,但它们的执行环境是分离的。对于服务器组件树中引用的 客户端组件,Next.js 服务器不会执行其核心代码逻辑(例如 Hooks 或事件处理):
- 服务器只负责处理其渲染标记(如占位符或初始 HTML 结构)
- 将其客户端 bundle 的 JavaScript 脚本引用发送给浏览器
- 客户端组件的实际代码执行(如状态管理和交互逻辑)完全在浏览器中进行
访问 csr-page
在浏览器中访问 http://10.9.33.133:3002/ssr-page:
- Next 服务日志
1 | GET /csr-page 200 in 28ms (compile: 2ms, render: 26ms) |
- Python 服务日志
1 | INFO: 127.0.0.1:33338 - "GET /api/random-number HTTP/1.1" 200 OK |
- 在 Chrome 的开发攻击中,可以看到 SSR 页面的返回结果
1 | ...... |
- 页面加载完成后,客户端
ClientComponent组件才会从浏览器发出 ajax 数据请求
1 | GET http://localhost:8000/api/random-number |
- 收到 ajax 的应答后,页面上
加载中...文本被替换为真实数据,渲染出最终的页面结果
- 而且每次刷新页面时,返回的初始 HTML 都是相同的(
加载中...)。当页面在浏览器加载完成后,客户端组件才会从浏览器发出 ajax 请求,之后才会看到不同的随机数结果
整个流程如下所示:
一个有意思的问题
我观察到一个很有趣的现象。我的 web 应用的是在服务器 10.9.33.133 上运行的的,而当 ClientComponent 在 Mac 上的浏览器渲染后,是往 http://localhost:8000/ fetch 数据的,那此时 localhost 指我的本地 Mac,那应该是往我的本地 Mac 上的 8000 端口请求数据,为啥 10.9.33.133:8000 反而收到请求了呢?这个示例为啥能运行成功?
我也排查了很久,发现我的示例项目是通过 VSCode Remote-SSH 开发的,而 VSCode 似乎会自动建立对应的 SSH 隧道来完成相关端口的转发(通过 VSCode 的 Ports: Focus on Ports View 命令可以查看当前端口转发),因此机缘巧合地使上述例子能成功运行。
如果关闭 vscode,果然出现错误:
当然在真正的项目开发中,推荐使用相对路径,这样浏览器自动使用当前域名发送 HTTP 请求。服务端通过 Nginx 等反向代理软件将 API 请求转发到 Python 服务上,例如:
1 | server { |
对比纯 React 应用
对于纯 客户端渲染(Client-Side Rendering,CSR)框架,如传统的 React 应用,浏览器接收到的初始 HTML 页面内容非常少,主要依靠 JavaScript 代码来完成后续的页面构建和渲染。对于一个典型的纯 React 应用,在首次访问时,服务器返回的 HTML 文件(通常是 index.html)非常精简,它主要包含以下三个核心元素:
- 骨架和元数据(
<head>部分):这部分包含了浏览器所需的基本信息,但没有实际的页面内容。 - 根挂载点(Root Mount Point):这是整个 CSR 模式中最关键的元素,它通常是一个空的 标签,后面 React 会将所有组件、UI 元素、数据和逻辑都注入到这个
<div>内部- JavaScript Bundle 引用(
<script>标签):这是页面的大脑,浏览器必须下载、解析并执行这些 JavaScript 文件,然后才能看到页面内容1
2
3
4
5<body>
<div id="root"></div>
<script src="/static/js/bundle.js"></script>
</body>整个请求过程大致如下所示:
- 浏览器请求:浏览器向服务器请求
/路径 - 服务器响应:服务器返回上述包含空
<div id="root">和<script>标签的极简 HTML 文件 - 下载 JS:浏览器解析 HTML,发现
<script>标签,开始下载大型的JavaScript Bundle(即整个 React 应用的代码) - 执行 JS(渲染开始):JS 下载完成后,浏览器开始执行代码
- React 运行时启动
- 代码找到 HTML 中的
<div id="root">元素 - React 调用:
ReactDOM.createRoot(document.getElementById('root')).render(...)
- 构建和注入 DOM:React 开始执行组件逻辑,根据组件的 State 和 Props,在内存中构建虚拟 DOM (Virtual DOM)
- 显示内容:React 将虚拟 DOM 转化为真实的 DOM 节点,并注入到
<div id="root">中。此时,用户才看到页面内容 - 数据获取:如果组件需要数据(如通过
useEffect),这些数据请求是在 JS 执行时、从浏览器中异步发出的。数据返回后,组件更新,UI 刷新
这个初始的极简 HTML 页面是在前端应用构建时生成的:
- 通过
Webpack、Vite等构建工具生成初始的index.html文件,并打包好所有静态资源 - 将这些静态文件部署到生产目录中
- 通过 Nginx 等 Web 服务器托管这些静态文件,对外提供响应
因此在生产环境中,纯 React 应用通常不需要一个专门的
Node.js服务器。纯 React 应用生成的 build 文件夹内,所有内容都是静态资源,可以使用任何高性能的静态文件服务器来托管它们。开发环境可能会使用Node.js来作为开发服务器,主要目的是提供热模块替换 (HMR)、代码实时编译等方便开发的功能。而对于 Next.js 的 SSR(服务端渲染)功能而言,Node.js 则是必要的,因为它提供了 JS 的
运行时环境,让 React/Next/应用程序代码能在服务端运行,并动态渲染出所需要返回的 HTML 页面。小结
本文通过一个实际例子,跟踪了 Next.js 框架下的 CSR(客户端渲染)和 SSR(服务端渲染)的交互过程,从而加深对 CSR 和现代 SSR 技术的理解。
Reference
- JavaScript Bundle 引用(