Next.js

30views

前言

本篇文章基于App Router

Routing

Route Groups

(folderName)创建路由分组。

image.png

(marketing)(shop)仅作为分组,括号中内容会在 URL 中省略。
同一个分组中可以共享同一个layout.tsxloading.tsx

Parallel Routes

image.png

并行路由基于插槽,即这里的@team@analytics

image.png

// dashboard/layout.tsx
export default function DashboardLayout({
  children,
  team,
  analytics
}: {
  children: React.ReactNode
  team: React.ReactNode
  analytics: React.ReactNode
}) {
  return (
    <div>
      <p>这里是dashboard layout:</p>

      <div className="border border-gray-200">
        <div className="border border-lime-500">{children}</div>
        <div className="border border-indigo-500">{team}</div>
        <div className="border border-blue-500">{analytics}</div>
      </div>
    </div>
  )
}

// @analytics/page.tsx
export default function Analytics() {
  return (
    <div>
      <div>Analytics Page</div>
      <Link
        href="/dashboard/settings"
        className="bg-indigo-500 px-1 py-1 rounded"
      >
        click to settings page
      </Link>
    </div>
  )
}

// @team/page.tsx
export default function Team() {
  return <div>Team Page</div>
}

// @team/settings/page.tsx
export default function TeamSettings() {
  return <div>TeamSettings Page</div>
}

输入http://localhost:3000/dashboard,得到如下界面:

image.png

⚠️注意,slots不是路由段,即如果访问settings页,那路由应该是:http://localhost:3000/dashboard/settings
如果输入这个地址,而非点击click to settings page跳转至次地址,页面此时会404,这里引出下一个问题。

默认情况下,Next.js 会跟踪每个槽的活动状态(或子页面),但是槽中呈现的内容取决于导航的类型:

  • Soft Navigation:在 client-side navigatio 中,next.js 执行部分渲染,更改槽内子页面,同时保持另一个槽的活动子页面,即使他们与当前 URL 不匹配。

这里我们点击click to settings page按钮,得到这样的结果:
image.png

  • Hard navigation:整页加载(浏览器刷新)后,Next.js无法确定与当前 URL 不匹配的槽的活动状态。相反,它降为不匹配的槽渲染一个default.js文件,如果不存在,则为404

这解释了为什么上面手动输入地址页面会404

现在新建两个default.tsx

// @analytics/default.tsx
export default function AnalyticsDefault() {
  return <div>Analytics Default</div>
}

// dashboard/default.tsx
export default function DashboardDefault() {
  return <div>Dashboard Default</div>
}

刷新页面,得到如下结果:

image.png

作用

1. 条件路由
import { checkUserRole } from '@/lib/auth'
 
export default function Layout({
  user,
  admin,
}: {
  user: React.ReactNode
  admin: React.ReactNode
}) {
  const role = checkUserRole()
  return role === 'admin' ? admin : user
}
2. Tab Groups

在一个插槽中建多个子页面,以共享数据。

3. Modals
  • Modal 内容可以通过 URL 共享
  • 刷新页面时保留上下文,不会关闭 Modal
  • 向后导航时关闭 Modal,非回到上一个路由
  • 向前导航时重新打开 Modal

拦截路由

使用(..)定义拦截路由:

  • (.):匹配同一级的路段
  • (..):匹配上一级的路段
  • (..)(..):匹配上两级的路段
  • (...):匹配根 app中的路段

作用:

image.png

利用插槽和(..),实现如下效果:

  1. 可以从他图片列表中选中图片打开照片模式,此时 URL 是 /photo/xxx
  2. 从分享的 URL /photo/xxx打开时,是照片详情页面

Route Handlers

Route Handlers 允许使用自定义 Web 请求和响应来创建自定义 request handlers。

Route Handlers 仅在 app目录中可用,相当于 pages中的 API 路由。
可以嵌套在app中任意位置,但是不能与page.js相同级的route.js文件。

公约

Route Handlers需要在approutes.js|ts中被定义:

