应用路由渐进式迁移指南

本指南将帮助您:

升级准备

Node.js 版本要求

最低 Node.js 版本要求现已提升至 v16.14。更多信息请参阅 Node.js 文档

Next.js 版本升级

使用您偏好的包管理器运行以下命令升级至 Next.js 13:

终端
npm install next@latest react@latest react-dom@latest

ESLint 版本升级

若使用 ESLint,需升级 ESLint 版本:

终端
npm install -D eslint-config-next@latest

须知:您可能需要重启 VS Code 中的 ESLint 服务器以使变更生效。打开命令面板(Mac 按 cmd+shift+p;Windows 按 ctrl+shift+p)并搜索 ESLint: 重启 ESLint 服务器

后续步骤

升级完成后,请参考以下章节:

新功能升级

Next.js 13 引入了全新的 应用路由 (App Router),具备新特性和约定。新路由在 app 目录中可用,并与 pages 目录共存。

升级至 Next.js 13 无需立即使用新的 应用路由。您可以继续使用 pages 目录,同时享受两个目录均支持的新功能,如更新的 Image 组件Link 组件Script 组件字体优化

<Image/> 组件

Next.js 12 通过临时导入 next/future/image 对 Image 组件进行了改进,包括减少客户端 JavaScript、更简便的图片扩展与样式设置、更好的可访问性以及原生浏览器懒加载。

在版本 13 中,这些改进现已成为 next/image 的默认行为。

我们提供两个代码修改工具协助迁移:

<Link> 组件 不再需要手动添加 <a> 标签作为子元素。该特性作为实验性选项在 12.2 版本 引入,现已成为默认行为。在 Next.js 13 中,<Link> 会自动渲染 <a> 并允许向底层标签传递属性。

示例:

import Link from 'next/link'

// Next.js 12: 必须嵌套 `<a>` 否则会被忽略
<Link href="/about">
  <a>关于</a>
</Link>

// Next.js 13: `<Link>` 会自动渲染底层的 `<a>`
<Link href="/about">
  关于
</Link>

可使用 new-link 代码修改工具 升级链接。

<Script> 组件

next/script 的行为已更新以同时支持 pagesapp,但需注意以下变更:

  • 将原先在 _document.js 中的 beforeInteractive 脚本移至根布局文件 (app/layout.tsx)。
  • 实验性的 worker 策略暂不支持 app 目录,需移除或改用其他策略(如 lazyOnload)。
  • onLoadonReadyonError 处理函数在服务端组件中无效,需移至 客户端组件 或完全移除。

字体优化

此前 Next.js 通过 内联字体 CSS 优化字体。版本 13 引入了新的 next/font 模块,在保证性能与隐私的同时提供更灵活的字体加载方案。next/font 同时支持 pagesapp 目录。

虽然 内联 CSSpages 中仍有效,但在 app 中需改用 next/font

详见 字体优化 文档。

pages 迁移到 app

🎥 视频教程:学习如何渐进式采用应用路由 → YouTube (16分钟)

迁移至应用路由可能需要首次接触 React 特性如服务端组件 (Server Components)、Suspense 等,结合 Next.js 的 特殊文件布局 (Layouts) 等新特性,意味着需要学习新的概念、心智模型和行为变更。

