Vue3 + uni-app 给商会小程序做一个动态表单引擎(

最近在做一个商会小程序的入会申请模块,遇到了一个绕不开的问题:不同商会的入会表,字段完全不一样。

A 商会要收企业全称、统一社会信用代码、主营业务、上一年营收;B 商会要收姓名、职务、所属行业、入会推荐人;C 商会除了这些,还要收专委会意向、是否愿意公开联系方式,甚至有两个字段是"只有选了制造业才显示"。

如果每接一家商会就改一次代码,三条分支还能忍,三十家就崩了。所以这套东西必须做成配置驱动------后端下发一份表单配置(schema),前端按配置渲染、按配置校验、按配置提交。

这篇记录一下这整套东西是怎么搭的,以及中间踩的几个真实的坑。

一、schema 怎么设计

先定数据结构。这份 schema 要能描述四件事:字段是什么类型、怎么校验、什么条件下显示、怎么排版。

typescript 复制代码
// types/form-schema.ts
export type FieldType =
  | 'input'      // 单行文本
  | 'textarea'   // 多行文本
  | 'number'     // 数字
  | 'select'     // 单选(picker)
  | 'multi'      // 多选
  | 'date'       // 日期
  | 'region'     // 地区
  | 'image'      // 图片上传
  | 'switch'     // 开关

export interface FieldSchema {
  key: string                 // 提交时的字段名
  label: string               // 展示标签
  type: FieldType
  placeholder?: string
  required?: boolean
  options?: { label: string; value: string | number }[]
  rules?: RuleItem[]          // 校验规则
  visibleWhen?: Condition     // 条件显示
  maxLength?: number
  unit?: string               // 数字后面的单位,比如"万元"
  group?: string              // 分组,用于分步骤渲染
  defaultValue?: unknown
}

export interface Condition {
  field: string
  op: 'eq' | 'ne' | 'in' | 'gt'
  value: unknown
}

export interface FormSchema {
  version: number
  groups: { key: string; title: string; fields: FieldSchema[] }[]
}

这里有几个决定是事后看来对的。

一是把 group 设计成数组而不是平铺的字段列表。商会的表通常很长,三十个字段平铺在手机上一屏滚到底,用户填到一半就跑了。分组之后可以做成分步表单,每屏五六个字段,完成率明显不一样。

二是 visibleWhen 只支持一个条件,不支持与或嵌套。这是刻意的------业务方一旦发现有嵌套条件,就会开始设计非常复杂的联动,最后没人维护。我们限制成单层条件,需要复杂的就拆成两次判断在业务层做。

三是 options 直接内联在 schema 里,不做远程字典。因为商会的表单选项(行业分类、会员级别)基本是稳定的,内联之后渲染逻辑简单很多。个别需要远程的选项(比如地区数据),单独用 type 区分。

二、渲染器怎么写

渲染器的核心是一个递归/循环组件,根据 type 分发到不同的输入控件。用 Vue3 的 script setup 写起来很干净:

vue 复制代码
<!-- components/SchemaField.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import type { FieldSchema } from '@/types/form-schema'

const props = defineProps<{ field: FieldSchema; modelValue: any }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: any): void }>()

const value = computed({
  get: () => props.modelValue,
  set: (v) => emit('update:modelValue', v)
})

function onPickerChange(e: any) {
  const idx = e.detail.value
  value.value = props.field.options?.[idx]?.value
}
</script>

<template>
  <view class="field">
    <view class="label">
      <text v-if="field.required" class="required">*</text>
      <text>{{ field.label }}</text>
    </view>

    <input
      v-if="field.type === 'input'"
      v-model="value"
      class="control"
      :placeholder="field.placeholder"
      :maxlength="field.maxLength || 100"
    />

    <textarea
      v-else-if="field.type === 'textarea'"
      v-model="value"
      class="control control-area"
      :placeholder="field.placeholder"
      :maxlength="field.maxLength || 500"
    />

    <picker
      v-else-if="field.type === 'select'"
      :range="field.options || []"
      range-key="label"
      :value="currentIndex"
      @change="onPickerChange"
    >
      <view class="control control-picker">
        {{ currentLabel || field.placeholder }}
      </view>
    </picker>

    <switch
      v-else-if="field.type === 'switch'"
      :checked="!!value"
      @change="(e: any) => (value = e.detail.value)"
    />
  </view>