export async function GET(request: Request) {}

Middleware

Middleware 允许在请求完成之前运行代码,然后根据传入的请求,可以通过重写、重定向、修改请求或响应标头或直接响应来修改响应。

Use Cases

  • Authentication & Authorization(身份验证和授权):在授予对特定页面或API路由的访问权限前,确保用户身份并检查会话 cookie。
  • Server-Side Redirects:根据条件在服务器级别重定向用户。
  • Path Rewriting:根据请求属性动态重写 API 路由或页面路径。
  • 机器人检测:检测和阻止机器人流量来保护资源。
  • 日志和分析:在页面或 API 处理之前捕获并分析请求数据以获取见解。
  • 功能标记:动态启用或禁用功能,以实现无缝的功能退出或测试。

⚠️注意,这些情况中间价不是最佳实践:

  • 复杂的数据获取和操作
  • 繁重的计算任务
  • 广泛的会话管理
  • 直接数据库操作

约定

在根目录中使用middleware.ts|js

虽然项目仅支持一个middleware.ts文件,但仍可以模块化地组织中间逻辑,将中间件功能分解为单独的.ts文件后导入主middleware.ts文件。

Matching Paths

中间件会被项目中每条路由执行。有两种办法定义中间件在哪些路径执行:

  1. Custom matcher config
  2. Conditional statements
Matcher

matcher值必须是常量,这样才能在构建时进行静态分析,变量等动态值将被忽略。

export const config = {
  matcher: '/about/:path*',
  // matcher: ['/about/:path*', '/dashboard/:path*'],
}

// 或者正则表达式
export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico, sitemap.xml, robots.txt (metadata files)
     */
    '/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
  ],
}

// 甚至使用 missing 或 has 数组
export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico, sitemap.xml, robots.txt (metadata files)
     */
    {
      source:
        '/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
      missing: [
        { type: 'header', key: 'next-router-prefetch' },
        { type: 'header', key: 'purpose', value: 'prefetch' },
      ],
    },
 
    {
      source:
        '/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
      has: [
        { type: 'header', key: 'next-router-prefetch' },
        { type: 'header', key: 'purpose', value: 'prefetch' },
      ],
    },
 
    {
      source:
        '/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
      has: [{ type: 'header', key: 'x-present' }],
      missing: [{ type: 'header', key: 'x-missing', value: 'prefetch' }],
    },
  ],
}
Conditional Statements
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
 
export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/about')) {
    return NextResponse.rewrite(new URL('/about-2', request.url))
  }
 
  if (request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.rewrite(new URL('/dashboard/user', request.url))
  }
}

Data Fetching

Data Fetching & Caching

默认情况下,fetch的响应不会被缓存。

