从next/router迁移:App Router中的参数、浅层URL和404处理方式
告别 next/router:在 Next.js App Router 中,参数、useParams、浅层 URL 更新、程序化导航以及真正的 404 页面是如何工作的。
来源: Next.js routing: the four next/router habits I had to unlearn
来自 Pages Router 的开发者往往仍会使用 import { useRouter } from 'next/router'。在 App Router 项目中,这种导入方式具有误导性:该模块确实存在,甚至可以通过类型检查,但 router.query 返回的值却是空的,且这种错误并不容易察觉。App Router(自 Next.js 13 起为默认路由方式)中的导航功能位于 next/navigation 中,而一些在 Pages Router 中常用的写法在此已不再适用。本指南将介绍在将小型博客迁移到 App Router 时需要面对的四大变化,所提供的代码基于写作时的 Next.js 16.3.1 和 React 19 版本。
动态参数以属性形式传递,而非通过钩子获取
在 Pages Router 中,常见的做法是将页面设为客户端组件并读取 router.query.id。而在 App Router 中,除非另有说明,否则页面均为服务器端组件,Next.js 会为其传递 params 属性。从 Next.js 15 开始,params 是一个 Promise,因此组件必须是 async 类型,并且需要在读取任何值之前使用 await 来等待其解析。
页面会导入 notFound 函数,以便在标识符与对应文章不匹配时立即终止执行:
import { notFound } from 'next/navigation'
下面的组件会在内存映射中查找对应文章,对于未知的 ID 则调用 notFound()。
const POSTS: Record<string, { title: string; body: string }> = {
'hello-world': { title: 'Hello World', body: 'The first post.' },
}export default async function BlogPost({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const post = POSTS[id]
if (!post) notFound() return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
)
}
此处没有钩子,也没有 'use client' 指令,查询会在任何 HTML 被发送之前在服务器端执行。对生产环境构建的 /blog/hello-world 发起请求会以 200 状态码返回文章标题和内容。在 Next.js 15 及更高版本中,最常见的迁移错误就是省略 await params:你解构得到的值是一个承诺对象,而非实际的文章 ID。
使用 useParams 在客户端读取该数据
服务器优先渲染并不意味着所有组件都在服务器端运行。评论表单需要在浏览器中获取文章 ID,为此应使用 next/navigation 中的 useParams 而非 router.query。该文件以以下指令开头:
'use client'
随后组件会读取该数据。向 useParams 传递类型参数即可获得带类型的返回值:
import { useParams } from 'next/navigation'export default function CommentBox() {
const { id } = useParams<{ id: string }>()
return <form data-post={id}>Commenting on {id}</form>
}
与 Pages Router 版本不同,此处永远不会返回 null:在下面的任何客户端组件渲染之前,路径段就已经被解析完毕。该表单会在页面首次绘制时就携带 data-post="hello-world",因此无需显示加载状态。
浅层路由实际上只是普通的 History API 调用
Pages Router 允许使用 router.push(url, as, { shallow: true }) 来更改 URL 而无需重新获取数据。而 App Router 没有 shallow 选项。若需在不重新运行服务器组件的情况下更新排序、筛选条件或当前标签页等相关的 URL,需手动调用 window.history.pushState,并通过 useSearchParams 读取返回的值。同样,相关文件也是客户端组件:
'use client'
该控件会复制当前的参数,设置sort值,然后生成新的查询字符串;当无法设置时则使用newest作为默认值:
import { useSearchParams } from 'next/navigation'export default function SortControls() {
const searchParams = useSearchParams()
const active = searchParams.get('sort') ?? 'newest' function setSort(sort: string) {
const next = new URLSearchParams(searchParams.toString())
next.set('sort', sort)
window.history.pushState(null, '', `?${next.toString()}`)
} return (
<div>
<button aria-pressed={active === 'newest'} onClick={() => setSort('newest')}>
Newest
</button>
<button aria-pressed={active === 'top'} onClick={() => setSort('top')}>
Top
</button>
</div>
)
}
Next.js会将原生的pushState和replaceState调用与自身的路由系统同步,因此useSearchParams能够获取到新值,按钮也会在无需服务器请求的情况下更新。有两个需要注意的隐患:
- 调用
useSearchParams的组件应置于<Suspense>作用域内。若没有该作用域,页面将采用最近的客户端渲染边界进行渲染而非静态渲染,构建输出会对此发出警告。 pushState每次点击都会在历史记录中添加一条新条目。如果通过一系列排序操作后使用“上一步”按钮会令用户困扰,建议改用replaceState。
程序化导航仍需使用 useRouter,从新路径进行
当导航需要通过路由器完成时,比如在表单提交后将用户重定向到首页,useRouter依然是首选工具。只有导入路径会发生变化,同时还需要相应的指令:
'use client'
调用方式本身则保持不变:
import { useRouter } from 'next/navigation'export default function GoHome() {
const router = useRouter()
return <button onClick={() => router.push('/')}>Home</button>
}
router.push('/')的功能与之前相同;真正的问题出在导入语句上。
自定义404页面是通过文件返回真实404状态码
原有的pages/404.js写法已被app/not-found.tsx文件取代,每当调用notFound()时该组件就会被渲染,包括在第一节中提到的服务器组件里。此页面会链接回首页,因此需要导入Link组件:
import Link from 'next/link'
该组件本身只是普通的标记代码:
export default function NotFound() {
return (
<main>
<h1>404 — no such post</h1>
<Link href="/">Back to the index</Link>
</main>
)
}
状态码非常重要:看似错误的404响应,或是以200状态码返回的错误页面,都可能影响搜索索引,因为爬虫会将其视为真实内容。当请求如/blog/nope这样的不存在的文章时,系统会返回HTTP 404状态码以及“没有此类文章”的提示信息。notFound()函数会设置该状态码,这样爬虫看到的内容与用户看到的内容一致。
通过构建输出确认迁移结果
完成端口迁移后,通过执行next build命令打印出的路由表是检查结果的最快方式:
Route (app)
┌ ○ /
├ ○ /_not-found
└ ƒ /blog/[id]
ƒ标记表示/blog/[id]是动态渲染的,因为它会读取参数,而○则标记静态索引页及未找到的路由。这些代码均未使用next/router、router.query或shallow: true。关于如何决定代码在何处运行,请参阅在App Router页面中如何划分服务器端与客户端边界。
关键要点
- 首先在代码库中搜索
from 'next/router';每个匹配项都对应上述四种修改之一。 - 在服务器端组件中从等待处理的
params属性读取动态片段,在客户端组件中则通过useParams来获取。
shallow: true 替换为包裹在 <Suspense> 中的 history.pushState 或 replaceState 结合 useSearchParams。next/navigation 导入的 useRouter。app/not-found.tsx 与 notFound() 来显示真正的 404 状态码,而非弱化的 404 显示。