在外人看来,Next.js 的Link组件就是“给<a>标签换个写法”,最多再拼一个href参数。但在实际项目里待久了你会发现,“页面导航”这四个字背后的门道,比很多前端八股文加起来都多。Link不仅仅负责“跳转”,它承担了预取、客户端路由切换、RSC payload 的传输、历史栈维护、滚动位置恢复这一整套体系。这篇文章我会把它从头到尾拆开,结合 App Router 和 Pages Router 两代架构下的差异,把Link的用法、参数、预取机制和常见坑位一次讲清楚,顺便把 Next.js payload 这套预取数据的流转过程也讲明白。
这篇指南适合正在用 Next.js 13/14/15(App Router)开发实际业务、却不满足于“能跳转就行”的同学,也适合刚接触 Next.js、想系统掌握导航机制的新手。看完之后,你至少能回答这几个问题:Link和直接用useRouter.push()到底怎么选?prefetch默认到底做了什么?为什么有些页面点击 Link 时依然白屏?以及为什么导航高亮有那么多种写法,各自坑在哪里。
1. Link 组件在 Next.js 导航体系中的角色
1.1 很多人第一步就理解错了:Link 不是 a 标签的语法糖
如果你只用过create-react-app或者纯静态 HTML 开发,打开 Next.js 的Link组件,第一反应一定是:这不就是个带路由跳转能力的<a>吗?实际上不对。
Link在浏览器里渲染出的确实是一个<a>标签,这保证了 SEO 爬虫能顺着链接爬取页面,也保证了用户中键点击、Ctrl+点击这些浏览器原生行为不失效。但这只是它的表象。真正的核心在于,Next.js 给这个<a>标签挂上了一层完整的事件拦截和客户端路由机制:它会在用户点击之前提前准备好目标页面的数据,点击发生时不再发起整页刷新请求,而是让浏览器“假装什么都没发生”,在后台把组件树切换掉,再通过 History API 修改地址栏。
Pages Router 时代,Link的客户端导航靠的是“下载目标页的 JS chunk + 执行路由上下文切换”;App Router 时代则变成“预取 RSC payload + 按需流式渲染”。所谓 RSC payload,指的是服务端组件在服务端执行后序列化出来的数据载体,里面包含了组件树的描述、服务端组件的 props、以及跨越服务端和客户端边界的数据。用户点击Link之后,Next.js 拿着已经预取到的 payload 去更新路由缓存,React 在客户端完成协调,整个过程页面不会刷新。这个机制的底层就是所谓 Next.js payload 教程里反复强调的东西——你在 Network 面板里看到的那些.rsc请求,就是导航的命脉。
这么说吧:整页刷新就是每次出门都从收拾行李开始,而Link的客户端导航就是你出差前已经把洗漱包放在门口,出门拿上就走。前者一个字“笨”,后者才叫“优化”。
1.2 该用 Link 的地方,和千万不能用 Link 的地方
Link是站内导航的主力,但它不是万能的。很多人踩坑就是因为在错误的地方用了Link,或者该用的时候没用。
先列几个“绝对该用 Link”的场景:
- 用户通过链接主动从 A 页面跳到 B 页面,且 B 页面是应用内部的业务页面;
- 需要预取加速的关键路径,比如首页到列表页、列表页到详情页;
- 需要被搜索引擎理解的普通链接,因为
<a>才是爬虫认得的东西; - 需要支持右键“在新标签页打开”、Ctrl+点击的用户行为。
再列几个“不该用 Link”的场景:
- 跳转外部站点,尤其是带
target="_blank"的外部链接,直接写原生<a>更干净,还能省掉一次路由处理开销; - 事件里的强制跳转,比如“表单提交成功后跳转到结果页”,这是典型的
useRouter().push()场景,语义是“应用替你做了决定”,而不是“用户点击了一个链接”; - 用户无权访问页面的权限拦截,这属于服务端重定向或者中间件层的职责,不该让
Link参与决策; - 按钮或卡片点击后的跳转,尤其是整块卡片可点击的场景,考虑用
Link包整块内容没问题,但注意内部不能再嵌套交互元素,否则会产生双重触发和 a11y 问题。
Link和useRouter不是“谁替代谁”的关系。Link面向“声明式导航”,useRouter().push()面向“命令式导航”。日常路由跳转以Link为主,表单提交、定时跳转、条件判断后的跳转交给useRouter。这是很多项目里约定俗成的分工,也是代码可读性的一道分水岭。
2. Link 的 API 与参数逐个拆开讲
2.1 href 的四种写法:字符串、对象、动态段和数组
Link的href接受多种形态,理解了每一种的适用场景,写起来才不纠结。
最基础的是字符串:
<Link href="/dashboard">控制台</Link>这个人人都会,不多说。
第二种是对象写法:
import Link from 'next/link' <Link href={{ pathname: '/posts/[id]', query: { id: 123 } }}> 文章详情 </Link>对象写法适合动态路由 + 查询参数同时存在的场景。上面的代码等价于/posts/123。需要注意的是,pathname里写的是[id]这种路由模板,真正生成的 URL 由 query 里的id填充。如果你觉得绕,也可以直接用模板字符串生成 href:
<Link href={`/posts/${post.id}`}>文章详情</Link>两种写法都能用,对象写法的好处是参数结构清晰、避免字符串拼接错误,坏处是多了层理解成本。
第三种是动态段直接拼 URL,适合复杂参数组合。比如传多个 query:
<Link href={{ pathname: '/search', query: { keyword, page: 2, sort: 'latest' } }}>第四种是数组写法,主要存在于as和动态路由混用的 Pages Router 老项目中,App Router 里基本可以忽略。
再说as参数。Pages Router 时代,as用来掩盖真实 URL,比如 href 是/post/[id],as 是/post/2024/abc,通过as伪装成一个多层路径,这对 SEO 和 URL 美观度很有用。App Router 里路由结构就是真实目录,不再需要as,直接把完整路径写进href即可。
2.2 replace、scroll、prefetch 这些参数什么时候改
replace参数控制的是历史栈行为。
默认情况下,Link跳转执行的是history.pushState,也就是说新页面会压入历史栈,用户按返回键能回到上一页。如果设置replace:
<Link href="/dashboard" replace>那么跳转执行的是history.replaceState,当前页面被替换掉,用户按返回键不会回到这个页面。典型应用场景是登录页、支付结果页、表单提交后的成功页——“我不想让用户回退到上一个页面再提交一次表单”,这种防重复操作的需求就该用replace。
scroll参数控制滚动位置。
默认情况下,Next.js 的Link跳转后会把页面滚动到顶部,除非目标页面存在#hash锚点。这个行为大多数时候符合预期,但在分页、Tab 切换、消息列表这类场景非常烦人——用户点击“下一页”,页面却滚回顶部,体验直接崩掉。此时设置:
<Link href="/list?page=2" scroll={false}> 下一页 </Link>scroll={false}会让 Next.js 跳转后保留当前 scroll 位置。这个参数在列表类页面、对话框类页面里几乎是刚需。
prefetch参数我在下一章详细讲,这里先给结论:当目标页面非常重、或者用户不太可能短时间点击时,把它设成false能省流量;当目标页面是关键路径时,保持默认让它自动预取即可。
2.3 自定义组件嵌套与 legacyBehavior:绕不开的兼容问题
日常开发里经常遇到这种封装:团队里统一了按钮组件,想让它内部接住 Link 的路由行为:
// 错误示范 function AppButton({ children, href }) { return <Link href={href}><button>{children}</button></Link> }这样写能跳转,但浏览器会给 button 增加一层嵌套,点击区域和继承样式都容易出问题。正确姿势是把 Link 传给自定义组件内部,让组件自己去渲染 a 标签。在 Pages Router 时代,需要配合passHref把 href 透传给子元素:
// Pages Router 时代 <Link href="/dashboard" passHref> <CustomButton>控制台</CustomButton> </Link>在 App Router 里,passHref的适用范围变窄了。如果你在自定义组件里使用了 HTML 原生的 a 标签,Link 的 href 会自动透传到 a 上,不再强制要passHref。如果你的自定义组件是第三方库封装的,比如 MUI 的Button组件,通常需要额外传一下:
<Link href="/dashboard" passHref legacyBehavior> <Button>控制台</Button> </Link>这里的legacyBehavior也很关键。App Router 里的Link默认要求只有一个子元素,并且子元素必须是<a>或能接受 href 的原生元素。当你使用自定义组件且希望保留旧版“包裹子元素”的行为时,需要给Link加上legacyBehavior:
<Link href="/dashboard" legacyBehavior> <a>控制台</a> </Link>legacyBehavior会在未来版本移除,新项目尽量不要依赖它。我的建议是:如果是自定义组件,直接让组件接收href并在内部使用Link,不要靠legacyBehavior做桥接,那是在给未来的自己埋坑。
3. 预取与 RSC payload:Link 高性能的秘密
3.1 预取机制究竟怎么运转:视口、生产环境、缓存边界
Next.js 的"快"很大程度上来自预取。默认情况下,Link会做两件事:一是当链接进入浏览器视口时,自动预取目标页面资源;二是当用户 hover 或 touchstart 时,进一步确保资源就绪。如果你在网络面板里打开过 Next.js 应用,会发现页面加载完成后还有一堆.rsc请求在飞,那些就是在预取当前可见区域里的 Link。
需要强调一个容易让人误解的点:预取只在生产环境生效,开发环境里 Network 面板看不到完整的预取行为,所以你没法在next dev下直观地验证预取逻辑。要验证,用next build && next start。
预取的“目标”不是完整 HTML,而是 RSC payload。Pages Router 里预取的是 JS 资源和数据接口需要的参数,App Router 里预取的是服务端组件序列化后的 payload,其中还包含服务端数据、缓存标签等元信息。也就是说,当你 hover 某个 Link 时,Next.js 已经把目标页面的组件树骨架和所需数据下载好,并放进了客户端路由缓存里。点击发生的瞬间,页面几乎即时切换。
但这个机制有一个隐蔽的代价:预取也会消耗带宽。如果一个页面里塞了几十个Link,而且每个目标页都是重量级报表页,预取的流量会被放大几十倍。对这种场景,prefetch参数才是真正的救星。
3.2 prefetch 属性怎么调,为什么
prefetch有三种取值:undefined(默认)、true、false。
默认行为最聪明:Next.js 只在视口内、且目标资源为静态可预取时执行预取。如果你给prefetch显式传true,则无论链接是否在视口内都会强制预取。这个用法的典型场景是“我很确定用户下次要点这个”,比如电商网站的“加入购物车后立刻看到推荐商品”的入口。
prefetch={false}则是关闭预取。什么时候用?我来举几个真实例子。
第一,列表页每一条数据都带一个详情页链接,而列表有上百条。如果你不关预取,首屏会一次性拉上百个详情页的 RSC payload,页面性能会灾难性地掉。第二,目标页面是重型报表,数据量大,预取的流量成本远大于点击后的等待成本。第三,目标页需要动态参数,用户点击前参数根本不明确,预取等于白做。
我的实操习惯是:默认保持不写,关键路径 Link 显式 prefetch={true},容易误触的列表和重型页面 prefetch={false}。只要项目有这个意识,性能提升立竿见影。
再补一个细节:App Router 的预取存在“部分预取”的优化。也就是 Next.js 14+ 开始,对于动态路由页面,预取可能只加载静态部分(比如布局、静态组件),真正依赖动态数据的部分等到点击时才流式加载。开启这个优化之后,首屏预取的 payload 会被大幅压缩。如果你想深入验证一个页面的 payload 到底有多大,可以看 Network 里对应请求 size,对比挂上prefetch={false}前后的请求变化,直观感受到预取的流量消耗。
3.3 为什么 App Router 和 Pages Router 的预取完全不同
很多从 Next.js 12 时代过来的老玩家,第一次用 App Router 时都会困惑:预取请求怎么长这样?这不奇怪,因为两代架构的预取模型从根上就不一样。
Pages Router 的预取策略是:进入视口时预取目标页面的 JS bundle 和数据,点击后执行客户端路由切换,然后用对应的 pages/api 数据接口拿数据。这属于“先下载资源,再取数据”的两段式导航。
App Router 的预取策略则是一次性拿到 RSC payload。服务端组件在服务端执行完成,把需要的 props、数据、子组件描述一起序列化进 payload,客户端拿到 payload 后,React 直接用它做协调和渲染。这意味着客户端不需要再单独发起数据请求,也不需要等待组件代码执行后再组装页面。这也解释了为什么 App Router 比 Pages Router 在导航切换上更快:它把“下载代码 + 请求数据 + 服务端渲染 + 客户端 hydrate”这几步合并成了“下载 payload + 协调渲染”两步。
但 App Router 的这一优势也要付出代价:payload 里如果塞入大量数据(比如直接请求了数据库中的大字段),预取请求的体积会迅速膨胀,反而拖慢导航。所以 App Router 项目里,控制服务端组件的返回数据量、合理使用loading.tsx和Suspense,变得比优化客户端代码更迫切。很多 Next.js payload 教程内容其实就是围绕这个点展开:payload 就是性能,payload 就是流量,把它管好了,导航就快了。
4. 导航过程中的状态同步与加载体验
4.1 usePathname 实现导航高亮:精确匹配与坑
导航高亮是侧边栏、Tab、面包屑场景中几乎必写的逻辑。正确姿势是用usePathname()拿当前路径,然后和每个菜单项的 href 做匹配。
基础版:
'use client' import Link from 'next/link' import { usePathname } from 'next/navigation' const menus = [ { href: '/dashboard', label: '控制台' }, { href: '/posts', label: '文章' }, { href: '/settings', label: '设置' }, ] export default function Sidebar() { const pathname = usePathname() return ( <aside> {menus.map((menu) => { const active = pathname === menu.href return ( <Link key={menu.href} href={menu.href} className={active ? 'active' : ''} > {menu.label} </Link> ) })} </aside> ) }如果菜单项下面还有子页面,比如/posts想同时高亮/posts/1和/posts/2,直接比较pathname === href就不行了,需要用startsWith:
const active = pathname === menu.href || pathname.startsWith(menu.href + '/')但startsWith会带来一个经典坑:/dashboard和/dashboard2这种路径前缀撞车。比如/dashboard2也会被/dashboard的前缀匹配命中。所以我习惯把匹配逻辑写全一点,在startsWith前先判断边界。
如果你用的是字典排序做嵌套路由,注意usePathname返回的是非解码的 URL 路径,遇到带中文或特殊字符的路径时要留意编码问题。我的经验是把菜单项的 href 和 pathname 统一编码后再比较,或者直接规范化 URL,避免“看起来一样但字符串不同”的假阴性。
4.2 loading.tsx 与 Suspense:让导航期间的加载不突兀
Link跳转再快,只要目标页面有动态数据,就存在等待时间。这个等待期的用户体验全靠loading.tsx和Suspense撑起来。
App Router 里,你可以在 route 目录下放一个loading.tsx,它就是该路由的默认加载态。当用户点击 Link 导航到该页面,但 RSC payload 还没流式加载完时,Next.js 会先展示loading.tsx的内容,数据到位后替换成真正的页面。这个机制对“点击 Link 后白屏”的问题几乎是根治性质的。
还有更细粒度的控制:在服务端组件里用<Suspense>包裹某个异步组件:
import { Suspense } from 'react' import { PostList } from '@/components/post-list' export default function PostsPage() { return ( <main> <h1>最新文章</h1> <Suspense fallback={<div>加载中...</div>}> <PostList /> </Suspense> </main> ) }这样,页面骨架先渲染,PostList内部的数据以流式方式到达,用户看到的是“页面框架立刻出来、内容逐步填充”,而不是整体白屏。配合 client 组件里的useTransition,还能在导航动作发生时手动标记 pending 状态,从而让按钮保持 loading 样式直到路由切换完成:
'use client' import { useTransition, useState } from 'react' import { useRouter } from 'next/navigation' export function ViewButton({ id }: { id: string }) { const router = useRouter() const [isPending, startTransition] = useTransition() const [loading, setLoading] = useState(false) const handleClick = () => { setLoading(true) startTransition(() => { router.push(`/posts/${id}?_=${Date.now()}`) // 路由缓存可能命中,这里不阻塞太久 }) } return ( <button onClick={handleClick} disabled={loading || isPending}> {loading ? '跳转中' : '查看详情'} </button> ) }这种“手动 pending + 流式加载”的组合,是实际业务里用户感知最顺滑的导航方案,比单纯依赖loading.tsx还能更早响应点击事件。
4.3 客户端导航失败时的兜底与防抖
客户端导航也并非永远成功。最常见的问题有两个:一是动态路由点击后 404,二是指标性的大列表页跳转卡顿。
404 的根源往往不在 Link,而在数据预取。如果你用动态路由/posts/[id],而[id]对应的数据在服务器端尚未生成(比如刚创建),预取拿到的 payload 可能是空的。给 Link 绑prefetch={false}并不能解决 404,因为 404 是服务端数据缺失导致的,正确的做法是在服务端组件里做兜底判断:
import { notFound } from 'next/navigation' export default async function PostPage({ params }) { const post = await getPost(params.id) if (!post) notFound() return <PostView post={post} /> }跳转卡顿则通常由 payload 过大引起。有些页面一次性渲染几千条列表数据,预取时把这些数据全塞进 RSC payload,点击时 React 协调大量节点,卡顿不可避免。此时优先做数据分页、虚拟滚动,其次是给 Link 关掉预取。
还有常见的“按钮和 Link 嵌套”导致的导航不触发问题。如果 a 标签里包了button,浏览器事件会被按钮拦截,Link 的事件没有触发。遇到这类问题先在 DOM 结构上检查,不要一头扎进 Next.js 配置里找原因。
5. 常见问题与坑位排查
5.1 点击 Link 后瞬间回到页面顶部
这个行为是scroll的默认值在起作用。Next.js 认为“新页面应该从顶部开始看”,所以在路由切换后调用了scrollTo(0, 0)。对大多数页面这个没有问题,但在两个场景会反转成 bug。
场景一是列表页点“下一页”。用户滚到第 3 屏,点了底部的下一页,结果新页面顶部出现,用户第 3 屏白看了,必须重新滚下来。解决:给这个 Link 设scroll={false},然后在目标组件内部自己控制滚动位置。
场景二是带 tab/hash 的页面。如果 Link 的 href 带#section:
<Link href="/docs#installation">安装说明</Link>Next.js 会跳转到/docs并尝试滚动到installation锚点。但如果你同时设了scroll={false},锚点滚动也会失效。所以保持默认,或者想清楚到底要不要禁用 scroll。
还有一个隐蔽点:如果在客户端组件里手动调了window.scrollTo,它会影响路由切换后的滚动结果。排查这类问题,先清掉所有手动滚动逻辑,再测试 Link 的默认行为,基本能定位问题源头。
5.2 预取请求太多或太大
这是 Next.js 项目最常见的性能投诉。
症状很明显:Network 面板打开后能看到一大片.rsc请求,而且每个请求体积都不小。通常原因就是页面里 Link 数量太多,或者目标页面数据太重。
我的排查方式分三步。
第一步,先看 Link 的 href 是否指向可预取路由。如果目标页面需要登录、权限控制,预取反而会拉取无意义的 payload。
第二步,统计数据量。如果页面是列表页,每行都有一个“详情” Link,强烈建议给这些 Link 加prefetch={false},只给第一个(通常是用户最可能点击的)保留预取。
第三步,服务端组件里检查是否有没必要的 CPU 密集型计算或大字段 JOIN。RSC payload 会把服务端渲染结果序列化传给客户端,体积膨胀主要来自服务端返回的数据。很多时候优化服务端返回的体积,比调整 Link 参数更有效。
如果你想让项目里的预取策略保持一致,可以在全局封装一个SmartLink组件,内部根据 props 决定是否prefetch:
'use client' import Link from 'next/link' export default function SmartLink({ href, children, prefetchBasis = 'auto', ...props }) { // prefetchBasis: 'auto' 正常预取;'light' 强制不预取 const prefetch = prefetchBasis === 'light' ? false : undefined return ( <Link href={href} prefetch={prefetch} {...props}> {children} </Link> ) }这个组件的思想是:把预取策略收敛到一个地方,后续想全局调整预取行为就不用逐页改了。
5.3 点击 Link 没反应或行为异常
这类问题通常是 DOM 结构或事件冲突,而不是 Link 本身的 bug。
第一种情况:Link 包自定义组件,组件内部渲染的不是<a>而是<div>。这种情况 Link 的 href 透传不到原生可点击元素上,点击自然失效。处理方式是让子组件直接接收 href 并渲染<a>,或者用legacyBehavior包裹。
第二种情况:Link 外层有 onClick 且调用了event.preventDefault()。如果你阻止了默认行为,Link 的内部导航逻辑也不会执行。不要在一个 Link 上同时既用 onClick 阻止默认事件又期望它跳转。
第三种情况:target="_blank"加了之后,Next.js 会跳转新标签页,但预取和客户端导航的优化在新标签页中不会完全生效。如果业务可以接受牺牲速度,那没问题;如果不行,尽量用普通跳转。
第四种情况:Link 在 Server Component 里用了,但 Server Component 里没法绑定 onClick 等客户端事件。如果想给 Link 绑定客户端事件,把它放 client component 里,或者给 Link 外层的 client component 包一层事件。
5.4 权限跳转与重定向的正确姿势
很多新手喜欢在渲染阶段用useRouter().push()做权限跳转:
// 不要这样做 useEffect(() => { if (!user) { router.push('/login') } }, [router, user])这会带来页面闪烁:未登录用户先看到了不该看的页面骨架,然后才被踢到登录页。正确做法是用中间件或服务端组件做权限判断。
中间件方案(适合全局登录态拦截):
// middleware.ts import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' export function middleware(request: NextRequest) { const token = request.cookies.get('token') if (!token && request.nextUrl.pathname.startsWith('/dashboard')) { return NextResponse.redirect(new URL('/login', request.url)) } } export const config = { matcher: ['/dashboard/:path*'], }服务端组件方案(适合单页细粒度权限):
import { redirect } from 'next/navigation' export default async function DashboardPage() { const user = await getCurrentUser() if (!user) redirect('/login') return <Dashboard /> }这两种方案在用户感知上是“无闪跳转”,因为它们发生在数据到达浏览器之前。Link只管导航动作本身,权限判断发生在服务端,职责清晰,也不容易踩客户端不一致的坑。
6. 一次完整的导航方案设计样例:把 Link 用出体系感
6.1 需求场景与结构设计
假设我在做一个带侧边栏的管理后台,要求满足三个点:菜单高亮准确、主要页面跳转丝滑、重型报表页不被自动预取拖慢。
结构设计如下:
- 左侧菜单分模块,每个菜单项用
Link实现,高亮状态由usePathname计算; - 顶栏右侧有“查看全部报表”按钮,这是一个高频入口,设置
prefetch={true}; - 报表详情页是一个重型列表,虽然从列表页跳过去的入口很多,但因为数据量巨大,设置
prefetch={false}; - 打包一个
SmartLink组件统一预取策略,避免散落的Link代码出现参数不一致。
6.2 代码实现
先写菜单组件。这边把路径匹配逻辑单独抽出来,避免页面里堆一堆startsWith。
'use client' import Link from 'next/link' import { usePathname } from 'next/navigation' const menuGroups = [ { name: '总览', items: [{ href: '/dashboard', label: '工作台' }], }, { name: '内容管理', items: [ { href: '/posts', label: '文章列表' }, { href: '/categories', label: '分类管理' }, ], }, { name: '系统', items: [ { href: '/settings', label: '基础设置' }, { href: '/team', label: '成员管理' }, ], }, ] function isPathActive(pathname: string, href: string) { if (pathname === href) return true if (href === '/dashboard') return false return pathname.startsWith(href + '/') } export default function Sidebar() { const pathname = usePathname() return ( <aside className="sidebar"> {menuGroups.map((group) => ( <div key={group.name}> <p className="group-label">{group.name}</p> {group.items.map((item) => { const active = isPathActive(pathname, item.href) return ( <Link key={item.href} href={item.href} className={active ? 'menu-item active' : 'menu-item'} aria-current={active ? 'page' : undefined} > {item.label} </Link> ) })} </div> ))} </aside> ) }注意到isPathActive里给/dashboard加了特判,因为根路径/dashboard不能误伤/dashboard2,但/posts这类一级菜单要继续支持子路径高亮。这种匹配策略本质上是“精确优先、前缀兜底、关键目录隔离”。
再写智能 Link:
'use client' import Link from 'next/link' import type { ReactNode } from 'react' type NavIntent = 'auto' | 'critical' | 'economy' export function NavLink({ href, children, intent = 'auto', scroll, replace, className, }: { href: string children: ReactNode intent?: NavIntent scroll?: boolean replace?: boolean className?: string }) { const prefetch = intent === 'critical' ? true : intent === 'economy' ? false : undefined return ( <Link href={href} prefetch={prefetch} scroll={scroll} replace={replace} className={className}> {children} </Link> ) }在页面里使用时:
// 工作台高频入口 <NavLink href="/reports" intent="critical">查看全部报表</NavLink> // 报表列表的每一行详情链接,经济模式 <NavLink href={`/reports/${id}`} intent="economy">详情</NavLink>写 loading 态。报表页数据大,必须放一个layout.tsx级别的加载骨架:
// app/reports/[id]/loading.tsx export default function ReportLoading() { return ( <div className="report-skeleton"> <div className="skeleton-title" /> <div className="skeleton-table" /> </div> ) }最后在服务端组件里把报表内容包进 Suspense,让表格数据分片流式渲染。
6.3 关键节点回顾与效果评估
这套方案跑起来之后,体验可以拆成三条线看:
第一条线是预取策略。两个高频入口(工作台、报告列表首页)保持自动预取,进入视口就下载 payload,点击几乎零等待;重型详情页全部关闭预取,首屏不再出现几十个并发.rsc请求。
第二条线是高亮同步。usePathname直接驱动菜单高亮,不用额外维护一个active状态,也不用监听路由变化事件,代码量少且不易出错。
第三条线是加载兜底。即便某些数据接口真的很慢,用户点击后也能立刻看到骨架屏,而不是一片白。加上loading.tsx和Suspense之后,页面在导航期间的反馈是连贯的。
我把这套方案沉淀成了团队里的一个内部文档,后来新项目基本都照这个模式套。Link这个组件单看确实简单,但把它和路由缓存、预取策略、加载反馈、权限跳转放在一起设计,才会真正发挥 Next.js 的性能优势。很多项目性能上不去,不是 Next.js 不行,而是这些细节没人统一管。
最后分享一个小技巧:每次压测导航性能时,把 Network 面板切到 Fetch/XHR 标签,再点一次页面上的重要入口,看有没有多余的.rsc请求。请求越少、越早完成,说明你的预取策略越健康。这个动作说起来简单,但实际操作时你会发现,光一个列表页就能把预取请求刷到几十个——那一刻你才知道,prefetch这个参数,远比看起来重要。