export default async function Page() {
  const data = await fetch('https://api.vercel.app/blog')
  const posts = await data.json()
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

若未在此路由中的其他任何地方使用任何 Dynamic APIs ,它将在next build时预渲染为静态页面。然后可以使用(ISR)增量静态再生来更新数据。

未防止页面预渲染,可以添加如下内容到文件中:

export const dynamic = 'force-dynamic'

但是,通常会使用cookiesheaders或从页面 props 中读取传入的searchParams等函数,这些函数会自动使页面动态呈现。在这种情况下,您不需要明确使用force-dynamic

这里比较混淆的是:“默认情况下,fetch的响应不会被缓存”和“页面预渲染为静态”

多个函数中重复使用数据

在 Next.js 的早期版本中,使用fetch时默认cache值为force-cache 。 在版本 15 中,该值已更改为默认值cache: no-store 。

如果你正在使用fetch ,可以通过添加cache: 'force-cache'来记忆请求。这意味着你可以安全地使用相同的选项调用相同的 URL,并且只会发出一个请求。

比如一个Page.ts中使用generateMetadatagenerateStaticParams等 API,导致Page和其API中获取相同数据。

import { notFound } from 'next/navigation'
 
interface Post {
  id: string
  title: string
  content: string
}
 
async function getPost(id: string) {
  const res = await fetch(`https://api.vercel.app/blog/${id}`, {
    cache: 'force-cache',
  })
  const post: Post = await res.json()
  if (!post) notFound()
  return post
}
 
export async function generateStaticParams() {
  const posts = await fetch('https://api.vercel.app/blog', {
    cache: 'force-cache',
  }).then((res) => res.json())
 
  return posts.map((post: Post) => ({
    id: String(post.id),
  }))
}
 
export async function generateMetadata({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const post = await getPost(id)
 
  return {
    title: post.title,
  }
}
 
export default async function Page({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const post = await getPost(id)
 
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  )
}

如果不使用fetch,可以使用 React cache功能,这将删除重复数据并仅进行一次查询。

import { cache } from 'react'
import { db, posts, eq } from '@/lib/db' // Example with Drizzle ORM
import { notFound } from 'next/navigation'
 
export const getPost = cache(async (id) => {
  const post = await db.query.posts.findFirst({
    where: eq(posts.id, parseInt(id)),
  })
 
  if (!post) notFound()
  return post
})

Rendering

Server Components

Server Components unlike Client Components, their code stays on the server and is nerver downloaded to the client.

React Server Components allow you to write UI that can be rendered and optionally cached on the server. 在 Next.js 中渲染工作进一步按照路由段拆分,以实现流式渲染和部分渲染,并且有三种不同的服务器渲染策略:

  • Static Rendering

Routes在构建时渲染,或者在数据重新验证后在后台渲染。结果被缓存并可以推送到 CDN。这种优化可以在用户和服务器请求之间共享渲染工作的结果。
当Route包含未针对用户个性化且在构建时知道的数据(例如静态博客文章或产品页面)时,静态渲染很有用。

  • Dynamic Rendering

通过动态渲染,可以在请求时为每个用户渲染 routes.
当路线具有针对用户个性化的数据或具有只能在请求时知道的信息(例如 cookie 或 URL 的搜索参数)时,动态渲染很有用。

  • Streaming

流式传输可从服务器逐步渲染 UI。工作被拆分成多个块,并在准备就绪时流式传输给客户端,用户可以在整个内容渲染完成之前立即看到页面的某些部分。

How are Server Components rendered?

在服务器上,Next.js 使用 React 的 API 来协调渲染。渲染工作被拆成 chunks:单独的路由段和 Suspense Boundaries。

每个 chunk 在两步中被渲染:

  1. React 渲染 Server Components 为一个特殊的数据格式,称为 RSC Payload.
  2. Next.js 使用 RSC Payload 和客户端组件 JavaScript 指令在服务器上呈现 HTML。

然后在客户端上:

  1. HTML 用于立即显示的 route,这仅适用于初始页面加载。
  2. RSC Payload 协调客户端和服务器组件树,并更新 DOM。
  3. JavaScript 指令用于 hydrate 客户端组件并使程序具有交互性。

什么是 RSC?
RSC Payload 是渲染的 React Server Components 树的紧凑二进制表示。客户端上的 React 使用它来更新浏览器的 DOM。RSC Payload 包含:

  • The rendered result of Server Components
  • 客户端组件应呈现的位置的占位符以及对其 JavaScript 文件的引用
  • 从服务器组件传递到客户端组件的任何 props

Client Components

Client Components允许编写在服务器上预渲染的交互式UI,且可以使用客户端 JavaScript 在浏览器中允许。

好处?

  • 交互性:Client Components 可以使用state、effects 和 event listeners,意味着他们可以向用户提供及时反馈并更新 UI
  • Browser APIs:可以访问浏览器 API,例如地理位置或 localStorage。

如何使用?

use client指令。但是, "use client"不需要在每个需要在客户端渲染的组件中定义。一旦定义了边界,导入到其中的所有子组件和模块都被视为客户端包的一部分。

How are Client Components Rendered?

在 Next.js 中,Client Components rendered 取决于请求是一个完整页面加载的一部分(首次访问应用程序或由浏览器刷新触发的重新加载)还是后续导航。

Full Page load

为了优化初始页面加载,Next.js 将使用 React 的 API 在服务器上为客户端和服务器组件呈现静态 HTML 渲染。意味着,用户首次访问程序时将立即看到内容,无需等待客户端下载、解析和执行客户端组件JS包。

在服务器上:

  1. React将 Server Components渲染为 RSC Payload,其中包含对客户端组件的引用。
  2. Next.js 使用 RSC Payload 和客户端JS指令在服务器上呈现路由的 HTML。
    在客户端上:
  3. HTML 立即显示路线的快速非交互式初始预览。
  4. RSC Payload 用于协调客户端和服务器组件树,并更新 DOM
  5. JS 指令被用于 hydrate Client Components 并使 UI 具有交互性。
后续导航

后续导航中,Client Components完全在客户端上呈现,无需服务器呈现的HTML。

这意味着 Client Components JS包已下载并解析。一旦包准备就绪,React 将使用 RSC Payload 协调客户端和服务器组件树,并更新 DOM。

注意

是否看着 Client Components 和 Server Compoonents 这俩渲染方式没有区别?其实有显著差别:

Server Components的渲染完全在服务器端,生成RSC Payload和HTML。而Client Components虽然在初始加载时服务器也生成HTML预览,但后续的交互和导航则由客户端处理。因此,两者的主要区别在于持续性:Server Components的渲染结果在客户端不会重新执行,而Client Components需要客户端的水合(hydration)和后续处理。

核心差异总结:

特性Server ComponentsClient Components
执行环境仅服务端服务端生成静态 HTML + 客户端 Hydration
数据获取直接访问数据库/API(无客户端代码)通过客户端 API 调用
更新机制每次请求重新渲染客户端状态驱动更新
可交互性无(纯静态内容)完全可交互
JS Bundle 影响不包含在客户端 JS包含在客户端 JS

组件更新机制:

行为Server ComponentsClient Components
状态变化需重新请求服务端生成新内容客户端直接更新
路由跳转重新获取 RSC Payload客户端路由直接渲染
数据实时性每次请求获取最新数据依赖客户端轮询或 WebSocket

性能影响对比:

指标Server ComponentsClient Components
首屏加载速度极快(纯静态内容)较快(需等待 Hydration)
客户端资源占用极低(无 JS 逻辑)较高(需加载和执行 JS)
SEO 支持完美支持需配合 SSR
首次页面加载渲染流程对比

Server Components 渲染流程:

    用户->>服务端: 请求页面
    服务端->>服务端: 1. 执行 Server Components
    服务端->>服务端: 2. 生成 RSC Payload(React 专用数据格式)
    服务端->>服务端: 3. 根据 RSC Payload 生成 HTML
    服务端->>客户端: 返回 HTML + RSC Payload
    客户端->>客户端: 4. 展示静态 HTML(非交互)
    客户端->>客户端: 5. 根据 RSC Payload 协调组件树

Client Components 渲染流程:

    用户->>服务端: 请求页面
    服务端->>服务端: 1. 预渲染 Client Components 的静态 HTML
    服务端->>客户端: 返回 HTML + JS Bundle
    客户端->>客户端: 2. 立即展示静态 HTML
    客户端->>客户端: 3. 下载 JS Bundle
    客户端->>客户端: 4. Hydration(使组件可交互)
后续导航渲染流程对比

Server Components 渲染流程:

    用户->>客户端: 触发导航(如点击链接)
    客户端->>服务端: 请求新路由的 RSC Payload
    服务端->>服务端: 重新执行 Server Components
    服务端->>客户端: 返回新的 RSC Payload
    客户端->>客户端: 协调组件树并更新 DOM

Client Components 渲染流程:

    用户->>客户端: 触发导航(如前端路由跳转)
    客户端->>客户端: 1. 直接下载新路由的 JS Bundle
    客户端->>客户端: 2. 完全在客户端渲染组件
    客户端->>客户端: 3. 更新 DOM(无需服务端参与)