Next.js Server Actions 如何进行错误处理?

1,524 阅读12分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

前言

Server Actions 算是 Next.js 项目高频使用的功能了,其中很麻烦但又很重要的一项内容就是错误处理。本篇我们来聊聊 Next.js Server Actions 中如何进行一个完备的错误处理

  1. 本篇已收录到掘金专栏《Next.js 开发指北》

  2. 系统学习 Next.js,欢迎入手小册《Next.js 开发指南》。基础篇、实战篇、源码篇、面试篇四大篇章带你系统掌握 Next.js!

欢迎围观我的“朋友圈”、加入“低调务实优秀中国好青年”前端社群,分享技术,带你成长。

项目准备

为了方便演示,创建一个空的 Next.js 项目:

npx create-next-app@latest

注意勾选使用 TypeScript、Tailwind CSS、src 目录、App Router:

image.png

新建 src/app/todo/page.tsx,代码如下:

import { findTodos, createTodo } from "./actions";

export default async function Page() {
  const todos = await findTodos();
  return (
    <div className="p-10">
      <form className="space-y-6" action={createTodo}>
        <input
          type="text"
          name="todo"
          className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
        />
        <button
          type="submit"
          className="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
        >
          提交
        </button>
      </form>
      <ul role="list" className="list-decimal mt-4 list-inside">
        {todos.map((todo, i) => (
          <li key={i} className="py-2">
            {todo}
          </li>
        ))}
      </ul>
    </div>
  );
}

新建 src/app/todo/actions.ts,代码如下:

"use server";

import { revalidatePath } from "next/cache";

const data: string[] = ["阅读", "写作", "冥想"];

export async function findTodos() {
  return data;
}

export async function createTodo(formData: FormData) {
  const todo = formData.get("todo") as string;
  data.push(todo);
  revalidatePath("/todo");
  return data;
}

浏览器效果如下:

next-error-1.gif

可是如果 Server Actions 出现错误呢?比如你加一句:

export async function createTodo(formData: FormData) {
  const todo = formData.get("todo") as string;
  data.push(todo);
+ throw new Error("这是一个错误");
  revalidatePath("/form2");
  return data;
}

如果是在开发环境触发,还可以看到具体的报错:

image.png

如果是在生产环境,则表现为应用直接崩溃:

next-error-2.gif

出现错误就会导致整个应用崩溃,所以错误处理非常重要!

然而在 Next.js 中,如何做错误处理呢?

方法 1:添加页面 error.tsx

第一种方式是添加 error.tsx,因为当错误被抛出的时候,它会被最近的 error.js 捕获。

修改 src/app/todo/actions.ts ,代码如下:

export async function createTodo(formData: FormData) {
  try {
    const todo = formData.get("todo") as string;
    data.push(todo);
    revalidatePath("/todo");
    // 模拟出现错误
    throw new Error();
    return data;
  } catch (e) {
    throw new Error("创建任务失败");
  }
}

新建 src/app/todo/error.tsx,代码如下:

"use client"; // Error components must be Client Components

import { useEffect } from "react";

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // Log the error to an error reporting service
    console.error(error);
  }, [error]);

  return (
    <div>
      <h2>Something went wrong!</h2>
      <button
        onClick={
          // Attempt to recover by trying to re-render the segment
          () => reset()
        }
      >
        Try again
      </button>
    </div>
  );
}

当出现错误的时候,浏览器效果如下:

next-error-3.gif

效果描述:出现错误的时候,页面会渲染错误界面,然后点击 Try again 按钮,页面正常渲染。

这是因为 error.tsx的本质是一个自动创建的 React Error Boundary,它会包裹 page.tsx,并使用 error.tsx 的导出作为 fallback 组件。如果在 Error Boundary 内抛出错误,则该错误将被捕获,并渲染 fallback 组件:

方法 2:添加 Error Boundary

使用 error.tsx 的问题在于它是页面级别的处理,会让整个页面都渲染错误内容,如果我只想让页面的部分内容渲染错误内容呢?

那你可以手动创建一个错误边界组件。新建 src/app/todo/ErrorBoundary.tsx,代码如下:

"use client";

