在传统的前端开发里,表单一直是个看似简单、实则容易翻车的东西。尤其当你辛辛苦苦把验证、提交、错误提示全部用 JavaScript 实现后,突然有用户告诉你:“我关闭了脚本,表单点提交没反应。” 那一刻,很多人会直接甩一句“这年代还有人关 JS?” 但现实是:有些用户为了安全、省流量、或者公司内部策略,确实会禁用 JavaScript。更常见的是一些辅助技术(比如屏幕阅读器)对脚本的兼容并不完美。所以,我们得让表单在 JS 缺席的情况下,依然能完成核心流程。这就是渐进增强的价值。

一、从“万能的 JS”到“没有 JS 也行”

以前我们写表单,思路常常是:用 JS 监听 submit 事件,阻止默认提交,然后发 AJAX,再更新页面。这套流程体验固然好,但把“提交表单”这个最基础的动作,完全绑架在了脚本上。一旦脚本加载失败、执行报错、或者被用户禁用,整个功能就瘫痪了。这就像你把保险丝直接接在电线上,还要指望它保护设备——不烧就已经是奇迹了。

渐进增强的思路刚好反过来:先确保没有 JavaScript 时,表单也能通过浏览器原生的能力完成提交;然后再用 JS 去包一层更顺滑的体验。这样,最坏情况下用户失去的只是“局部刷新”和“动画过渡”,但绝对不至于“不能提交”。

二、SvelteKit 表单的原生能力,就是你想要的起点

SvelteKit 在框架层面就手动内置了这条路线。它提供的表单 action 是基于浏览器原生 form 提交的:当你写一个 <form method="POST" action="?/login">,并把它指向 /login 这个 action,那么在没有任何脚本的情况下,浏览器会老老实实地把表单数据编码成 POST 请求发送给服务端。SvelteKit 接收到后,会执行对应的 action 函数,然后返回新的页面还有数据。

这个过程中有没有 JS 参与?没有。浏览器原生完成了全部工作,SvelteKit 只是在服务端“接住”了请求。所以,你写的第一个表单,在默认状态下就已经具备降级能力了。听起来是不是很爽?更爽的是,你可以在页面组件里用 use:enhance 来“升级”它,让它悄悄变成 AJAX 提交,而不用改动服务端逻辑。

三、从零写一个支持降级的登录表单

技术栈:SvelteKit(JavaScript)

我们先做一个最基础的服务端 action,它接收用户名和密码,然后模拟一个登录验证。

3.1 服务端 action 处理登录逻辑

新建 src/routes/login/+page.server.js

// 这个文件运行在服务端,浏览器永远看不到它
// 引入重定向函数
import { redirect, fail } from '@sveltejs/kit';

// 从环境变量里读取用户表(这里只是演示,实际应该查数据库)
const USERS = {
  admin: '123456'
};

// 定义和表单名对应的 action,也就是 <form action="?/login"> 里的 login
export const actions = {
  login: async ({ request }) => {
    // 读取浏览器提交上来的表单数据
    const formData = await request.formData();
    const username = String(formData.get('username') || '');
    const password = String(formData.get('password') || '');

    // 简单的服务端校验,不管前端是否校验过,这里都必须做
    if (!username || !password) {
      // fail 会返回带有错误数据的响应,同时保持 HTTP 状态码为 400
      return fail(400, { 
        error: '用户名和密码都不能为空', 
        username // 把输入框里的值回传,方便用户修改
      });
    }

    // 模拟验证
    if (USERS[username] !== password) {
      return fail(401, { 
        error: '用户名或密码不正确', 
        username 
      });
    }

    // 验证通过,重定向到 /dashboard,同时会带上一个登录标记
    // 实际项目里应该用 cookie + session,这里简化
    throw redirect(302, '/dashboard');
  }
};

3.2 创建页面组件

新建 src/routes/login/+page.svelte

<!-- 这个组件可以运行在客户端,也可以被服务端渲染 -->
<!-- 表单使用 method="POST",action 指向服务端 action 的名字 -->
<!-- 没有 use:enhance 时,它就是最普通的原生表单 -->

<script>
  // 从 SvelteKit 的页面数据中读取 form 返回值
  // 当服务端 action 返回 fail 时,SvelteKit 会把结果放到 $page.form 里
  import { page } from '$app/stores';
</script>

<h1>登录</h1>

<!-- 注意:没有 JavaScript 也能提交! -->
<!-- 如果 $page.form 里有 error 信息,就显示出来 -->
{#if $page?.form?.error}
  <p class="error" role="alert">
    {$page.form.error}
  </p>
{/if}

<form method="POST" action="?/login">
  <div>
    <label for="username">用户名</label>
    <input 
      id="username" 
      name="username" 
      type="text" 
      value={$page?.form?.username || ''}
      autocomplete="username"
      required
    >
  </div>

  <div>
    <label for="password">密码</label>
    <input 
      id="password" 
      name="password" 
      type="password" 
      autocomplete="current-password"
      required
    >
  </div>

  <button type="submit">登录</button>
</form>

<style>
  .error {
    color: #c00;
    background: #ffeaea;
    padding: 0.5rem 1rem;
    border-radius: 6px;
  }
</style>

看到了吗?这个表单在 JS 完全关闭时,依然可以提交。用户点“登录”,浏览器跳转,服务端 action 执行,验证失败就返回一个带错误信息的页面,验证成功就让浏览器重定向到新页面。功能完整,只是没有那种“正在提交”的加载动画而已。

四、用 use:enhance 给体验加点润滑剂

原生表单虽然可用,但每次提交都要整页刷新,体验不够流畅。SvelteKit 的 use:enhance 就是来解决这个问题的:它能在支持 JS 的环境下,拦截表单提交,改用 fetch 发送请求,然后只更新页面里变化的部分,还能保留错误提示、加载状态等。重点在于,当 JS 不可用时,它并不会阻止原生行为——因为它压根就没执行。这就是渐进增强的“优雅”所在。

4.1 给登录表单加上 use:enhance

我们只需要在 <form> 标签上写 use:enhance,SvelteKit 的 $app/forms 会提供这个指令。它默认是“无侵入”的:你不需要所谓“AJAX 化”的按钮,也不需要手动处理 fetch 响应。只需要在 <script> 里引入,然后绑定到 form 上。

<!-- 这个文件就是演示 use:enhance 最简单的用法 -->
<script>
  import { enhance } from '$app/forms';
</script>

<form method="POST" action="?/login" use:enhance>
  <!-- 表单内容和上面完全一样,此处省略 -->
</form>

就这么简单。当用户支持 JS 时,点登录,表单会以 AJAX 方式提交,页面不会跳转,但服务端的 redirect 会被 SvelteKit 捕获,并且自动在客户端进行导航。当用户支持 JS 但网络较慢时,浏览器不会出现“等待页面刷新”的白屏。当用户不支持 JS 时,这个属性就像不存在一样——浏览器正常提交整个页面。

4.2 用回调控制加载状态和反馈

use:enhance 接受一个回调函数作为参数,这个函数会在提交的不同阶段被调用。回调里有一个 cancel() 函数可以取消提交;还有一个 update() 函数可以手动更新页面。我们可以在 onSubmit 阶段开启一个 loading 动画,在 onSuccessonError 阶段关闭它。

<!-- 这个文件展示了增强后的完整交互状态 -->

<script>
  import { enhance } from '$app/forms';
  import { page } from '$app/stores';

  // 本地变量控制“提交中”的状态
  let submitting = false;

  // enhance 的回调会在提交前、成功、失败时触发
  // update 是 SvelteKit 给我们提供的函数,用来刷新 $page.form 等数据
  function handleEnhance({ formElement, formData, action, cancel, submitter }) {
    // 提交开始,打开 loading
    submitting = true;

    return async ({ result, update }) => {
      // result 是服务端 action 的返回值,类型可能是 'success' / 'failure' / 'redirect'
      if (result.type === 'failure' || result.type === 'success') {
        // 如果 action 返回了 fail(),这里会自动更新 $page.form
        await update();
      }
      
      if (result.type === 'redirect') {
        // redirect 时会自动跳转,我们不需要额外代码
        // 如果不想让浏览器直接跳,可以调用 cancel()
      }

      // 提交结束,关闭 loading
      submitting = false;
    };
  }
</script>

{#if submitting}
  <p>正在提交,请稍候…</p>
{/if}

<form 
  method="POST" 
  action="?/login" 
  use:enhance={handleEnhance}
>
  <label for="username">用户名</label>
  <input id="username" name="username" type="text" value={$page?.form?.username || ''} required>

  <label for="password">密码</label>
  <input id="password" name="password" type="password" required>

  <button type="submit" disabled={submitting}>登录</button>
</form>

注意:这里 submitting 是纯客户端状态,只在脚本执行的环境里生效。如果脚本没执行,这个状态也不会被创建,更不会渲染出来。所以就算用户禁用了 JS,这份代码也不会报错——这就是“优雅”的另一个体现:你写的 UI 逻辑在降级模式下会“隐身”而不是“爆炸”。

五、让禁用 JavaScript 的用户也能得到完整反馈

有些开发者以为,只要把表单交给 use:enhance,就万事大吉了。他们往往忽略了一个关键点:服务端 action 返回的 fail() 数据,需要依靠 SvelteKit 的页面渲染才能变成用户能看到的内容。在无 JS 模式下,浏览器提交表单后会重新加载整个页面,这时 $page.form 的值会被服务端渲染到 HTML 中。所以,你必须在服务端组件里把错误信息显示出来,而不能只在客户端用 JS 去处理。上面的示例已经做到了,但我们再深化一下,把错误信息和输入框关联起来。

5.1 无 JS 模式下的错误提示和 aria 设计

为了兼顾可访问性,我们需要让屏幕阅读器也能感知错误。不要只靠颜色,还要用 aria-describedby 把错误文本关联到输入框上。同时,输入框的行为也要符合语义:类型正确、autocomplete 正确、label 关联正确。

<!-- 这个页面专门演示带无障碍细节的降级版本 -->
<script>
  import { page } from '$app/stores';

  // 从服务端返回的 form 数据里提取错误信息
  $: formError = $page?.form?.error;
  $: oldUsername = $page?.form?.username ?? '';
</script>

<form method="POST" action="?/login" novalidate>
  <!-- 全局错误提示,用 aria-live 让读屏软件自动播报 -->
  {#if formError}
    <p 
      id="form-error" 
      role="alert" 
      class="error-box" 
      aria-live="assertive"
    >
      {formError}
    </p>
  {/if}

  <div>
    <label for="username">用户名</label>
    <input 
      id="username" 
      name="username" 
      type="text" 
      value={oldUsername}
      aria-required="true"
      aria-describedby={formError ? 'form-error' : undefined}
      autocomplete="username"
    >
  </div>

  <div>
    <label for="password">密码</label>
    <input 
      id="password" 
      name="password" 
      type="password" 
      aria-required="true"
      aria-describedby={formError ? 'form-error' : undefined}
      autocomplete="current-password"
    >
  </div>

  <button type="submit">登录</button>
</form>

注意这里给 <form> 添加了 novalidate,这是因为浏览器自带的 HTML5 验证(比如 required)在 JS 关闭时依然有效,但它弹出的气泡在不同操作系统上风格不一致,而且不能自定义样式。更重要的是,有些辅助工具对浏览器气泡的朗读效果并不好。因此我们关闭原生气泡,把验证全部交给服务端,然后通过统一的 role="alert" 展示。这样一来,无论 JS 是否开启,错误反馈都是同一种方式:服务端返回文字,页面文字渲染。无 JS 时是整页刷新,有 JS 时是局部刷新,视觉结果完全一致。

5.2 保持 URL 和跳转逻辑一致

在无 JS 模式下,throw redirect(302, '/dashboard') 会让浏览器直接发起一次新的 GET 请求。对于用户来说,这就是普通的页面跳转,地址栏会变成 /dashboard,刷新、收藏都没问题。在有 JS 模式下,SvelteKit 的 enhance 会拦截这个 redirect,并且使用客户端路由跳转,地址栏同样会变,行为也接近。这里要注意:不要为了“增强”而用 fetch 直接返回 JSON,却忘记在服务端设置正确的重定向,那样会失去降级能力。

六、进阶:把 CRUD 里的删除和编辑也做成渐进增强

表单不只包含登录,还有评论、搜索、修改资料、删除确认等。我们拿“删除一条评论”举例,这个过程通常需要 POST 请求,因为删除会修改数据。这里有个小技巧:用 <button formaction> 提交到不同的 action。

6.1 一个评论列表下的删除按钮

技术栈依然是 SvelteKit(JavaScript),我们定义一个 deleteComment action。

// +page.server.js

import { fail } from '@sveltejs/kit';

export const actions = {
  // 其他 action 省略

  // 删除评论的 action
  deleteComment: async ({ request, params }) => {
    const formData = await request.formData();
    const commentId = formData.get('commentId');

    // 在这里调用数据库删除操作
    // 假设删除成功
    console.log('删除评论:', commentId);

    // 可以返回一个普通对象,SvelteKit 会把表单数据更新到页面
    return { deleted: true };
  }
};

+page.svelte 里,我们让每个删除按钮都对应一个子表单,并给按钮设置 formaction="?/deleteComment"。这样同一个页面可以有多个删除按钮,每个按钮提交到同一个 action,同时带上了自己的 commentId

<script>
  import { enhance } from '$app/forms';
  import { page } from '$app/stores';

  // 存储“删除成功”的提示信息
  let deletedMessage = '';
</script>

<!-- 如果删除成功,显示提示 -->
{#if deletedMessage}
  <p class="success-msg">{deletedMessage}</p>
{/if}

<!-- 评论列表(模拟数据) -->
{#each comments as comment}
  <article>
    <p>{comment.text}</p>

    <!-- 每个评论一个表单,但按钮的 formaction 指向同一个 action -->
    <form 
      method="POST" 
      action="?/deleteComment" 
      use:enhance={{
        // 在提交回调里
        onSubmit: () => {
          deletedMessage = '';
        },
        // 使用成功回调
        onSuccess: ({ formElement }) => {
          // 把当前评论从列表里移除(这里用注释说明想法)
          // 实际项目中可能需要操作 store 或重新获取数据
          deletedMessage = '评论已删除。';
        }
      }}
    >
      <!-- 隐藏字段传递评论 ID,name 必须存在,否则服务端接收不到 -->
      <input type="hidden" name="commentId" value={comment.id}>
      <button type="submit">删除</button>
    </form>
  </article>
{/each}

这个例子里,你可能会问:无 JS 时,onSuccess 不会执行,那用户怎么看到“删除成功”的反馈?答案是:无 JS 时,服务端 action 返回的 { deleted: true } 会被 SvelteKit 合并到页面数据里,然后整页刷新,页面组件可以从 $page.form 中读取这个值来显示提示。也就是说,我们必须在模板里同样处理“从 form 数据中读取状态”,而不是只依赖客户端回调。这有点麻烦,但很可靠。

6.2 更好的写法:用内联数据保持状态同步

为了减少重复逻辑,可以把“删除成功”的提示拆分服务端和客户端两套代码,但它们都要读取同一个数据源。推荐的做法是:服务端 action 返回 { deletedId },页面组件 $: deletedId = $page.form?.deletedId,然后统一使用这个变量渲染提示。

<script>
  import { page } from '$app/stores';
  $: deletedId = $page?.form?.deletedId;
</script>

{#if deletedId}
  <p class="success-msg">评论 #{{deletedId}} 已删除。</p>
{/if}

无 JS 时,整页重新渲染,$page.form 从服务端数据中恢复。有 JS 时,use:enhanceupdate() 函数也会自动把新的 form 数据写入 $page。这样,无论有没有脚本,页面都能响应服务端的状态,而且代码只有一份。这才是优雅降级的核心素养:不把核心业务逻辑绑定在客户端,而是放在服务端,前端只是“显示”和“增强”的皮肤。

七、渐进增强的应用场景和优缺点分析

7.1 应用场景盘点

  • 登录注册:用户可能使用老款手机浏览器,或企业安全设置禁用了 JS,此时表单必须可用。
  • 搜索引擎入口:谷歌等爬虫虽然会渲染 JS,但仍有部分引擎或代理不会执行脚本,表单提交如果依赖 JS,会丢失爬取到的表单页面数据(但一般爬虫不提交表单,这里更多是链接可访问性)。
  • 评论区:很多用户习惯关闭脚本以加快浏览速度,评论功能如果被 JS 绑架,这些人就完全无法互动。
  • 搜索框:搜索框如果只能用 JS 跳转,禁用 JS 的用户连站内搜索都用不了。
  • 后台管理表单:企业内部系统由于安全策略,有时会禁用 JS,但核心数据录入功能不能废。

7.2 技术优点

  • 最大化兼容性:最低可以在纯文本浏览器上使用,不挑设备。
  • 所有逻辑可测试:服务端 action 可以直接用 curl 测试,不依赖 UI 自动化。
  • 更健壮:JS 报错不会导致功能崩溃。
  • 推动你写出更好的服务端代码:因为不能依赖 JS,所以服务端验证必须做全,这恰恰是安全性的基石。

7.3 技术缺点

  • 开发成本略高:你需要同时考虑两种交互模式,不能只写个 fetch 就收工。
  • 状态同步要更细心:客户端回调和服务端返回的 form 数据要管理得井井有条。
  • 部分华丽特效无法落地:比如拖拽上传、动态多级联动等,纯原生表单很难实现同等体验,只能额外做增强。
  • 测试工作量加大:必须在无 JS 环境下过一遍所有场景。

八、注意事项清单,新手必看

  1. 给所有输入元素设置 name 属性。否则点击提交后,浏览器不会发送这个字段,服务端拿不到值。
  2. 尽量使用 method="POST",不要用 GET 处理敏感数据,也避免 URL 过长。
  3. 服务端一定要做完整的数据校验,不能信任客户端传来的任何校验结果。
  4. 不要使用 <button onclick="return false"> 这类屏蔽默认行为的障眼法,那样会彻底阻断表单提交。
  5. 在无 JS 环境下,错误提示必须静态渲染到 HTML 里,不能依赖 JS 弹出 dialog。
  6. 使用 autocomplete 并确保 label 关联,这是可访问性的基础。
  7. 如果使用 use:enhance,不要忘了在回调里调用 update(),否则 $page.form 不会更新,错误提示会一直停留在旧状态。
  8. 重定向状态码建议用 303302,SvelteKit 的 redirect 函数默认用 302,能正确处理 POST 后的刷新问题。
  9. 多表单共用一个页面时,用 formaction 区分按钮,而不是用同一个按钮做所有判断。
  10. 测试的时候一定要在浏览器设置里勾选“禁止 JavaScript”,然后重新刷新页面,把所有流程走一遍。

九、总结

渐进增强不是一种高深莫测的架构思想,它只是提醒我们:把“功能”和“体验”分开。功能是底线,体验是加分项。SvelteKit 的表单 action 体系特别好地贴合了这个原则,因为它的默认实现就是原生表单提交,后续通过 use:enhance 增加 AJAX 能力。这种“先降级后增强”的路径,让开发者不需要在你的代码里做各种“如果浏览器不支持”的兼容判断。你只需要专注于服务端逻辑和 UI 展现,剩下的,框架已经帮你把梯子铺好了。哪怕你的用户群体里几乎没有人会关 JS,抱着这种心态去写表单,也能让你少踩很多坑,尤其是那些字段没提交、验证漏掉、错误信息不明不白的坑。下次再有人说起“禁用 JavaScript”,希望你能淡定地回答:没问题,我的表单照样能跑。