乐闻世界logo
搜索文章和话题

Next.js 的 Server Actions 是如何工作的?

2月17日 23:16

Server Actions 是 Next.js 13.4+ 引入的一个强大功能,它允许你在服务器组件中直接调用服务器端函数,简化了表单提交和数据变更操作。

什么是 Server Actions?

Server Actions 是一种在服务器上执行的异步函数,可以从客户端或服务器组件中调用。它们提供了一种简单的方式来处理表单提交、数据变更和其他服务器端操作。

基本语法

javascript
'use server'; export async function createTodo(formData) { const title = formData.get('title'); const description = formData.get('description'); await db.todo.create({ data: { title, description } }); revalidatePath('/todos'); }

Server Actions 的使用方式

1. 在表单中使用

javascript
import { createTodo } from './actions'; export default function TodoForm() { return ( <form action={createTodo}> <input name="title" placeholder="Title" required /> <textarea name="description" placeholder="Description" /> <button type="submit">Create Todo</button> </form> ); }

2. 在事件处理器中使用

javascript
'use client'; import { createTodo } from './actions'; export default function CreateTodoButton() { const [pending, startTransition] = useTransition(); const handleClick = () => { const formData = new FormData(); formData.append('title', 'New Todo'); formData.append('description', 'Description'); startTransition(async () => { await createTodo(formData); }); }; return ( <button onClick={handleClick} disabled={pending}> {pending ? 'Creating...' : 'Create Todo'} </button> ); }

3. 在服务器组件中直接调用

