Remix错误处理最佳实践 #

一、错误分类 #

1.1 错误类型 #

类型 HTTP状态码 说明
客户端错误 400 请求参数错误
未授权 401 需要登录
禁止访问 403 无权限
未找到 404 资源不存在
验证错误 422 表单验证失败
服务器错误 500 服务器内部错误

1.2 错误基类 #

tsx
export class AppError extends Error {
  constructor(
    message: string,
    public status: number = 500,
    public code?: string
  ) {
    super(message);
    this.name = "AppError";
  }
}

export class NotFoundError extends AppError {
  constructor(message: string = "资源不存在") {
    super(message, 404, "NOT_FOUND");
    this.name = "NotFoundError";
  }
}

export class UnauthorizedError extends AppError {
  constructor(message: string = "请先登录") {
    super(message, 401, "UNAUTHORIZED");
    this.name = "UnauthorizedError";
  }
}

export class ForbiddenError extends AppError {
  constructor(message: string = "禁止访问") {
    super(message, 403, "FORBIDDEN");
    this.name = "ForbiddenError";
  }
}

export class ValidationError extends AppError {
  constructor(
    public errors: Record<string, string[]>,
    message: string = "验证失败"
  ) {
    super(message, 422, "VALIDATION_ERROR");
    this.name = "ValidationError";
  }
}

二、统一错误处理 #

2.1 错误处理工具 #

tsx
import { json } from "@remix-run/node";

export function handleError(error: unknown) {
  console.error(error);
  
  if (error instanceof AppError) {
    return json(
      { 
        error: error.message, 
        code: error.code,
        errors: error instanceof ValidationError ? error.errors : undefined
      },
      { status: error.status }
    );
  }
  
  if (error instanceof Response) {
    return error;
  }
  
  return json(
    { error: "服务器内部错误", code: "INTERNAL_ERROR" },
    { status: 500 }
  );
}

2.2 在loader中使用 #

tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";

export async function loader({ params }: LoaderFunctionArgs) {
  try {
    const post = await getPost(params.id);
    
    if (!post) {
      throw new NotFoundError("文章不存在");
    }
    
    return json({ post });
  } catch (error) {
    return handleError(error);
  }
}

三、错误日志 #

3.1 日志服务 #

tsx
interface ErrorLog {
  message: string;
  stack?: string;
  url: string;
  userId?: string;
  timestamp: Date;
  userAgent?: string;
}

export async function logError(error: unknown, request: Request) {
  const errorLog: ErrorLog = {
    message: error instanceof Error ? error.message : String(error),
    stack: error instanceof Error ? error.stack : undefined,
    url: request.url,
    timestamp: new Date(),
    userAgent: request.headers.get("User-Agent") || undefined,
  };
  
  // 发送到日志服务
  if (process.env.NODE_ENV === "production") {
    await sendToLogService(errorLog);
  } else {
    console.error(errorLog);
  }
}

3.2 在ErrorBoundary中记录 #

tsx
import { useEffect } from "react";
import { useRouteError } from "@remix-run/react";

export function ErrorBoundary() {
  const error = useRouteError();
  
  useEffect(() => {
    logError(error);
  }, [error]);
  
  return (
    <div>
      <h1>出错了</h1>
      <p>我们已记录此错误</p>
    </div>
  );
}

四、用户体验 #

4.1 友好的错误页面 #

tsx
import { Link, useRouteError, isRouteErrorResponse } from "@remix-run/react";

export function ErrorBoundary() {
  const error = useRouteError();
  
  let title = "出错了";
  let message = "发生未知错误";
  let action = <Link to="/">返回首页</Link>;
  
  if (isRouteErrorResponse(error)) {
    switch (error.status) {
      case 404:
        title = "页面不存在";
        message = "您访问的页面可能已被移除或暂时不可用";
        break;
      case 401:
        title = "请先登录";
        message = "您需要登录后才能访问此页面";
        action = <Link to="/login">前往登录</Link>;
        break;
      case 403:
        title = "禁止访问";
        message = "您没有权限访问此页面";
        break;
      case 500:
        title = "服务器错误";
        message = "服务器发生错误,请稍后重试";
        break;
    }
  }
  
  return (
    <div className="error-page">
      <div className="error-content">
        <h1>{title}</h1>
        <p>{message}</p>
        <div className="error-actions">
          {action}
          <button onClick={() => window.location.reload()}>
            刷新页面
          </button>
        </div>
      </div>
    </div>
  );
}

