Administrator
发布于 2026-09-03 / 1,229 阅读
0
0

鸿蒙复杂表单引擎设计:动态渲染、校验体系与字段联动

系列文章《鸿蒙开发从入门到专家》第 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。

  • 手机号先做正则校验,通过后异步查重。

七、对比传统方式

维度

传统 @State 方式

表单引擎方式

100字段代码量

~3000行

Schema 200行 + 引擎 500行(复用)

新增字段

改组件+改校验+改联动

Schema 加一条

修改联动规则

找到联动逻辑改代码

Schema 改 onChange 条件

联动循环检测

手动管理,易出 bug

propagating Set 自动拦截

异步校验

手写 Promise

配 async validator 自动

小结

表单引擎的核心思路是"数据驱动":字段定义、校验规则、联动逻辑全部用 Schema 描述,渲染和执行逻辑由引擎统一处理。好处是新增/修改字段不用改代码,改 Schema 即可。代价是初期搭建引擎的成本,以及 Schema 格式需要团队达成共识。

对于 10 个字段以下的简单表单,直接写 @State 反而更清晰。表单引擎的 ROI 在 30 个字段以上的场景才开始体现。

下篇讲 WebView Bridge。第 41 篇讲了 Web 组件基础,第 49 篇讲了 H5 容器化,这篇聚焦 Bridge 本身的协议设计和安全。

参考阅读:

  • 第 41 篇:鸿蒙 ArkWeb 实战(Web 组件基础)

  • 第 49 篇:鸿蒙 H5 容器化(容器框架设计)

  • 第 50 篇:设计模式在鸿蒙里的应用(策略模式部分)


评论