你有过这种经历吗?每个表单页面都要写上几十行 if 判断来校验用户输入,还得手动控制提示文字,改一个字段名就要全局搜索替换。上个月我在 uniapp 项目里写了三个差不多的表单页面,每页都有手机号、验证码、同意协议这种组合,代码几乎复制粘贴。一时没忍住,就自己写了一个基于 Vue3 组合式 API 的 `useForm` 校验工具,从此表单验证变得轻松多了。
今天把思路和代码拆出来,虽然不是特别复杂,但能帮你在项目里统一表单校验逻辑,少写几百行重复代码。
首先,先看一段原始的表单校验代码
假设这是一个登录页,需要校验手机号和密码:
<template>
<view>
<input v-model="phone" placeholder="手机号" />
<input v-model="password" type="password" placeholder="密码" />
<button @tap="submit">登录</button>
</view>
</template>
<script setup>
import { ref } from 'vue'
const phone = ref('')
const password = ref('')
function submit() {
if (!phone.value) {
uni.showToast({ title: '请输入手机号', icon: 'none' })
return
}
if (!/^1[3-9]d{9}$/.test(phone.value)) {
uni.showToast({ title: '手机号格式不正确', icon: 'none' })
return
}
if (!password.value) {
uni.showToast({ title: '请输入密码', icon: 'none' })
return
}
if (password.value.length < 6) {
uni.showToast({ title: '密码至少6位', icon: 'none' })
return
}
// TODO 发送登录请求
}
</script>
以后再加一个“确认密码”和“同意协议”,你就会发现代码里全是重复的 if 和 return。校验规则一旦变动,比如手机号号段增加一个,你得在一个个页面里搜索。这种写法在页面少的时候还能忍,多了真是折磨。
核心思路:把校验规则抽离成数据
我的想法很简单:把每个字段的校验规则写成一个配置对象,然后用一个统一的 `validate` 方法遍历规则,遇到错误就返回第一条错误信息。页面里只需要调用 `validate()` 就能拿到是否通过,如果没通过,直接用 toast 报错。
这其实就是把命令式的 if 判断转换为声明式的规则描述。所以第一步,先定义一个规则对象的结构:
const rules = {
phone: [
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]d{9}$/, message: '手机号格式不正确' }
],
password: [
{ required: true, message: '请输入密码' },
{ minLength: 6, message: '密码至少6位' }
]
}
这个结构非常直观。`phone` 字段对应一个数组,数组里每个对象就是一个校验规则,`message` 是失败时的提示信息。你可以扩展任何规则,比如 `min`、`max`、`validator` 函数等。
然后我写一个通用校验函数,传入字段值以及这个字段的所有规则,返回错误信息或空字符串:
function validateField(value, ruleList) {
for (const rule of ruleList) {
if (rule.required && (value === undefined || value === null || String(value).trim() === '')) {
return rule.message
}
if (rule.pattern && !rule.pattern.test(value)) {
return rule.message
}
if (rule.minLength && value.length rule.maxLength) {
return rule.message
}
if (typeof rule.validator === 'function') {
const result = rule.validator(value)
if (result !== true) {
return result || rule.message
}
}
}
return ''
}
这里只是演示几种常见规则,你可以按项目需求继续扩展。
把校验方法封装成 useForm
光有校验规则还不够,我需要把它和页面里的表单数据关联起来。Vue3 的组合式函数正好适合做这件事。
我创建一个 `useForm.js` 文件,核心代码如下:
import { reactive, toRefs } from 'vue'
// 模拟 uni.showToast,方便测试
function showToast(message) {
uni.showToast({
title: message,
icon: 'none',
duration: 2000
})
}
export function useForm(formData, rules) {
// 表单数据
const state = reactive({
values: formData,
errors: {}
})
// 校验单个字段
const validateField = (field) => {
const value = state.values[field]
const ruleList = rules[field]
if (!ruleList) return true
const message = checkValue(value, ruleList)
state.errors[field] = message
return !message
}
// 校验整个表单
const validate = () => {
const fields = Object.keys(rules)
let firstErrorMessage = ''
for (const field of fields) {
const isValid = validateField(field)
if (!isValid && !firstErrorMessage) {
firstErrorMessage = state.errors[field]
}
}
if (firstErrorMessage) {
showToast(firstErrorMessage)
return false
}
return true
}
// 校验过程中如果用户修改某个字段,清除对应的错误信息
const clearError = (field) => {
if (state.errors[field]) {
delete state.errors[field]
}
}
// 为了防止多个表单实例混在一起,这里返回一个绑定到当前表单的方法
return {
values: toRefs(state.values), // 如果需要在模板中 v-model 使用,也可以用 toRefs
errors: state.errors,
validate,
validateField,
clearError
}
}
这里有个需要注意的点,`formData` 我直接用 `reactive` 包起来了,所以初始化必须是对象。然后返回的值中 `values` 用 `toRefs` 解构,让你在模板里能直接使用 `phone` 这样的响应式属性。
不过上面我写的 `checkValue` 函数还没实现,别急,我把它一起补上:
function checkValue(value, ruleList) {
for (const rule of ruleList) {
if (rule.required && (value === undefined || value === null || String(value).trim() === '')) {
return rule.message
}
if (rule.pattern && !rule.pattern.test(String(value))) {
return rule.message
}
if (rule.minLength !== undefined && String(value).length rule.maxLength) {
return rule.message
}
if (typeof rule.validator === 'function') {
const res = rule.validator(value)
if (res !== true) {
return res || rule.message
}
}
}
return ''
}
至此,`useForm` 的核心功能已经完成。接下来把它用在页面里。
在登录页里使用 useForm
改造后的登录页面如下:
<template>
<view>
<input v-model="phone" placeholder="手机号" type="text" />
<input v-model="password" type="password" placeholder="密码" />
<button @tap="submit">登录</button>
</view>
</template>
<script setup>
import { useForm } from '@/utils/useForm'
// 初始化要校验的字段
const rules = {
phone: [
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]d{9}$/, message: '手机号格式不正确' }
],
password: [
{ required: true, message: '请输入密码' },
{ minLength: 6, message: '密码至少6位' }
]
}
const { values, errors, validate } = useForm({
phone: '',
password: ''
}, rules)
const { phone, password } = values
function submit() {
if (validate()) {
// 校验通过,执行登录逻辑
uni.showLoading({ title: '登录中' })
// 模拟请求
setTimeout(() => {
uni.hideLoading()
uni.showToast({ title: '登录成功', icon: 'success' })
}, 500)
}
}
</script>
这里我把 `phone` 和 `password` 从 `values` 里解构出来,直接用 `v-model=”phone”` 就能双向绑定,因为 `values` 是响应式对象,解构出来的属性依然是响应式的引用。注意不能这样写:
const { phone } = values
这样 `phone` 是一个固定值,不会自动更新。所以必须用 `toRefs` 或者直接使用 `values.phone`。上面我使用 `const { phone, password } = values` 实际上解构的是 refs。在 `useForm` 中返回的 `values` 是通过 `toRefs` 转换过的,所以 `phone` 是 ref 对象,在模板中自动解包,在 script 中要 `phone.value`。同样,方法内可以正常使用。
由于我在模板里写了 `v-model=”phone”`,这事就自动完成了。现在当你点击登录时,`validate()` 会依次检查所有字段,如果有错,它会在内部调用 `uni.showToast`,返回 `false`;只有全部通过时才返回 `true`,继续执行登录。
这样,表单校验逻辑完全从业务代码中剥离,以后想要修改提示语,只需要在 rules 里改,不用再动 `submit` 函数。
提高扩展性:加入自定义校验函数
有些字段的校验光靠 patterns 和 minLength 不够,比如需要验证两次密码是否一致。这可以用 `validator` 函数解决。看一个注册页面的例子:
const form = useForm({
username: '',
password: '',
confirmPassword: ''
}, {
username: [
{ required: true, message: '请填写用户名' },
{ minLength: 3, message: '用户名至少3个字符' }
],
password: [
{ required: true, message: '请填写密码' },
{ minLength: 6, message: '密码至少6位' }
],
confirmPassword: [
{ required: true, message: '请再次输入密码' },
{
validator: (value) => {
if (value !== form.values.password.value) {
return '两次密码输入不一致'
}
return true
}
}
]
})
由于 `useForm` 内部提供了一个 `form.values`,你可以在校验函数里访问其他字段的值。注意上面 `form.values.password` 是 ref 对象,所以要加 `.value`。如果不喜欢 `.value`,也可以在规则定义前先提取 `const passwordRef = ref(”)`,然后直接使用 `passwordRef.value`。
这种写法非常灵活,几乎能覆盖所有场景。而且它只返回一个字符串,如果返回的是 `true` 则通过,其他任意字符串都会作为错误提示显示。
进阶:在输入框交互时清除错误提示
一个好的表单体验是:当用户开始修改某个字段,该字段的错误提示应该立即消失。我可以在 `useForm` 里暴露 `clearError` 方法,然后在页面里监听 input 事件。但为了更简单,我直接给输入框绑定一个 `@input` 事件:
<input v-model="phone" @input="clearError('phone')" placeholder="手机号" />
这样当用户输入第一个字时,错误信息就会被移除。你可以配合一个用于显示错误文字的小标签:
<view v-if="errors.phone" class="error">{{ errors.phone }}</view>
但为了不破坏模板,我给 `useForm` 增加一个 `setErrorsField` 方法,或者更直接,在 `validateField` 里自动清除旧的错误。因为我们在 `validateField` 里已经在每次校验时为新错误赋值,如果校验通过,我们直接把该字段的 `error` 删除即可。修改 `validateField` 实现:
const validateField = (field) => {
const value = state.values[field]
const ruleList = rules[field]
if (!ruleList) {
// 没有规则,默认清除错误
delete state.errors[field]
return true
}
const message = checkValue(value, ruleList)
if (message) {
state.errors[field] = message
} else {
delete state.errors[field]
}
return !message
}
这样当你调用 `validateField` 时,它会自动更新错误状态,不需要手动清除。你只需要在输入框上绑定 `@blur=”validateField(‘phone’)”`,失焦时校验该字段,效果立刻就有了。
解决多实例之间共享状态的问题
一开始我写了一个全局单例,结果发现两个页面同时使用时会相互污染。所以我在 `useForm` 内部使用了 `reactive` 创建独立的状态,每个表单实例都有自己的 `state`,完全隔离。这是组合式函数的一大优势,你只管调用,它会自己管理作用域。
还有一点,如果你在多个页面同时引入 `useForm`,它不会出现 Vuex 那种全局状态互相干扰的问题。因为内部数据是函数作用域内的局部变量,每次调用 `useForm` 都会生成新的 `state`。
额外功能:返回错误字段并高亮聚焦
校验失败时,如果能把滚动条滚动到第一个出错的输入框并聚焦,用户体验会更好。在 uni-app 里这可以通过 `uni.pageScrollTo` 和 selector 实现。我们可以让 `validate` 返回第一个出错的字段名,然后在页面中处理:
const validate = () => {
const fields = Object.keys(rules)
let firstErrorField = ''
let firstMessage = ''
for (const field of fields) {
const message = checkValue(state.values[field], rules[field])
state.errors[field] = message
if (message && !firstErrorField) {
firstErrorField = field
firstMessage = message
}
}
if (firstErrorField) {
showToast(firstMessage)
// 可以通过 id 或 ref 聚焦
// #ifdef H5
// document.getElementById(`field-${firstErrorField}`)?.focus()
// #endif
return { valid: false, field: firstErrorField }
}
return { valid: true }
}
然后在页面里给需要聚焦的输入框加上 `id`,比如 `:id=”‘field-phone'”`。在 `validate` 返回有错误后,调用 `uni.pageScrollTo` 滚动到对应位置,再手动调 `uni.createSelectorQuery()` 拿到节点上下文并调用 `.focus()`。不过这个操作有些繁琐,而且小程序里需要 `focus` 属性,不是所有平台都支持。为了保持代码统一,我在项目里只做了 toast 提示,没有做自动聚焦,目前也够用。
如果你需要这个功能,可以单独去实现,思路就是通过 `uni.createSelectorQuery().select(‘#field-‘ + field).boundingClientRect()` 获取位置,然后 `uni.pageScrollTo({scrollTop})`。
这个 useForm 还能用在那些场景?
不光是登录和注册,任何需要收集表单数据的页面都可以用。比如填写收货地址、发布文章、实名认证等等。我最爽的是在几个需要多类型校验的页面里,直接定义 rules,然后调用 `validate()`,再也不用一个字段一个字段地写了。
如果你有特殊校验逻辑,比如检查用户名是否已存在,可以在 validator 里直接发起异步请求,但要注意返回值是个 Promise。这里为了简单,我们没有做异步校验。不过可以在页面里先校验当前字段,通过后再单独发送异步请求。
比如这样改:
validator: async (value) => {
const res = await api.checkUsername(value)
return res.exists ? '用户名已被占用' : true
}
然后在 `validateField` 里对 validator 返回的 Promise 做处理。但为了不把 `useForm` 的同步逻辑复杂化,我一般会把异步校验单独拿出去,在页面里用额外的方法调用。毕竟同步校验已经覆盖了 80% 的场景。
总结:亲手造轮子其实也挺好
这个 `useForm` 不同于第三方插件,它就是一套非常契合 uniapp + Vue3 的轻量方案。虽然它没有像 VeeValidate 那样强大,但它让我理解了表单校验的本质:规则驱动、统一报错、自动清除。
从最开始的复制粘贴,到后来只需传 rules 就能完成校验,维护起来轻松多了。如果你也在用 uniapp 和 Vue3,不妨把这段逻辑抽出来,根据自己的业务场景扩展一下,也许就是你想要的表单解决方案。
如果之后有空,我还会给它加上异步校验、字段联动、自动聚焦等功能。今天这个版本已经让我的项目里少了几百行 if,也让我更加喜欢组合式 API 了。

