路由处理程序是指使用 Web Request 和 Response API 对于给定的路由自定义处理逻辑。
简单的来说,前后端分离架构中,客户端与服务端之间通过 API 接口来交互。这个“API 接口”在 Next.js 中有个更为正式的称呼,就是路由处理程序。
本篇我们会讲解如何定义一个路由处理程序以及写路由处理程序时常遇到的一些问题。
写路由处理程序,你需要定义一个名为 route.js
的特殊文件。(注意是 route
不是 router
)
该文件必须在 app
目录下,可以在 app
嵌套的文件夹下,但是要注意 page.js
和 route.js
不能在同一层级同时存在。
想想也能理解,page.js
和 route.js
本质上都是对路由的响应。page.js
主要负责渲染 UI,route.js
主要负责处理请求。如果同时存在,Next.js 就不知道用谁的逻辑了。
让我们从写 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
查看接口返回的数据:
在这个例子中:
我们 export
一个名为 GET
的 async
函数来定义 GET 请求处理,注意是 export 而不是 export default
我们使用 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 等。
app/api
文件夹下,并不是因为接口一定要放在名为 api
文件夹下(与 Pages Router 不同)。如果你代码写在 app/posts/route.js
,对应的接口地址就是 /posts
。放在 api
文件夹下只是为了方便区分地址是接口还是页面。Next.js 支持 GET
、POST
、PUT
、PATCH
、DELETE
、HEAD
和 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
,添加代码如下:
export async function POST(request) {
const article = await request.json()
return NextResponse.json({
id: Math.random().toString(36).slice(-8),
data: article
}, { status: 201 })
}
现在让我们用 Postman 调用一下:
现在让我们具体看下请求方法。每个请求方法的处理函数会被传入两个参数,一个 request
,一个 context
。两个参数都是可选的:
export async function GET(request, context) {}
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
只有一个值就是 params
,它是一个包含当前动态路由参数的对象。举个例子:
// app/dashboard/[team]/route.js
export async function GET(request, { params }) {
const team = params.team
}
当访问 /dashboard/1
时,params 的值为 { team: '1' }
。其他情况还有:
Example | URL | params |
---|---|---|
app/dashboard/[team]/route.js |
/dashboard/1 |
{ team: '1' } |
app/shop/[tag]/[item]/route.js |
/shop/1/2 |
{ tag: '1', item: '2' } |
app/blog/[...slug]/route.js |
/blog/1/2 |
{ slug: ['1', '2'] } |
注意第二行:此时 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)
}
用 Postman 测试一下,如果请求地址是 http://localhost:3000/api/posts/1?dataField=title
,效果如下:
如果请求地址是 http://localhost:3000/api/posts/1
,效果如下:
默认情况下,使用 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)时的命令行输出:
根据输出的结果,你会发现 /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
:
运行效果如下:
此时会动态渲染,也就是在请求的时候再进行服务端渲染,所以时间会改变。
修改 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 设置时间的首次访问会触发缓存更新,如果更新成功,后续的返回就都是新的内容,直到下一次触发缓存更新。
还有一种是使用 next.revalidate
选项。
为了演示它的效果,我们需要准备一个能够随机返回数据的接口。
如果你喜欢猫猫,可以调用 https://api.thecatapi.com/v1/images/search,每次调用它都会返回一张随机的猫猫图片数据。
如果你喜欢狗狗,可以调用 https://dog.ceo/api/breeds/image/random,每次调用它都会返回一张随机的狗狗图片数据。
如果你喜欢美女帅哥,请自己解决。
现在让我们开始吧!新建 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)
}
让我们在开发模式下打开这个页面:
你会发现与之前的 /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
显示的是静态渲染,但是数据会更新。具体更新的规律和第一种方案是一样的,这里就不多赘述了。
注:Next.js 的缓存方案我们还会在 《缓存篇 | Caching》中详细介绍。
接下来我们讲讲写接口时常遇到的一些问题,比如如何获取网址参数,如何读取 cookie,各种方法了解即可。实际开发中遇到问题的时候再来查就行。
// app/api/search/route.js
// 访问 /api/search?query=hello
export function GET(request) {
const searchParams = request.nextUrl.searchParams
const query = searchParams.get('query') // query
}
第一种方法是通过 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}` },
})
}
第一种方法是通过 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 },
})
}
重定向使用 next/navigation
提供的 redirect
方法,示例如下:
import { redirect } from 'next/navigation'
export async function GET(request) {
redirect('https://nextjs.org/')
}
// 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 })
}
// 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',
},
})
}
你可以返回无 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.xml
、robots.txt
、app icons
和 open graph images
这些特殊的文件,Next.js 都已经提供了内置支持,这些内容我们会在《Metadata 篇 | 基于文件》详细讲到。
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)
}
恭喜你,完成了本节内容的学习!
这一节我们介绍了如何定义一个路由处理程序,那就是使用新的约定文件 route.js
,切记 route.js
不能跟同级的 page.js
一起使用。
同时我们介绍了写路由处理程序中可能会遇到的问题。在开发的时候,尽可能使用 NextRequest 和 NextResponse,它们是基于原生 Request 和 Response 的封装,提供了快捷处理 url 和 cookie 的方法。