系列文章《鸿蒙开发从入门到专家》第 54 篇 · 高级
每个中大应用都有复杂表单:注册、配置、报销、审批。字段多则上百,字段间联动复杂(选了 A 项 B 变必填,C 项隐藏,D 的选项列表刷新)。手写几十个 @State 变量加一堆 if/else 能跑,但维护成本随字段数指数增长。
这篇讲怎么用"JSON Schema 驱动 + 校验引擎 + 依赖追踪"的方式搭一个可复用的表单引擎。不是理论框架,是能落地的代码。
一、问题拆解
一个典型的复杂表单需求:
字段数量:30-100 个
字段类型:文本、数字、选择器、日期、文件上传、级联选择
联动规则:字段 A 值变化 → 字段 B 显隐/必填/选项刷新
校验规则:必填、正则、范围、自定义函数、异步校验
提交时机:实时校验、失焦校验、提交时全量校验
如果用传统方式(每个字段一个 @State + 手写校验 + 手写联动逻辑),100 个字段的表单代码量在 3000 行以上,且改动一个联动规则要翻好几个文件。
表单引擎的目标:把字段定义、校验规则、联动规则用数据描述,引擎负责渲染和逻辑执行。
二、Schema 定义
先设计一个 JSON Schema 格式来描述表单:
// form/types.ts
export interface FieldSchema {
/** 字段唯一标识 */
key: string;
/** 字段类型 */
type: 'text' | 'number' | 'select' | 'date' | 'switch' | 'textarea' | 'cascader';
/** 标签文本 */
label: string;
/** 占位提示 */
placeholder?: string;
/** 是否必填 */
required?: boolean;
/** 校验规则 */
validators?: ValidatorConfig[];
/** 显隐条件 */
visibleWhen?: Condition;
/** 联动:值变化时执行的动作 */
onChange?: Action[];
/** 选项列表(select/cascader 用) */
options?: Option[];
/** 默认值 */
defaultValue?: string | number | boolean;
}
export interface ValidatorConfig {
type: 'required' | 'regex' | 'range' | 'custom' | 'async';
/** 正则表达式(regex 类型用) */
pattern?: string;
/** 范围(range 类型用) */
min?: number;
max?: number;
/** 自定义校验函数名 */
customFn?: string;
/** 错误提示 */
message: string;
}
export interface Condition {
/** 依赖的字段 key */
field: string;
/** 比较操作 */
op: 'eq' | 'neq' | 'in' | 'notIn' | 'gt' | 'lt';
/** 比较值 */
value: string | number | (string | number)[];
}
export interface Action {
/** 目标字段 key */
target: string;
/** 动作类型 */
type: 'setValue' | 'setOptions' | 'setRequired' | 'clear';
/** 动作参数 */
params?: any;
}
export interface FormSchema {
fields: FieldSchema[];
}
三、表单数据模型
// form/FormModel.ts
export class FormModel {
/** 所有字段定义 */
private schema: Map<string, FieldSchema> = new Map();
/** 当前值 */
values: Record<string, any> = {};
/** 校验错误 */
errors: Record<string, string> = {};
/** 字段是否被联动修改过(防止循环触发) */
private propagating: Set<string> = new Set();
constructor(schema: FormSchema) {
schema.fields.forEach(field => {
this.schema.set(field.key, field);
if (field.defaultValue !== undefined) {
this.values[field.key] = field.defaultValue;
}
});
}
/** 设置字段值并触发联动 */
setValue(key: string, value: any): void {
this.values[key] = value;
// 清除该字段的错误
delete this.errors[key];
// 执行 onChange 联动动作
const field = this.schema.get(key);
if (field?.onChange && !this.propagating.has(key)) {
this.propagating.add(key);
field.onChange.forEach(action => this.executeAction(action));
this.propagating.delete(key);
}
}
/** 执行联动动作 */
private executeAction(action: Action): void {
switch (action.type) {
case 'setValue':
this.values[action.target] = action.params.value;
break;
case 'setOptions':
const field = this.schema.get(action.target);
if (field) {
field.options = action.params.options;
}
break;
case 'setRequired':
const target = this.schema.get(action.target);
if (target) {
target.required = action.params.required;
}
break;
case 'clear':
delete this.values[action.target];
break;
}
}
/** 判断字段是否可见 */
isVisible(key: string): boolean {
const field = this.schema.get(key);
if (!field?.visibleWhen) return true;
const condition = field.visibleWhen;
const depValue = this.values[condition.field];
switch (condition.op) {
case 'eq': return depValue === condition.value;
case 'neq': return depValue !== condition.value;
case 'in': return Array.isArray(condition.value) && condition.value.includes(depValue);
case 'notIn': return Array.isArray(condition.value) && !condition.value.includes(depValue);
case 'gt': return typeof depValue === 'number' && depValue > condition.value;
case 'lt': return typeof depValue === 'number' && depValue < condition.value;
default: return true;
}
}
/** 获取所有可见字段 */
getVisibleFields(): FieldSchema[] {
const result: FieldSchema[] = [];
this.schema.forEach(field => {
if (this.isVisible(field.key)) {
result.push(field);
}
});
return result;
}
}
四、校验引擎
// form/ValidatorEngine.ts
export class ValidatorEngine {
/** 自定义校验函数注册表 */
private customFns: Map<string, (value: any) => boolean | Promise<boolean>> = new Map();
/** 注册自定义校验函数 */
registerCustom(name: string, fn: (value: any) => boolean | Promise<boolean>): void {
this.customFns.set(name, fn);
}
/** 校验单个字段 */
async validateField(field: FieldSchema, value: any): Promise<string | null> {
if (!field.validators || field.validators.length === 0) {
return null;
}
for (const validator of field.validators) {
switch (validator.type) {
case 'required':
if (!value || (typeof value === 'string' && value.trim() === '')) {
return validator.message;
}
break;
case 'regex':
if (value && !new RegExp(validator.pattern!).test(value)) {
return validator.message;
}
break;
case 'range':
if (typeof value === 'number') {
if (validator.min !== undefined && value < validator.min) {
return validator.message;
}
if (validator.max !== undefined && value > validator.max) {
return validator.message;
}
}
break;
case 'custom':
const fn = this.customFns.get(validator.customFn!);
if (fn) {
const result = fn(value);
if (result === false) {
return validator.message;
}
}
break;
case 'async':
const asyncFn = this.customFns.get(validator.customFn!);
if (asyncFn) {
const result = await asyncFn(value);
if (result === false) {
return validator.message;
}
}
break;
}
}
return null;
}
/** 全量校验 */
async validateAll(formModel: FormModel): Promise<boolean> {
const visibleFields = formModel.getVisibleFields();
let allValid = true;
for (const field of visibleFields) {
if (field.required && !formModel.values[field.key]) {
formModel.errors[field.key] = `${field.label}为必填项`;
allValid = false;
continue;
}
const error = await this.validateField(field, formModel.values[field.key]);
if (error) {
formModel.errors[field.key] = error;
allValid = false;
}
}
return allValid;
}
}
五、动态渲染组件
// form/FormRenderer.ets
@Component
export struct FormRenderer {
@Prop schema: FormSchema;
model: FormModel = new FormModel(this.schema);
validator: ValidatorEngine = new ValidatorEngine();
aboutToAppear(): void {
// 注册自定义校验
this.validator.registerCustom('checkPhoneUnique', async (value: string) => {
// 异步校验:手机号是否已注册
await new Promise(resolve => setTimeout(resolve, 200));
return value !== '13800000000';
});
}
build() {
Column() {
ForEach(this.model.getVisibleFields(), (field: FieldSchema) => {
FormItem({
field: field,
model: this.model,
validator: this.validator
})
})
}
}
}
@Component
export struct FormItem {
@Prop field: FieldSchema;
@Prop model: FormModel;
@Prop validator: ValidatorEngine;
@State value: string | number | boolean = '';
@State error: string = '';
build() {
Column() {
Text(this.field.label)
.fontSize(14)
.fontColor(this.field.required ? '#FF0000' : '#333333')
// 根据类型渲染不同控件
if (this.field.type === 'text') {
TextInput({ text: this.value as string, placeholder: this.field.placeholder })
.onChange((val: string) => {
this.value = val;
this.model.setValue(this.field.key, val);
})
.onBlur(async () => {
const err = await this.validator.validateField(this.field, this.value);
this.error = err || '';
})
} else if (this.field.type === 'select') {
Select(this.field.options?.map(o => ({ value: o.value })) || [])
.onSelect((index: number) => {
const selected = this.field.options![index];
this.value = selected.value;
this.model.setValue(this.field.key, selected.value);
})
} else if (this.field.type === 'date') {
DatePicker()
.onDateChange((value: Date) => {
this.value = value.getTime();
this.model.setValue(this.field.key, value.getTime());
})
} else if (this.field.type === 'switch') {
Toggle({ type: ToggleType.Switch, isOn: this.value as boolean })
.onChange((isOn: boolean) => {
this.value = isOn;
this.model.setValue(this.field.key, isOn);
})
}
if (this.error) {
Text(this.error)
.fontSize(12)
.fontColor('#FF0000')
}
}
.margin({ bottom: 16 })
}
}
六、完整表单示例
一个报销申请表单的 Schema 定义:
const expenseFormSchema: FormSchema = {
fields: [
{
key: 'expenseType',
type: 'select',
label: '报销类型',
required: true,
options: [
{ label: '差旅', value: 'travel' },
{ label: '餐饮', value: 'meal' },
{ label: '办公用品', value: 'office' },
]
},
{
key: 'travelDestination',
type: 'text',
label: '出差目的地',
required: true,
visibleWhen: { field: 'expenseType', op: 'eq', value: 'travel' },
onChange: [
{ target: 'travelDays', type: 'clear' }
]
},
{
key: 'travelDays',
type: 'number',
label: '出差天数',
required: true,
visibleWhen: { field: 'expenseType', op: 'eq', value: 'travel' },
validators: [
{ type: 'range', min: 1, max: 90, message: '出差天数须在1-90天' }
]
},
{
key: 'amount',
type: 'number',
label: '报销金额',
required: true,
validators: [
{ type: 'range', min: 0.01, max: 50000, message: '金额须在0.01-50000元' }
]
},
{
key: 'phone',
type: 'text',
label: '联系电话',
required: true,
validators: [
{ type: 'regex', pattern: '^1[3-9]\\d{9}$', message: '手机号格式不正确' },
{ type: 'async', customFn: 'checkPhoneUnique', message: '该手机号已存在' }
]
}
]
};
这段 Schema 描述了:
报销类型选了"差旅"才显示目的地和天数字段。
目的地变化时清空天数(防止数据不一致)。
金额校验范围 0.01-50000。
手机号先做正则校验,通过后异步查重。
七、对比传统方式
小结
表单引擎的核心思路是"数据驱动":字段定义、校验规则、联动逻辑全部用 Schema 描述,渲染和执行逻辑由引擎统一处理。好处是新增/修改字段不用改代码,改 Schema 即可。代价是初期搭建引擎的成本,以及 Schema 格式需要团队达成共识。
对于 10 个字段以下的简单表单,直接写 @State 反而更清晰。表单引擎的 ROI 在 30 个字段以上的场景才开始体现。
下篇讲 WebView Bridge。第 41 篇讲了 Web 组件基础,第 49 篇讲了 H5 容器化,这篇聚焦 Bridge 本身的协议设计和安全。
参考阅读:
第 41 篇:鸿蒙 ArkWeb 实战(Web 组件基础)
第 49 篇:鸿蒙 H5 容器化(容器框架设计)
第 50 篇:设计模式在鸿蒙里的应用(策略模式部分)