我们建议通过分步迁移降低复杂度。app 目录设计为可与 pages 目录共存,支持逐页迁移。

  • app 目录支持嵌套路由 布局。了解更多
  • 使用嵌套文件夹 定义路由,并通过特殊的 page.js 文件公开路由段。了解更多
  • 特殊文件约定 用于为每个路由段创建 UI。最常见的特殊文件是 page.jslayout.js
    • 使用 page.js 定义路由专属 UI。
    • 使用 layout.js 定义多路由共享 UI。
    • 特殊文件可使用 .js.jsx.tsx 扩展名。
  • 您可以在 app 目录中放置组件、样式、测试等其他文件。了解更多
  • 数据获取函数如 getServerSidePropsgetStaticProps 已被替换为 app 中的 新 APIgetStaticPaths 被替换为 generateStaticParams
  • pages/_app.jspages/_document.js 被替换为单一的 app/layout.js 根布局。了解更多
  • pages/_error.js 被更细粒度的 error.js 特殊文件取代。了解更多
  • pages/404.js 被替换为 not-found.js 文件。
  • pages/api/* 目前仍需保留在 pages 目录中。

步骤 1:创建 app 目录

升级至最新 Next.js 版本(需 13.4 或更高):

npm install next@latest

然后在项目根目录(或 src/ 目录)创建新的 app 目录。

步骤 2:创建根布局

app 目录中新建 app/layout.tsx 文件。这是将应用于 app 内所有路由的 根布局 (Root Layout)

export default function RootLayout({
  // 布局必须接收 children 属性
  // 这将填充嵌套布局或页面
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}
  • app 目录 必须 包含根布局。
  • 根布局必须定义 <html><body> 标签,因为 Next.js 不会自动创建它们。
  • 根布局替代了 pages/_app.tsxpages/_document.tsx 文件。
  • 布局文件可使用 .js.jsx.tsx 扩展名。

要管理 <head> HTML 元素,可使用 内置 SEO 支持

import { Metadata } from 'next'

export const metadata: Metadata = {
  title: '首页',
  description: '欢迎使用 Next.js',
}

迁移 _document.js_app.js

若有现有的 _app_document 文件,可将内容(如全局样式)复制到根布局 (app/layout.tsx)。app/layout.tsx 中的样式 不会 影响 pages/*。迁移期间应保留 _app/_document 以防止 pages/* 路由失效。完全迁移后可安全删除。

若使用 React Context 提供者,需将其移至 客户端组件

getLayout() 模式迁移至布局(可选)

Next.js 曾建议通过 向页面组件添加属性 实现 pages 目录的每页布局。该模式可被 app 目录原生支持的 嵌套布局 替代。

查看前后对比示例

迁移前

components/DashboardLayout.js
export default function DashboardLayout({ children }) {
  return (
    <div>
      <h2>我的仪表盘</h2>
      {children}
    </div>
  )
}
pages/dashboard/index.js
import DashboardLayout from '../components/DashboardLayout'

export default function Page() {
  return <p>我的页面</p>
}

Page.getLayout = function getLayout(page) {
  return <DashboardLayout>{page}</DashboardLayout>
}

迁移后

  1. pages/dashboard/index.js 移除 Page.getLayout 属性,并按照 页面迁移步骤 将页面移至 app 目录。

    app/dashboard/page.js
    export default function Page() {
      return <p>我的页面</p>
    }
  2. DashboardLayout 内容移至新的 客户端组件 以保留 pages 目录行为。

    app/dashboard/DashboardLayout.js
    'use client' // 该指令应位于文件顶部,所有导入之前
    
    // 这是一个客户端组件
    export default function DashboardLayout({ children }) {
      return (
        <div>
          <h2>我的仪表盘</h2>
          {children}
        </div>
      )
    }
  3. DashboardLayout 导入到 app 目录的新 layout.js 文件。

    app/dashboard/layout.js
    import DashboardLayout from './DashboardLayout'
    
    // 这是一个服务端组件
    export default function Layout({ children }) {
      return <DashboardLayout>{children}</DashboardLayout>
    }
  4. 可逐步将 DashboardLayout.js(客户端组件)中的非交互部分移至 layout.js(服务端组件),减少发送至客户端的 JavaScript 代码量。

步骤 3:迁移 next/head

pages 目录中,next/head React 组件用于管理 <head> HTML 元素如 titlemeta。在 app 目录中,next/head 被替换为新的 内置 SEO 支持

迁移前:

import Head from 'next/head'

export default function Page() {
  return (
    <>
      <Head>
        <title>我的页面标题</title>
      </Head>
    </>
  )
}

迁移后:

import { Metadata } from 'next'

export const metadata: Metadata = {
  title: '我的页面标题',
}

export default function Page() {
  return '...'
}

查看所有元数据选项

步骤 4:迁移页面

我们建议将页面迁移分为两个主要步骤:

  • 步骤 1:将默认导出的页面组件移至新的客户端组件中。
  • 步骤 2:将新的客户端组件导入到 app 目录中的新 page.js 文件。

须知:这是最简单的迁移路径,因为它的行为与 pages 目录最为相似。

步骤 1:创建新的客户端组件

  • app 目录中创建一个新的独立文件(例如 app/home-page.tsx 或类似名称),并导出一个客户端组件。要定义客户端组件,请在文件顶部(在任何导入之前)添加 'use client' 指令。
  • pages/index.js 中的默认导出页面组件移至 app/home-page.tsx
'use client'

// 这是一个客户端组件。它接收数据作为 props,
// 并且可以访问状态和副作用,就像 `pages` 目录中的页面组件一样。
export default function HomePage({ recentPosts }) {
  return (
    <div>
      {recentPosts.map((post) => (
        <div key={post.id}>{post.title}</div>
      ))}
    </div>
  )
}

步骤 2:创建新页面

  • app 目录中创建一个新的 app/page.tsx 文件。默认情况下,这是一个服务端组件。

  • home-page.tsx 客户端组件导入到页面中。

  • 如果您在 pages/index.js 中获取数据,请使用新的数据获取 API 将数据获取逻辑直接移至服务端组件。有关更多详细信息,请参阅数据获取升级指南

    // 导入您的客户端组件
    import HomePage from './home-page'
    
    async function getPosts() {
      const res = await fetch('https://...')
      const posts = await res.json()
      return posts
    }
    
    export default async function Page() {
      // 在服务端组件中直接获取数据
      const recentPosts = await getPosts()
      // 将获取的数据传递给您的客户端组件
      return <HomePage recentPosts={recentPosts} />
    }
  • 如果您的旧页面使用了 useRouter,则需要更新为新的路由钩子。了解更多

  • 启动开发服务器并访问 http://localhost:3000。您应该会看到现有的索引路由,现在通过 app 目录提供服务。

步骤 5:迁移路由钩子

新增了一个路由器以支持 app 目录中的新行为。

app 目录中,您应该使用从 next/navigation 导入的三个新钩子:useRouter()usePathname()useSearchParams()

  • 新的 useRouter 钩子从 next/navigation 导入,其行为与从 next/router 导入的 pages 中的 useRouter 钩子不同。
  • 新的 useRouter 不返回 pathname 字符串。请改用单独的 usePathname 钩子。
  • 新的 useRouter 不返回 query 对象。请改用单独的 useSearchParams 钩子。
  • 您可以结合使用 useSearchParamsusePathname 来监听页面变化。有关更多详细信息,请参阅路由器事件 (Router Events) 部分。
  • 这些新钩子仅在客户端组件中受支持。它们不能在服务端组件中使用。
'use client'

import { useRouter, usePathname, useSearchParams } from 'next/navigation'

export default function ExampleClientComponent() {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()

  // ...
}

此外,新的 useRouter 钩子有以下变化:

  • isFallback 已被移除,因为 fallback被替换
  • localelocalesdefaultLocalesdomainLocales 值已被移除,因为 app 目录中不再需要内置的 Next.js i18n 功能。了解更多关于 i18n 的信息
  • basePath 已被移除。替代方案将不属于 useRouter 的一部分。目前尚未实现。
  • asPath 已被移除,因为新路由器中已移除 as 的概念。
  • isReady 已被移除,因为它不再必要。在静态渲染 (static rendering) 期间,任何使用 useSearchParams() 钩子的组件将跳过预渲染步骤,改为在客户端运行时渲染。

查看 useRouter() API 参考

步骤 6:迁移数据获取方法

pages 目录使用 getServerSidePropsgetStaticProps 来为页面获取数据。在 app 目录中,这些旧的数据获取函数已被基于 fetch()async React 服务端组件的更简单 API 取代。

export default async function Page() {
  // 此请求应缓存直到手动失效。
  // 类似于 `getStaticProps`。
  // `force-cache` 是默认值,可以省略。
  const staticData = await fetch(`https://...`, { cache: 'force-cache' })

  // 此请求应在每次请求时重新获取。
  // 类似于 `getServerSideProps`。
  const dynamicData = await fetch(`https://...`, { cache: 'no-store' })

  // 此请求应缓存 10 秒。
  // 类似于带有 `revalidate` 选项的 `getStaticProps`。
  const revalidatedData = await fetch(`https://...`, {
    next: { revalidate: 10 },
  })

  return <div>...</div>
}

服务端渲染 (getServerSideProps)

pages 目录中,getServerSideProps 用于在服务器上获取数据并将 props 传递给文件中的默认导出 React 组件。页面的初始 HTML 由服务器预渲染,然后在浏览器中“水合”(hydrating)页面(使其可交互)。

pages/dashboard.js
// `pages` 目录

export async function getServerSideProps() {
  const res = await fetch(`https://...`)
  const projects = await res.json()

  return { props: { projects } }
}

export default function Dashboard({ projects }) {
  return (
    <ul>
      {projects.map((project) => (
        <li key={project.id}>{project.name}</li>
      ))}
    </ul>
  )
}

app 目录中,我们可以使用服务端组件 (Server Components) 将数据获取逻辑与 React 组件放在一起。这使我们能够向客户端发送更少的 JavaScript,同时保留来自服务器的渲染 HTML。

通过将 cache 选项设置为 no-store,我们可以指示获取的数据应永不缓存。这与 pages 目录中的 getServerSideProps 类似。

// `app` 目录

// 此函数可以命名为任何名称
async function getProjects() {
  const res = await fetch(`https://...`, { cache: 'no-store' })
  const projects = await res.json()

  return projects
}

export default async function Dashboard() {
  const projects = await getProjects()

  return (
    <ul>
      {projects.map((project) => (
        <li key={project.id}>{project.name}</li>
      ))}
    </ul>
  )
}

访问请求对象

pages 目录中,您可以根据 Node.js HTTP API 获取基于请求的数据。

例如,您可以从 getServerSideProps 获取 req 对象,并使用它来获取请求的 cookies 和 headers。

pages/index.js
// `pages` 目录

export async function getServerSideProps({ req, query }) {
  const authHeader = req.getHeaders()['authorization'];
  const theme = req.cookies['theme'];

  return { props: { ... }}
}

export default function Page(props) {
  return ...
}

app 目录公开了新的只读函数来获取请求数据:

// `app` 目录
import { cookies, headers } from 'next/headers'

async function getData() {
  const authHeader = headers().get('authorization')

  return '...'
}

export default async function Page() {
  // 您可以直接在服务端组件中使用 `cookies()` 或 `headers()`,
  // 或者在数据获取函数中使用
  const theme = cookies().get('theme')
  const data = await getData()
  return '...'
}

静态站点生成 (getStaticProps)

pages 目录中,getStaticProps 函数用于在构建时预渲染页面。此函数可用于从外部 API 或直接从数据库获取数据,并在构建期间将此数据传递给整个页面。

pages/index.js
// `pages` 目录

export async function getStaticProps() {
  const res = await fetch(`https://...`)
  const projects = await res.json()

  return { props: { projects } }
}

export default function Index({ projects }) {
  return projects.map((project) => <div>{project.name}</div>)
}

app 目录中,使用 fetch() 获取数据将默认为 cache: 'force-cache',这将缓存请求数据直到手动失效。这与 pages 目录中的 getStaticProps 类似。

app/page.js
// `app` 目录

// 此函数可以命名为任何名称
async function getProjects() {
  const res = await fetch(`https://...`)
  const projects = await res.json()

  return projects
}

export default async function Index() {
  const projects = await getProjects()

  return projects.map((project) => <div>{project.name}</div>)
}

动态路径 (getStaticPaths)

pages 目录中,getStaticPaths 函数用于定义构建时应预渲染的动态路径。

pages/posts/[id].js
// `pages` 目录
import PostLayout from '@/components/post-layout'

export async function getStaticPaths() {
  return {
    paths: [{ params: { id: '1' } }, { params: { id: '2' } }],
  }
}

export async function getStaticProps({ params }) {
  const res = await fetch(`https://.../posts/${params.id}`)
  const post = await res.json()

  return { props: { post } }
}

export default function Post({ post }) {
  return <PostLayout post={post} />
}

app 目录中,getStaticPaths 已被 generateStaticParams 替代。

generateStaticParams 的行为与 getStaticPaths 类似,但提供了更简洁的 API 来返回路由参数,并且可以在 布局 (layouts) 内部使用。generateStaticParams 的返回形式是一个段数组,而不是嵌套的 param 对象数组或解析后的路径字符串。

app/posts/[id]/page.js
// `app` 目录
import PostLayout from '@/components/post-layout'

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

async function getPost(params) {
  const res = await fetch(`https://.../posts/${params.id}`)
  const post = await res.json()

  return post
}

export default async function Post({ params }) {
  const post = await getPost(params)

  return <PostLayout post={post} />
}

对于 app 目录中的新模型,使用 generateStaticParamsgetStaticPaths 更合适。get 前缀被更具描述性的 generate 替代,现在可以单独使用,因为不再需要 getStaticPropsgetServerSidePropsPaths 后缀被 Params 替代,更适合具有多个动态段 (dynamic segments) 的嵌套路由。


替换 fallback

pages 目录中,getStaticPaths 返回的 fallback 属性用于定义未在构建时预渲染的页面的行为。此属性可以设置为 true 以在页面生成时显示回退页面,设置为 false 以显示 404 页面,或设置为 blocking 以在请求时生成页面。

pages/posts/[id].js
// `pages` 目录

export async function getStaticPaths() {
  return {
    paths: [],
    fallback: 'blocking'
  };
}

export async function getStaticProps({ params }) {
  ...
}

export default function Post({ post }) {
  return ...
}

app 目录中,config.dynamicParams 属性 控制如何处理 generateStaticParams 之外的参数:

  • true:(默认值)未包含在 generateStaticParams 中的动态段 (dynamic segments) 会按需生成。
  • false:未包含在 generateStaticParams 中的动态段将返回 404。

这替代了 pages 目录中 getStaticPathsfallback: true | false | 'blocking' 选项。fallback: 'blocking' 选项未包含在 dynamicParams 中,因为在流式传输 (streaming) 下,'blocking'true 之间的差异可以忽略不计。

app/posts/[id]/page.js
// `app` 目录

export const dynamicParams = true;

export async function generateStaticParams() {
  return [...]
}

async function getPost(params) {
  ...
}

export default async function Post({ params }) {
  const post = await getPost(params);

  return ...
}

dynamicParams 设置为 true(默认值)时,如果请求的路由段尚未生成,它将在服务器端渲染并缓存。

增量静态再生 (getStaticPropsrevalidate)

pages 目录中,getStaticProps 函数允许您添加 revalidate 字段以在一定时间后自动重新生成页面。

pages/index.js
// `pages` 目录

export async function getStaticProps() {
  const res = await fetch(`https://.../posts`)
  const posts = await res.json()

  return {
    props: { posts },
    revalidate: 60,
  }
}

export default function Index({ posts }) {
  return (
    <Layout>
      <PostList posts={posts} />
    </Layout>
  )
}

app 目录中,使用 fetch() 获取数据时可以使用 revalidate,这将缓存请求指定的秒数。

app/page.js
// `app` 目录

async function getPosts() {
  const res = await fetch(`https://.../posts`, { next: { revalidate: 60 } })
  const data = await res.json()

  return data.posts
}

export default async function PostList() {
  const posts = await getPosts()

  return posts.map((post) => <div>{post.name}</div>)
}

API 路由

API 路由在 pages/api 目录中继续正常工作,无需任何更改。然而,在 app 目录中,它们已被 路由处理器 (Route Handlers) 替代。

路由处理器允许您使用 Web RequestResponse API 为给定路由创建自定义请求处理器。

export async function GET(request: Request) {}

须知:如果您之前使用 API 路由从客户端调用外部 API,现在可以使用 服务器组件 (Server Components) 来安全地获取数据。了解更多关于 数据获取 的内容。

步骤 7:样式

pages 目录中,全局样式表仅限于 pages/_app.js。在 app 目录中,此限制已被取消。全局样式可以添加到任何布局、页面或组件中。

Tailwind CSS

如果您使用 Tailwind CSS,您需要将 app 目录添加到 tailwind.config.js 文件中:

tailwind.config.js
module.exports = {
  content: [
    './app/**/*.{js,ts,jsx,tsx,mdx}', // <-- 添加此行
    './pages/**/*.{js,ts,jsx,tsx,mdx}',
    './components/**/*.{js,ts,jsx,tsx,mdx}',
  ],
}

您还需要在 app/layout.js 文件中导入全局样式:

app/layout.js
import '../styles/globals.css'

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

了解更多关于 使用 Tailwind CSS 进行样式设计

代码修改工具 (Codemods)

Next.js 提供了代码修改工具 (Codemod) 转换,以帮助在功能弃用时升级您的代码库。更多信息请参阅 代码修改工具 (Codemods)