ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Next.js 服务端组件与客户端组件:运行边界、踩坑与实战指南

2026/9/15 6:37:35 拓冰建站 浏览量
Next.js 服务端组件与客户端组件:运行边界、踩坑与实战指南 上个月有位读者在我的博客下面留言“我在 Next.js 的页面组件里写了 console.log浏览器控制台里死活看不到是不是框架把我的日志吃掉了”这个问题我太熟悉了因为我自己第一次从 Vite React 切到 Next.js 时同样的事情也让我怀疑人生——排除了各种缓存问题之后才意识到在 Next.js 的 App Router 里组件默认运行在服务端而不是浏览器里。这正是 Next.js 服务端组件的核心设计理解不了这一层后面写任何页面都会处处踩坑。这篇文章我会结合自己的项目和踩坑记录把服务端组件Server Components和客户端组件Client Components彻底讲透包括它们各自运行在哪里、边界在哪里、实际开发中怎么用、遇到报错怎么解以及和传统 Vite React 纯客户端渲染相比预渲染到底给项目带来了什么。不管你是刚接触框架的新手还是从 Vite 切换过来的老 React 开发者这篇文章应该都能帮你少走不少弯路。1. 先从“为什么是 Next.js”说起与 Vite React 的本质差异1.1 Vite React 的纯客户端渲染问题出在哪很多从 Vite React 转过来的同学第一反应是Next.js 不就是个带路由的 React 框架吗我原来的项目用 Vite 跑得好好的为什么要换我们先看看 Vite React 项目的实际情况。你用 Vite 构建一个普通的 React 应用时浏览器拿到的入口 HTML 大概长这样!DOCTYPE html html body div idroot/div script typemodule src/src/main.tsx/script /body /html整个页面的可见内容全凭那一个 JavaScript Bundle 在浏览器里运行后动态生成。这个过程叫客户端渲染CSRClient-Side Rendering。它的问题有两个第一首屏白屏时间长。用户在浏览器输入地址后需要先下载整个 JS Bundle再解析、执行React 才会把 DOM 画到页面上。项目规模一大Bundle 动不动几 MB在弱网环境下首屏体验非常难受。第二SEO 不友好。虽然爬虫现在能执行部分 JavaScript但 Google 之外的很多搜索引擎平台抓取到的 HTML 往往只有一个空壳div idroot/div标题、描述、正文内容都拿不到。对于内容型网站来说这是致命伤。我自己接过一个企业官网项目最初用 Vite 做的客户反馈百度收录一直不理想零散的关键词排名也上不去最后不得不重构迁移到了 Next.js。这个需求就是典型的“内容需要被搜索引擎识别”。1.2 Next.js 用预渲染解决首屏和 SEO 问题Next.js 的核心思路是预渲染Pre-rendering在构建阶段或者请求到达时先把 React 组件渲染成完整的 HTML 字符串发给浏览器。浏览器拿到的就不再是空壳而是包含真实内容的 HTML。用户即使不开 JavaScript也能看到页面主体内容。这个能力由框架内部的服务端渲染层层实现对外只暴露几个简单的配置和约定开发者的心智负担比完全手写 SSR 要小得多。预渲染分两种时机静态生成SSGStatic Site Generation构建时生成 HTML内容全站共享速度最快适合博客、文档、落地页。服务端渲染SSRServer-Side Rendering每次请求时动态生成 HTML适合需要个性化的页面比如登录后的仪表盘。Next.js 还提供了第三种结合体增量静态再生ISRIncremental Static Regeneration让静态页面可以按时间自动更新。这一块我在后面第 6 章会详细展开这里你先有个印象这些能力都建立在服务端组件这套运行机制之上。1.3 选型建议什么项目适合 Next.js什么不适合说了这么多 Next.js 的优势我也要泼点冷水。SSR 不是银弹不是所有项目都适合上 Next.js。如果你的项目是纯内部的运营后台、管理系统没有 SEO 需求也没有首屏性能焦虑那么 Vite React 的“构建简单、逻辑直白、生态贴合”依然是优势。我见过很多团队为了赶时髦把内部系统硬迁到 Next.js结果反而被 App Router 的目录约定、服务端组件边界、构建缓存配置折腾得够呛开发效率不升反降。我的判断标准很简单需要内容被搜索、被分享比如官网、博客、电商详情页、内容聚合站 → 适合 Next.js。对首屏加载有强诉求比如 C 端活动页、用户主页 → 适合 Next.js。纯交互型应用比如复杂的编辑器、后台分析面板 → 留在 Vite React 完全没问题。这个前提理清楚之后我们再进入本文的核心服务端组件与客户端组件到底怎么理解。2. 服务端组件到底“服务”在哪运行边界与能力清单2.1 组件默认就跑在服务端这是什么意思在 Next.js 14 和 15 的 App Router 中逻辑是反转的所有组件默认都是服务端组件Server Components简称 RSC不需要额外声明。只有当你明确在文件顶部写use client时这个组件才变成客户端组件。服务端组件不是一个“只是负责输出 HTML”的普通函数它其实是 React 的新架构能力React Server Components。它意味着第一组件代码只会在服务端执行浏览器永远拿不到这部分源码和逻辑。你在服务端组件里写console.log日志会打印在 Node.js 终端而不是浏览器 DevTools。第二服务端组件可以直接访问 Node.js 能力。比如直接读取数据库、调用文件系统fs、读取服务端环境变量。这些操作在客户端组件里是实现不了的因为浏览器根本没有这些权限。举个例子这是一个典型的服务端组件// app/posts/page.tsx import { db } from /lib/db; export default async function PostsPage() { const posts await db.post.findMany({ take: 10 }); return ( ul {posts.map((post) ( li key{post.id}{post.title}/li ))} /ul ); }注意这个async function配合await的写法。服务端组件支持异步因为它在服务端运行环境里有完整的 I/O 能力。你在 Vite React 里永远不可能在组件函数里直接await数据库查询但在服务端组件里这是最自然、最推荐的做事方式。2.2 加入“use client”后的组件并不是“只在浏览器跑”这是很多新手最容易误解的点。一个组件标了use client不代表它只在浏览器运行也不代表它不是 SSR 的。客户端组件依然会被服务端做预渲染生成 HTML 字符串发给浏览器。在这之后React 才在浏览器端做“水合”Hydration加载对应的 JavaScript Bundle解析组件树绑定事件处理器接管交互。为什么要保留预渲染这一步因为首屏的 HTML 是完整内容爬虫和弱网用户都能第一时间看到东西。真正“只在浏览器跑”的是水合之后的那份 JavaScript Bundle以及它里面的交互逻辑。所以你可以把客户端组件理解为服务端负责首屏 HTML预渲染。浏览器负责事件、状态、副作用水合后执行。两者共享同一份组件代码。这里我建议你记住一句话use client不是“只在客户端运行”的意思而是“这个组件需要被水合”的意思。2.3 一张表看清两类组件的能力边界理解边界最简单的方式是把两类组件铺在一张表里对比能力服务端组件客户端组件使用 useState / useEffect / 事件处理不支持支持直接访问数据库、文件系统支持不支持读取服务端环境变量支持直接使用不支持需通过NEXT_PUBLIC_前缀暴露异步组件async / await支持不支持浏览器的 DOM / BOM API不支持支持Bundle 是否打包到浏览器不会会默认状态默认需要use client声明很多人的困惑来源于为什么服务端组件不能用useState和事件为什么客户端组件不能直接用数据库本质上没什么玄机就是运行环境不同导致的。服务端组件运行在 Node.js 里没有事件循环之外的东西没有window没有document自然不能处理浏览器点击但它有完整的 I/O 管线可以直接连数据库。客户端组件运行在浏览器沙箱里有完整的交互能力但拿不到数据库密码也不应该拿到。2.4 为什么服务端组件能显著缩小 JS 体积刚才那张表里有一行大家可能没在意“Bundle 是否打包到浏览器——服务端组件不会。”这件事的实际意义非常大。我优化过一个项目首屏 JS 从 780KB 降到了 210KB核心操作就是把一个富文本编辑器相关的依赖链从页面组件里抽离到了服务端组件侧。原因很简单如果某段逻辑只在服务端组件里用它根本不会被打进浏览器的 Bundle。比如数字格式化库day.js、Markdown 解析库marked、甚至一套完整的 ORM 代码写在服务端组件里就永远不会被发送到浏览器用户也就永远不需要下载这些代码。这个特性在 Vite React 里没有办法直接实现——所有被组件引用的代码最终都会被 webpack/rollup 分析进客户端 Bundle。所以在 Next.js 里包体积的优化路径不再只是“按需加载”“拆 chunks”而是多了一条思路把非交互的代码尽量往服务端组件里放。到这里你应该已经对两类组件的角色有了一个立体的认识。接下来我们动手实操从零搭一个项目把理论知识落到代码上。3. 零基础实操从脚手架到第一个服务端 / 客户端组件3.1 用 create-next-app 搭建项目版本怎么选新项目我建议直接使用 create-next-app 脚手架命令如下npx create-next-applatest执行过程中会有一些交互选项我的建议是选项建议值原因TypeScriptYes类型约束对组件边界理解很有帮助ESLintYes后续能帮你尽早发现组件边界错误Tailwind CSS按需不相关不影响组件学习App RouterYes本文基于 App Router它也是未来方向import alias默认/指向根目录好用创建完成后进入目录启动项目cd my-app npm run dev浏览器打开http://localhost:3000你会看到一个默认欢迎页。这个欢迎页的代码就在app/page.tsx里它本身就是一个服务端组件——没有任何use client声明。3.2 认识 App Router 的目录约定App Router 的核心目录约定是app目录下的每个文件夹对应一个路由路径文件夹里的page.tsx就是该路由的页面组件。my-app/ ├── app/ │ ├── layout.tsx # 根布局全站共享 │ ├── page.tsx # 对应首页 / │ └── about/ │ └── page.tsx # 对应 /about ├── components/ # 自己建的公共组件目录 │ ├── server-list.tsx │ └── like-button.tsx └── lib/ └── db.ts # 数据库连接模拟layout.tsx是布局组件它包裹所有子页面你可以在这里放导航栏、页脚。page.tsx是页面主体。两者默认也都是服务端组件。3.3 写第一个服务端组件异步读取数据我们新建一个components/server-list.tsx模拟从数据库读取文章列表// components/server-list.tsx const articles [ { id: 1, title: Next.js 入门指南 }, { id: 2, title: 服务端组件实战 }, { id: 3, title: 客户端组件使用技巧 }, ]; export default async function ServerList() { // 在服务端组件里await 是家常便饭 const data await new Promisetypeof articles((resolve) { setTimeout(() resolve(articles), 300); }); return ( ul {data.map((article) ( li key{article.id}{article.title}/li ))} /ul ); }然后在app/page.tsx里引入它// app/page.tsx import ServerList from /components/server-list; export default function Home() { return ( main h1文章列表/h1 ServerList / /main ); }这个页面在浏览器里打开内容正常渲染。现在你去浏览器 DevTools 的 Network 面板里翻找是搜不到任何包含文章标题的 JavaScript 文件的——因为ServerList的代码只在服务端执行返回给浏览器的只有 HTML 字符串。你可以用curl localhost:3000验证一下响应里能直接看到liNext.js 入门指南/li。3.4 写第一个客户端组件状态与事件接下来创建一个真正“想用状态”的按钮。新建components/like-button.tsx// components/like-button.tsx use client; import { useState } from react; export default function LikeButton() { const [liked, setLiked] useState(false); return ( button onClick{() setLiked((prev) !prev)} style{{ color: liked ? #f00 : #333 }} {liked ? 已点赞 : 点赞} /button ); }注意文件第一行的use client。少了这一行useState直接报错加了这一行组件就进入客户端组件阵营状态和事件都归浏览器管。在服务端组件里使用客户端组件完全不需要特殊语法// app/page.tsx import ServerList from /components/server-list; import LikeButton from /components/like-button; export default function Home() { return ( main h1文章列表/h1 ServerList / LikeButton / /main ); }刷新页面按钮能点击状态能切换。到这里你已经迈过了“两类组件各自怎么写”的门槛。但实际项目里组件之间是要互相组合、传递数据的这个环节才是真正的分水岭。4. 实战串联服务端组件取数据客户端组件做交互4.1 一个具体场景文章页 点赞按钮理论说太多容易飘我们用一个贴近实际的结构来串一遍文章详情页 点赞功能。文章内容来自数据库量大、非交互适合服务端组件直接读取。点赞按钮需要点击交互、需要维护点赞状态适合做成客户端组件。服务端组件把文章 ID 和初始点赞数传给客户端组件。先说一个我在实际项目里反复使用的原则把“拿数据”和“做交互”拆开。数据的获取、过滤、权限校验全部放在服务端组件里客户端组件只接收已经加工好的数据负责展示和交互。这样数据链路清晰也避免把敏感逻辑打进浏览器。4.2 数据层直接写在服务端组件里不需要单独的 API 层在传统 Vite React 项目里前端要拿数据免不了写一套 API 接口后端出/api/posts前端用fetch去调。在 Next.js 的 App Router 里这个链路可以大大简化——服务端组件本身就是“后端”。我们模拟一个文章详情页直接读取数据库// app/posts/[id]/page.tsx import { getPostById } from /lib/posts; import LikeButton from /components/like-button; export default async function PostDetailPage({ params, }: { params: Promise{ id: string }; }) { const { id } await params; const post await getPostById(Number(id)); return ( article h1{post.title}/h1 p{post.content}/p LikeButton postId{post.id} initialLikes{post.likes} / /article ); }这段代码里没有useEffect、没有fetch、没有“数据加载中”的状态因为数据已经在服务端拿到了浏览器拿到的就是完整 HTML。lib/posts.ts里的getPostById就是普通的数据库查询函数这里用静态数组模拟// lib/posts.ts export type Post { id: number; title: string; content: string; likes: number; }; const posts: Post[] [ { id: 1, title: 服务端组件详解, content: 这是一段很长的正文……, likes: 12 }, { id: 2, title: 客户端组件实战, content: 这是另外一篇……, likes: 30 }, ]; export async function getPostById(id: number) { return posts.find((post) post.id id) ?? posts[0]; }如果你以前写过 Next.js Pages Router老版本那种getServerSideProps会感觉特别相似——都是“页面先从服务端拿数据再渲染”。但 Pages Router 是“整个页面是服务端渲染”而 App Router 是“页面里的这个组件是服务端组件”其他组件可以各是各的类型。这个细粒度差异化正是 RSC 架构的精髓。4.3 客户端组件通过 props 接收数据与回调点赞按钮接收两个 proppostId和initialLikes。它是这样用的// components/like-button.tsx use client; import { useState } from react; export default function LikeButton({ postId, initialLikes, }: { postId: number; initialLikes: number; }) { const [likes, setLikes] useState(initialLikes); const [liked, setLiked] useState(false); const handleLike async () { if (liked) return; const res await fetch(/api/like, { method: POST, body: JSON.stringify({ postId }), headers: { Content-Type: application/json }, }); if (res.ok) { setLikes((count) count 1); setLiked(true); } }; return ( button onClick{handleLike} disabled{liked} 点赞{likes} /button ); }这里是常见的组合方式服务端组件负责数据读取把初始状态通过 props 传入客户端组件客户端组件维护自己的交互状态通过fetch调用后端接口完成数据更新。你也可以在按钮里改用 Server Actions 直接更新数据库那会少一层 API 路由但考虑到文章的篇幅这里不展开你只要知道有这个进阶方向即可。还有一个容易被忽略的细节initialLikes是数字postId也是数字它们是能安全序列化的。这是服务端组件传数据给客户端组件的前提。如果你传一个函数、一个 Date 对象或一个带循环引用的类实例会直接报错。这部分我放在第 5 章的踩坑环节详细讲。4.4 用 Suspense 与 loading.tsx 做流式加载服务端组件拿到数据才输出 HTML这个“拿数据”的时间如果很长用户岂不是要一直等Next.js 的解法是流式渲染加 Suspense 边界。你可以在页面里给某个耗时组件加一个Suspense包裹// app/posts/[id]/page.tsx import { Suspense } from react; import RelatedPosts from /components/related-posts; export default function PostDetailPage() { return ( article Suspense fallback{div相关阅读加载中……/div} RelatedPosts / /Suspense /article ); }RelatedPosts是一个异步服务端组件它没有立刻返回页面其他内容会先被发送给浏览器等它完成之后HTML 再以流的形式补充进来。浏览器里不会白屏而是先看到文章主体再看到下方“相关阅读”区域逐渐填上内容。如果整个页面都是数据密集型的你还可以直接在路由目录下建一个loading.tsx。它的优先级比page.tsx低当page.tsx还在准备数据时loading.tsx会作为兜底 UI 显示。// app/posts/[id]/loading.tsx export default function Loading() { return div文章加载中……/div; }这个文件带来的体验提升比你单独写useEffect loading状态要优雅得多而且完全不用动page.tsx的业务代码。我在迁移旧项目时把它描述为“零成本体验升级”——加一个文件就有一整套 loading 状态谁用谁知道。5. 踩坑实录组件边界相关的典型报错与正确解法下面这部分是我的压箱底干货全部来自实际开发中踩过的坑。我把常见边界问题归类附上报错信息和完整解决思路方便你直接查。5.1 在服务端组件里使用 useEffect / onClick报错信息如果记不太清但核心是这句Youre importing a component that needs useEffect. It only works in a Client Component but none of its parents are marked with use client.原因很直白useEffect是水合阶段才跑的逻辑服务端组件不会被水合自然没有useEffect可跑。你是想在这个组件里请求数据吗是想监听浏览器事件吗都行但请先问自己这段逻辑是不是必须放在浏览器里执行解法分三种组件本身负责交互比如弹窗、下拉、点赞按钮 → 在文件顶部加use client。组件只是展示从服务端拿到的数据 → 保持服务端组件用useEffect的逻辑整体删掉。只是某个子组件需要交互 → 不要把父组件整棵改掉只需给那个子组件新建一个文件并加use client。我见过很多团队一报这个错就把整个页面文件加上use client。这是一个短期解长期看会把大量服务端逻辑拉进客户端 Bundle性能损失很大。尽量把交互组件抽出来单独标。5.2 “Only plain objects can be passed to Client Components”序列化错误完整报错Only plain objects can be passed to Client Components from Server Components. Date objects are not supported. { id: ..., createdAt: ... }这是服务端组件向客户端组件传 props 时传了不是纯对象的类型。React 在传递边界上会做序列化这个序列化不是 JSON.stringify做到了类似 JSON 的“纯数据”要求。Date、Map、Set、函数、类实例统统不支持。我在项目里遇到过最典型的场景拿到数据库里的createdAt类型是 Date直接传给客户端组件。错误写法ArticleMeta createdAt{article.createdAt} / // Date 对象会报错正确写法ArticleMeta createdAt{article.createdAt.toISOString()} / // 先转字符串然后客户端组件里再new Date(createdAt)恢复。其他类型同理金额用数字或字符串状态用字符串或数字枚举不要传奇奇怪怪的复杂对象。这个边界你要在脑子里的那根弦上到下传的 props 必须是一份“可序列化的简历”不是活的 JavaScript 对象。5.3 “use client” 的位置和继承问题use client不是 CSS 类名它是模块级别的指令必须放在文件第一行注释之后也不行必须是第一个语句。你可以把它理解成“这个文件和它自己导入的所有依赖都进入客户端 Bundle”的开关。这里有个容易踩的小坑如果你在父组件里没有标use client但引入的子组件标了这个子组件是可以用的。反过来也一样你在一个use client的组件里导入一个服务端组件是无效的——一旦进入客户端组件它导入的所有模块都会被当作客户端模块处理。还有一个更隐蔽的问题你在use client文件里导入一个第三方库这个库没有标记use client但它内部用了useState。正常情况下没问题因为该库本来就可以在客户端运行但如果那个库是服务端专用的比如用了 Node.js 的fs模块会在浏览器报错。解法是确保use client文件以及它的依赖图里不包含任何服务端专用代码。这也是为什么我强烈建议把客户端组件压缩得足够“薄”——逻辑越少越不容易踩依赖雷。5.4 数据请求全部堆在客户端造成的瀑布流与白屏迁移项目时常见的一个反面模式是把整个页面标为use client然后所有数据请求都在useEffect里用fetch写。问题出在哪首屏白屏时间变长浏览器要先下载 JS、执行 React、再触发useEffect这中间页面是空的。瀑布流请求如果页面里有多个组件各自在useEffect里请求数据浏览器会串行或延后发起请求加载速度会显著变慢。对 SEO 不利爬虫看到的是空白 HTML。我的建议是数据拿取的责任尽量往服务端组件靠。你可以在服务端组件里并行await多个 Promise然后把数据一次性传给客户端组件// app/dashboard/page.tsx export default async function DashboardPage() { // Promise.all 并行请求减少等待时间 const [profile, stats] await Promise.all([ getProfile(), getStats(), ]); return DashboardView profile{profile} stats{stats} /; }DashboardView如果是客户端组件它只管渲染和交互不再管数据怎么来。这套结构在性能上要比“客户端 useEffect 连环请求”好一个量级。5.5 “use client” 滥用带来的包体积暴涨有些朋友学完“服务端组件性能好”之后反向操作不管什么组件都先加个use client保平安。结果是整个页面的 JavaScript 包体飞速膨胀用户的加载体验反而比纯 Vite 项目还差。判断原则很简单如果一个组件里没有useState、useEffect、onClick、onChange等交互逻辑它就不需要use client。它可以是纯函数组件继续在服务端渲染。我用一个血泪案例说下后果之前做的一个后台项目某个列表页原本是服务端组件首屏零 JS 开销。后来有人把公共组件的索引文件统一加了一遍use client——注意这是最狠的坑一个index.ts统一导出了所有公共组件在它顶部加use client后这个文件里导入的所有组件全变成了客户端组件包括那些纯展示的表格、表单、图标库。页面首屏 JS 从 150KB 涨到 700KB。排查了很久才发现元凶是那个索引文件。修复方法也很简单去掉索引文件顶部的use client只给真正有交互的组件单独声明。从那以后我在团队里立了条规矩use client必须写在具体的交互组件文件顶部禁止写在 barrel 文件批量导出文件里。6. 预渲染与渲染模式正确理解 Next.js 的三种页面输出方式6.1 SSG / SSR / ISR到底预渲染了什么理解服务端组件之后再回头看整个页面的输出方式你会看得更透。Next.js 的预渲染其实是一个谱系从“完全静态”到“完全动态”分为三档模式生成时机适合场景优点缺点SSG 静态生成构建时博客文章、文档、营销页最快、可 CDN 缓存内容更新需要重新构建ISR 增量静态再生构建时 定时后台更新半动态内容如邮件订阅列表、价格页兼具静态速度和动态更新更新有延迟、配置稍复杂SSR 服务端渲染每次请求个性化内容、登录后可变的页面内容永远最新每个请求都要重新渲染在 App Router 里渲染模式通常不是写死的配置而是由你“用了什么 API”自动决定的。这是个比较重要的思维转变你不必在next.config.js里先声明每个页面走哪个模式而是通过代码里的行为来触发对应的渲染策略。6.2 哪些 API 会把页面“推”回动态渲染这是我最想强调的知识点因为它直接关系到生产环境的“状态漂移”问题。在 Next.js 中有几个动态 API 只要你在服务端组件里用到页面就会被标记为“动态渲染”不复用构建时生成的静态内容cookies()headers()动态路由参数searchParams某些需要在请求时才知道的值比如这样// app/dashboard/page.tsx import { cookies } from next/headers; export default async function DashboardPage() { const cookieStore await cookies(); const theme cookieStore.get(theme)?.value ?? light; return div当前主题{theme}/div; }因为每个用户的 cookie 不同页面自然不能“一份 HTML 共享所有用户”Next.js 会把它改成每次请求都重新渲染。这是合理的但你要意识到如果你的页面只是为了显示一个静态标题却在里面无关紧要地读了headers()它就会被强制动态化失去静态缓存的机会性能直接掉一个档次。所以排查“为什么文档写得像静态页线上却始终不缓存”时先去这个文件里搜cookies、headers、searchParams大概率立刻找到原因。6.3 缓存控制的日常用法fetch 与 revalidate除了动态 APIfetch的缓存策略也直接参与预渲染行为。在 App Router 里fetch默认有缓存行为但你也可以显式控制// 默认带缓存适合内容不频繁变化的数据 const data await fetch(https://api.example.com/posts).then((res) res.json() ); // 设置 60 秒后自动重新验证适合定时刷新的数据 const data await fetch(https://api.example.com/posts, { next: { revalidate: 60 }, }).then((res) res.json());revalidate: 60的意思是在最多 60 秒内页面直接复用上一次生成的结果60 秒之后的下一个请求触发一次后台重新生成把新内容替换旧内容。这就是 ISR 的最常见写法。需要注意的是直接操作数据库比如用 Prisma 查询的页面不会自动被fetch缓存管到你得自己决定是否手动控制页面级缓存。我常用的策略是高频内容用fetch限制缓存低频内容用revalidate控制更新时间完全静态的内容在下一次构建时统一更新。6.4 给你的项目做一个渲染策略选型最后我分享一个自己反复验证过的选型流程你可以直接拿去用页面数据是否和用户身份相关是 → SSR 或客户端渲染否 → 下一步。内容更新的频率是多久每天以内 →revalidate: 60或按小时级别每周以上 → 用 SSG构建时生成一次。页面里是否有大量在客户端才能做的交互有 → 把交互部分抽成客户端组件作为独立子树挂到服务端组件里。页面是否所有用户看到的内容完全一致是 → 默认走 SSGCDN 缓存获益最大。这套流程不一定适合所有项目但作为一个思考框架能帮你快速在每个页面上做出合理的渲染决策。实际生产里我见过很多团队把每个页面都开着动态渲染成本很高。记住预渲染的最大价值在于“能静态就静态该动态才动态”把资源留给真正需要动态的页面。说实话组件边界这套东西我第一次接触时也绕了很多弯。当时习惯性地把所有东西都标成use client结果页面越写越重代码逻辑也越来越乱。后来在几个真实项目里反复调试、压测、看包体报告才慢慢理清“数据往服务端靠、交互往客户端拆”这条主线。如果你正在打算把 Vite 项目往 Next.js 迁移或者刚学完基本语法准备写第一个正式页面我建议你先检查一下app目录里每个页面组件头上有没有多余的use client。能去掉就去掉你会立刻感受到服务端组件带来的轻量感。还有一个我自己反复用的小技巧开发时多打开终端看看console.log到底打在终端还是浏览器控制台遇到报错别急着全网搜先把“这个组件需要交互能力吗”这个问题想清楚边界问题自然会变少。这套思路在多个项目里帮我和团队省下了大量排查时间希望你也能直接受益。