返回文章列表
技术实践

前端工程化(九):React 与 Next.js 的渲染边界

SSR、SSG、ISR、CSR 描述页面内容的生成时机与方式;Server Component 和 Client Component 描述组件的运行边界。判断一个 Next.js 页面需要同时检查源码、数据获取、构建结果和实际响应,不能只看有没有 `use client`

页面什么时候生成

学习 Next.js 时,我最初容易把“组件在服务端运行”和“页面每次请求都在服务端生成”混在一起。实际上,服务端组件可以参与构建阶段的静态生成,也可以参与请求阶段的动态渲染。

策略
主要生成时机
适合的场景
主要代价
SSR
请求阶段在服务端生成内容
需要请求级数据、身份或较高新鲜度的页面
请求阶段的数据读取和渲染成本
SSG
通常在构建阶段提前生成
更新较少的公开内容
更新通常需要重新构建部署
ISR
复用静态产物,按时间或按需重新验证
持续更新但允许短暂延迟的内容
需要设计失效策略,可能短暂展示旧内容
CSR
浏览器运行 JavaScript 后生成或更新主要内容
强交互、客户端状态驱动的界面
客户端加载、执行和数据请求成本

这些策略可以组合使用。CSR 也不意味着服务器绝对返回空白文档,通常仍会返回基础 HTML 壳;SSR 则不意味着所有数据天然实时,仍需检查数据层缓存。

Server Component 与 Client Component 是另一组边界

Server Component 适合服务端数据读取、服务端配置处理和内容展示,其组件实现代码不需要为了交互而发送给浏览器。敏感配置应保留在服务端模块中,也不能作为 props 或输出内容传给客户端。

Client Component 适合状态更新、事件处理、Effect 和浏览器 API。use client 建立客户端模块边界,该模块及其客户端依赖会进入客户端构建图,增加下载、解析、执行和水合成本。自定义 Hook 是否需要客户端,要看它实际使用的能力,不能仅凭“它是 Hook”判断。

Client Component 并不等于纯 CSR。Next.js 首次加载时仍可为客户端组件预渲染初始 HTML,让用户先看到内容,再通过浏览器中的 React 建立交互能力。因此,把所有组件都改成客户端组件,主要问题是扩大客户端代码和水合范围,而不是简单地让页面完全没有服务端 HTML。

客户端包装器不会把所有 children 都变成客户端组件

首页使用了这样的组合:

TypeScript
// src/app/(frontend)/page.tsx
<AnimateOnScroll variant="fadeUp">
  <DailyBibleInsightSection />
</AnimateOnScroll>

AnimateOnScroll 是客户端动画组件,DailyBibleInsightSection 在服务端读取 CMS。两者在页面中嵌套,并不意味着后者自动进入客户端模块图。

原因是服务端的 page.tsx 引入并渲染每日微光,将服务端渲染的内容作为 children 交给动画包装器。包装器只运行动画逻辑,没有直接导入每日微光的 CMS 查询模块。

这里需要区分模块导入关系和渲染树嵌套。没有 use client 的普通同步组件如果被客户端模块直接导入,可以成为客户端模块图的一部分,并不必然报错。但依赖数据库、server-only 或其他服务端专属能力的模块不能这样导入。组合这类服务端内容时,应由服务端父组件组织,再通过 children 等方式传给客户端包装器。

首页从请求到动画启动的链路

my-world 首页入口是 src/app/(frontend)/page.tsx,公共前台结构位于同目录的 layout.tsx。首页入口没有 use client,并显式声明:

TypeScript
export const dynamic = "force-dynamic";

首页的三个内容区块在服务端获取数据:每日微光调用 getCurrentDailyBibleInsight,精选项目调用 getFeaturedProjects,最新内容调用 getPublishedArticles 和 getPublishedLifeNotes。数据函数位于 src/lib/content.ts,通过 Payload Local API 的 payload.find 查询内容,未见显式结果缓存包装。

HeroSection 则需要 Canvas、window、document、useEffect 和 requestAnimationFrame;AnimateOnScroll 使用视口观察与 Framer Motion 动画。这些能力属于客户端运行范围。

首次打开首页的逻辑链路是:请求进入服务端,动态读取 CMS 并生成初始内容;浏览器解析并展示 HTML;客户端 JavaScript 就绪后,React 水合 Client Component,使其能处理事件和状态更新;随后 Effect 启动 Canvas 等动画。HTML 解析和资源下载可能并行,这个顺序描述的是展示与交互能力的建立过程。

