动态表单(4)—— 表单渲染

881 阅读5分钟

在前面的文章中,我们已经完成了 DSL 设计。这篇文章中,我们会利用设计好的 DSL,将表单渲染出来。

我们会使用一个 form library,用于管理表单状态。本文使用 react-hook-form 作为例子。当然,你也可以选择任何你喜欢的 form library。

本文所对应的 GitHub 源码地址:d-form

整体架构

整个设计分为四个部分:Form、FieldList、Field 和 Widget: image.png **Form:**入口组件,负责接收 DSL 数据、绑定事件并初始化表单状态 **FieldList:**循环遍历 widgets ,根据 widget 不同类型进行不同处理 **Field:**核心组件,主要为 Widget 准备各种数据并绑定状态 **Widget:**具体组件,只负责渲染,不负责管理表单状态

Form 和 FieldList 的实现都比较简单,如果有不清楚的地方可以去这里看源代码,这里就不细讲了。本文会着重讲解 Field 的实现以及 Field 和 Widget 的交互。

Widget

先来说说 Widget,也就是表单组件。在实际场景中,可能遇到各种表单组件,比如 Checkbox 单选框、CheckBox 多选框、Select 下拉选择框、Select + Checkbox 组成的下拉多选框等等。每个组件需要传入的 Props 都不一样,但是在动态表单中我们必须用相同方式将它们渲染出来,而不是去写各种 if-else。

统一接口 value/onChange

为了解决这个问题,我们可以设计一组统一接口:value/onChange。其中 value 用于为表单组件提供值,而 onChange 会在表单组件值发生变化时通知外部。这也就是 React 中所说的「受控组件」。

{
  value: any
  onChange: (value: any) => void
}

换句话说,一个组件不管它的 UI 和交互是什么,只要遵循这一规则接收 value/onChange 这一组 props,我们就能够将其接入动态表单。

使用 UI 组件库

如果我们使用了 UI 组件库(比如 Material UI 或者 AntDesign),还需要额外封装。比如 CheckBox 组件,它接收 checked 而非 value 属性,并且 onChange 也和我们需要的结构不一致,因此需要对它进行封装:

type MyCheckBoxProps = Omit<SwitchProps, "checked"> & {
  value: boolean;
  onChange: (value: FieldValue) => void;
};

const MyCheckbox: FC<MyCheckBoxProps> = forwardRef(({ value, onChange, ...others }, ref) => (
  <Checkbox 
    {...others} 
    ref={ref} 
    checked={value} 
    onChange={(_, checked) => onChange(checked)} 
  />
));

除此之外,所有提供给动态表单使用的组件都需要进行类似封装。通过这些简单封装,我们可以将任意 UI 组件库接入动态表单。

Field

Field 作为 form library 和 Widget 的桥梁,负责提供 Widget 渲染所需数据,同时也会和 form library 绑定方便获取表单状态。通过 form library 提供的属性或方法,完成表单校验、联动等功能。

name

name 看似不起眼,但是它是 Field 的「唯一标识」,我们也可以通过 name 从 form library 中获取 field 状态。因此,在一个表单中务必要保证 name 的唯一性。如果 name 重复了,会导致相同 name 的 Field 互相污染。

表单校验

前面我们已经定义好了用于表单校验的 DSL,这里就需要解析 DSL 并绑定到 form library。 先来回顾一下:

{
  rule: ["all", ["gte", 18], ["lte", 55]],
  errorMsg: "年龄必须在 18 - 55 岁之间",
  when: []
}

rule 是一个表达式,解析之后会得到一个布尔值。如下:

export const parseOperator =
  (operator: Operator, fnList: Operators) =>
  (value: FieldValue, formValue: FormValue): boolean | FieldValue => {
    // 第一个 item 为函数名,后面所有 item 都会作为这个函数的参数
    const [fnName, ...others] = operator;
    // 定义一个 args 列表用于收集传递给名为 fnName 函数的参数
    const args: any[] = [];

    // 由于参数也可能为一个 operator,因此需要循环判断
    others.forEach((item) => {
      // 如果 item 是一个 operator,递归解析得到结果后放入 args 列表
      if (isOperator(item)) {
        args.push(parseOperator(item, fnList)(value, formValue));
      } else {
        // 如果 item 不是一个表达式,则直接放入 args 列表
        args.push(item);
      }
    });

    // 通过 fnName 从 fnList 列表中获取到对应的函数,将参数传入后调用该函数
    return fnList[fnName] && fnList[fnName](...args)(value, formValue);
  };