import React, { Component, ErrorInfo } from "react";

interface Props {
  children: React.ReactNode;
}

interface State {
  hasError: boolean;
}

class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error(error, errorInfo.componentStack);
  }

  render() {
    if (this.state.hasError) {
      return <h1>很抱歉,发生了错误</h1>;
    }

    return this.props.children;
  }
}

export default ErrorBoundary;

修改 src/app/todo/page.tsx,完整代码如下:

import { findTodos, createTodo } from "./actions";
import ErrorBoundary from "./ErrorBoundary";

export default async function Page() {
  const todos = await findTodos();
  return (
    <div className="p-4">
      <ErrorBoundary>
        <form className="space-y-6" action={createTodo}>
          <input
            type="text"
            name="todo"
            className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
          />
          <button
            type="submit"
            className="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
          >
            提交
          </button>
        </form>
      </ErrorBoundary>
      <ul role="list" className="list-decimal mt-4 list-inside">
        {todos.map((todo, i) => (
          <li key={i} className="py-2">
            {todo}
          </li>
        ))}
      </ul>
    </div>
  );
}

此时浏览器效果如下:

next-error-4.gif

效果描述:只有使用 <ErrorBoundary> 组件包裹的表单提交部分渲染了错误内容,原本的列表展示界面不受影响。在开发模式下,有错误的 toast 提示,但在生产模式不会有此提示。

改进 1:使用 react-error-boundary

其实我们并不每次都手动创建一个 ErrorBoundary 组件来处理错误,我们也可以直接使用 react-error-boundary

# npm
npm install react-error-boundary

# pnpm
pnpm add react-error-boundary

# yarn
yarn add react-error-boundary

修改 src/app/todo/page.tsx,完整代码如下:

import { findTodos, createTodo } from "./actions";
import { ErrorBoundary } from "react-error-boundary";

export default async function Page() {
  const todos = await findTodos();
  return (
    <div className="p-4">
      <ErrorBoundary fallback={<h1>很抱歉,发生了错误</h1>}>
        <form className="space-y-6" action={createTodo}>
          <input
            type="text"
            name="todo"
            className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
          />
          <button
            type="submit"
            className="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
          >
            提交
          </button>
        </form>
      </ErrorBoundary>
      <ul role="list" className="list-decimal mt-4 list-inside">
        {todos.map((todo, i) => (
          <li key={i} className="py-2">
            {todo}
          </li>
        ))}
      </ul>
    </div>
  );
}

此时浏览器效果不变:

next-error-5.gif

改进 2:使用平行路由

如果真的要实现页面的某个模块在出现错误时渲染错误界面,Next.js 其实提供了平行路由功能。

使用平行路由有很多好处:

  1. 使用平行路由可以将单个布局拆分为多个插槽,使代码更易于管理,尤其适用于团队协作的时候

  2. 每个插槽都可以定义自己的加载界面和错误状态,比如某个插槽加载速度比较慢,那就可以加一个加载效果,加载期间,也不会影响其他插槽的渲染和交互。当出现错误的时候,也只会在具体的插槽上出现错误提示,而不会影响页面其他部分,有效改善用户体验

  3. 每个插槽都可以有自己独立的导航和状态管理,这使得插槽的功能更加丰富,这可以让同一个插槽区域根据路由显示不同的内容

那我们就用平行路由来实现一版吧。文件目录结构如下:

app                           
├─ todo2                 
│  ├─ @form              
│  │  ├─ error.tsx       
│  │  └─ page.tsx        
│  ├─ actions.ts         
│  ├─ layout.tsx         
│  └─ page.tsx                     

新建 src/app/todo2/page.tsx,代码如下:

import { findTodos } from "./actions";

export default async function Page() {
  const todos = await findTodos();
  return (
    <ul role="list" className="list-decimal mt-4 list-inside">
      {todos.map((todo, i) => (
        <li key={i} className="py-2">
          {todo}
        </li>
      ))}
    </ul>
  );
}

新建 src/app/todo2/layout.tsx,代码如下:

export default function Layout({
  children,
  form,
}: {
  children: React.ReactNode;
  form: React.ReactNode;
}) {
  return (
    <div className="p-4">
      {form}
      {children}
    </div>
  );
}

新建 src/app/todo2/actions.ts,代码如下:

"use server";

import { revalidatePath } from "next/cache";

export const sleep = (time: number) =>
  new Promise((res) => setTimeout(res, time));

const data: string[] = ["阅读", "写作", "冥想"];

export async function findTodos() {
  await sleep(1000);
  return data;
}

export async function createTodo(formData: FormData) {
  try {
    const todo = formData.get("todo") as string;
    data.push(todo);
    revalidatePath("/todo2");
    throw new Error();
    return data;
  } catch (e) {
    throw new Error("创建任务失败");
  }
}

新建 src/app/todo2/@form/page.tsx,代码如下:

import { createTodo } from "../actions";

export default async function Page() {
  return (
    <form className="space-y-6" action={createTodo}>
      <input
        type="text"
        name="todo"
        className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
      />
      <button
        type="submit"
        className="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
      >
        提交
      </button>
    </form>
  );
}

新建 src/app/todo2/@form/error.tsx,代码如下:

"use client"; // Error components must be Client Components

import { useEffect } from "react";

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // Log the error to an error reporting service
    console.error(error);
  }, [error]);

  return (
    <div>
      <h2>很抱歉,发生了错误!</h2>
      <button
        className="border-2 border-blue-500 rounded-lg px-4 py-1"
        onClick={
          // Attempt to recover by trying to re-render the segment
          () => reset()
        }
      >
        点击恢复
      </button>
    </div>
  );
}

浏览器效果如下:

next-error-6.gif

其实跟上节效果一致,但实现方式截然不同。我们将布局拆分为两个插槽,一个插槽渲染 page.tsx,一个插槽渲染 @form/page.tsx,每个插槽都可以渲染自己独立的错误和加载状态。使用这种方式,专注点分离,整体代码更加简约易懂。

方法 3:返回错误信息

现在我们的方法都是围绕 ErrorBoundary 包裹可能抛出错误的内容,然后渲染错误界面。这顶多算一个兜底方案。实际项目开发中,当出现错误的时候,应该优先给与错误提示,遇到一些不可预料的错误时再渲染错误界面,从而带来更好的用户体验。

所以我们可以用 try/catch 包裹住 Server Actions 中的错误,然后前端根据错误信息提示用户。文件目录结构如下:

src                         
└─ app                              
   ├─ todo3                 
   │  ├─ actions.ts         
   │  ├─ form.tsx           
   │  └─ page.tsx                   

新建 src/app/todo3/page.tsx,完整代码如下:

import { findTodos } from "./actions";
import Form from "./form";

export default async function Page() {
  const todos = await findTodos();
  return (
    <div className="p-4">
      <Form />
      <ul role="list" className="list-decimal mt-4 list-inside">
        {todos.map((todo, i) => (
          <li key={i} className="py-2">
            {todo}
          </li>
        ))}
      </ul>
    </div>
  );
}

新建 src/app/todo3/form.tsx,完整代码如下:

"use client";

import { createTodo } from "./actions";

export default function Form() {
  async function createTodoAction(formData: FormData) {
    const result = await createTodo(formData);
    if (result?.error) {
      alert(result.error);
    }
  }

  return (
    <form className="space-y-6" action={createTodoAction}>
      <input
        type="text"
        name="todo"
        className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
      />
      <button
        type="submit"
        className="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
      >
        提交
      </button>
    </form>
  );
}

新建 src/app/todo3/actions.ts ,代码如下:

"use server";

import { revalidatePath } from "next/cache";

export const sleep = (time: number) =>
  new Promise((res) => setTimeout(res, time));

const data: string[] = ["阅读", "写作", "冥想"];

export async function findTodos() {
  await sleep(1000);
  return data;
}

export async function createTodo(formData: FormData) {
  try {
    const todo = formData.get("todo") as string;
    data.push(todo);
    revalidatePath("/todo3");
    throw new Error();
  } catch (e) {
    return {
      error: "抱歉,出错了",
    };
  }
}

浏览器效果如下:

next-error-7.gif

