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 跨请求保持状态。