Chapter 09

表单 Actions 与服务端逻辑

用 SvelteKit Form Actions 实现渐进增强的表单处理,无 JS 也能工作的全栈体验

Form Actions 概念

什么是渐进增强?

SvelteKit 的 Form Actions 基于渐进增强(Progressive Enhancement)原则设计:表单在没有 JavaScript 的情况下仍然能够正常工作(标准 HTML 表单提交),当 JavaScript 加载后,use:enhance 指令将其升级为 AJAX 表单提交,获得更好的用户体验。

为什么渐进增强很重要?

网络质量差时 JS 可能未加载、用户可能禁用 JS、搜索引擎爬虫不执行 JS——渐进增强确保你的核心功能(登录、提交数据)在所有情况下都可用。

定义 Actions

// src/routes/login/+page.server.ts
import type { Actions, PageServerLoad } from './$types';
import { fail, redirect } from '@sveltejs/kit';

export const actions: Actions = {
  // default action:form 没有指定 action 属性时触发
  default: async ({ request, cookies }) => {
    const data = await request.formData();
    const email = data.get('email')?.toString() || '';
    const password = data.get('password')?.toString() || '';

    // 表单验证
    const errors: Record<string, string> = {};
    if (!email) errors.email = '邮箱不能为空';
    if (!/^[^@]+@[^@]+$/.test(email)) errors.email = '邮箱格式不正确';
    if (password.length < 6) errors.password = '密码至少 6 个字符';

    if (Object.keys(errors).length > 0) {
      // fail() 返回错误,HTTP 状态码 400
      // 原始表单数据也返回,方便前端回填
      return fail(400, { errors, email });
    }

    // 验证用户
    const user = await verifyUser(email, password);
    if (!user) {
      return fail(401, { errors: { global: '邮箱或密码错误' }, email });
    }

    // 设置 session cookie
    cookies.set('session', await createSession(user.id), {
      path: '/',
      httpOnly: true,
      secure: true,
      maxAge: 60 * 60 * 24 * 7
    });

    // 重定向到仪表盘
    redirect(303, '/dashboard');
  }
};

多个命名 Actions

// src/routes/profile/+page.server.ts
export const actions: Actions = {
  // 更新个人信息
  updateProfile: async ({ request, locals }) => {
    const data = await request.formData();
    await db.user.update({
      where: { id: locals.user.id },
      data: { name: data.get('name') }
    });
    return { success: true, message: '个人信息已更新' };
  },

  // 上传头像
  uploadAvatar: async ({ request, locals }) => {
    const data = await request.formData();
    const file = data.get('avatar') as File;

    if (!file || file.size === 0) {
      return fail(400, { error: '请选择文件' });
    }
    if (file.size > 5 * 1024 * 1024) {
      return fail(400, { error: '文件大小不能超过 5MB' });
    }

    const url = await uploadToStorage(file);
    await db.user.update({ where: { id: locals.user.id }, data: { avatarUrl: url } });
    return { success: true, avatarUrl: url };
  },

  // 删除账户
  deleteAccount: async ({ locals, cookies }) => {
    await db.user.delete({ where: { id: locals.user.id } });
    cookies.delete('session', { path: '/' });
    redirect(303, '/');
  }
};

use:enhance 渐进增强

<!-- src/routes/login/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  import type { ActionData } from './$types';

  let { form } = $props<{ form: ActionData }>();
  let loading = $state(false);
</script>

<form
  method="POST"
  use:enhance={({ formElement, formData, action, cancel }) => {
    // 提交前回调
    loading = true;

    // 可以在这里修改 formData 或取消提交
    // cancel() 阻止提交

    return async ({ result, update }) => {
      // 提交后回调
      loading = false;

      if (result.type === 'redirect') {
        // 处理重定向
        await update();
      } else {
        // 默认行为:更新 form 数据,失败时保留表单
        await update();
      }
    };
  }}
