Chapter 08

文件上传与高级表单

掌握 Remix 的文件上传处理、zod+conform 表单验证与 useFetcher 局部提交

8.1 multipart 文件上传

Remix 通过 unstable_parseMultipartFormData 处理 multipart/form-data 文件上传,提供内存上传和磁盘上传两种处理器。

// app/routes/upload.tsx
import {
  unstable_parseMultipartFormData,
  unstable_createMemoryUploadHandler,
  unstable_createFileUploadHandler,
  type ActionFunctionArgs,
} from "@remix-run/node";

export async function action({ request }: ActionFunctionArgs) {
  // 磁盘上传处理器(存到 /tmp)
  const uploadHandler = unstable_createFileUploadHandler({
    directory: "/tmp/uploads",
    maxPartSize: 5 * 1024 * 1024, // 5MB 限制
    file: ({ filename }) => filename, // 保留原文件名
  });

  const formData = await unstable_parseMultipartFormData(request, uploadHandler);
  const file = formData.get("avatar"); // NodeOnDiskFile 对象

  if (!file || !(file instanceof File)) {
    return json({ error: "请选择文件" }, { status: 400 });
  }

  // 获取文件信息
  console.log(file.name, file.size, file.type);

  // 此处可将文件上传到 S3 / 云存储
  const url = await uploadToStorage(file);
  return json({ url });
}

export default function UploadPage() {
  return (
    <form method="post" encType="multipart/form-data">
      <input type="file" name="avatar" accept="image/*" />
      <button type="submit">上传头像</button>
    </form>
  );
}
TSX

8.2 上传到 S3(Presigned URL 方案)

直接在服务端中转文件到 S3 会消耗服务器带宽。更好的方案是服务端生成 Presigned URL,客户端直接上传到 S3,绕过服务器。

// app/routes/upload.presign.tsx — 资源路由(API 端点)
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { json, type LoaderFunctionArgs } from "@remix-run/node";

const s3 = new S3Client({ region: "ap-northeast-1" });

export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const filename = url.searchParams.get("filename")!;
  const contentType = url.searchParams.get("contentType")!;
  const key = `uploads/${Date.now()}-${filename}`;

  const presignedUrl = await getSignedUrl(
    s3,
    new PutObjectCommand({ Bucket: "my-bucket", Key: key, ContentType: contentType }),
    { expiresIn: 60 } // 60 秒有效
  );

  return json({ presignedUrl, key });
}
TS

8.3 useFetcher — 局部提交(不导航)

标准 <Form> 提交后会刷新整个页面数据(触发全路由 loader 重验证)。useFetcher 允许在不导航的情况下调用 Action 或 Loader,实现"点赞"、"收藏"、"自动保存"等交互。

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

function LikeButton({ postId, liked }: { postId: string; liked: boolean }) {
  const fetcher = useFetcher();

  // 乐观 UI:立即反映预期状态
  const isLiking = fetcher.state !== "idle";
  const optimisticLiked = isLiking
    ? fetcher.formData?.get("intent") === "like"
    : liked;

  return (
    <fetcher.Form method="post" action=`/posts/${postId}/like`>
      <button
        name="intent"
        value={optimisticLiked ? "unlike" : "like"}
        type="submit"
      >
        {optimisticLiked ? "❤️ 已点赞" : "🤍 点赞"}
      </button>
    </fetcher.Form>
  );
}
TSX

8.4 zod + conform 表单验证

zod 是流行的 TypeScript 数据校验库,@conform-to/zod 将 Zod schema 桥接到 Remix 的 Action,实现服务端验证与客户端反馈的统一。

npm install zod @conform-to/react @conform-to/zod
SHELL
// app/routes/register.tsx
import { parseWithZod } from "@conform-to/zod";
import { useForm, getInputProps } from "@conform-to/react";
import { z } from "zod";

// 1. 定义 Zod schema
const RegisterSchema = z.object({
  email: z.string().email("请输入有效邮箱"),
  password: z.string().min(8, "密码至少8位"),
  name: z.string().min(2, "昵称至少2个字符"),
});

// 2. 在 Action 中使用 parseWithZod
export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const submission = parseWithZod(formData, { schema: RegisterSchema });

  // 验证失败:返回错误信息(conform 格式)
  if (submission.status !== "success") {
    return json(submission.reply());
  }

  // 验证通过:submission.value 已完整类型化
  const { email, password, name } = submission.value;
  await createUser({ email, password, name });
  return redirect("/dashboard");
}

// 3. 在组件中使用 useForm
export default function RegisterPage() {
  const lastResult = useActionData<typeof action>();
  const [form, fields] = useForm({
    lastResult,
    onValidate({ formData }) {
      return parseWithZod(formData, { schema: RegisterSchema });
    },
    shouldValidate: "onBlur", // 失焦时验证
    shouldRevalidate: "onInput",
  });

  return (
    <form id={form.id} onSubmit={form.onSubmit} method="post">
      <div>
        <input {...getInputProps(fields.email, { type: "email" })} />
        <p>{fields.email.errors}</p>
      </div>
      <div>
        <input {...getInputProps(fields.password, { type: "password" })} />
        <p>{fields.password.errors}</p>
      </div>
      <div>
        <input {...getInputProps(fields.name, { type: "text" })} />
        <p>{fields.name.errors}</p>
      </div>
      <button type="submit">注册</button>
    </form>
  );
}
TSX
💡

conform 的优势conform 自动处理了错误状态回填(用户提交失败后表单值不丢失)、无障碍属性(aria-invalid、aria-describedby)、客户端实时验证与服务端验证共用同一 Zod schema,极大减少了表单的样板代码。

8.5 多步骤表单(Multi-Step Form)

利用 Session 在多步骤表单之间传递数据,每步提交后验证并保存到 Session,最后一步汇总提交。

// 利用 Session 保存多步表单状态
export async function action({ request }: ActionFunctionArgs) {
  const session = await getSession(request.headers.get("Cookie"));
  const form = await request.formData();
  const step = form.get("step");

  if (step === "1") {
    // 保存第一步数据到 Session
    session.set("step1", { name: form.get("name"), email: form.get("email") });
    return redirect("/register/step2", {
      headers: { "Set-Cookie": await commitSession(session) },
    });
  }

  if (step === "2") {
    const step1 = session.get("step1");
    // 汇总所有步骤数据
    const userData = { ...step1, company: form.get("company"), role: form.get("role") };
    await createUser(userData);
    session.unset("step1");
    return redirect("/welcome", {
      headers: { "Set-Cookie": await commitSession(session) },
    });
  }
}
TSX
ℹ️

本章小结Remix 的高级表单能力:unstable_parseMultipartFormData 处理文件上传;S3 Presigned URL 方案让客户端直传存储;useFetcher 实现不导航的局部 Action 调用(点赞、自动保存);zod + conform 组合提供服务端/客户端统一验证;多步骤表单借助 Session 跨请求保持状态。