水合会复用并接管已有 HTML,不是重新执行 Server Component 的数据库查询。普通链接和原生表单等 HTML 行为可以在水合前工作,React 自定义事件依赖相应组件完成水合。

为什么水合要求首次渲染一致

如果服务端 HTML 与客户端首次渲染不同,会产生 hydration mismatch(水合不匹配)。React 可能报告错误并重新生成受影响的部分,带来额外渲染或闪动。

常见原因包括渲染时使用 Date.now() 或 Math.random()、根据 window 或 localStorage 输出不同初始内容、两端时区不同,以及非法 HTML 嵌套。

处理原则是保持首次渲染一致:使用稳定初始值或服务端传入的同一份数据,将需要浏览器读取的状态放到 Effect 中更新,并修正嵌套和日期格式。suppressHydrationWarning 只能用于受控的特例,不能作为通用修复方式。

用构建输出和响应头验证判断

学习过程中,我重新执行生产构建,首页路由输出为:

plaintext
Route (app)
┌ ƒ /

ƒ / 表示这次构建将首页标记为按请求动态服务端渲染,与源码配置一致。这是本地构建证据,不能直接证明线上部署使用相同提交。

随后查看线上首页 document 请求,以及一个 /_next/static/ 下的 JS 请求,记录到两组响应头:

响应
Cache-Control
含义
首页 HTML
private, no-cache, no-store, max-age=0, must-revalidate
其中 no-store 指示不要存储该响应
一个静态 JS
max-age=2592000, public, immutable
允许浏览器和共享缓存存储,新鲜期为 30 天,期间内容不变

no-cache 单独使用时表示复用前需要验证,并不等于禁止存储;private 单独使用时允许私有缓存,但禁止共享缓存。首页这组指令中,关键是 no-store。

HTML、JS 和 CSS 是独立响应,可以采用不同策略。HTML 获取动态内容,静态脚本复用缓存;带内容 hash 的文件在代码变化时使用新 URL,避免旧缓存覆盖新版本。这里实际观察的是一个 JS 响应,不能据此推断所有静态资源配置。

还要更正一个容易过度概括的说法:动态渲染与数据缓存确实是不同层次,但在使用该路由段配置的模式下,force-dynamic 会让路由内 Next.js 管理的 fetch 跳过数据缓存,不能再用某个 fetch 的 force-cache 或正数 revalidate 简单覆盖。独立 Redis、业务缓存等仍需单独分析;本项目的 Payload 查询也不是 Next.js fetch,因此要直接检查其实现。HTML 响应头不能证明数据库查询次数。

空内容与查询失败需要分别处理

首页已有正常空数据的展示逻辑:每日微光没有记录时返回 null;精选项目和最新内容数组为空时展示空状态。

查询失败则是另一种情况。数据库连接或 CMS 查询抛错,并不一定会自动转为空数组。学习期间检查的内容函数未见异常捕获,前台路由目录未发现 loading.tsx、error.tsx;这不等于整个应用不存在其他错误边界,也不能据此断言一定白屏。

可考虑通过 loading.tsx 或局部 Suspense 提供等待反馈,通过 error.tsx 提供安全错误提示和重试入口。错误边界有作用范围,例如同段 layout 的错误需要考虑父级边界;也不应把所有查询异常都吞掉并伪装成“暂无内容”。这些是本次讨论的改进方向,尚未实施或注入故障验证。

如果改成 ISR,需要考虑每日微光的日期切换

首页文章偶尔更新,每日微光每天切换,如果允许短暂延迟,可以评估 ISR,减少重复渲染和查询成本。但采用前要结合访问量、服务器负载和数据新鲜度要求,不能只删除 force-dynamic 就认为改造完成。

revalidate: 86400 表示时间间隔,不表示每天上海时间零点自动更新。时间型重新验证通常需要缓存过期后的访问触发,可能继续短暂返回旧内容。

如果希望跨日后尽快更新,可以设计上海时间零点后的定时任务,通过受保护的 Route Handler 调用 revalidatePath("/") 标记首页重新验证,随后请求首页生成新内容;必要时主动请求预热,并核对结果。具体失效行为还要结合 Next.js 版本、调用位置和实际缓存实现。已经打开的浏览器页面也需要刷新或额外的客户端更新策略。

当前首页仍采用动态渲染。本次只完成 ISR 选型与失效方案讨论,没有实施改造,也没有验证跨日更新。

有想法或建议? 联系我