资讯详情

资讯详情

Next.js 14 App Router实战:服务端组件、流式渲染与中间件解析

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 的服务端组件RSCReact 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 h1Server 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 h1Dashboard/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 classNameloading-card p文章加载中.../p /div ) }实际切换路由时Next.js 会把新页面的 loading 组件插进来等新页面数据准备好了再替换掉。这样在路由跳转时整个页面不会白屏而是有一个平滑的过渡态。我一般会把loading.tsx和Suspense配合使用loading.tsx负责章节级别的加载态Suspense负责章节内部的局部加载态两层各有侧重点组合起来体验非常完整。3.4 流式渲染时的数据获取约束流式渲染的坑在哪里我踩过最深的一个是流式渲染并不能解决 N1 请求问题。如果在一个页面里三个异步组件各自调用一个接口而这三个接口内部又有依赖关系比如先拿用户信息再用用户 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 针对“N1 请求”的排查技巧如果你发现某页面的接口请求数量多得像瀑布一样逐个发起可以打开浏览器 Network 面板看下请求时序。如果请求从左到右依次排列说明是串行依赖如果请求在时间轴上并排发出说明是并行无依赖。排查时我通常会先侧重这两点请求之间是否存在参数依赖比如先拿 id再拿详情请求的代码位置是否在同一个组件内部以及是否都放在了服务端组件中。如果都放在服务端组件中那么即便不用Promise.allNext.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 替代品你会更容易适应这个新范式。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →