Form 组件
Next.js 15 引入了全新的 <Form> 组件,它扩展了 HTML 原生的 <form> 元素,提供了自动预取、客户端导航和渐进增强等特性。使用 Form 组件可以大幅减少处理表单和搜索参数的样板代码。
为什么需要 Form 组件?
在传统的 Next.js 开发中,处理搜索表单需要:
- 监听表单提交事件
- 阻止默认行为
- 手动构建 URL 参数
- 使用
router.push进行导航
<Form> 组件将这些操作封装起来,提供开箱即用的体验:
- 自动预取:表单进入视口时预取目标页面的共享 UI(布局、加载状态)
- 客户端导航:提交时保持共享布局和客户端状态,避免完整页面刷新
- 渐进增强:即使 JavaScript 未加载,表单仍可正常工作
基本用法
搜索表单
最常见的用例是搜索表单,提交后导航到搜索结果页面:
import Form from "next/form";
export default function SearchForm() {
return (
<Form action="/search">
<input
name="query"
placeholder="搜索..."
className="px-4 py-2 border rounded"
/>
<button
type="submit"
className="ml-2 px-4 py-2 bg-blue-500 text-white rounded"
>
搜索
</button>
</Form>
);
}
当用户输入搜索词并提交时,表单数据会自动编码为 URL 参数,例如 /search?query=react。
处理搜索结果
在目标页面中,通过 searchParams 获取查询参数:
// app/search/page.tsx
import Form from "next/form";
import { searchPosts } from "@/lib/search";
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ query?: string }>;
}) {
const { query } = await searchParams;
const results = query ? await searchPosts(query) : [];
return (
<div className="container mx-auto p-4">
{/* 搜索表单 */}
<Form action="/search" className="mb-8">
<input
name="query"
defaultValue={query}
placeholder="搜索文章..."
className="px-4 py-2 border rounded w-64"
/>
<button
type="submit"
className="ml-2 px-4 py-2 bg-blue-500 text-white rounded"
>
搜索
</button>
</Form>
{/* 搜索结果 */}
{query && (
<div>
<h2 className="text-xl font-bold mb-4">
搜索 "{query}" 的结果 ({results.length} 条)
</h2>
<ul className="space-y-2">
{results.map((post) => (
<li key={post.id} className="p-4 bg-white rounded shadow">
<h3 className="font-semibold">{post.title}</h3>
<p className="text-gray-600 mt-1">{post.excerpt}</p>
</li>
))}
</ul>
</div>
)}
</div>
);
}
加载状态
配合 loading.tsx 提供加载中的 UI:
// app/search/loading.tsx
export default function SearchLoading() {
return (
<div className="container mx-auto p-4">
<div className="animate-pulse">
<div className="h-10 bg-gray-200 rounded w-80 mb-8"></div>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="p-4 bg-gray-100 rounded">
<div className="h-5 bg-gray-200 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-full"></div>
</div>
))}
</div>
</div>
</div>
);
}
当表单提交后,用户会立即看到加载状态,同时后台开始获取数据。
action 属性的两种模式
<Form> 组件的行为取决于 action 属性的类型:
action 为字符串(GET 请求)
当 action 是 URL 路径时,表单执行传统的 GET 导航:
<Form action="/products">
<select name="category">
<option value="electronics">电子产品</option>
<option value="clothing">服装</option>
</select>
<button type="submit">筛选</button>
</Form>
行为特点:
- 表单数据编码为 URL 参数:
/products?category=electronics - 进入视口时预取目标页面的共享 UI
- 提交时执行客户端导航,保持布局和状态
- 支持浏览器前进/后退
action 为函数(Server Action)
当 action 是 Server Action 时,表单执行数据变更操作:
// app/posts/new/page.tsx
import Form from "next/form";
import { createPost } from "../actions";
export default function NewPostPage() {
return (
<Form action={createPost} className="space-y-4">
<div>
<label className="block mb-1">标题</label>
<input
name="title"
required
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label className="block mb-1">内容</label>
<textarea
name="content"
rows={5}
required
className="w-full px-3 py-2 border rounded"
/>
</div>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded"
>
发布
</button>
</Form>
);
}
// app/posts/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
const post = await db.post.create({
data: { title, content },
});
revalidatePath("/posts");
redirect(`/posts/${post.id}`);
}
行为特点:
- 在服务端执行 action 函数
- 不支持预取(目标 URL 在 action 执行后才确定)
- 可使用
redirect导航到新页面
属性详解
action 为字符串时的属性
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
action | string | 必填 | 目标 URL 或相对路径 |
replace | boolean | false | 使用 replace 替代 push |
scroll | boolean | true | 导航后滚动到顶部 |
prefetch | boolean | true | 进入视口时预取目标页面 |
replace 属性
使用 replace 替换当前历史记录,用户无法通过后退按钮返回:
// 切换语言时不需要保留历史记录
<Form action="/en" replace>
<button type="submit">English</button>
</Form>
scroll 属性
禁用自动滚动,保持当前滚动位置:
// 过滤列表时保持滚动位置
<Form action="/products" scroll={false}>
<select name="sort">
<option value="newest">最新</option>
<option value="price">价格</option>
</select>
<button type="submit">排序</button>
</Form>
prefetch 属性
对于不常用的表单,可以禁用预取:
// 管理后台的表单不需要预取
<Form action="/admin/export" prefetch={false}>
<button type="submit">导出数据</button>
</Form>
action 为函数时的属性
当 action 是 Server Action 时,只支持 action 属性:
<Form action={deletePost}>
<input type="hidden" name="id" value="123" />
<button type="submit">删除</button>
</Form>
当 action 为函数时,replace、scroll 和 prefetch 属性会被忽略。
实战示例
完整的搜索页面
结合 Form 组件和 Suspense 实现流式搜索结果:
// app/search/page.tsx
import Form from "next/form";
import { Suspense } from "react";
import { searchPosts } from "@/lib/search";
// 搜索结果组件(异步)
async function SearchResults({ query }: { query: string }) {
if (!query) {
return (
<p className="text-gray-500">输入关键词开始搜索</p>
);
}
const results = await searchPosts(query);
if (results.length === 0) {
return (
<p className="text-gray-500">未找到 "{query}" 的相关结果</p>
);
}
return (
<ul className="space-y-4">
{results.map((post) => (
<li key={post.id} className="p-4 bg-white rounded-lg shadow">
<h3 className="font-semibold text-lg">{post.title}</h3>
<p className="text-gray-600 mt-2">{post.excerpt}</p>
<span className="text-sm text-gray-400 mt-2 block">
{post.date}
</span>
</li>
))}
</ul>
);
}
// 搜索骨架屏
function SearchSkeleton() {
return (
<div className="space-y-4 animate-pulse">
{[1, 2, 3].map((i) => (
<div key={i} className="p-4 bg-gray-100 rounded-lg">
<div className="h-5 bg-gray-200 rounded w-3/4"></div>
<div className="h-4 bg-gray-200 rounded w-full mt-2"></div>
<div className="h-3 bg-gray-200 rounded w-1/4 mt-2"></div>
</div>
))}
</div>
);
}
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ query?: string }>;
}) {
const { query = "" } = await searchParams;
return (
<main className="container mx-auto p-4 max-w-2xl">
<h1 className="text-2xl font-bold mb-6">搜索文章</h1>
{/* 搜索表单 */}
<Form action="/search" className="mb-8">
<div className="flex gap-2">
<input
name="query"
defaultValue={query}
placeholder="搜索文章标题或内容..."
className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition"
>
搜索
</button>
</div>
</Form>
{/* 搜索结果 */}
<Suspense fallback={<SearchSkeleton />}>
<SearchResults query={query} />
</Suspense>
</main>
);
}
带状态的提交按钮
使用 useFormStatus 显示提交状态:
// components/submit-button.tsx
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton({
children,
loadingText = "提交中..."
}: {
children: React.ReactNode;
loadingText?: string;
}) {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{pending ? loadingText : children}
</button>
);
}
在 Form 中使用:
import Form from "next/form";
import { createPost } from "../actions";
import { SubmitButton } from "@/components/submit-button";
export default function NewPostPage() {
return (
<Form action={createPost} className="space-y-4">
<input name="title" placeholder="标题" required />
<textarea name="content" placeholder="内容" required />
<SubmitButton loadingText="发布中...">发布文章</SubmitButton>
</Form>
);
}
清空搜索参数
使用空字符串作为 action 清空当前页面的搜索参数:
// 当前 URL: /products?category=electronics&sort=price
<Form action="">
<button type="submit">清除筛选</button>
</Form>
// 提交后 URL: /products
多条件筛选
构建复杂的多条件筛选表单:
// app/products/page.tsx
import Form from "next/form";
import { getProducts } from "@/lib/products";
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<{
category?: string;
minPrice?: string;
maxPrice?: string;
sort?: string;
}>;
}) {
const params = await searchParams;
const products = await getProducts(params);
return (
<div className="container mx-auto p-4">
<div className="flex gap-8">
{/* 筛选侧边栏 */}
<aside className="w-64 shrink-0">
<Form action="/products" className="space-y-4">
<div>
<label className="block mb-1 font-medium">分类</label>
<select name="category" defaultValue={params.category}>
<option value="">全部</option>
<option value="electronics">电子产品</option>
<option value="clothing">服装</option>
<option value="books">图书</option>
</select>
</div>
<div>
<label className="block mb-1 font-medium">最低价格</label>
<input
type="number"
name="minPrice"
defaultValue={params.minPrice}
className="w-full px-2 py-1 border rounded"
/>
</div>
<div>
<label className="block mb-1 font-medium">最高价格</label>
<input
type="number"
name="maxPrice"
defaultValue={params.maxPrice}
className="w-full px-2 py-1 border rounded"
/>
</div>
<div>
<label className="block mb-1 font-medium">排序</label>
<select name="sort" defaultValue={params.sort}>
<option value="newest">最新上架</option>
<option value="price-asc">价格从低到高</option>
<option value="price-desc">价格从高到低</option>
</select>
</div>
<button
type="submit"
className="w-full py-2 bg-blue-500 text-white rounded"
>
应用筛选
</button>
</Form>
</aside>
{/* 商品列表 */}
<main className="flex-1">
<div className="grid grid-cols-3 gap-4">
{products.map((product) => (
<div key={product.id} className="p-4 border rounded">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>¥{product.price}</p>
</div>
))}
</div>
</main>
</div>
</div>
);
}
嵌套表单操作
使用 formAction 覆盖表单的默认 action:
import Form from "next/form";
import { saveDraft, publishPost } from "./actions";
export default function EditPostPage() {
return (
<Form action={saveDraft} className="space-y-4">
<input name="title" placeholder="标题" />
<textarea name="content" placeholder="内容" />
<div className="flex gap-2">
{/* 使用表单默认 action */}
<button type="submit">保存草稿</button>
{/* 覆盖 action 为发布 */}
<button formAction={publishPost}>发布</button>
</div>
</Form>
);
}
使用 formAction 时不支持预取功能,因为目标 action 在按钮上定义。
注意事项
不支持的属性
以下 HTML 表单属性与 <Form> 组件的客户端导航行为冲突,不应使用:
method:Form 组件始终使用 GET(字符串 action)或 POST(函数 action)encType:不支持自定义编码类型target:不支持在新窗口打开
如果需要这些属性,请使用原生 <form> 元素。
文件上传
当 action 为字符串时,文件输入框只会提交文件名而非文件对象:
// 只会提交文件名,如 "photo.jpg"
<Form action="/upload">
<input type="file" name="file" />
<button type="submit">上传</button>
</Form>
要上传文件,应使用 Server Action:
"use server";
export async function uploadFile(formData: FormData) {
const file = formData.get("file") as File;
// 处理文件上传...
}
// 页面
<Form action={uploadFile}>
<input type="file" name="file" />
<button type="submit">上传</button>
</Form>
basePath 配置
如果应用配置了 basePath,formAction 也需要包含:
// next.config.ts
const config = {
basePath: "/blog",
};
// 错误:缺少 basePath
<button formAction="/search">搜索</button>
// 正确:包含 basePath
<button formAction="/blog/search">搜索</button>
onSubmit 事件
如果在 onSubmit 中调用 event.preventDefault(),会阻止 Form 组件的客户端导航:
// 不推荐:会阻止客户端导航
<Form action="/search" onSubmit={(e) => {
e.preventDefault(); // 这会阻止导航行为
// 自定义逻辑...
}}>
如果需要自定义验证,应在服务端进行或使用 Server Action。
与原生 form 的对比
| 特性 | <Form> | 原生 <form> |
|---|---|---|
| 客户端导航 | ✅ 自动处理 | ❌ 需要手动处理 |
| 预取目标页面 | ✅ 自动预取 | ❌ 不支持 |
| 渐进增强 | ✅ 支持 | ✅ 支持 |
| 保持布局状态 | ✅ 自动保持 | ❌ 页面刷新 |
| URL 参数管理 | ✅ 自动编码 | ✅ 自动编码 |
| Server Actions | ✅ 支持 | ✅ 支持 |
小结
本章我们学习了:
- Form 组件的基本概念和优势
- action 为字符串和函数时的不同行为
- replace、scroll、prefetch 属性的使用
- 搜索表单和筛选表单的实现
- 与 useFormStatus 配合显示加载状态
- formAction 覆盖默认 action
- 使用 Form 组件的最佳实践和注意事项
练习
- 创建一个搜索页面,使用 Form 组件处理搜索参数
- 实现一个多条件筛选的产品列表页面
- 配合 loading.tsx 创建带加载状态的搜索体验
- 使用 formAction 实现一个"保存草稿"和"发布"的编辑页面