Next.js 14 的 App Router 推出来之后,关于“到底要不要迁移”的讨论就没停过。我自己的态度很明确:如果你是在做一个以内容展示为主、交互逻辑相对有边界的新项目,或者手头正好有维护压力不大的中小型项目,App Router 的这套新范式值得认真吃透。尤其是服务端组件和流式渲染,它们并不是概念上的炫技,而是真正改变了我们写页面时的数据获取方式与渲染决策逻辑。
这篇文章不会把 Next.js 14 的每一项 API 都搬出来罗列一遍。我会聚焦在三个关键点上:服务端组件到底改了什么、流式渲染的最佳实践方式、以及中间件在真实业务里的落地用法。你可以把这篇文章当成一份从“知道”到“会写”的实战地图,跟着过一遍,应该能少踩不少坑。
1. 整体设计思路:App Router 到底在解决什么问题
1.1 先从“水合”这个老麻烦说起
传统的 React 页面渲染方式,简单说就是:服务端返回一段 HTML 字符串,浏览器解析 HTML 后,再加载 JS bundle,然后 React 在客户端把整棵组件树重新执行一遍,挂载事件、绑定状态。这个过程叫水合。问题就在于,如果页面大部分内容其实是静态的,只有少数几个按钮需要交互,那这整棵组件树都得跟着一起被水合一遍。
我之前维护过一个后台报表页面,表格数据是通过接口动态获取的,但布局和侧边栏常年不变。用 Pages Router 写的时候,每次切路由,整个布局都要重新经历一次服务端获取数据、返回 HTML、再水合的流程,体感上就是“白屏一下、内容跳一下”。为了缓解,我只能把“页头 + 页脚 + 侧边栏”抽出来做成共享组件,再配合 getStaticProps 做本地缓存,但数据一旦变化,这层缓存策略又得重新设计,挺折腾的。
App Router 的服务端组件(RSC,React Server Components)想解决的核心问题,就是把“组件是否需要在客户端执行”这件事,从“整个页面一刀切”变成“每个组件单独决定”。它允许你在服务端渲染一些组件,直接输出 HTML 片段,不参与客户端的水合,也不建议它们使用任何浏览器 API 或 React 状态。这部分组件通常承担的就是“展示型内容”和“数据获取”的职责。
1.2 约定式路由与文件命名规则
Pages Router 时代,我们通过pages/index.js、pages/about.js这样的文件路径定义路由。到了 App Router 时代,惯例上把页面文件放进app目录,同时用更细的文件名约定来区分不同职责。比如layout.tsx代表布局,page.tsx代表页面内容,loading.tsx代表加载态,error.tsx代表错误边界,not-found.tsx代表 404 页面。
这些约定本身就是一种架构规范,尤其在多人协作时价值很大。新同学接手项目,一看到app/blog/[slug]/page.tsx这个结构,基本就能猜到每个文件是干嘛的,不需要长篇文档解释。而且每个 layout 都可以拥有自己的 loading、error、not-found 边界,也就是说,局部加载失败不会让整个页面崩掉,只会影响该边界下的内容区域。
app/ ├── layout.tsx // 根布局 ├── page.tsx // 首页 ├── blog/ │ ├── layout.tsx // 博客模块布局 │ ├── page.tsx // 博客列表页 │ └── [slug]/ │ ├── page.tsx // 博客详情页 │ └── loading.tsx // 详情页加载态这个设计最直观的收益是:你不再需要手动维护路由表,文件名即路由;同时每一层都自带渲染状态管理能力,组合起来非常灵活。
1.3 数据获取方式的改变
Pages Router 里,服务端取数依赖getServerSideProps或getStaticProps。这两个函数的问题在于,它们是“页面级”的:要么一个页面整块做服务端渲染,要么整块做成静态生成。粒度不够精细,常常为了一个动态区块,导致整页都不能静态化。
App Router 下,服务端组件里可以直接写async函数并await一个Promise来取数。这意味着你可以在组件级别决定“这段数据是服务端读取的”,并且组合多个服务端组件时,它们各自独立取数,互不阻塞。
// app/user/page.tsx async function getUsers() { const res = await fetch('https://api.example.com/users') if (!res.ok) { throw new Error('Failed to fetch users') } return res.json() } export default async function UserPage() { const users = await getUsers() return ( <ul> {users.map((user) => ( <li key={user.id}>{user.name}</li> ))} </ul> ) }这里没有多余的配置,函数默认是服务端执行的,不会被打包进浏览器端 JS。这个“默认服务端”的心智模型,是所有后续优化的基础。
2. 核心细节:服务端组件的运行机制与常见误解
2.1 服务端组件到底能做什么、不能做什么
很多从 Pages Router 迁移过来的同学,最困惑的是:服务端组件里到底能不能写交互?答案是不能直接写,因为服务端组件的代码不会发送到浏览器。它不能使用useState、useEffect、useReducer这类需要运行时状态的 Hook,也不能绑定onClick等事件处理器。
但这不代表它和交互完全绝缘。在服务端组件里,可以引入一个客户端组件作为子组件,把这些交互逻辑放进去。服务端组件负责取数、组合内容结构,客户端组件负责具体交互行为。这个边界一旦清晰,页面的结构与性能都能得到明显改善。
// app/counter.tsx (客户端组件) 'use client' import { useState } from 'react' export default function Counter() { const [count, setCount] = useState(0) return ( <button onClick={() => setCount(count + 1)}> Clicked {count} times </button> ) }// app/page.tsx (服务端组件) import Counter from './counter' export default function Page() { return ( <div> <h1>Server Component Page</h1> <Counter /> </div> ) }需要特别注意的是,'use client'并不是“只在客户端渲染”的意思,而是“这个组件需要被水合,因此会被纳入客户端构建产物”。它依然可以在服务端被渲染成 HTML,只是随后浏览器还会执行一次水合,恢复它的状态和事件。
2.2 数据缓存与请求去重
服务端组件里最容易被忽略的一个特性,是请求缓存。Next.js 14 在服务端组件中默认会缓存fetch请求的结果,这意味着在同一个渲染周期内,多个组件调用同一个 URL,实际只会发出一次请求。这个默认行为对性能的提升非常显著。
// app/dashboard/page.tsx async function getProjects() { // 默认会被缓存,除非显式禁用 const res = await fetch('https://api.example.com/projects') return res.json() } async function getTasks() { const res = await fetch('https://api.example.com/tasks') return res.json() }不过这里有个坑:如果你在一个页面中,两个组件分别请求了不同的接口,但这些接口内部有关联(比如一个失败另一个也需要跟着回滚),那就不能单纯依赖请求缓存来做一致性处理。更可靠的做法是理解好业务边界,必要时把数据获取逻辑收敛到数据层。
缓存失效的控制主要有两种方式:一种是在fetch选项中显式设置next: { revalidate: 60 },让缓存每 60 秒自动失效一次;另一种是使用revalidatePath或revalidateTag,在特定动作后手动触发缓存更新。我实际项目里用得更多的是第二种,因为它的语义更明确——在创建新文章、更新用户信息后主动刷新相关页面,而不是靠时间窗去等待。
如: fetch('https://api.example.com/posts', { next: { revalidate: 60 }, })2.3 服务端组件与客户端组件的边界实践
在实际项目里,最容易出现的问题就是:父子组件边界划分不合理,导致整个页面被拉进客户端组件阵营。
我见过一个项目,为了图省事,把包含大段富文本展示的组件,直接加上了'use client',理由是“富文本内容里有图片懒加载,需要用到浏览器 API”。结果就是,整段内容都被打包到了客户端,水合开销直线上升。当时排查性能问题时,打开 Network 面板看 JS 体积,一份原本可以服务端直出的文档页,硬生生多了 300 多 KB 的客户端代码。
正确的做法是:只把真正需要浏览器的部分(比如图片懒加载的loading属性扩展、点击事件、滚动监听)抽成客户端组件,包裹在服务端组件里面。
// app/post/[slug]/page.tsx import RichContent from './rich-content.client' import Comments from './comments.client' export default async function PostPage({ params }) { const post = await getPost(params.slug) return ( <article> <h1>{post.title}</h1> {/* RichContent 才是客户端组件 */} <RichContent content={post.content} /> {/* Comments 只有加载更多按钮是交互 */} <Comments /> </article> ) }所以这里有一个非常实用的判断标准:如果这个组件没有事件处理、没有生命周期、没有浏览器 API 依赖,那默认就应该是服务端组件。
3. 流式渲染与 Suspense 实战
3.1 流式渲染的原理:边取数边吐内容
传统服务端渲染,服务端必须等所有页面数据全部准备好,才能一次性返回完整 HTML。如果某个接口很慢,整页都卡在那里。App Router 引入的流式渲染,则是允许你在 HTML 还没完全生成完的时候,先返回一部分内容让浏览器先展示。
默认情况下,如果不做任何处理,页面依然会等所有数据就绪后才返回。但当你使用Suspense包裹某个异步组件后,Next.js 会把它标记为一个流式边界,浏览器可以先行展示 Suspense 之外的静态内容,而 Suspense 内部的组件,在数据加载完成后以流的形式再补上。
服务端:先生成 header / sidebar / 骨架屏 → 返回浏览器 浏览器:先渲染已到达的部分 服务端:等慢接口数据回来 → 继续流式输出评论区 浏览器:接收新内容并增量渲染这个体验上的提升很明显。我测过一张带瀑布流的图片展示页,首屏只需要等待导航部分,图片列表交给多个 Suspense 边界分别加载。TTFB(首字节时间)几乎从原来的 1.8 秒降到了 400 毫秒左右,用户能更快看到页面的整体结构,而不是盯着空白页等接口。
3.2 Suspense 的形态设计:骨架屏与局部占位
用Suspense时,fallback的设计很关键。如果不设计好占位内容,用户看到的只是“一片空白被一块内容替代”,体验提升有限。我通常会为每个异步区域设计对应的骨架屏:
// app/dashboard/page.tsx import { Suspense } from 'react' import UserProfile from './user-profile' import OrdersList from './orders-list' import { SkeletonProfile, SkeletonOrders } from './skeletons' export default function DashboardPage() { return ( <main> <h1>Dashboard</h1> <Suspense fallback={<SkeletonProfile />}> <UserProfile /> </Suspense> <Suspense fallback={<SkeletonOrders />}> <OrdersList /> </Suspense> </main> ) }这里每个Suspense边界都是独立加载的。UserProfile的数据先回来,用户就能先看到个人资料;OrdersList的接口慢一些,订单区域就先显示骨架屏,等数据到了再补充渲染。用户全程不会感觉到页面“卡住”,这种渐进式加载的体验,比一整块 loading 动画要友好得多。
3.3 嵌套布局与局部 loading 态
App Router 还提供了一个比Suspense更上层的约定式加载态文件:loading.tsx。每个路由段都可以放一个loading.tsx,它会在页面组件还在等待数据的时候展示出来。这个方案的好处是,加载态被自动绑定到了路由切换的时机,而且支持嵌套。
// app/blog/loading.tsx export default function Loading() { return ( <div className="loading-card"> <p>文章加载中...</p> </div> ) }实际切换路由时,Next.js 会把新页面的 loading 组件插进来,等新页面数据准备好了再替换掉。这样在路由跳转时,整个页面不会白屏,而是有一个平滑的过渡态。我一般会把loading.tsx和Suspense配合使用:loading.tsx负责章节级别的加载态,Suspense负责章节内部的局部加载态,两层各有侧重点,组合起来体验非常完整。
3.4 流式渲染时的数据获取约束
流式渲染的坑在哪里?我踩过最深的一个是:流式渲染并不能解决 N+1 请求问题。如果在一个页面里,三个异步组件各自调用一个接口,而这三个接口内部又有依赖关系(比如先拿用户信息,再用用户 id 查订单),那整个数据流依然是瀑布式的,Suspense 只会让“每一层”分别显示自己的 loading 态,但整体等待时间并没有缩短。
处理方法有两种:一种是在服务端组件里用Promise.all并行取数,把可并行的请求打平;另一种是直接在数据层做聚合接口,减少客户端与服务端的往返次数。
// app/user/[id]/page.tsx async function getUserData(id: string) { const [user, orders, profile] = await Promise.all([ fetch(`https://api.com/users/${id}`), fetch(`https://api.com/users/${id}/orders`), fetch(`https://api.com/users/${id}/profile`), ]) return { user: await user.json(), orders: await orders.json(), profile: await profile.json(), } }像上面这样,把三个互不依赖的请求同时发起,然后等它们全部返回,整体耗时就从“三个串行”变成了“一个最长请求的耗时”。
4. 中间件实战:机制、匹配与真实场景
4.1 中间件的“管道”模型
聊到中间件,很多人第一反应是“拦截器”。确实,中间件的核心机制就是“请求到达页面组件之前,你先插一脚”。Next.js 中间件基于 Edge Runtime 运行,它在所有页面执行之前被调用,可以统一处理请求重定向、鉴权校验、请求头修改、路径重写等逻辑。
中间件最常见的一个误解是“它可以替代服务端组件的数据获取”。不是这样的。中间件更适合做“请求级别的策略判断”,比如“这个请求的 cookie 是否有效”或者“这个请求是否需要重定向到登录页”,而具体的数据获取和页面组装,还是交给服务端组件,两者分工完全不同。
如果你接触过 Laravel 或者其他框架的中间件实现原理,会发现思路是相通的——都是把请求放进一条流水线,中间件可以在流水线的前后插入逻辑。Next.js 的中间件就是这个流水线上的统一控制点。
4.2 中间件的基础配置与匹配规则
创建一个middleware.ts文件(放在项目根目录或src目录下),就可以开始写逻辑了。
// middleware.ts import { NextResponse, NextRequest } from 'next/server' export function middleware(request: NextRequest) { const token = request.cookies.get('token')?.value const isLoggedIn = Boolean(token) if (!isLoggedIn) { return NextResponse.redirect(new URL('/login', request.url)) } return NextResponse.next() } export const config = { matcher: ['/dashboard/:path*', '/admin/:path*'], }这里用config.matcher指定了中间件的生效范围,只有匹配到的路径才会触发这段逻辑。NextResponse.redirect是重定向,NextResponse.next()是放行。理解了这两个 API,就掌握了中间件的基本使用方法。
matcher支持:path*这样的通配符,也支持数组形式,甚至支持正则字符串。我习惯把需要保护的路径集中管理,用数组明确列出来,比如:
matcher: ['/dashboard/:path*', '/account/:path*', '/settings/:path*']这样一眼就能看出项目的受保护区域,后续扩展也方便。
4.3 实际业务场景:登录校验与角色守卫
登录校验是中间件最典型的应用。但真实场景里,往往不是简单地“有 token 就放行”,还需要校验 token 的有效性以及用户的角色权限。
考虑到 Edge Runtime 的网络环境与 Node.js 不完全相同(不能使用node:开头的核心模块),所以我习惯的做法是:把 token 的解析逻辑尽量保持轻量,不在这里做复杂的数据库查询和加解密计算。更合理的分工是:
- 用中间件只做“该不该重定向”的粗粒度判断;
- 细粒度的用户身份、角色信息,通过服务端组件里的数据查询来获取。
假如一个后台系统,普通用户不能访问/admin路径,你可以在 token 解析后取出角色字段,然后做判断:
// middleware.ts import { NextResponse, NextRequest } from 'next/server' const ADMIN_ROLE = 'admin' export function middleware(request: NextRequest) { const token = request.cookies.get('token')?.value const role = request.cookies.get('role')?.value // 假设角色信息放在 cookie 里 if (request.nextUrl.pathname.startsWith('/admin') && role !== ADMIN_ROLE) { return NextResponse.redirect(new URL('/403', request.url)) } if (!token) { return NextResponse.redirect(new URL('/login', request.url)) } return NextResponse.next() } export const config = { matcher: ['/dashboard/:path*', '/admin/:path*'], }这里有一个细节:在中间件里做角色判断时,cookie 里的 role 字段可能存在伪造的风险。如果对安全要求很高,不能单纯依赖 cookie 里的角色字段,还是需要去后端或其他服务里再验证一下。中间件做的是“入口守卫”,但不是最终的安全边界。
4.4 请求头修改与 A/B 测试
除了登录校验,中间件还非常适合做“在请求进入页面之前,给请求附加额外信息”的操作。
举个例子,我给一个多语言站点做过中间件,根据用户 cookie 里的语言偏好自动改写请求头和 URL。假设默认语言是中文,用户访问/en/xxx时,我们把语言标记x-language: en写进请求头,后续服务端组件读取这个请求头来决定渲染语言;同时把带语言前缀的路径保留在浏览器地址栏,保证分享链接可读性。
// middleware.ts import { NextResponse, NextRequest } from 'next/server' const LOCALE_COOKIE = 'locale' const DEFAULT_LOCALE = 'zh' export function middleware(request: NextRequest) { const locale = request.cookies.get(LOCALE_COOKIE)?.value || DEFAULT_LOCALE const requestHeaders = new Headers(request.headers) requestHeaders.set('x-locale', locale) return NextResponse.next({ request: { headers: requestHeaders, }, }) } export const config = { matcher: ['/:path*'], }在服务端组件里,你可以通过headers()方法读取这个自定义头:
// app/page.tsx import { headers } from 'next/headers' export default function Page() { const headersList = headers() const locale = headersList.get('x-locale') || 'zh' return <p>当前语言:{locale}</p> }中间件也可以用作灰度发布:根据 cookie 或者请求头里的标识,决定某个用户是被分流到新版页面还是旧版页面。具体操作就是在中间件里对路径做重写(rewrite),而不是重定向。
// middleware.ts import { NextResponse, NextRequest } from 'next/server' export function middleware(request: NextRequest) { const bucket = request.cookies.get('bucket')?.value || 'old' if (bucket === 'new' && request.nextUrl.pathname.startsWith('/product')) { return NextResponse.rewrite(new URL('/product-v2', request.url)) } return NextResponse.next() }这样用户体验完全是无感的,URL 地址栏不变,但内容已经切换到了新版页面。灰度放量时,直接通过控制 cookie 的分发比例就能操作,非常方便。
4.5 中间件的性能边界与与“中间件全家桶”的区分
要注意,Next.js 的中间件不是万能的,它运行在 Edge Runtime 中,与 Node.js 运行时有差异,且代码会被打包成一个轻量的函数。它的定位是“快速的请求预处理”,而不是“完整的应用业务逻辑”。因此,不要在中间件里做大量 I/O 操作、数据库查询,也不要加载体积过大的第三方库,否则会影响所有请求的响应速度。
我在实际项目中对中间件的使用原则只有一条:只做轻量级、可快速返回的判断与改写。复杂的业务处理,宁可多一次服务端组件内部的请求,也不要拖慢中间件的执行速度。
说到“中间件”这个概念,社区里还经常出现“消息中间件”“东方通”“金蝶中间件”这些词,它们跟 Next.js 不是一回事。消息中间件是分布式系统里用于异步解耦的组件(比如 Kafka、RabbitMQ),而 Next.js 中间件是请求管道上的拦截器。两者名字相似,应用场景完全不同,注意区分即可。
5. 常见问题与排查技巧实录
5.1 高频报错:fetch failed 与缓存问题
服务端组件里最常遇到的报错就是fetch failed。通常原因不是网络断了,而是你访问的接口返回了非 2xx 状态,而 Fetch API 默认不把 4xx/5xx 当作异常抛出,所以你需要在代码里手动处理res.ok。
async function getData() { const res = await fetch('https://api.com/data') if (!res.ok) { throw new Error(`API responded with status ${res.status}`) } return res.json() }另一个高发问题是请求缓存导致的“数据不刷新”。如果在开发环境测试时,发现修改后端数据后页面没变,大概率是缓存没有失效。解决办法是在本地开发时,给相关fetch加上cache: 'no-store'选项,或者部署后主动调用revalidatePath。
const res = await fetch('https://api.com/data', { cache: 'no-store', })5.2 关于客户端组件的导入限制
很多人第一次写 App Router 时会犯一个错:在服务端组件里尝试导入一个import db from 'some-db-library'的数据库驱动,或者使用了process.env但忘记它是服务端专属环境变量。这些在渲染到浏览器时会报错,因为浏览器根本找不到这些模块或变量。
排查思路是:确认该组件确实被当成了客户端组件。如果一个组件文件开头没有'use client',它就是服务端组件,其内部使用的任何 Node.js 模块(比如fs、数据库驱动)都不应该被打包进客户端。只要严格遵守“客户端组件只做 UI 与交互”的划分,这类报错基本不会出现。
5.3 中间件与 Node.js API 的共存问题
中间件运行在 Edge Runtime,所以不能使用path、fs等 Node.js 核心模块。如果需要在中间件里做较为复杂的业务逻辑,比如解析 JWT、读取某个文件,就会遇到运行时不支持的异常。
我自己遇到过的情况是:想在中间件里用jsonwebtoken库解析 token,但该库依赖 Node 的某些原生模块,结果在 Edge Runtime 下直接抛错。当时的处理方案是换用了兼容 Edge Runtime 的轻量 token 解析方式,或者干脆把验签逻辑放到服务端组件里去做,中间件只做基本的 cookie 存在性判断。
排查这类问题,最直接的办法是在本地next build && next start后,把相关逻辑跑一遍,看有没有运行时报错。本地开发环境默认跑在 Node.js 上,有时能过,但部署到 Edge 环境就露馅了,所以务必在构建后模拟生产环境测试。
5.4 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 页面不更新 | fetch 请求缓存未过期 | 使用revalidatePath/revalidateTag或在 fetch 中设置cache: 'no-store' |
| 浏览器报错找不到模块 | 服务端组件引用了 Node 专属模块 | 移入服务端组件,以免打包进客户端 |
| 中间件里使用 node 模块报错 | Edge Runtime 限制 | 将逻辑迁移到服务端组件或改用兼容的库 |
| 数据接口报 4xx/5xx 但页面没反应 | Fetch 默认不 throw HTTP 错误 | 手动判断res.ok,抛出自定义错误 |
| 路由跳转但 loading 不生效 | loading.tsx文件名或位置错误 | 检查是否位于需要加载的路由段下,文件名需要完全一致 |
| 客户端组件与服务端组件混用报错 | 边界划分不清 | 明确标注'use client'分隔,保证传参可序列化 |
5.5 针对“N+1 请求”的排查技巧
如果你发现某页面的接口请求数量多得像瀑布一样,逐个发起,可以打开浏览器 Network 面板看下请求时序。如果请求从左到右依次排列,说明是串行依赖;如果请求在时间轴上并排发出,说明是并行无依赖。排查时,我通常会先侧重这两点:
- 请求之间是否存在参数依赖(比如先拿 id,再拿详情);
- 请求的代码位置是否在同一个组件内部,以及是否都放在了服务端组件中。
如果都放在服务端组件中,那么即便不用Promise.all,Next.js 的请求缓存也能在同一轮渲染中做去重。但如果有些请求被放在了客户端组件里,就会产生前后端两轮请求,时序上往往更容易出现瀑布。
6. 迁移建议与个人实践心得
6.1 什么时候值得迁移
我接手过三种类型的项目,分别说说迁移 App Router 的体感:
- 纯展示型网站(公司官网、博客、文档站):收益最大。大部分内容可以直接用服务端组件渲染,配合静态生成和 Incremental Static Regeneration(增量静态再生成),首屏速度和 SEO 都能得到明显改善。
- 后台管理系统(有大量表单、列表):中等收益。服务端组件适合做列表的初始数据获取,但表单校验、复杂交互还是得依赖客户端组件,迁移时需要仔细划分边界。
- 实时互动型应用(聊天室、协作工具):迁移成本高,收益较低。这类应用核心是长连接和频繁的客户端状态同步,服务端组件的优势不明显,中间件的收益也有限。如果你完全依赖 WebSocket 做实时更新,App Router 带给你的体验提升可能不会太明显。
本质上是想清楚:你的页面是“重内容、轻交互”还是“重交互、轻内容”。前者完全能吃下新范式的红利,后者则需要多加权衡。
6.2 我的一些“反套路”经验
第一,不要盲目地把所有fetch都加上cache: 'no-store'。这样等于彻底放弃了 Next.js 为你做好的缓存优化。先让默认缓存跑起来,遇到明确的数据更新需求,再用revalidatePath或revalidateTag定向解决。
第二,loading.tsx和Suspense不是用一个就够。实践中,我通常用loading.tsx管理路由级别的加载态,用Suspense管理页面内部的局部动态区块。两者配合使用,才能真正做到“整个页面不白屏,局部区域边加载边填坑”。
第三,中间件里能少写就少写,尽量不要把完整鉴权放到 middleware 里去实现。中间件适合的是对“路径是否属于受保护区域”做统一拦截,但真正的用户信息校验必须依赖安全服务端。
我最初接手 App Router 项目时,最不习惯的反而不是 API 用法,而是“组件到底会在哪个环境执行”这一层心智模型。Pages Router 时代,只要写 React,基本都是客户端组件,所有麻烦都堆在水合上;到了 App Router 时代,默认变成服务端组件,很多曾经为了水合写的防御代码可以直接删掉,但新的边界划分也需要重新适应。
6.3 下一步可以继续扩展的方向
如果你已经玩转了上面这些内容,下一步可以往这几个方向探索:
- 用 Server Actions 处理表单提交,把请求逻辑收敛到服务端组件内部;
- 结合 OpenTelemetry 追踪服务端渲染耗时,定位流式渲染与数据获取的瓶颈;
- 使用 Turbopack 做本地开发打包,进一步缩短冷启动时间。
这几个方向在实际项目里都能直接提升开发体验与站点性能。
最后再分享一个小经验:无论技术怎么迭代,页面的最终目标始终是让用户尽快看到有效内容、顺畅完成操作。App Router 这套体系本身为这个目标提供了更细腻的控制粒度。把它当作一种新的思考方式去理解,而不是当成一系列 API 替代品,你会更容易适应这个新范式。