Skip to content

Next.js手把手系列:05、路由篇 | 路由处理程序 #99

@MagicalBridge

Description

@MagicalBridge

基本概念

路由处理程序是指使用 Web Request 和 Response API 对于给定的路由自定义处理逻辑。

简单来说,前后端分离架构中,客户端与服务端之间通过 API 接口来交互。这个API 接口在 Next.js 中有个更为正式的称呼,就是路由处理程序

本篇我们会讲解如何定义一个路由处理程序以及写路由处理程序时常遇到的一些问题。

1、定义路由处理程序

写路由处理程序,你需要定义一个名为 route.js的特殊文件。(注意是 route 不是 router

Xnip2024-07-28_21-00-58.png

该文件必须在 app目录下,可以在 app 嵌套的文件夹下,但是要注意 page.js和 route.js不能在同一层级同时存在。

想想也能理解,page.js和 route.js本质上都是对路由的响应。page.js主要负责渲染 UI,route.js主要负责处理请求。如果同时存在,Next.js 就不知道用谁的逻辑了。

1.1GET请求

让我们从写 GET 请求开始,比如写一个获取文章列表的接口。

新建 app/api/posts/route.js 文件,代码如下:

import { NextResponse } from 'next/server'
 
export async function GET() {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts')
  const data = await res.json()
 
  return NextResponse.json({ data })
}

浏览器访问 http://localhost:3000/api/posts 查看接口返回的数据:

Xnip2024-07-28_22-15-51.png

在这个例子中:

  1. 我们 export 一个名为 GET 的 async 函数来定义 GET 请求处理,注意是 export 而不是 export default
  2. 我们使用 next/server 的 NextResponse 对象用于设置响应内容,但这里不一定非要用 NextResponse,直接使用 Response 也是可以的:
export async function GET() {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts')
  const data = await res.json()
  
  return Response.json({ data })
}

但在实际开发中,推荐使用 NextResponse,因为它是 Next.js 基于 Response 的封装,它对 TypeScript 更加友好,同时提供了更为方便的用法,比如获取 Cookie 等。

  1. 我们将接口写在了 app/api 文件夹下,并不是因为接口一定要放在名为 api 文件夹下(与 Pages Router 不同)。如果你代码写在 app/posts/route.js,对应的接口地址就是 /posts。放在 api 文件夹下只是为了方便区分地址是接口还是页面。

1.2 支持方法

Next.js 支持 GETPOSTPUTPATCHDELETEHEAD 和 OPTIONS 这些 HTTP 请求方法。如果传入了不支持的请求方法,Next.js 会返回 405 Method Not Allowed

// route.js
export async function GET(request) {}
 
export async function HEAD(request) {}
 
export async function POST(request) {}
 
export async function PUT(request) {}
 
export async function DELETE(request) {}
 
export async function PATCH(request) {}
 
// 如果 `OPTIONS` 没有定义, Next.js 会自动实现 `OPTIONS`
export async function OPTIONS(request) {}

现在让我们再写一个 POST 请求练练手。

继续修改 app/api/posts/route.js,添加代码如下:

import { NextResponse } from 'next/server'

export async function POST(request) {
  const article = await request.json()
  
  return NextResponse.json({
    id: Math.random().toString(36).slice(-8),
    data: article
  }, { status: 201 })
}

现在让我们用接口请求工具调用一下:

Xnip2024-07-28_22-36-29.png

1.3. 传入参数

现在让我们具体看下请求方法。每个请求方法的处理函数会被传入两个参数,一个 request,一个 context 。两个参数都是可选的:

export async function GET(request, context) {}

request (optional)

request 对象是一个 NextRequest 对象,它是基于 Web Request API 的扩展。使用 request ,你可以快捷读取 cookies 和处理 URL。

我们这里讲讲如何获取 URL 参数:

export async function GET(request, context) {
  //  访问 /home, pathname 的值为 /home
	const pathname = request.nextUrl.pathname
	// 访问 /home?name=lee, searchParams 的值为 { 'name': 'lee' }
	const searchParams = request.nextUrl.searchParams
}

其中 nextUrl 是基于 Web URL API 的扩展(如果你想获取其他值,参考 URL API,同样提供了一些方便使用的方法。

context (optional)

目前context 只有一个值就是 params,它是一个包含当前动态路由参数的对象。举个例子:

// app/dashboard/[team]/route.js
export async function GET(request, { params }) {
  const team = params.team
}

当访问 /dashboard/1 时,params 的值为 { team: '1' }。其他情况还有:

Xnip2024-07-28_22-46-09.png

注意第二行:此时 params 返回了当前链接所有的动态路由参数。

示例代码

现在让我们写个 demo 复习下这些知识。

需求:目前 GET 请求 /api/posts 时会返回所有文章数据,现在希望 GET 请求 /api/posts/1?dataField=title 获取 post id 为 1 的文章数据,dataField 用于指定返回哪些字段数据。

让我们开始写吧。新建 /api/posts/[id]/route.js,代码如下:

import { NextResponse } from 'next/server'

export async function GET(request, { params }) {
  const field = request.nextUrl.searchParams.get("dataField")
  const data = await ((await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}`)).json())
  const result = field ? { [field]: data[field] } : data
  return NextResponse.json(result)
}

用接口测试工具测试一下,如果请求地址是 http://localhost:3000/api/posts/1?dataField=title,效果如下:

Xnip2024-07-28_22-50-14.png

如果请求地址是 http://localhost:3000/api/posts/1,效果如下:

Xnip2024-07-28_22-52-03.png

1.4. 缓存行为

默认缓存

默认情况下,使用 Response 对象(NextResponse 也是一样的)的 GET 请求会被缓存。

让我们举个例子,新建 app/api/time/route.js,代码如下:

export async function GET() {
  console.log('GET /api/time')
  return Response.json({ data: new Date().toLocaleTimeString() })
}

注意:在开发模式下,并不会被缓存,每次刷新时间都会改变,现在我们部署生产版本,运行 npm run build && npm run start

你会发现,无论怎么刷新,时间都不会改变。这就是被缓存了。

可是为什么呢?Next.js 是怎么实现的呢?

让我们看下构建(npm run build)时的命令行输出:

Xnip2024-07-28_22-53-42.png

根据输出的结果,你会发现 /api/time 是静态的,也就是被预渲染为静态的内容,换言之,/api/time 的返回结果其实在构建的时候就已经确定了,而不是在第一次请求的时候才确定。

退出缓存

但大家也不用担心默认缓存带来的影响。实际上,默认缓存的条件是非常“严苛”的,这些情况都会导致退出缓存:

  • GET 请求使用 Request 对象

修改 app/api/time/route.js,代码如下:

export async function GET(request) {
  const searchParams = request.nextUrl.searchParams
  return Response.json({ data: new Date().toLocaleTimeString(), params: searchParams.toString() })
}

现在我们部署生产版本,运行 npm run build && npm run start

Xnip2024-07-28_22-55-44.png

此时会动态渲染,也就是在请求的时候再进行服务端渲染,所以时间会改变。

  • 添加其他 HTTP 方法,比如 POST

修改 app/api/time/route.js,代码如下:

export async function GET() {
  console.log('GET /api/time')
  return Response.json({ data: new Date().toLocaleTimeString() })
}

export async function POST() {
  console.log('POST /api/time')
  return Response.json({ data: new Date().toLocaleTimeString() })
}

此时会转为动态渲染。这是因为 POST 请求往往用于改变数据,GET 请求用于获取数据。如果写了 POST 请求,表示数据会发生变化,此时不适合缓存。

修改 app/api/time/route.js,代码如下:

export async function GET(request) {
  const token = request.cookies.get('token')
  return Response.json({ data: new Date().toLocaleTimeString() })
}

此时会转为动态渲染。这是因为 cookies、headers 这些数据只有当请求的时候才知道具体的值。

修改 app/api/time/route.js,代码如下:

export const dynamic = 'force-dynamic'

export async function GET() {
  return Response.json({ data: new Date().toLocaleTimeString() })
}

此时会转为动态渲染。这是因为你手动设置为了动态渲染模式……

重新验证

除了退出缓存,也可以设置缓存的时效,适用于一些重要性低、时效性低的页面。

有两种常用的方案,一种是使用路由段配置项

修改 app/api/time/route.js,代码如下:

export const revalidate = 10

export async function GET() {
  return Response.json({ data: new Date().toLocaleTimeString() })
}

export const revalidate = 10 表示设置重新验证频率为 10s,但是要注意:

这句代码的效果并不是设置服务器每 10s 会自动更新一次 /api/time。而是最少 10s 后才重新验证。

举个例子,假设你现在访问了 /api/time,此时时间设为 0s,10s 内持续访问,/api/time返回的都是之前缓存的结果。当 10s 过后,假设你第 12s 又访问了一次 /api/time,此时虽然超过了 10s,但依然会返回之前缓存的结果,但同时会触发服务器更新缓存,当你第 13s 再次访问的时候,就是更新后的结果。

简单来说,超过 revalidate 设置时间的首次访问会触发缓存更新,如果更新成功,后续的返回就都是新的内容,直到下一次触发缓存更新。

为了演示它的效果,我们需要准备一个能够随机返回数据的接口。

如果你喜欢猫猫,可以调用 api.thecatapi.com/v1/images/search 每次调用它都会返回一张随机的猫猫图片数据。

如果你喜欢狗狗,可以调用 dog.ceo/api/breeds/…
每次调用它都会返回一张随机的狗狗图片数据。

如果你喜欢美女帅哥,请自己解决。

现在让我们开始吧!新建 app/api/image/route.js,代码如下:

export async function GET() {
  const res = await fetch('https://api.thecatapi.com/v1/images/search')
  const data = await res.json()
  console.log(data)
  return Response.json(data)
}

让我们在开发模式下打开这个页面:localhost:3000/api/image, 你会发现与之前的 /api/time 不同,/api/image 接口返回的数据在开发模式下刷新就已经不会改变了,即使 console.log 每次都会打印,返回的结果却还是一样。

这是因为 Next.js 拓展了原生的 fetch 方法,会自动缓存 fetch 的结果。现在我们使用 next.revalidate 设置 fetch 请求的重新验证时间,修改 app/api/image/route.js,代码如下:

export async function GET() {
  const res = await fetch('https://api.thecatapi.com/v1/images/search', {
    next: { revalidate: 5 }, //  每 5 秒重新验证
  })
  const data = await res.json()
  console.log(data)
  return Response.json(data)
}

在本地多次刷新页面,你会发现数据发生了更新:

如果你使用生产版本,虽然在构建的时候,/api/image 显示的是静态渲染,但是数据会更新。具体更新的规律和第一种方案是一样的,这里就不多赘述了。

2. 写接口常见问题

接下来我们讲讲写接口时常遇到的一些问题,比如如何获取网址参数,如何读取 cookie,各种方法了解即可。实际开发中遇到问题的时候再来查就行。

2.1. 如何获取网址参数?

import { NextResponse } from 'next/server'
// app/api/search/route.js
// 访问 /api/search?query=hello
export function GET(request) {
  const searchParams = request.nextUrl.searchParams
  const query = searchParams.get('query')

  return NextResponse.json(query) // hello 
}

2.2. 如何处理 Cookie?

第一种方法是通过 NextRequest对象:

// app/api/route.js
export async function GET(request) {
  const token = request.cookies.get('token')
  request.cookies.set(`token2`, 123)
}

其中,request 是一个 NextRequest 对象。正如上节所说,NextRequest 相比 Request 提供了更为便捷的用法,这就是一个例子。

此外,虽然我们使用 set 设置了 cookie,但设置的是请求的 cookie,并没有设置响应的 cookie。

第二种方法是通过next/headers包提供的 cookies方法。

因为 cookies 实例只读,如果你要设置 Cookie,你需要返回一个使用 Set-Cookie header 的 Response 实例。示例代码如下:

// app/api/route.js
import { cookies } from 'next/headers'
 
export async function GET(request) {
  const cookieStore = cookies()
  const token = cookieStore.get('token')
 
  return new Response('Hello, Next.js!', {
    status: 200,
    headers: { 'Set-Cookie': `token=${token}` },
  })
}

2.3. 如何处理 Headers ?

第一种方法是通过 NextRequest对象:

// app/api/route.js
export async function GET(request) {
  const headersList = new Headers(request.headers)
  const referer = headersList.get('referer')
}

第二种方法是next/headers包提供的 headers 方法。

因为 headers 实例只读,如果你要设置 headers,你需要返回一个使用了新 header 的 Response 实例。使用示例如下:

// app/api/route.js
import { headers } from 'next/headers'
 
export async function GET(request) {
  const headersList = headers()
  const referer = headersList.get('referer')
 
  return new Response('Hello, Next.js!', {
    status: 200,
    headers: { referer: referer },
  })
}

2.4. 如何重定向?

重定向使用 next/navigation 提供的 redirect 方法,示例如下

import { redirect } from 'next/navigation'
 
export async function GET(request) {
  redirect('https://nextjs.org/')
}

2.5. 如何获取请求体内容?

// app/items/route.js 
import { NextResponse } from 'next/server'
 
export async function POST(request) {
  const res = await request.json()
  return NextResponse.json({ res })
}

如果请求正文是 FormData 类型:

// app/items/route.js
import { NextResponse } from 'next/server'
 
export async function POST(request) {
  const formData = await request.formData()
  const name = formData.get('name')
  const email = formData.get('email')
  return NextResponse.json({ name, email })
}

2.6. 如何设置 CORS ?

// app/api/route.ts
export async function GET(request) {
  return new Response('Hello, Next.js!', {
    status: 200,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    },
  })
}

2.7. 如何响应无 UI 内容?

你可以返回无 UI 的内容。在这个例子中,访问 /rss.xml的时候,会返回 XML 结构的内容:

// app/rss.xml/route.ts
export async function GET() {
  return new Response(`<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
 
<channel>
  <title>Next.js Documentation</title>
  <link>https://nextjs.org/docs</link>
  <description>The React Framework for the Web</description>
</channel>
 
</rss>`)
}

注:sitemap.xmlrobots.txtapp icons 和 open graph images 这些特殊的文件,Next.js 都已经提供了内置支持。

2.8. Streaming

openai 的打字效果背后用的就是流:

// app/api/chat/route.js
import OpenAI from 'openai'
import { OpenAIStream, StreamingTextResponse } from 'ai'
 
const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
})
 
export const runtime = 'edge'
 
export async function POST(req) {
  const { messages } = await req.json()
  const response = await openai.chat.completions.create({
    model: 'gpt-3.5-turbo',
    stream: true,
    messages,
  })
 
  const stream = OpenAIStream(response)
 
  return new StreamingTextResponse(stream)
}

当然也可以直接使用底层的 Web API 实现 Streaming:

// app/api/route.js
// https://developer.mozilla.org/docs/Web/API/ReadableStream#convert_async_iterator_to_stream
function iteratorToStream(iterator) {
  return new ReadableStream({
    async pull(controller) {
      const { value, done } = await iterator.next()
 
      if (done) {
        controller.close()
      } else {
        controller.enqueue(value)
      }
    },
  })
}
 
function sleep(time) {
  return new Promise((resolve) => {
    setTimeout(resolve, time)
  })
}
 
const encoder = new TextEncoder()
 
async function* makeIterator() {
  yield encoder.encode('<p>One</p>')
  await sleep(200)
  yield encoder.encode('<p>Two</p>')
  await sleep(200)
  yield encoder.encode('<p>Three</p>')
}
 
export async function GET() {
  const iterator = makeIterator()
  const stream = iteratorToStream(iterator)
 
  return new Response(stream)
}

注:Streaming 更完整详细的示例和解释可以参考 《如何用 Next.js v14 实现一个 Streaming 接口?》

小结

恭喜你,完成了本节内容的学习!

这一节我们介绍了如何定义一个路由处理程序,那就是使用新的约定文件 route.js,切记 route.js 不能跟同级的 page.js 一起使用。

同时我们介绍了写路由处理程序中可能会遇到的问题。在开发的时候,尽可能使用 NextRequest 和 NextResponse,它们是基于原生 Request 和 Response 的封装,提供了快捷处理 url 和 cookie 的方法。

参考链接

  1. Routing: Route Handlers
  2. File Conventions: route.js
  3. Functions: NextResponse

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions