在传统的前端开发里,表单一直是个看似简单、实则容易翻车的东西。尤其当你辛辛苦苦把验证、提交、错误提示全部用 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 动画,在 onSuccess 或 onError 阶段关闭它。
<!-- 这个文件展示了增强后的完整交互状态 -->
<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:enhance 的 update() 函数也会自动把新的 form 数据写入 $page。这样,无论有没有脚本,页面都能响应服务端的状态,而且代码只有一份。这才是优雅降级的核心素养:不把核心业务逻辑绑定在客户端,而是放在服务端,前端只是“显示”和“增强”的皮肤。
七、渐进增强的应用场景和优缺点分析
7.1 应用场景盘点
- 登录注册:用户可能使用老款手机浏览器,或企业安全设置禁用了 JS,此时表单必须可用。
- 搜索引擎入口:谷歌等爬虫虽然会渲染 JS,但仍有部分引擎或代理不会执行脚本,表单提交如果依赖 JS,会丢失爬取到的表单页面数据(但一般爬虫不提交表单,这里更多是链接可访问性)。
- 评论区:很多用户习惯关闭脚本以加快浏览速度,评论功能如果被 JS 绑架,这些人就完全无法互动。
- 搜索框:搜索框如果只能用 JS 跳转,禁用 JS 的用户连站内搜索都用不了。
- 后台管理表单:企业内部系统由于安全策略,有时会禁用 JS,但核心数据录入功能不能废。
7.2 技术优点
- 最大化兼容性:最低可以在纯文本浏览器上使用,不挑设备。
- 所有逻辑可测试:服务端 action 可以直接用 curl 测试,不依赖 UI 自动化。
- 更健壮:JS 报错不会导致功能崩溃。
- 推动你写出更好的服务端代码:因为不能依赖 JS,所以服务端验证必须做全,这恰恰是安全性的基石。
7.3 技术缺点
- 开发成本略高:你需要同时考虑两种交互模式,不能只写个
fetch就收工。 - 状态同步要更细心:客户端回调和服务端返回的
form数据要管理得井井有条。 - 部分华丽特效无法落地:比如拖拽上传、动态多级联动等,纯原生表单很难实现同等体验,只能额外做增强。
- 测试工作量加大:必须在无 JS 环境下过一遍所有场景。
八、注意事项清单,新手必看
- 给所有输入元素设置
name属性。否则点击提交后,浏览器不会发送这个字段,服务端拿不到值。 - 尽量使用
method="POST",不要用GET处理敏感数据,也避免 URL 过长。 - 服务端一定要做完整的数据校验,不能信任客户端传来的任何校验结果。
- 不要使用
<button onclick="return false">这类屏蔽默认行为的障眼法,那样会彻底阻断表单提交。 - 在无 JS 环境下,错误提示必须静态渲染到 HTML 里,不能依赖 JS 弹出 dialog。
- 使用
autocomplete并确保label关联,这是可访问性的基础。 - 如果使用
use:enhance,不要忘了在回调里调用update(),否则$page.form不会更新,错误提示会一直停留在旧状态。 - 重定向状态码建议用
303或302,SvelteKit 的redirect函数默认用 302,能正确处理 POST 后的刷新问题。 - 多表单共用一个页面时,用
formaction区分按钮,而不是用同一个按钮做所有判断。 - 测试的时候一定要在浏览器设置里勾选“禁止 JavaScript”,然后重新刷新页面,把所有流程走一遍。
九、总结
渐进增强不是一种高深莫测的架构思想,它只是提醒我们:把“功能”和“体验”分开。功能是底线,体验是加分项。SvelteKit 的表单 action 体系特别好地贴合了这个原则,因为它的默认实现就是原生表单提交,后续通过 use:enhance 增加 AJAX 能力。这种“先降级后增强”的路径,让开发者不需要在你的代码里做各种“如果浏览器不支持”的兼容判断。你只需要专注于服务端逻辑和 UI 展现,剩下的,框架已经帮你把梯子铺好了。哪怕你的用户群体里几乎没有人会关 JS,抱着这种心态去写表单,也能让你少踩很多坑,尤其是那些字段没提交、验证漏掉、错误信息不明不白的坑。下次再有人说起“禁用 JavaScript”,希望你能淡定地回答:没问题,我的表单照样能跑。
评论
围绕“SvelteKit表单处理中渐进增强策略落地:禁用JavaScript后的优雅降级与可访问性保障”参与讨论