逐步从 Pages 路由迁移到 App 路由

App 路由是 NextJS 13 引入的新特性。

它的功能相较于目前使用的 Pages 路由更加丰富,并且支持更多的特性。

例如它支持布局,服务器组件等特性。

具体可以查看官方的文档

App 路由是可以和 Pages 路由共存的。

这就意味着我们可以逐步进行迁移,逐步替换。

NextJS 会优先使用 App 路由。

在 App 路由中,使用 app 目录而不是 pages 目录。
且不支持 pages/_app.jspages/_document.js。取而代之的是 app/layout.jsx

App 路由文件约定

文件名 作用 扩展名
layout 页面布局(不重新渲染) .js, .jsx, .tsx
page 页面文件 .js, .jsx, .tsx
loading 加载 UI .js, .jsx, .tsx
not-found 未找到 UI .js, .jsx, .tsx
error 错误 UI .js, .jsx, .tsx
global-error 全局错误 UI .js, .jsx, .tsx
route API 终端节点 .js, .ts
template 会重新渲染的布局 .js, .jsx, .tsx
default 默认页面 .js, .jsx, .tsx

路由段差异

App 路由的文件约定与 Pages 路由不同。

Pages 路由的路由段可以使用文件名和文件夹名。

  • pages/index.jsx 对应 /
  • pages/about.jsx 对应 /about
  • pages/user/[id].jsx 对应 /user/1

而 App 路由定义路由段只能使用文件夹名,而不允许使用文件名。

  • app/page.jsx 对应 /
  • app/about/page.jsx 对应 /about
  • app/user/[id]/page.jsx 对应 /user/1

路由分组

使用 () 包裹文件夹,可以将子目录视为一个路由组。这个文件夹将不参与路由段。

  • app/user/[id]/page.jsx 对应 /user/1
  • app/user/[id]/(profile)/page.jsx 对应 /user/1/profile

退出路由

为文件夹增加 _ 前缀,即可将该文件夹和它的所有子目录退出路由段。

  • app/_user/[id]/page.jsx 不渲染
  • app/_user/[id]/(profile)/page.jsx 不渲染

并行路由

允许在同一布局中渲染多个页面。形成类似仪表盘的形式。

|- app
  |- @menus
    |- default.jsx
    |- page.jsx
  |- layout.jsx
  |- page.jsx

default.jsx 将作为默认显示内容

// app/layout.jsx
export default function RootLayout({ children, menus }) {
  return (
    <main>
      <aside>{menus}</aside>
      <main>
        {children}
      </main>
    </main>
  )
}

拦截路由

允许拦截路由跳转,但当页面刷新时,则不会触发。

可查看官方文档

可以用在用户登录,权限验证等场景。在当前页打开模态框显示登录表单(此时路由已经变化为登录页面)。刷新后,会渲染登录页面。

不支持静态导出!

引入布局

在项目中,对于重复布局将进行重复的引入,传值。将会繁琐且易错。

通过使用布局,可以避免这一问题。此外,布局之间还可以进行嵌套。

在根布局,可以编写共同的页眉和页脚。在商品页,可以编写商品分类,描述等共同组件。

|- Root Layout            根布局      编写通用的页眉页脚
  |- Product Layout       商品页布局   编写商品页通用的分类,描述
    |- Product Content    商品页      展示商品明细
// app/layout.jsx
import Header from '@/components/Header'
import Footer from '@/components/Footer'

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <Header />
        {children}
        <Footer />
      </body>
    </html>
  )
}

// app/product/layout.jsx
import ProductCategory from '@/components/ProductCategory'
import ProductDescription from '@/components/ProductDescription'
export default function ProductLayout({ children }) {
  return (
    <div>
      <ProductCategory />
      {children}
      <ProductDescription />
    </div>
  )
}

// app/product/page.jsx
export default function ProductPage() {
  return (
    <div>
      商品明细
    </div>
  )
}

布局在页面中只会渲染一次,如果需要每次加载都重新渲染,则使用 template 文件。

// app/template.jsx

export default function RootTemplate({ children }){

  // todo...

  return (
    <div>
      {children}
    </div>
  )
}

修改元数据

原先的 Pages 路由,元数据都是通过 next/head 编写在组件中的。

App 路由则是将元数据单独拆分出来了。

// index.jsx

export const metadata = {
  title: '首页标题',
  description: '首页描述'
}

export default function HomePage(){
  return (
    <div>
      首页
    </div>
  )
}

这样子,我们只需要在顶层处理元数据,而不需要在页面内额外处理。使得我们可以专注于页面内容的开发。

动态元数据

有时,静态元数据不满足业务需求,需要动态变化。可以使用 generateMetadata API。

export async function generateMetadata() {
  return requestHomeMetadata()
    .then(response => response.json())
    .then(raw => {
      // todo ...
      return raw
    })
    .then(data => {
      return {
        title: data.title,
        description: data.description
      }
    })
}

export default function HomePage(){
  return (
    <div>
      首页
    </div>
  )
}

更加丰富的元数据

除了基本的元数据,App 路由还支持更多的元数据。

export const metadata = {
  title: '首页标题',
  description: '首页描述',
  keywords: '首页关键词',
  openGraph: {
    title: 'OG 标题',
    description: 'OG 描述',
    url: 'https://example.com/page',
    images: [{ url: '/og.png' }],
  },
  twitter: {
    title: 'Twitter 标题',
    description: 'Twitter 描述',
    images: [{ url: '/twitter.png' }],
  },
  robots: {
    index: true,
    follow: true,
    googleBot: {
      index: true,
      follow: true,
    }
  },
  manifest: '/manifest.json',
  alternates: {
    canonical: '/',
    languages: {
      'zh-CN': '/zh-cn',
    },
  },
  viewport: {
    width: 'device-width',
    initialScale: 1,
    maximumScale: 1,
  },
  // ...
}