在当前的实现中,我们在 Server Actions 出现错误的时候,返回 error 对象,然后前端获取返回的 error 对象给与错误提醒。这是一个大致的实现逻辑。

改进 1:使用 react-hot-toast

如果你需要 toast 提示错误信息,如果你已经用了组件库比如 Shadcn UI,那就使用自带的 Toaster 即可。如果没有,react-hot-toast 是一个不错且使用非常多的 toast 库。

npm install react-hot-toast

使用之前,你需要先将 Toaster 组件添加到布局中,修改 app/layout.tsx

// ...
import { Toaster } from "react-hot-toast";
// ...

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        {children}
        <Toaster />
      </body>
    </html>
  );
}

修改 src/app/todo3/form.tsx,完整代码如下:

"use client";

import { createTodo } from "./actions";
import toast from "react-hot-toast";

export default function Form() {
  async function createTodoAction(formData: FormData) {
    const result = await createTodo(formData);
    if (result?.error) {
      toast.error(result.error);
    }
  }

  return (
    <form className="space-y-6" action={createTodoAction}>
      <input
        type="text"
        name="todo"
        className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
      />
      <button
        type="submit"
        className="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
      >
        提交
      </button>
    </form>
  );
}

浏览器效果如下:

next-error-8.gif

改进 2:使用 useActionState

因为项目用的还是 React 18,所以我们使用 useFormState 作为替代。React 19 RC 已将 useFormState 重命名为 useActionState。

修改 src/app/todo3/form.tsx,完整代码如下:

"use client";

import { createTodo } from "./actions";
import { useFormState } from "react-dom";

export default function Form() {
  const [state, createTodoAction] = useFormState(createTodo, { message: "" });
  return (
    <form className="space-y-6" action={createTodoAction}>
      <input
        type="text"
        name="todo"
        className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
      />
      {state && <p>{state.message}</p>}
      <button
        type="submit"
        className="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
      >
        提交
      </button>
    </form>
  );
}

修改 src/app/todo3/actions.ts,完整代码如下:

"use server";

import { revalidatePath } from "next/cache";

export const sleep = (time: number) =>
  new Promise((res) => setTimeout(res, time));

const data: string[] = ["阅读", "写作", "冥想"];

export async function findTodos() {
  await sleep(1000);
  return data;
}

export async function createTodo(prev: any, formData: FormData) {
  try {
    const todo = formData.get("todo") as string;
    data.push(todo);
    revalidatePath("/todo3");
    throw new Error();
    return {
      message: "创建成功!",
    };
  } catch (e) {
    return {
      message: "抱歉,出错了",
    };
  }
}

浏览器效果如下:

next-error-9.gif

使用 useFormState,Server Actions 返回的数据将通过 state 变量进行渲染。

注意使用 useFormState 的时候,你不能这样写:

  <form className="space-y-6" action={async (formData) => {
    const result = await createTodoAction(formData)
  }}>

这样是获取不了 Server Actions 返回的信息的,只能通过 state 来获取,你可以监听 state 的改变来进行消息提示。

总结

实际这些方法并不冲突,你甚至可以一起使用。最基本要做到 Server Actions 的所有操作都放到 try/catch 中,至于是抛出错误由 error.tsx 处理还是返回错误信息由前端处理,取决于开发者想要实现的效果。

此外,页面的 error.tsx 总是必要的,用于处理各种预期之外的错误。如果要实现页面部分模块展示错误,可以借助 Next.js 的平行路由或者手动添加错误边界。

Next.js 系列

本篇已收录在掘金专栏 《Next.js 开发指北》,该系列一共 24 篇。

系统学习 Next.js,欢迎入手小册《Next.js 开发指南》。基础篇、实战篇、源码篇、面试篇四大篇章带你系统掌握 Next.js!

此外我还写过 JavaScript 系列TypeScript 系列React 系列Next.js 系列冴羽答读者问等 14 个系列文章, 全系列文章目录:github.com/mqyqingfeng…

通过文字建立交流本身就是一种缘分,欢迎围观我的“朋友圈”、加入“低调务实优秀中国好青年”前端社群,分享技术,带你成长。