</template>

外层再套一个循环:

vue 复制代码
<!-- components/SchemaForm.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'
import SchemaField from './SchemaField.vue'
import type { FormSchema } from '@/types/form-schema'

const props = defineProps<{ schema: FormSchema; model: Record<string, any> }>()

// 条件显示:每次 model 变化重新计算
const visibleMap = computed(() => {
  const map: Record<string, boolean> = {}
  props.schema.groups.forEach((g) => {
    g.fields.forEach((f) => {
      map[f.key] = !f.visibleWhen || match(f.visibleWhen, props.model)
    })
  })
  return map
})

function match(c: Condition, model: Record<string, any>) {
  const v = model[c.field]
  switch (c.op) {
    case 'eq': return v === c.value
    case 'ne': return v !== c.value
    case 'in': return Array.isArray(c.value) && c.value.includes(v)
    case 'gt': return Number(v) > Number(c.value)
    default: return true
  }
}
</script>

<template>
  <view v-for="g in schema.groups" :key="g.key" class="group">
    <view class="group-title">{{ g.title }}</view>
    <SchemaField
      v-for="f in g.fields"
      v-show="visibleMap[f.key]"
      :key="f.key"
      :field="f"
      v-model="model[f.key]"
    />
  </view>
</template>

三、校验:async-validator + 只校验可见字段

校验用 async-validator,规则直接从 schema 的 rules 转过来。这里有个容易漏的点:隐藏字段不能参与校验。

typescript 复制代码
import Schema from 'async-validator'
import type { FormSchema } from '@/types/form-schema'

export async function validate(
  schema: FormSchema,
  model: Record<string, any>,
  visibleMap: Record<string, boolean>
): Promise<{ ok: boolean; firstError?: string }> {
  const descriptor: Record<string, any> = {}

  schema.groups.forEach((g) => {
    g.fields.forEach((f) => {
      // 关键:隐藏字段直接跳过
      if (!visibleMap[f.key]) return
      const rules: any[] = []
      if (f.required) rules.push({ required: true, message: `请填写${f.label}` })
      if (f.rules) rules.push(...f.rules)
      if (rules.length) descriptor[f.key] = rules
    })
  })

  const validator = new Schema(descriptor)
  try {
    await validator.validate(model, { firstFields: true })
    return { ok: true }
  } catch (err: any) {
    return { ok: false, firstError: err.errors?.[0]?.message || '请检查填写内容' }
  }
}

跳过隐藏字段这件事,第一版我们忘了做,结果用户选了"服务业",提交的时候提示"请填写生产许可证编号"------那个字段是制造业才显示的,根本没渲染出来。用户一脸懵。

四、草稿续填

入会表很长,用户填到一半退出的情况非常普遍。我们的草稿方案是双层的:本地即时存 + 服务端定时存。

typescript 复制代码
// composables/useFormDraft.ts
import { watch } from 'vue'

export function useFormDraft(formId: string, model: Record<string, any>) {
  const localKey = `draft:${formId}`

  // 本地:任何一次修改都写入 storage,退出再进还在
  watch(model, (v) => {
    uni.setStorageSync(localKey, JSON.stringify({ data: v, ts: Date.now() }))
  }, { deep: true })

  // 服务端:每 30 秒同步一次,换设备也能续填
  let timer: number | null = null
  function startSync() {
    timer = setInterval(async () => {
      await uni.request({
        url: '/api/form/draft',
        method: 'POST',
        data: { formId, payload: JSON.stringify(model) }
      })
    }, 30_000) as unknown as number
  }

  function restore() {
    const cached = uni.getStorageSync(localKey)
    if (!cached) return null
    const { data, ts } = JSON.parse(cached)
    if (Date.now() - ts > 7 * 24 * 3600 * 1000) {
      uni.removeStorageSync(localKey)
      return null
    }
    Object.assign(model, data)
    return data
  }

  function clear() {
    uni.removeStorageSync(localKey)
  }

  return { startSync, restore, clear }
}