const isOperator = (data: any): data is Operator => Array.isArray(data);

对于表单校验来说,rule 无论如何都会返回一个布尔值。如果这个布尔值为 true 则表示验证通过,如果为 false 则表示验证不通过。我们的目的就是生成一个 validate 函数,如果校验通过则返回 undefined,否则返回 errorMsg。

但是,一个 Field 可能包含多条验证规则,也就是多个 rule,这种情况应该如何处理呢?这取决于你的业务需求。通常来说,我们不会一次性将所有错误信息展示给用户,如果第一条规则不满足就一直展示它对应的错误信息,直到第一条规则满足之后才会触发第二条规则,以此类推。这里我们用这种方法来实现:


interface ParseRuleParams {
  rules: Rule[];
  formValue: FormValue;
  operators: Operators;
}

export const parseRules = ({ rules, operators, formValue }: ParseRuleParams): Validate<FieldValue> => {
  return (value: FieldValue) => {
    if (!rules) {
      return undefined;
    }

    for (let i = 0; i < rules.length; i++) {
      const rule = rules[i];

      // 如果 when 存在,只有当解析 when 的结果为 true 时这条验证才会生效,否则直接跳过
      if ((rule.when && parseOperator(rule.when, operators)(value, formValue)) || !rule.when) {
        if (!parseOperator(rule.rule, operators)(value, formValue)) {
          return rule.errorMsg;
        }
      }
    }

    return undefined;
  };
};

表单联动

要想实现表单联动,我们需要监听所有依赖的 field 值的变化。react-hook-form中提供了 useWatch 方法,可以帮助我们实现这个功能。我们只需要分析当前 field 依赖于哪些 field 即可。

{
  name: "annualIncome", 
  visible: ["eq", ["get", "showMore"], true], // 当 name 为 showMore 的组件的值等于 true 才显示此 field
}

通过表达式,我们可以分析出 annualIncome 这个 field 依赖于另一个名为 showMore 的 field。通过监听名为 showMore 的 field 获取到它的值的变化,就能分析出当前 field 应该显示还是隐藏。

由于 get 方法是用来从 form 中获取某个 field 的值,只需要分析出当前表达式中所有 get 方法的参数,即可得出依赖列表:

export const pickDependentFields = (data: any): string[] => {
  if (isOperator(data)) {
    let collections: string[] = [];
    const [name, ...others] = data;

    if (name === "get") {
      collections = collections.concat(others[0] as string);
    }

    others.forEach((item) => {
      if (Array.isArray(item)) {
        collections = collections.concat(pickDependentFields(item));
      }
    });

    return collections;
  }

  return [];
};

最后,再跟 useWatch 结合使用:

const changed = useWatch({ control, name: pickDependentFields(visible) });

// shouldShow 用于决定某个 field 是显示还是隐藏
const [shouldShow, setShouldShow] = useState(
  checkAndParseOperator({
    operators,
    data: visible,
    value: getValues()[name],
    formValue: getValues(),
  }),
);

useEffect(() => {
  setShouldShow(
    checkAndParseOperator({ operators, data: visible, value: getValues()[name], formValue: getValues() }),
  );
}, [changed]);

小结

到这里,一个功能相对比较完备的动态表单就已经完成。可能还有很多场景我们没有考虑到,大家可以基于这套体系继续扩展。这个方案是我自己在使用动态表单时总结出的一套方案,可能不是特别完善,如果大家有更好的实践或者意见建议,欢迎在评论区跟我一起讨论。后续有时间也会继续更新一些特殊场景的解决方案。

最后,还是回到我们开篇所说的,动态表单不是银弹,不可能适配所有场景,在扩展的过程中一定要「克制」,权衡清楚利弊,不要无限扩展导致最后无法维护。

一句话与君共勉「克制带来自由」。