javascript
import { createTodo } from './actions'; export default async function Page() { // 直接在服务器组件中调用 await createTodo(new FormData()); const todos = await db.todo.findMany(); return <TodoList todos={todos} />; }

Server Actions 的特性

1. 自动处理表单数据

javascript
'use server'; export async function updateUser(formData) { const name = formData.get('name'); const email = formData.get('email'); const avatar = formData.get('avatar'); // File 对象 // 处理文件上传 if (avatar instanceof File) { const url = await uploadFile(avatar); await db.user.update({ where: { id: userId }, data: { name, email, avatar: url } }); } }

2. 返回数据

javascript
'use server'; export async function searchProducts(query) { const products = await db.product.findMany({ where: { name: { contains: query } } }); return { success: true, data: products, count: products.length }; } // 使用 'use client'; import { searchProducts } from './actions'; export default function SearchComponent() { const [results, setResults] = useState(null); const handleSearch = async (e) => { e.preventDefault(); const formData = new FormData(e.target); const query = formData.get('query'); const response = await searchProducts(query); setResults(response.data); }; return ( <form onSubmit={handleSearch}> <input name="query" /> <button type="submit">Search</button> {results && <ResultsList results={results} />} </form> ); }

3. 错误处理

javascript
'use server'; export async function createPost(formData) { try { const title = formData.get('title'); const content = formData.get('content'); if (!title || !content) { return { error: 'Title and content are required' }; } const post = await db.post.create({ data: { title, content } }); return { success: true, post }; } catch (error) { return { error: 'Failed to create post' }; } } // 使用错误状态 'use client'; import { useFormState } from 'react-dom'; import { createPost } from './actions'; export default function PostForm() { const [state, formAction] = useFormState(createPost, null); return ( <form action={formAction}> <input name="title" /> <textarea name="content" /> <button type="submit">Create Post</button> {state?.error && ( <div className="error">{state.error}</div> )} {state?.success && ( <div className="success">Post created!</div> )} </form> ); }

4. 重定向

javascript
'use server'; import { redirect } from 'next/navigation'; export async function login(formData) { const email = formData.get('email'); const password = formData.get('password'); const user = await authenticate(email, password); if (user) { redirect('/dashboard'); } else { return { error: 'Invalid credentials' }; } }

5. 缓存重新验证

javascript
'use server'; import { revalidatePath, revalidateTag } from 'next/cache'; export async function updatePost(postId, formData) { const title = formData.get('title'); const content = formData.get('content'); await db.post.update({ where: { id: postId }, data: { title, content } }); // 重新验证特定路径 revalidatePath('/posts'); revalidatePath(`/posts/${postId}`); // 或使用标签重新验证 revalidateTag('posts'); }

高级用法

1. 带参数的 Server Actions

javascript
'use server'; export async function deletePost(postId: string) { await db.post.delete({ where: { id: postId } }); revalidatePath('/posts'); } // 使用 'use client'; import { deletePost } from './actions'; export default function PostCard({ post }) { const handleDelete = async () => { if (confirm('Are you sure?')) { await deletePost(post.id); } }; return ( <div> <h2>{post.title}</h2> <button onClick={handleDelete}>Delete</button> </div> ); }

2. 使用 bind 绑定参数

javascript
'use server'; export async function updateTodo(todoId, formData) { const title = formData.get('title'); const completed = formData.get('completed') === 'true'; await db.todo.update({ where: { id: todoId }, data: { title, completed } }); revalidatePath('/todos'); } // 使用 bind 'use client'; import { updateTodo } from './actions'; export default function TodoItem({ todo }) { const updateTodoWithId = updateTodo.bind(null, todo.id); return ( <form action={updateTodoWithId}> <input name="title" defaultValue={todo.title} /> <input type="checkbox" name="completed" defaultChecked={todo.completed} value="true" /> <button type="submit">Update</button> </form> ); }

3. 乐观更新

javascript
'use client'; import { useOptimistic } from 'react'; import { toggleTodo } from './actions'; export default function TodoList({ todos }) { const [optimisticTodos, addOptimisticTodo] = useOptimistic( todos, (state, newTodo) => { return state.map(todo => todo.id === newTodo.id ? { ...todo, completed: newTodo.completed } : todo ); } ); return ( <ul> {optimisticTodos.map(todo => ( <li key={todo.id}> <input type="checkbox" checked={todo.completed} onChange={async () => { addOptimisticTodo({ id: todo.id, completed: !todo.completed }); await toggleTodo(todo.id); }} /> {todo.title} </li> ))} </ul> ); }

4. 文件上传

javascript
'use server'; export async function uploadAvatar(formData) { const file = formData.get('avatar') as File; if (!file) { return { error: 'No file uploaded' }; } const bytes = await file.arrayBuffer(); const buffer = Buffer.from(bytes); // 上传到云存储 const url = await uploadToCloudStorage(buffer, file.name); // 更新用户头像 await db.user.update({ where: { id: userId }, data: { avatar: url } }); revalidatePath('/profile'); return { success: true, url }; } // 使用 'use client'; export default function AvatarUpload() { return ( <form action={uploadAvatar}> <input type="file" name="avatar" accept="image/*" /> <button type="submit">Upload</button> </form> ); }

实际应用场景

1. 博客文章创建

javascript
// app/actions/posts.ts 'use server'; import { revalidatePath } from 'next/navigation'; import { auth } from '@/auth'; export async function createPost(formData: FormData) { const session = await auth(); if (!session) { return { error: 'Unauthorized' }; } const title = formData.get('title') as string; const content = formData.get('content') as string; const tags = formData.get('tags') as string; const post = await db.post.create({ data: { title, content, authorId: session.user.id, tags: tags.split(',').map(tag => tag.trim()) } }); revalidatePath('/blog'); revalidatePath('/blog/new'); return { success: true, post }; } // app/blog/new/page.tsx import { createPost } from '@/app/actions/posts'; export default function NewPostPage() { return ( <div> <h1>Create New Post</h1> <form action={createPost}> <input name="title" placeholder="Title" required /> <textarea name="content" placeholder="Content" required /> <input name="tags" placeholder="Tags (comma separated)" /> <button type="submit">Publish</button> </form> </div> ); }

2. 电商购物车

javascript
// app/actions/cart.ts 'use server'; import { revalidateTag } from 'next/cache'; import { auth } from '@/auth'; export async function addToCart(productId: string, quantity: number = 1) { const session = await auth(); if (!session) { return { error: 'Unauthorized' }; } await db.cartItem.upsert({ where: { userId_productId: { userId: session.user.id, productId } }, update: { quantity: { increment: quantity } }, create: { userId: session.user.id, productId, quantity } }); revalidateTag('cart'); return { success: true }; } export async function removeFromCart(itemId: string) { const session = await auth(); if (!session) { return { error: 'Unauthorized' }; } await db.cartItem.delete({ where: { id: itemId } }); revalidateTag('cart'); return { success: true }; } // app/components/AddToCartButton.tsx 'use client'; import { addToCart } from '@/app/actions/cart'; import { useTransition } from 'react'; export default function AddToCartButton({ productId }) { const [pending, startTransition] = useTransition(); const handleClick = () => { startTransition(async () => { await addToCart(productId); }); }; return ( <button onClick={handleClick} disabled={pending}> {pending ? 'Adding...' : 'Add to Cart'} </button> ); }

3. 评论系统

javascript
// app/actions/comments.ts 'use server'; import { revalidatePath } from 'next/navigation'; import { auth } from '@/auth'; export async function addComment(postId: string, formData: FormData) { const session = await auth(); if (!session) { return { error: 'Unauthorized' }; } const content = formData.get('content') as string; const comment = await db.comment.create({ data: { content, postId, authorId: session.user.id } }); revalidatePath(`/posts/${postId}`); return { success: true, comment }; } // app/posts/[id]/page.tsx import { addComment } from '@/app/actions/comments'; export default function PostPage({ params }) { const post = await getPost(params.id); return ( <div> <PostContent post={post} /> <CommentList comments={post.comments} /> <form action={addComment.bind(null, post.id)}> <textarea name="content" placeholder="Write a comment..." required /> <button type="submit">Post Comment</button> </form> </div> ); }

最佳实践

1. 验证输入

javascript
'use server'; import { z } from 'zod'; const createPostSchema = z.object({ title: z.string().min(1).max(200), content: z.string().min(1), tags: z.array(z.string()).optional() }); export async function createPost(formData: FormData) { const validatedFields = createPostSchema.safeParse({ title: formData.get('title'), content: formData.get('content'), tags: formData.get('tags')?.split(',') }); if (!validatedFields.success) { return { error: 'Invalid input', details: validatedFields.error }; } // 处理验证后的数据 const { title, content, tags } = validatedFields.data; await db.post.create({ data: { title, content, tags } }); revalidatePath('/posts'); return { success: true }; }

2. 权限检查

javascript
'use server'; import { auth } from '@/auth'; export async function deleteUser(userId: string) { const session = await auth(); // 检查用户是否已登录 if (!session) { return { error: 'Unauthorized' }; } // 检查用户是否有权限 if (session.user.role !== 'admin' && session.user.id !== userId) { return { error: 'Forbidden' }; } await db.user.delete({ where: { id: userId } }); revalidatePath('/users'); return { success: true }; }

3. 错误处理和日志记录

javascript
'use server'; import { revalidatePath } from 'next/navigation'; export async function sensitiveAction(formData: FormData) { try { // 执行操作 await performAction(formData); revalidatePath('/'); return { success: true }; } catch (error) { // 记录错误 console.error('Action failed:', error); // 返回用户友好的错误消息 return { error: 'Something went wrong. Please try again.' }; } }

4. 使用 TypeScript

javascript
'use server'; import { z } from 'zod'; // 定义输入类型 const CreatePostInput = z.object({ title: z.string(), content: z.string() }); // 定义返回类型 type CreatePostResult = | { success: true; post: Post } | { success: false; error: string }; export async function createPost( formData: FormData ): Promise<CreatePostResult> { const input = CreatePostInput.parse({ title: formData.get('title'), content: formData.get('content') }); const post = await db.post.create({ data: input }); revalidatePath('/posts'); return { success: true, post }; }

Server Actions 简化了服务器端操作,使表单处理和数据变更变得更加直观和高效。通过合理使用 Server Actions,可以构建出更简洁、更易维护的 Next.js 应用。

标签:Next.js