别再前后端各写一套表单校验了

0 阅读4分钟

别再前后端各写一套表单校验了

先说结论

若依默认给了前端 Element-Plus 的 rules + 后端 JSR-303 注解两套校验规则。字段一改要改两处、还容易不一致——「总对不上」的根因就在此。

我们的做法:校验全部收口到后端,前端不维护任何校验 rules(除了必填),后端返回错误列表(带 i18n key),前端只负责定位到对应输入框 + 展示翻译后的文案。一套代码、一处改动、语言切换自动跟随。


若依默认的双套校验长啥样

以用户管理为例,若依生成的 CRUD 页面里:

// 前端 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' }
  ]
})
// 后端 @Valid 注解 — 同样的约束写一遍
@Size(min = 2, max = 20, message = "登录账号长度必须在 2 到 20 个字符之间")
private String username;

@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;

两个问题:

  1. 改了规则要改两处:username 从 20 改成 30?rules 改一下、Java 注解改一下,忘了就对不上。
  2. 报错信息可能不一致:前端提示「长度在 2 到 20 个字符」、后端报「长度必须在 2 到 20 个字符之间」。

这还不是最麻烦的——业务级唯一性校验更头疼。比如「登录名不能重复」「手机号已存在」,这种逻辑天然在后端,但为了体验好,很多人会顺手在前端也做一轮预检查,于是又多一套……越来越乱。


我们的方案:校验统一后端,前端只做展示

设计思路

核心原则:校验是后端的责任,前端只是 UI 表现层。

具体做法分三步:

  1. 后端 JSR-303 校验失败或业务唯一性校验失败时,一次性返回所有字段错误(不再逐条弹)。
  2. 错误数据携带 i18n key,前端按当前语言翻译。
  3. 前端用 el-form-item :error 把错误定位到对应输入框,不弹窗、不分散。

后端实现

1. 定义字段错误 DTO
/**
 * 参数校验字段错误。
 * 后端 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. 自定义异常
/**
 * 字段级业务校验异常。
 * 与 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

@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. 响应拦截器:识别字段错误响应
// 响应拦截器
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. 组件内捕获 + 定位输入框
<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. 清除错误 + 语言切换跟随

两个实用细节:

// 内容更改时自动清除错误——避免用户已经改对了但错误还在
watch(() => form.username, () => {
  fieldErrors.value.username = ''
})

因为我们直接把后端翻译好的 message 传给前端展示,切换语言时后端需要重新翻译。实际方案中,前端存储的是后端按当前请求语言翻译后的结果——如果需要纯前端翻译跟随语言切换,则改为存 key,由 fieldErrors[f.field] = $t(key) 来翻译。


踩坑记录

坑一:刷新页面跳 404(动态路由 redirect 时序)

做完校验统一后部署测试,发现一个诡异 bug:直接打开子页面 URL 或刷新后,页面跳转到 404

根因是 Vue Router 的动态路由挂载时序问题。若依采用后端拉权限→前端生成路由的模式:

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 对象,结果表单连「请输入登录账号」这种必填提示都没了。正确的做法是保留最基础的必填校验,只去掉那些和业务相关的复杂规则(正则、长度范围等)——这些才是应该交给后端的:

// 只保留必填,其余交给后端
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 展示错误。讲讲设计思路和踩过的那些坑。