这里有个细节:草稿要带 schema 的 version。商会改了表单配置之后,老草稿里的字段可能已经不存在了,直接回填会出现一堆没渲染出来的脏数据。我们的做法是恢复时按当前 schema 做一次过滤,只保留还存在的 key。

五、踩过的坑

坑一:v-show 和 picker 的值残留。用 v-show 隐藏字段(而不是 v-if)是为了保留已填的值,但 uni-app 的 picker 组件在 v-show 切换回来的时候,显示的文字有时候不会刷新。解决办法是给 picker 加 :key 绑定当前值,强制重新渲染:

vue 复制代码
<picker :key="`${field.key}-${value}`" ... />

坑二:校验时机。一开始我们做的是"失焦即校验",结果用户刚点进输入框、还没打字,就飘红一片。后来改成"失焦校验 + 提交时全量校验",且失焦时只对已填写但格式错误的字段报错,不报必填错误。必填错误只在点提交的时候提示。

坑三:数字输入的类型。uni-app 的 input 拿到的永远是字符串,提交前要按 schema 的 type 转一次。特别是空字符串转数字会变成 0,会费的默认值就变成了 0 元。我们的转换函数里对空串统一返回 undefined。

坑四:schema 的热更新。商会在后台改了表单配置,小程序端如果缓存了老 schema,用户填完提交会报字段不匹配。我们的做法是 schema 请求带 ETag,每次进页面做一次协商缓存校验,变更了就提示"表单已更新,是否重新填写"。

六、效果

这套东西上线之后,接一家新商会的入会表单,从原来的两到三天开发,变成了后台配一份 JSON、二十分钟搞定。字段调整不用发版,改完配置刷新就生效。表单的填写完成率也有提升,主要是分步渲染和草稿续填两个改动带来的。

现在这套动态表单也用在未来漫城·商会互联平台的小程序端,不同商会的入会表、活动报名表、需求登记表都走同一套引擎,区别只是后台配置的 schema 不一样。

最后说一句感受:动态表单这类需求,技术上没什么高深的地方,难的是把边界情况想全------隐藏字段要不要校验、草稿过期怎么办、schema 变了老数据怎么处理。这些事想清楚了,代码本身反而很简单。

希望对做类似需求的同学有点参考。你们的动态表单是怎么处理的,评论区可以交流一下。

相关推荐
烂蜻蜓26 分钟前
Flask入门教程(二十四):请求对象API——获取客户端数据的完整指南
后端·python·flask
geovindu27 分钟前
CSharp: Template Method Pattern
开发语言·后端·c#·.net·模板方法模式·行为模式
步行cgn33 分钟前
Spring Boot application.properties 配置文件详解
后端
分支预测失败36 分钟前
RISC-V 虚拟内存与 MMU 实战:从 Sv39 页表到 Linux 地址空间
后端
步行cgn36 分钟前
@SpringBootTest 详解:Spring Boot 测试的核心注解
后端
对象存储与RustFS41 分钟前
给 RustFS 拆多租户权限:IAM 用户、组与策略的实战
后端·rust·开源
ZGG0031 小时前
MCP 02:MCP 的各类比对——和 Function Calling、A2A、Skill 到底什么关系
人工智能·后端·python
IT_陈寒1 小时前
Vite热更新突然失效?可能是这个配置在捣鬼
前端·人工智能·后端
SelectDB技术团队2 小时前
统一全文检索与 SQL 分析:Apache Doris 日志分析实践
大数据·数据结构·后端·python·全文检索·doris·日志分析