最近在做一个商会小程序的入会申请模块,遇到了一个绕不开的问题:不同商会的入会表,字段完全不一样。
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 变了老数据怎么处理。这些事想清楚了,代码本身反而很简单。
希望对做类似需求的同学有点参考。你们的动态表单是怎么处理的,评论区可以交流一下。