>
  <div>
    <label>
      邮箱
      <input type="email" name="email" value={form?.email || ''} />
    </label>
    {#if form?.errors?.email}
      <p class="error">{form.errors.email}</p>
    {/if}
  </div>

  <div>
    <label>
      密码
      <input type="password" name="password" />
    </label>
    {#if form?.errors?.password}
      <p class="error">{form.errors.password}</p>
    {/if}
  </div>

  {#if form?.errors?.global}
    <div class="error-banner">{form.errors.global}</div>
  {/if}

  <button type="submit" disabled={loading}>
    {loading ? '登录中...' : '登录'}
  </button>
</form>

<!-- 命名 action 的使用 -->
<form method="POST" action="?/uploadAvatar" use:enhance>
  <input type="file" name="avatar" accept="image/*" />
  <button type="submit">上传头像</button>
</form>
use:enhance 最简用法

直接写 use:enhance 不传参数,获得默认增强行为:AJAX 提交、自动更新 ActionData、失败时保留表单值。大多数场景下这已足够。

Form Actions 的工作机制

渐进增强的技术实现原理

SvelteKit Form Actions 实现渐进增强的关键在于:标准 HTML 表单和 AJAX 表单提交使用相同的服务端处理逻辑,差异只在于客户端如何处理响应:

不使用 use:enhance(纯 HTML 表单):
  浏览器 → POST /login → 服务器执行 action → 返回 HTTP 302 重定向
  浏览器跟随重定向,整页刷新

使用 use:enhance(AJAX 增强):
  浏览器 → fetch POST /login → 服务器执行同一个 action → 返回 JSON
  Svelte 更新 $page.form 和页面 data,无整页刷新

关键点:
  服务器的 action 函数完全相同——不需要区分两种情况。
  SvelteKit 在响应时会检测请求头中的 Accept 字段:
    - 普通表单:Accept: text/html → 返回重定向
    - use:enhance:Accept: application/json → 返回 JSON

action 函数的返回值类型

fail(status, data) — 验证失败
返回 fail(400, { errors: { email: '格式不正确' } }) 时,HTTP 状态码为 400,ActionData(form)会被更新为 data 内容,表单不重置(用户可以修正并重新提交)。这是表单验证失败的标准处理。
redirect(status, url) — 成功跳转
返回 redirect(303, '/dashboard') 时,用户被重定向到目标页面(303 See Other 是 POST 后重定向的正确状态码,防止浏览器重复提交)。注意:redirect 在 SvelteKit 内部通过抛出异常实现,应该直接 throw redirect(303, '/dashboard')
返回普通对象 — 成功但不跳转
返回 { success: true, message: '已保存' } 时,页面不刷新,ActionData 更新为该对象,可以在组件中读取 form?.success 来显示成功提示。适合"原地操作"(如点赞、添加评论不需要跳转)。
什么都不返回(void)
如果 action 什么都不返回,ActionData 保持为 null,通常配合 redirect 使用:执行操作后重定向到另一页面,不需要在当前页面显示任何状态。

表单 CSRF 保护

SvelteKit 的 Form Actions 内置了 CSRF(跨站请求伪造)防护,了解其工作原理有助于正确配置:

// SvelteKit 默认在所有 Action 上启用 CSRF 保护:
// 1. 检查请求的 Origin 头部是否与服务器域名匹配
// 2. 如果不匹配(跨域 POST),拒绝请求(403 Forbidden)

// 如果需要允许跨域的 form 提交(少见),在 svelte.config.js 中配置:
import { sveltekit } from '@sveltejs/kit/vite';

export default {
  kit: {
    csrf: {
      // 警告:禁用 CSRF 保护会引入安全风险
      checkOrigin: false   // 只在完全理解后果时才禁用
    }
  }
};
action 中不要忘记验证数据类型

表单提交的所有数据都是字符串类型(HTML 表单不传递类型信息)。即使 input 是 type="number"formData.get('price') 返回的也是字符串 "99.9" 而不是数字 99.9。始终在 action 中做明确的类型转换和验证。推荐使用 Zod 或 Valibot 进行 Schema 验证。

Zod Schema 验证集成

使用 Zod 在 Form Action 中进行类型安全的数据验证是生产级应用的标准做法:

// src/routes/products/new/+page.server.ts
import { z } from 'zod';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';

// 定义数据验证 Schema
const productSchema = z.object({
  name: z.string().min(2, '名称至少2个字符').max(100),
  price: z.coerce.number().positive('价格必须大于0'),  // coerce 自动转换字符串→数字
  category: z.enum(['electronics', 'clothing', 'food']),
  description: z.string().max(1000).optional(),
  inStock: z.coerce.boolean().default(true)
});

export const actions: Actions = {
  create: async ({ request, locals }) => {
    if (!locals.user) {
      throw redirect(303, '/login');
    }

    const formData = await request.formData();
    const rawData = Object.fromEntries(formData);

    // Zod 验证:parse 失败时抛出异常,safeParse 返回结果对象
    const parsed = productSchema.safeParse(rawData);

    if (!parsed.success) {
      // 将 Zod 错误转换为字段级别的错误 map
      const errors = Object.fromEntries(
        Object.entries(parsed.error.flatten().fieldErrors)
          .map(([field, msgs]) => [field, msgs?.[0] ?? '验证失败'])
      );
      return fail(400, {
        errors,
        values: rawData  // 回填用户输入(避免重新填写)
      });
    }

    // 类型安全:parsed.data 是完整的 Product 类型
    await db.product.create({ data: { ...parsed.data, userId: locals.user.id } });

    throw redirect(303, '/products');
  }
};
<!-- +page.svelte:显示字段级别错误 -->
<script lang="ts">
  import { enhance } from '$app/forms';
  import type { ActionData } from './$types';

  let { form } = $props<{ form: ActionData }>();
  let submitting = $state(false);
</script>

<form method="POST" action="?/create" use:enhance={() => {
  submitting = true;
  return async ({ update }) => {
    submitting = false;
    await update();
  };
}}>
  <div>
    <label>产品名称</label>
    <input name="name" value={form?.values?.name ?? ''}/>
    <!-- 显示字段错误 -->
    {#if form?.errors?.name}
      <span class="error">{form.errors.name}</span>
    {/if}
  </div>

  <div>
    <label>价格</label>
    <input type="number" name="price" value={form?.values?.price ?? ''}/>
    {#if form?.errors?.price}
      <span class="error">{form.errors.price}</span>
    {/if}
  </div>

  <button type="submit" disabled={submitting}>
    {submitting ? '提交中...' : '创建产品'}
  </button>
</form>
本章小结