首页 / 文章

从next/router迁移:App Router中的参数、浅层URL和404处理方式

告别 next/router:在 Next.js App Router 中,参数、useParams、浅层 URL 更新、程序化导航以及真正的 404 页面是如何工作的。

发布者:REACTAPP.TOP阅读约 6 分钟1340 词

来源: 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 显示。