本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
前言
Server Actions 算是 Next.js 项目高频使用的功能了,其中很麻烦但又很重要的一项内容就是错误处理。本篇我们来聊聊 Next.js Server Actions 中如何进行一个完备的错误处理!
本篇已收录到掘金专栏《Next.js 开发指北》
系统学习 Next.js,欢迎入手小册《Next.js 开发指南》。基础篇、实战篇、源码篇、面试篇四大篇章带你系统掌握 Next.js!
欢迎围观我的“朋友圈”、加入“低调务实优秀中国好青年”前端社群,分享技术,带你成长。
项目准备
为了方便演示,创建一个空的 Next.js 项目:
npx create-next-app@latest
注意勾选使用 TypeScript、Tailwind CSS、src 目录、App Router:
新建 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;
}
浏览器效果如下:
可是如果 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;
}
如果是在开发环境触发,还可以看到具体的报错:
如果是在生产环境,则表现为应用直接崩溃:
出现错误就会导致整个应用崩溃,所以错误处理非常重要!
然而在 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>
);
}
当出现错误的时候,浏览器效果如下:
效果描述:出现错误的时候,页面会渲染错误界面,然后点击 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>
);
}
此时浏览器效果如下:
效果描述:只有使用 <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>
);
}
此时浏览器效果不变:
改进 2:使用平行路由
如果真的要实现页面的某个模块在出现错误时渲染错误界面,Next.js 其实提供了平行路由功能。
使用平行路由有很多好处:
-
使用平行路由可以将单个布局拆分为多个插槽,使代码更易于管理,尤其适用于团队协作的时候
-
每个插槽都可以定义自己的加载界面和错误状态,比如某个插槽加载速度比较慢,那就可以加一个加载效果,加载期间,也不会影响其他插槽的渲染和交互。当出现错误的时候,也只会在具体的插槽上出现错误提示,而不会影响页面其他部分,有效改善用户体验
-
每个插槽都可以有自己独立的导航和状态管理,这使得插槽的功能更加丰富,这可以让同一个插槽区域根据路由显示不同的内容
那我们就用平行路由来实现一版吧。文件目录结构如下:
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>
);
}
浏览器效果如下:
其实跟上节效果一致,但实现方式截然不同。我们将布局拆分为两个插槽,一个插槽渲染 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: "抱歉,出错了",
};
}
}
浏览器效果如下:
在当前的实现中,我们在 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>
);
}
浏览器效果如下:
改进 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: "抱歉,出错了",
};
}
}
浏览器效果如下:
使用 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…
通过文字建立交流本身就是一种缘分,欢迎围观我的“朋友圈”、加入“低调务实优秀中国好青年”前端社群,分享技术,带你成长。