Next.js App Router 中页面异常如何处理?
在服务端渲染中,页面预渲染需要的数据一般由服务器提供。在 Next.js 框架中,可分为两种。Next.js 作为全站框架,获取数据直接在 Next.js 服务中调用方法;或者借助外部 API 服务,通过 HTTP 或者其他方式获取数据。
在获取数据的过程中,可能会出现异常,例如网络请求超时、服务端异常等。这时候,我们需要对异常进行处理,以保证页面的正常渲染。
编写一个简单的数据接口和页面渲染
下面是一个简单的例子。这是一个简单的获取 posts
接口实现。
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export const GET = (
req: NextRequest,
{
params,
}: {
params: {
id: string
}
},
) => {
const { id } = params
switch (id) {
case '1': {
return NextResponse.json({
id: 1,
title: 'First Post',
content: 'This is the first post',
})
}
case '2': {
const res = new Response(
JSON.stringify({
message: 'You do not have permission to access this post',
}),
{ status: 403 },
)
res.headers.set('Content-Type', 'application/json')
return res
}
default: {
const res = new Response('', { status: 404 })
return res
}
}
}
上面的例子中我们模拟了几种情况:
- 当请求
posts/1
时,返回正常数据 - 当请求
posts/2
时,返回 403 错误 - 当请求其他路径时,返回 404 错误
然后我们来编写一个简单的数据渲染页面。
import { $fetch } from 'ofetch'
const endpoint = 'http://localhost:2323/api/posts'
export default async ({
params: { id },
}: {
params: {
id: string
}
}) => {
const res = await $fetch<{
title: string
content: string
}>(`${endpoint}/${id}`)
return (
<div className="m-auto mt-16 max-w-[60ch]">
<h1 className="mt-8 text-2xl font-bold">{res.title}</h1>
<article className="mt-4">{res.content}</article>
</div>
)
}
现在我们来访问 posts/1
,可以看到页面正常渲染。
页面错误兜底页
在 App Router 中,我们可以编写 error.jsx
和 global-error.jsx
去处理服务端渲染中的异常,当页面渲染异常,那么会回退到错误页面。
对于 error.jsx
和 global-error.jsx
的区别:
- 前者是对于每个 Page 或者 Layout 中发生的错误处理。这个错误是局部的,所以在错误组件的上层仍然存在其他组件。
- 后者是全局的错误处理,当渲染 Root Layout 中发生错误时,此时会回退到全局错误处理页面。
发生错误时,如果当前的 Route Segment 不存在 error.jsx
,那么会向上查找。
Noteglobal-error.jsx
无法处理所有的 Next.js 渲染页面中发生的异常。例如 Next.js 在 Middleware 中发生异常,此时我们会得到 Next.js 直接抛出的异常页,500 | Internal Server Error
。
编写一个简单的 error.jsx
页面。
'use client'
export default ({ error }: any) => {
return <div>Page Error</div>
}
Error Page 必须是一个 Client Component,并且他可以接受一个 error
props,但是这个 error
prop 的 message
是被处理过的,我们无法根据这个 error 去判断任何页面渲染逻辑,比如当 Error 为 RequestError 时根据 HTTP Code 去渲染不同的 UI。
error
prop 的 Error 对象,存在两个属性:
message
: 在生产环境中,error
的 message 一般都是Server Component error
,这个信息对于 UI 渲染来说是没有意义的。digest
: 可以方便开发者在生产环境中快速定位异常。
现在我们来访问 posts/2
,可以看到页面渲染了错误页面。
404 处理
404 处理是最常见的异常,例如当我们访问一篇不存在的文章时,我们需要渲染 404 页面,此时预渲染页面请求的数据接口是异常的。我们需要根据这个异常去让 Next.js 应用触发 NOT_FOUND
的逻辑。
再之前的例子中,我们直接访问 posts/3
,这个路径是不存在的,我们可以看到请求报错了,页面回退到了我们定义的 error.jsx
。
但是因为在 error.jsx
中,我们已经拿不到原始的 Error 对象,所以在 error.jsx
中无法判断是 404 错误还是其他错误。
在 App Router 架构中,我们可以使用 notFound()
方法,强制跳转到 404 页面,此时页面的 HTTP 状态为 404
。
import { notFound } from 'next/navigation'
import { $fetch } from 'ofetch'
const endpoint = 'http://localhost:2323/api/posts'
export default async ({
params: { id },
}: {
params: {
id: string
}
}) => {
const res = await $fetch<{
title: string
content: string
}>(`${endpoint}/${id}`).catch((error) => {
if (error.status === 404) {
notFound()
}
return error
})
return (
<div className="m-auto mt-16 max-w-[60ch]">
<h1 className="mt-8 text-2xl font-bold">{res.title}</h1>
<article className="mt-4">{res.content}</article>
</div>
)
}
此时再次访问 posts/3
,可以看到页面已经跳转到 404 页面。
这是 Next.js 默认的 404 页面,我们也可以编写一个自定义的 404 页面。
export default () => <div className="max-h-[60ch]">My Custom 404 Page</div>
not-found.jsx
和 error.jsx
一样,如果发生错误的 Route Segment 层级不存在定义时,会逐级向上查找。
其他异常处理
在请求中或许还会出现其他的异常,而最常见的还是请求异常。比如 403 异常,或者服务器异常导致 500 等等。
因为 Next.js
并没有提供这类异常的处理方法,所以根据这些情况我们需要手动判断去渲染不同 UI。
import { notFound } from 'next/navigation'
import { $fetch } from 'ofetch'
const endpoint = 'http://localhost:2323/api/posts'
class RequestError extends Error {
constructor(
public status: number,
public message: string,
public bizMessage: string,
) {
super(message)
}
}
export default async ({
params: { id },
}: {
params: {
id: string
}
}) => {
const res = await $fetch<{
title: string
content: string
}>(`${endpoint}/${id}`).catch((error) => {
if (error.status === 404) {
notFound()
}
return new RequestError(
error.status,
error.message,
error.response._data.message,
)
})
if (res instanceof RequestError) {
switch (res.status) {
case 403: {
return (
<div className="m-auto mt-16 max-w-[60ch]">
<pre>
<code>{res.message}</code>
</pre>
</div>
)
}
default:
return null
}
}
return (
<div className="m-auto mt-16 max-w-[60ch]">
<h1 className="mt-8 text-2xl font-bold">{res.title}</h1>
<article className="mt-4">{res.content}</article>
</div>
)
}
这样虽然达成了目的,但是这样的代码显得有些冗余,我们可以通过封装一个函数来简化这个逻辑。
// 可以定义一个默认的错误渲染
const defaultErrorRenderer = (error: any) => {
return createElement(
NormalContainer,
null,
createElement(
'p',
{
className: 'text-center text-red-500',
},
error.message,
),
)
}
export const definePrerenderPage =
<Params extends {}>() =>
<T = {}>(options: {
fetcher: (params: Params) => Promise<T>
errorRenderer?: (error: any, params: Params) => ReactNode | void
requestErrorRenderer?: (
error: RequestError,
parsed: {
status: number
bizMessage: string
},
params: Params,
) => ReactNode | void
Component: FC<NextPageParams<Params> & { data: T }>
handleNotFound?: boolean
}) => {
const {
errorRenderer = defaultErrorRenderer,
fetcher,
Component,
handleNotFound = true,
} = options
return async (props: any) => {
const { params, searchParams } = props as NextPageParams<Params, any>
try {
const data = await fetcher({
...params,
...searchParams,
})
return createElement(
Component,
{
data,
...props,
},
props.children,
)
} catch (error: any) {
// 如果在内部已经处理了 NEXT_NOT_FOUND,就不再处理
if (error?.message === 'NEXT_NOT_FOUND') {
notFound()
}
if (error instanceof RequestError) {
if (error.status === 404 && handleNotFound) {
notFound()
}
return (
options.requestErrorRenderer?.(
error,
{
bizMessage: getErrorMessageFromRequestError(error), // 一个自定义的从 RequestError 中获取业务错误信息的方法
status: error.status,
},
params,
) ??
createElement(BizErrorPage, {
status: error.status,
bizMessage: getErrorMessageFromRequestError(error),
})
)
}
console.error('error in fetcher: ', error)
return errorRenderer(error, params) ?? defaultErrorRenderer(error)
}
}
}
使用方法为:
import { $fetch } from 'ofetch'
import { definePrerenderPage, RequestError } from '~/app/lib/define-page'
const endpoint = 'http://localhost:2323/api/posts'
const myFetch = $fetch.create({
onRequestError: ({ response, error }) => {
if (response)
throw new RequestError(
response.status,
error.message,
response._data.message,
)
},
})
export default definePrerenderPage<{ id: string }>()({
fetcher({ id }) {
return myFetch<{
title: string
content: string
}>(`${endpoint}/${id}`)
},
Component: ({ data }) => {
return (
<div className="m-auto mt-16 max-w-[60ch]">
<h1 className="mt-8 text-2xl font-bold">{data.title}</h1>
<article className="mt-4">{data.content}</article>
</div>
)
},
})
因为在 definePrerenderPage
中,我们已经处理对 RequestError 的各种情况做了 UI 的处理,所以这里我们不需要在手写这些逻辑,而是更加关注业务本身。
需要注意的是,RequestError 这里需要借助请求库的 onRequestError 等钩子去抛出,这样我们才能在异常时判断出是请求的异常,然后再做相应的处理。