4.2 错误恢复策略 #

tsx
import { useRevalidator, useNavigate, Link } from "@remix-run/react";

export function ErrorBoundary() {
  const error = useRouteError();
  const revalidator = useRevalidator();
  const navigate = useNavigate();
  
  const handleRetry = () => {
    revalidator.revalidate();
  };
  
  const handleGoBack = () => {
    navigate(-1);
  };
  
  return (
    <div className="error-page">
      <h1>出错了</h1>
      <p>{error.message}</p>
      <div className="actions">
        <button onClick={handleRetry}>重试</button>
        <button onClick={handleGoBack}>返回上一页</button>
        <Link to="/">返回首页</Link>
      </div>
    </div>
  );
}

五、表单错误处理 #

5.1 统一验证错误 #

tsx
import { z } from "zod";
import { json } from "@remix-run/node";

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const result = schema.safeParse(Object.fromEntries(formData));
  
  if (!result.success) {
    throw new ValidationError(result.error.flatten().fieldErrors);
  }
  
  try {
    const item = await createItem(result.data);
    return redirect(`/items/${item.id}`);
  } catch (error) {
    return handleError(error);
  }
}

5.2 显示表单错误 #

tsx
import { useActionData, Form } from "@remix-run/react";

export default function NewItem() {
  const actionData = useActionData<typeof action>();
  
  return (
    <Form method="post">
      {actionData?.code === "VALIDATION_ERROR" && (
        <div className="alert alert-error">
          请修正以下错误后重试
        </div>
      )}
      
      <div>
        <label>标题</label>
        <input name="title" />
        {actionData?.errors?.title && (
          <p className="error">{actionData.errors.title.join(", ")}</p>
        )}
      </div>
      
      <button type="submit">提交</button>
    </Form>
  );
}

六、API错误处理 #

6.1 API错误响应 #

tsx
export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  
  if (url.searchParams.get("format") === "json") {
    try {
      const data = await fetchData();
      return json({ success: true, data });
    } catch (error) {
      return json(
        { success: false, error: "获取数据失败" },
        { status: 500 }
      );
    }
  }
  
  // HTML响应
  // ...
}

6.2 客户端错误处理 #

tsx
import { useFetcher } from "@remix-run/react";

export function useApiAction(action: string) {
  const fetcher = useFetcher();
  
  const submit = async (data: Record<string, any>) => {
    fetcher.submit(data, {
      method: "post",
      action,
      encType: "application/json",
    });
  };
  
  return {
    submit,
    data: fetcher.data,
    error: fetcher.data?.success === false ? fetcher.data.error : null,
    isLoading: fetcher.state !== "idle",
  };
}

七、最佳实践总结 #

7.1 错误处理清单 #

  • [ ] 定义统一的错误类型
  • [ ] 实现错误日志记录
  • [ ] 提供友好的错误信息
  • [ ] 支持错误恢复操作
  • [ ] 区分开发和生产环境

7.2 代码组织 #

text
app/
├── errors/
│   ├── classes.ts       # 错误类定义
│   ├── handler.ts       # 错误处理函数
│   └── logger.ts        # 错误日志
├── components/
│   └── ErrorBoundary.tsx
└── routes/
    └── *.tsx            # 各路由的ErrorBoundary

八、总结 #

本章我们学习了:

  1. 错误分类:定义统一的错误类型
  2. 统一处理:集中处理错误
  3. 错误日志:记录错误信息
  4. 用户体验:友好的错误页面
  5. 恢复策略:提供恢复选项

核心要点:

  • 使用统一的错误类型
  • 记录所有错误
  • 提供友好的错误信息
  • 支持错误恢复
最后更新:2026-03-28