IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    Next.js App Router 中页面异常如何处理?

    Innei发表于 2024-04-11 11:31:00
    love 0
    该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/how-to-handle-page-exceptions-in-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,那么会向上查找。

    这个内容只能在原文中查看哦

    编写一个简单的 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 等钩子去抛出,这样我们才能在异常时判断出是请求的异常,然后再做相应的处理。

    看完了?说点什么呢



沪ICP备19023445号-2号
友情链接