页面的异常处理

在服务端渲染中,页面预渲染需要的数据一般由服务器提供。在 Next.js 框架中,可分为两种。Next.js 作为全站框架,获取数据直接在 Next.js 服务中调用方法;或者借助外部 API 服务,通过 HTTP 或者其他方式获取数据。

在获取数据的过程中,可能会出现异常,例如网络请求超时、服务端异常等。这时候,我们需要对异常进行处理,以保证页面的正常渲染。

编写一个简单的数据接口和页面渲染

下面是一个简单的例子。这是一个简单的获取 posts 接口实现。

api/posts/[id]/route.ts
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 错误

然后我们来编写一个简单的数据渲染页面。

app/posts/[id]/page.tsx
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.jsxglobal-error.jsx 去处理服务端渲染中的异常,当页面渲染异常,那么会回退到错误页面。

对于 error.jsxglobal-error.jsx 的区别:

  • 前者是对于每个 Page 或者 Layout 中发生的错误处理。这个错误是局部的,所以在错误组件的上层仍然存在其他组件。
  • 后者是全局的错误处理,当渲染 Root Layout 中发生错误时,此时会回退到全局错误处理页面。

发生错误时,如果当前的 Route Segment 不存在 error.jsx,那么会向上查找。

Note

global-error.jsx 无法处理所有的 Next.js 渲染页面中发生的异常。例如 Next.js 在 Middleware 中发生异常,此时我们会得到 Next.js 直接抛出的异常页,500 | Internal Server Error

编写一个简单的 error.jsx 页面。

app/posts/[id]/error.tsx
'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

app/posts/[id]/page.tsx
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 页面。

app/posts/[id]/not-found.tsx
export default () => <div className="max-h-[60ch]">My Custom 404 Page</div>

not-found.jsxerror.jsx 一样,如果发生错误的 Route Segment 层级不存在定义时,会逐级向上查找。

其他异常处理

在请求中或许还会出现其他的异常,而最常见的还是请求异常。比如 403 异常,或者服务器异常导致 500 等等。

因为 Next.js 并没有提供这类异常的处理方法,所以根据这些情况我们需要手动判断去渲染不同 UI。

app/posts/[id]/page.tsx
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)
      }
    }
  }

使用方法为:

app/posts/[id]/page.tsx
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 等钩子去抛出,这样我们才能在异常时判断出是请求的异常,然后再做相应的处理。

上面的 Demo 位于:nextjs-book/tree/main/demo/page-error-handle


最后更新于 2024/4/27 20:14:01

更新历史

本书还在编写中..

前往 https://innei.in/posts/tech/my-first-nextjs-book-here#comment 发表你的观点吧。