export default function HomePage(){
  return (
    <div>
      首页
    </div>
  )
}

useRouter

与 Pages 路由不同,App 路由虽然也使用 useRouter,但是是从 next/navigation 中导入的,而不是 Pages 路由的 next/router

原先的 Pages 路由的 useRouter 提供的 querypathname 等属性在 App 路由中不存在,取而代之的是需要从 next/navigation 导入的 useSearchParamsusePathname 等 API。

且需要注意,App 路由和 Pages 路由的 useRouter 是不兼容的。

官方文档中也有说明。

因为是逐步迁移,组件也将复用。所以要手动封装一下,用来处理两者之间的不同。

'use client'

import { useParams, usePathname, useRouter as useRawAppRouter, useSearchParams } from 'next/navigation'
import { useRouter as useRawPagesRouter } from 'next/router'
import { useEffect, useMemo, useRef } from 'react'

const useAppRouter = () => {
  // App Router 模拟 router 对象结构
  const router = useRawAppRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const params = useParams()
  const query = useMemo(
    () => ({
      ...Object.fromEntries(searchParams.entries()),
      ...params,
    }),
    [searchParams, params],
  )
  const searchString = searchParams.toString()
  const asPath = searchString ? `${pathname}?${searchString}` : pathname
  const events = useRef({})

  return {
    push: router.push,
    replace: router.replace,
    forward: router.forward,
    back: router.back,
    prefetch: router.prefetch,
    refresh: router.refresh,
    asPath,
    pathname,
    query,
    params,
  }
}

export const useRouter = () => {
  // 判断是否处于 Pages Router 环境(__NEXT_DATA__ 是 Pages 特有的)
  const isPagesRouter = typeof window !== 'undefined' && '__NEXT_DATA__' in window
  const useRouter = isPagesRouter ? useRawPagesRouter : useAppRouter
  const router = useRouter()
  return router
}

export default useRouter

用于静态导出的动态路由

在 App 路由中,没有 getStaticPaths,取而代之的是 generateStaticParams

如果需要 getStaticProps,则在页面组件中直接 fetch 即可。

页面必须是服务端组件才能够直接 fetch

// user/[id]/page.jsx

export async function generateStaticParams(){
  return [
    { id: '1' },
    { id: '2' },
    { id: '3' }
  ]
}

export default async function HomePage({ params }){
  const info = await requestUserInfo(params.id)

  return (
    <div>
      <p>当前用户 ID {params.id}</p>
      <p>昵称 {info.name}</p>
    </div>
  )
}

因为 NextJS 路径时字符串匹配的

所以 generateStaticParams 应该返回的是 Record<string, string> 类型的数据
以避免路径匹配出错

除了页面外,还可以在布局中使用 generateStaticParams。页面会自动获取到 generateStaticParams 的返回值。

// user/[id]/layout.jsx

export async function generateStaticParams(){
  return [
    { id: '1' },
    { id: '2' },
    { id: '3' }
  ]
}

export default function UserLayout({ children, params }){
  return (
    <div>
      <p>当前用户 ID {params.id}</p>
      {children}
    </div>
  )
}

以上将最终渲染:

  • user/1
  • user/2
  • user/3

这三个路由。

使用客户端组件

App 路由默认所有组件都是服务端组件。不允许使用例如 useEffect, useState 等客户端 API。

如果需要使用,则将交互部分抽离为单独组件,并在文件顶部定义 "use client" 即可将组件声明为客户端组件。

只需要在最顶层的客户端组件标注即可
客户端组件使用的其他组件都将被视为客户端组件

// goto.jsx
'use client'

const GotoDiscord = () => {
  emitGAClickCod6DiscordButton()
  window.open('https://discord.gg/xxx', '_blank')
}

export default function Goto() {
  return (
    <div
      onClick={GotoDiscord}
      className='reviewsButterh mx-auto mb-[20px] mt-[26px] flex h-[50px] w-[188px] items-center justify-center rounded-[5px] bg-[linear-gradient(270deg,_#328EFF_0%,_#5E34E7_100%)] text-center font-[600] Fsm:mx-auto Fsm:mb-[45px] Fsm:mt-[23px] Fsm:h-[42px]'
    >
      Save 8% off Now
    </div>
  )
}

错误显示和加载页面

App 路由提供了以 errorloading 命名的文件,来支持自定义页面加载和错误显示。

在页面初次加载或请求失败时,会自动渲染,而无需手动处理。

// /user/error.jsx
"use client"

export default function Error({ error, reset }) {
  return (
    <div>
      <h2>加载用户信息出错!</h2>
      <p>{error.message}</p>
      <button onClick={reset}>重试</button>
    </div>
  )
}
// /user/loading.jsx

export default function Loading() {
  return <p>加载用户信息中...</p>
}

最佳实践

  • 不要滥用 "use client",将组件职责划分清清楚。仅在必要时使用客户端组件。
  • 合理使用 loading/error/template 提高用户体验。
  • 服务端组件只负责请求数据和结构输出,不负责交互。
  • 需要交互的部分才抽离为客户端组件,避免客户端负载过重。

逐步从 Pages 路由迁移到 App 路由
http://www.inksha.com/archives/zhu-bu-cong-pages-lu-you-qian-yi-dao-app-lu-you
作者
inksha
发布于
2025年04月09日
许可协议