0%

Next.js 渲染流程追踪

在 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
2
3
4
# cd /root/code/private/web/learn/nextjs

# npx create-next-app@latest fullstack-demo
# cd fullstack-demo

app/ssr-page/page.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import ClientComponent from '../csr-page/client-component';

// 这是一个默认的服务器组件,代码在 Node.js 环境执行
async function getSSRData() {
// ⚠️ 注意:这里使用 Node.js 的 fetch,直接在服务器内部发送请求
const res = await fetch('http://localhost:8000/api/random-number', {
cache: 'no-store' // 确保每次请求都重新获取数据
});

if (!res.ok) {
throw new Error('Failed to fetch SSR data from Python API');
}

return res.json();
}

export default async function SSRPage() {
const data = await getSSRData(); // 阻塞渲染,直到数据获取完成

return (
<div style={{ padding: '20px', border: '1px solid blue' }}>
<h1>SSR 页面 (服务端渲染)</h1>
<p>
<strong>数据流:</strong> Node.js 服务器 **直接** 调用 Python API。
</p>

<h2>渲染结果:</h2>
<p>来源: {data.source}</p>
<p>随机数: **{data.value}**</p>
<p>时间戳: {data.timestamp}</p>

{/* 这是一个客户端组件,但它的初始 HTML 仍然是在 Node.js 生成的 */}
{/* 这里的 ClientComponent 仅用于演示 Server Component 可以包裹 Client Component */}
<ClientComponent title="客户端组件" />
</div>
);
}

app/csr-page/page.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 这个文件定义了页面的路由,但主体内容由客户端组件渲染
import ClientComponent from './client-component';

export default function CSRPage() {
return (
<div style={{ padding: '20px', border: '1px solid green' }}>
<h1>CSR 页面 (客户端渲染)</h1>
<p>
<strong>数据流:</strong> 浏览器在组件加载后 **异步** 调用 Python API。
</p>
<ClientComponent title="客户端组件" />
</div>
);
}

app/csr-page/client-component.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
'use client'; // 👈 标记为客户端组件

import React, { useState, useEffect } from 'react';

// 这是一个客户端组件,可以在浏览器上使用 Hooks
function ClientComponent({ title }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);

// 1. useEffect 在组件挂载(在浏览器上)后运行
useEffect(() => {
async function getCSRData() {
// 2. ⚠️ 浏览器发起 HTTP 请求到 Python API
const res = await fetch('http://localhost:8000/api/random-number');
const json = await res.json();

setData(json);
setLoading(false);
}

getCSRData();
}, []); // 依赖数组为空,只在组件首次加载时运行一次

return (
<div style={{ marginTop: '20px', borderTop: '1px dashed #ccc', paddingTop: '10px' }}>
<h3>{title} 获取数据:</h3>
{loading ? (
<p>加载中...</p>
) : (
<>
<p>来源: {data.source}</p>
<p>随机数: **{data.value}**</p>
<p>时间戳: {data.timestamp}</p>
<p style={{ color: 'green' }}>**此数据在浏览器上获取并渲染。**</p>
</>
)}
</div>
);
}

export default ClientComponent;

python API 服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import random

app = FastAPI()

# ⚠️ 必须设置 CORS,否则浏览器会阻止跨域请求
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 允许所有来源访问,实际项目中应限制为 Next.js 域名
allow_methods=["*"],
allow_headers=["*"],
)

@app.get("/api/random-number")
def get_random_number():
"""返回一个随机数和服务器时间"""
return {
"source": "Python FastAPI Server",
"value": random.randint(100, 999),
"timestamp": "Server Time: 2026-01-07 10:39:26 KST" # 示例时间
}

启动服务

在我的 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
2
INFO:     127.0.0.1:33324 - "GET /api/random-number HTTP/1.1" 200 OK
INFO: 127.0.0.1:33326 - "GET /api/random-number HTTP/1.1" 200 OK
  • 在 Chrome 的开发攻击中,可以看到 SSR 页面的返回结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
......
<div style="padding:20px;border:1px solid blue">
<h1>SSR 页面 (服务端渲染)</h1>
<p>
<strong>数据流:</strong>
Node.js 服务器 **直接** 调用 Python API。
</p>
<h2>渲染结果:</h2>
<p>来源:
<!-- -->
Python FastAPI Server</p>
<p>随机数: **
<!-- -->
464
<!-- -->
**</p>
<p>时间戳:
<!-- -->
Server Time: 2026-01-07 10:39:26 KST</p>
<div style="margin-top:20px;border-top:1px dashed #ccc;padding-top:10px">
<h3>客户端组件
<!-- -->
获取数据:</h3>
<p>加载中...</p>
</div>
</div>
......

结果分析

Next.jsApp Router 中,默认的 page.tsxServer 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
2
3
4
5
6
7
GET http://localhost:8000/api/random-number

{
"source": "Python FastAPI Server",
"value": 826,
"timestamp": "Server Time: 2026-01-07 10:39:26 KST"
}
  • 状态更新和客户端渲染 (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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
......
<div style="padding:20px;border:1px solid green">
<h1>CSR 页面 (客户端渲染)</h1>
<p>
<strong>数据流:</strong>
浏览器在组件加载后 **异步** 调用 Python API。
</p>
<div style="margin-top:20px;border-top:1px dashed #ccc;padding-top:10px">
<h3>客户端组件
<!-- -->
获取数据:</h3>
<p>加载中...</p>
</div>
</div>
......
  • 页面加载完成后,客户端 ClientComponent 组件才会从浏览器发出 ajax 数据请求
1
2
3
4
5
6
7
GET http://localhost:8000/api/random-number

{
"source": "Python FastAPI Server",
"value": 420,
"timestamp": "Server Time: 2026-01-07 10:39:26 KST"
}
  • 收到 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
server {
listen 80;
server_name my-app.com;

# 规则 1: 代理 Next.js 应用的所有非 API 请求
# 任何不以 /api 开头的请求,都转发给 Next.js 渲染页面
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}

# 规则 2: 代理 API 请求到 Python 服务
# 任何以 /api 开头的请求,都转发给 Python FastAPI 处理
location /api/ {
# 转发到 Python 服务的内部地址和端口
proxy_pass http://localhost:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

对比纯 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 页面是在前端应用构建时生成的:

  • 通过 WebpackVite 等构建工具生成初始的 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