别再前后端各写一套表单校验了
先说结论
若依默认给了前端 Element-Plus 的 rules + 后端 JSR-303 注解两套校验规则。字段一改要改两处、还容易不一致------「总对不上」的根因就在此。
我们的做法:校验全部收口到后端,前端不维护任何校验 rules(除了必填),后端返回错误列表(带 i18n key),前端只负责定位到对应输入框 + 展示翻译后的文案。一套代码、一处改动、语言切换自动跟随。
若依默认的双套校验长啥样
以用户管理为例,若依生成的 CRUD 页面里:
js
// 前端 rules --- 每个字段都要写一遍
const rules = reactive({
username: [
{ required: true, message: '请输入登录账号', trigger: 'blur' },
{ pattern: /^.{2,20}$/, message: '长度在 2 到 20 个字符', trigger: 'blur' }
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
]
})
java
// 后端 @Valid 注解 --- 同样的约束写一遍
@Size(min = 2, max = 20, message = "登录账号长度必须在 2 到 20 个字符之间")
private String username;
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
两个问题:
- 改了规则要改两处:username 从 20 改成 30?rules 改一下、Java 注解改一下,忘了就对不上。
- 报错信息可能不一致:前端提示「长度在 2 到 20 个字符」、后端报「长度必须在 2 到 20 个字符之间」。
这还不是最麻烦的------业务级唯一性校验更头疼。比如「登录名不能重复」「手机号已存在」,这种逻辑天然在后端,但为了体验好,很多人会顺手在前端也做一轮预检查,于是又多一套......越来越乱。
我们的方案:校验统一后端,前端只做展示
设计思路
核心原则:校验是后端的责任,前端只是 UI 表现层。
具体做法分三步:
- 后端 JSR-303 校验失败或业务唯一性校验失败时,一次性返回所有字段错误(不再逐条弹)。
- 错误数据携带 i18n key,前端按当前语言翻译。
- 前端用
el-form-item :error把错误定位到对应输入框,不弹窗、不分散。
后端实现
1. 定义字段错误 DTO
java
/**
* 参数校验字段错误。
* 后端 JSR-303 校验失败时,一次性返回所有字段的错误。
* <p>
* message 是后端翻译后的文案(供直接调接口方阅读);
* key 是 i18n 消息键(供前端 vue-i18n 翻译,切换语言跟随)。
*/
@Data
public class FieldErrorVo implements Serializable {
/** 字段名(与前端表单 prop 对应) */
private String field;
/** 后端翻译后的文案 */
private String message;
/** i18n 消息键 */
private String key;
}
三个字段各司其职:field 用来让前端找到对应的输入框,message 给非前端调用方看,key 让前端能用 vue-i18n 翻译并跟随语言切换。
2. 自定义异常
java
/**
* 字段级业务校验异常。
* 与 BusinessException 区别:携带字段名,全局处理器转成 FieldErrorVo 列表。
*/
public class FieldErrorException extends RuntimeException {
private final String field;
public FieldErrorException(String field, String message) {
super(message);
this.field = field;
}
public String getField() {
return field;
}
}
用于服务层的唯一性校验等场景(如「登录名已存在」),抛出时带上字段名,全局处理器会自动转换成 FieldErrorVo。
3. 全局异常处理器
关键改动在 GlobalExceptionHandler:
java
@RestControllerAdvice
public class GlobalExceptionHandler {
/** 参数校验失败:一次性返回所有字段错误 */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiDataResult<List<FieldErrorVo>> handleValid(MethodArgumentNotValidException e) {
List<FieldErrorVo> errors = e.getBindingResult().getFieldErrors().stream()
.map(f -> {
String key = f.getDefaultMessage();
return new FieldErrorVo(f.getField(),
I18nMessage.getMessage(key), key);
})
.toList();
return ApiDataResult.other(CODE_ERROR, "参数校验失败", errors);
}
/** 字段级业务校验:转单字段错误 */
@ExceptionHandler(FieldErrorException.class)
public ApiDataResult<List<FieldErrorVo>> handleFieldError(FieldErrorException e) {
String key = e.getMessage();
return ApiDataResult.other(CODE_ERROR, "参数校验失败",
List.of(new FieldErrorVo(e.getField(),
I18nMessage.getMessage(key), key)));
}
}
MethodArgumentNotValidException 处理 @Valid 触发的参数校验失败------遍历所有 FieldError,提取字段名和错误消息,通过 I18nMessage 翻译后组装成 FieldErrorVo。
FieldErrorException 处理器同理,不过是从自定义异常中提取信息。
注意这里返回的是 ApiDataResult<List<FieldErrorVo>>,和普通业务异常走的 ApiOperaResult 不同------因为需要携带结构化数据。
前端实现
1. 响应拦截器:识别字段错误响应
js
// 响应拦截器
service.interceptors.response.use(
async (response) => {
const res = response.data
if (res.code === 200) return res
if (res.code === 401) { /* refresh token 重试... */ }
// 字段校验失败:data 是数组
if (res.code === 500 && Array.isArray(res.data)) {
const err = new Error(res.msg || '参数校验失败')
err.fieldErrors = res.data // 附加字段
return Promise.reject(err)
}
ElMessage.error(res.msg || '请求失败')
return Promise.reject(new Error(res.msg || '请求失败'))
},
// ...
)
重点在这里:后端校验失败的响应走通用错误码 500,data 字段是 FieldErrorVo[] 数组。拦截器检测到这个模式后,把数组附加到错误对象上 reject------这样调用方的 catch 可以区分出这是字段校验错误还是普通错误。
2. 组件内捕获 + 定位输入框
vue
<template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item prop="username" :error="fieldErrors.username">
<el-input v-model="form.username" />
</el-form-item>
<!-- 更多字段 -->
</el-form>
</template>
<script setup>
const formRef = ref()
const fieldErrors = ref({})
async function submitForm() {
try {
await api.submit(form)
// 成功后清空错误
clearFieldErrors()
} catch (e) {
if (e.fieldErrors && e.fieldErrors.length) {
clearFieldErrors()
// 直接存后端翻译好的 message
e.fieldErrors.forEach((f) => {
fieldErrors[f.field] = f.message
})
}
}
}
function clearFieldErrors() {
fieldErrors.value = {}
}
</script>
el-form-item 的 :error 绑定到 fieldErrors[fieldName]------这就是最关键的映射关系。后端返回 { field: 'username', message: '...' },前端直接挂到 fieldErrors.username,输入框下方自然显示错误提示。
3. 清除错误 + 语言切换跟随
两个实用细节:
js
// 内容更改时自动清除错误------避免用户已经改对了但错误还在
watch(() => form.username, () => {
fieldErrors.value.username = ''
})
因为我们直接把后端翻译好的 message 传给前端展示,切换语言时后端需要重新翻译。实际方案中,前端存储的是后端按当前请求语言翻译后的结果------如果需要纯前端翻译跟随语言切换,则改为存 key,由 fieldErrors[f.field] = $t(key) 来翻译。
踩坑记录
坑一:刷新页面跳 404(动态路由 redirect 时序)
做完校验统一后部署测试,发现一个诡异 bug:直接打开子页面 URL 或刷新后,页面跳转到 404。
根因是 Vue Router 的动态路由挂载时序问题。若依采用后端拉权限→前端生成路由的模式:
js
router.beforeEach(async (to, from, next) => {
// 如果没有已加载的权限,先拉权限再 addRoute
if (!hasLoadedPermissions) {
await userStore.getInfo()
const routes = await permissionStore.generateRoutes()
routes.forEach(route => router.addRoute(route))
// 动态路由刚挂载完,需要重新导航到目标页
// 但 Vue Router 4 的通配符路由 /:pathMatch(.*)* 的 redirect
// 在 beforeEach 之前就已经把 /system/user 重定向到 /404
// 此时 to.fullPath 已经是 /404,原始路径丢了
next({ path: to.redirectedFrom?.fullPath ?? to.fullPath, replace: true })
} else {
next()
}
})
修复方式:利用 to.redirectedFrom 拿到被通配符重定向前的原始路径,重新导航。关键是一步到位 replace: true,避免二次跳转闪烁。
坑二:setFieldError 不是 Element-Plus 的 API
早期版本尝试用 formRef.value.setFieldError('username', '错误信息') 来动态设置字段错误提示,结果报错找不到方法。后来查文档确认:Element-Plus 的 FormInstance 没有 setFieldError 方法------这是 Element-UI(Vue 2 版)才有的。
改用 el-form-item :error 绑定解决了,而且写法更简洁,一个绑定覆盖所有字段。
坑三:删除旧的前端校验 rules 后,必填验证丢失
最初做减法时一刀切去掉了整个 rules 对象,结果表单连「请输入登录账号」这种必填提示都没了。正确的做法是保留最基础的必填校验,只去掉那些和业务相关的复杂规则(正则、长度范围等)------这些才是应该交给后端的:
js
// 只保留必填,其余交给后端
const rules = reactive({
username: [{ required: true, trigger: 'blur' }]
// 其他字段同理,只有 required: true
})
效果对比
| 维度 | 双套校验(改前) | 后端单源(改后) |
|---|---|---|
| 新增/修改校验规则 | 两处都要改,容易漏 | 只改后端注解 |
| 报错信息一致性 | 前后可能文字不一 | 同一份 i18n 资源 |
| 语言切换 | 前端单独维护一套 i18n | 后端翻译,前端直接展示 |
| 用户体验 | 多个弹窗分散注意力 | 直接在对应输入框下标红 |
| 代码行数 | 约 80 行 rules + 弹窗 | 约 20 行 :error 绑定 |
全模块(角色、部门、字典、菜单、岗位、参数、租户、公告)迁移完毕后,相关代码减少了约 60 行。更重要的是,以后每加一个新模块、或调整一条校验规则,只需要动后端。
总结
表单校验这件事,看似简单实则暗藏很多细节。若依开箱即用方便,但默认的双套校验方案在长期维护中暴露出不少问题------字段一改改两处、报错信息可能打架、多语言要维护两套文案。
把校验统一收口到后端,前端只做错误展示定位,看似少了一层前端防护,但实际上:网络请求一定会经过后端,前端校验只能算锦上添花。真正该负责的应该是后端。
这套方案的核心就是三步:后端返回结构化错误列表 → 前端用 :error 绑定定位到输入框 → i18n 跟随语言切换。简单、有效、好维护。
标签:Java、Spring Boot、Element-Plus、表单校验、若依
摘要:若依默认前后端各维护一套表单校验规则,改一处要改两处还容易对不上。我们把校验统一收口到后端,前端只用 el-form-item :error 展示错误。讲讲设计思路和踩过的那些坑。