前端工程化(九):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 都变成客户端组件
首页使用了这样的组合:
// 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,并显式声明:
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 只能用于受控的特例,不能作为通用修复方式。
用构建输出和响应头验证判断
学习过程中,我重新执行生产构建,首页路由输出为:
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 选型与失效方案讨论,没有实施改造,也没有验证跨日更新。
有想法或建议? 联系我