当你在互联网上注册账号、填写收货地址,或是提交一份在线问卷时,你大概率都在和表单打交道。表单是我们与网页交互的重要方式,可别小瞧了它。但你知道吗?表单里藏着一个强大却常被忽视的功能 ------HTML 表单验证。这个功能就像游戏里的隐藏关卡,明明有着超高的实用价值,却因为开发者的忽视,一直没机会大展身手。今天,咱们就来聊聊,HTML 表单验证为何会被冷落,它又能在哪些地方帮我们打造出更优质的用户体验。
属性、方法和特性
通过添加required
属性,禁止输入为空非常容易实现:
html
<input required={true} />
除此之外,还有许多其他方式可以为输入添加限制条件。具体来说,有三种方法:
-
使用特定的
type
属性值,如"email"
(电子邮件)、"number"
(数字)或"url"
(网址); -
使用其他用于创建限制的输入属性,例如
"pattern"
(正则表达式匹配模式)或"maxlength"
(最大长度); -
使用输入元素的
setCustomValidity
DOM 方法。
最后一种方法最为强大,因为它允许创建任意的验证逻辑,以处理复杂的验证场景。你注意到它与前两种方法的区别了吗?前两种是通过属性来定义的,而setCustomValidity
是一个方法。
这里有一篇很棒的文章,解释了 DOM 属性和特性之间的区别:jakearchibald.com/2024/attrib...
命令式 API 的细微差别
setCustomValidity
API 仅作为一种方法暴露,没有与之对应的属性,这导致了使用体验不佳。我通过一个示例为你说明。
首先,简单介绍一下这个 API 的工作原理:
javascript
// 使输入无效
input.setCustomValidity("任意文本提示");
这会使输入变为无效状态,浏览器将显示原因 "任意文本提示"。
javascript
// 移除自定义限制,使输入有效
input.setCustomValidity("");
传入空字符串可使输入变为有效(前提是没有应用其他限制条件)。
就是这么简单!现在我们来应用这些知识。
假设我们想要实现一个类似于required
属性的功能,这意味着空输入必须阻止表单提交。
html
<input
name="example"
placeholder="..."
onChange={(event) => {
const input = event.currentTarget;
if (input.value === "") {
input.setCustomValidity("自定义提示:输入为空");
} else {
input.setCustomValidity("");
}
}}
/>
看起来我们似乎完成了任务,这段代码应该足以实现该功能。但实际运行一下就会发现问题:
它看似正常工作,但存在一个重要的边缘情况:输入初始状态是有效的。如果你重置组件并按下 "提交" 按钮,表单提交会成功。然而,在我们尚未输入内容时,输入框显然为空,理应是无效状态。但我们的代码仅在输入值发生变化时才进行处理。
如何解决这个问题呢?
我们可以在组件挂载时执行一些代码:
javascript
import { useRef, useLayoutEffect } from "react";
function Form() {
const ref = useRef();
useLayoutEffect(() => {
// 如果输入为空,在初始渲染时使输入无效
const input = ref.current;
const empty = input.value === "";
input.setCustomValidity(empty ? "初始提示:输入为空" : "");
}, []);
return (
<form>
<input
ref={ref}
name="example"
onChange={(event) => {
const input = event.currentTarget;
if (input.value === "") {
input.setCustomValidity("自定义提示:输入为空");
} else {
input.setCustomValidity("");
}
}}
/>
<button>提交</button>
</form>
);
}
很好!现在一切按预期工作。但代价是什么呢?
样板代码问题
看看我们验证初始值时繁琐的方式:
javascript
const ref = useRef();
useLayoutEffect(() => {
// 如果输入为空,在初始渲染时使输入无效
const input = ref.current;
const empty = input.value === "";
input.setCustomValidity(empty ? "初始提示:输入为空" : "");
}, []);
哎呀!肯定不想每次都写这样的代码。让我们思考一下问题出在哪里。
-
验证逻辑在
onChange
事件处理程序和初始渲染阶段重复; -
初始验证代码没有和输入框的代码放在一起,这破坏了代码的内聚性。这种结构很脆弱:如果更新验证逻辑,可能会忘记同时更新两个地方的代码;
-
useRef
+useLayoutEffect
+onChange
的组合过于繁琐,尤其是当表单中有大量输入框时。如果只有部分输入框使用customValidity
,情况会变得更加混乱。
这就是在声明式组件中使用纯命令式 API 会出现的问题。
与验证属性不同,CustomValidity
是一个纯命令式 API。换句话说,没有可以用来设置自定义有效性的输入属性。
实际上,我认为这就是原生表单验证使用率低的主要原因。如果 API 使用起来很麻烦,那么它再强大也无济于事。
缺失的部分
本质上,我们需要这样一个属性:
html
<input custom-validity="错误提示信息" />
在声明式框架中,这将允许以非常强大的方式定义输入验证:
javascript
function Form() {
const [value, setValue] = useState();
const handleChange = (event) => setValue(event.target.value);
return (
<form>
<input
name="example"
value={value}
onChange={handleChange}
custom-validity={value.length ? "请填写此字段" : ""}
/>
<button>提交</button>
</form>
);
}
很酷吧!至少我是这么认为的。不过你可能会反驳说,这仅仅实现了现有required
属性已经具备的功能,强大之处体现在哪呢?
让我来展示一下。但首先,由于目前 HTML 规范中实际上并没有custom-validity
属性,我们在用户代码中实现它。
javascript
function Input({ customValidity, ...props }) {
const ref = useRef();
useLayoutEffect(() => {
if (customValidity != null) {
const input = ref.current;
input.setCustomValidity(customValidity);
}
}, [customValidity]);
return <input ref={ref} {...props} />;
}
对于演示目的来说,这段代码已经足够。
如需用于生产环境的组件,请查看更完整的实现。
强大之处
现在我们来探索这种设计可以解决哪些复杂的实际问题。
在实际应用中,验证往往比本地检查更复杂。想象一下,用户名输入框只有在用户名未被占用时才有效。这需要向服务器发起异步请求,并涉及中间状态:在检查过程中,表单不应被视为有效。让我们看看我们的抽象设计如何处理这种情况。
试试这个示例。它使用required
属性防止输入为空,然后依靠customValidity
在加载状态和根据响应结果将输入标记为无效。
实现过程
首先,创建一个异步函数来检查用户名是否唯一,通过延迟来模拟服务器请求。
javascript
export async function verifyUsername(userValue) {
// 模拟网络延迟
await new Promise((r) => setTimeout(r, 3000));
const value = userValue.trim().toLowerCase();
if (value === "bad input") {
throw new Error("输入错误");
}
const validationMessage = value === "taken" ? "用户名已被占用" : "";
return { validationMessage };
}
接下来,创建一个受控表单组件,并在输入值变化时使用react-query
来管理服务器请求:
javascript
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { verifyUsername } from "./verifyUsername";
import { Input } from "./Input";
function Form() {
const [value, setValue] = useState("");
const { data, isLoading, isError } = useQuery({
queryKey: ["verifyUsername", value],
queryFn: () => verifyUsername(value),
enabled: Boolean(value),
});
return (
<form>
<Input
name="username"
required={true}
value={value}
onChange={(event) => {
setValue(event.currentTarget.value);
}}
/>
<button>提交</button>
</form>
);
}
很好!我们已经完成了基础设置。它包含两个关键部分:
-
由
useQuery
管理的验证请求状态; -
能够接收
customValidity
属性的自定义<Input />
组件。
让我们把这些部分组合起来:
javascript
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { verifyUsername } from "./verifyUsername";
import { Input } from "./Input";
function Form() {
const [value, setValue] = useState("");
const { data, isLoading, isError } = useQuery({
queryKey: ["verifyUsername", value],
queryFn: () => verifyUsername(value),
enabled: Boolean(value),
});
const validationMessage = data?.validationMessage;
return (
<form>
<Input
name="username"
required={true}
customValidity={
isLoading
? "正在验证用户名..."
: isError
? "无法验证"
: validationMessage
}
value={value}
onChange={(event) => {
setValue(event.currentTarget.value);
}}
/>
<button>提交</button>
</form>
);
}
就这样!我们用一个属性描述了整个异步验证流程,包括加载、错误和成功状态。如果你想回顾,可以再去看看效果。
另一个示例
这个示例会简短一些,但也很有趣,因为它涉及到相关联的输入字段。我们来实现一个需要重复输入密码的表单:
javascript
import { useState } from "react";
import { Input } from "./Input";
function ConfirmPasswordForm() {
const [password, setPassword] = useState("");
const [confirmedPass, setConfirmedPass] = useState("");
const matches = confirmedPass === password;
return (
<form>
<Input
type="password"
name="password"
required={true}
value={password}
onChange={(event) => {
setPassword(event.currentTarget.value);
}}
/>
<Input
type="password"
name="confirmedPassword"
required={true}
value={confirmedPass}
customValidity={matches ? "" : "两次输入的密码必须一致"}
onChange={(event) => {
setConfirmedPass(event.currentTarget.value);
}}
/>
<button>提交</button>
</form>
);
}
总结
家人们,咱这篇文章讲的是 HTML 表单验证,功能超强大,却没被充分利用。
HTML 表单验证方法有好几种,加required
属性,输入框就不能为空,用特定type
属性值,还有pattern
、maxlength
等属性,都能设置限制条件。其中setCustomValidity
这个 DOM 方法最厉害,能自己定义各种验证逻辑。但它有个大问题,只能通过方法调用,没有对应的属性。在 React 这类声明式组件里使用时,特别麻烦。验证初始值时,要写好多重复代码,代码内聚性也差。要是表单输入框多,那看着就更乱了,这也是大家不咋用它的主要原因。
我就琢磨着,要是有个custom-validity
属性就好了,这样在声明式框架里验证输入就简单多了。虽然 HTML 规范里目前还没有这个属性,不过我自己在代码里模拟实现了类似效果。
文章还举了两个例子,来展示customValidity
有多牛。一个是验证用户名,检查用户名是否被占用得向服务器发请求,这个过程结合useQuery
,用customValidity
就能把加载、出错和成功这些状态的验证提示处理得很到位。另一个是确认密码的表单,通过对比两次输入的密码,用customValidity
就能轻松保证两次输入一致。总之,HTML 表单验证潜力巨大,就等大家好好去挖掘利用啦!