基于React的无限表单设计

2,415 阅读10分钟

前言 - 问题定义

需求定义能通过定义符合一定规范的JSON格式数据模版,生成对应表单组件,返回结构化的表单数据。包含基本的可扩展的表单原子组件和布局组件,同时嵌套层级可自由控制,在一定程度上达到渲染效率最优。

问题分解(TLDR)

  • 定义表单组件模板:表单生成器模型
  • 无限嵌套组件的设计:树形的数据结构设计
  • 兄弟节点联动问题:控制/依赖性转移至父组件
  • 组件状态管理问题:受控组件和非受控组件的选择
  • 优化数据更新问题:非可变数据优化+数据更新检测
  • 节点数据更新问题:useReducer 模式

一、定义表单组件模板

JSON 表单生成:利用JSON格式描述组件的外观样式、控件类型以及控件扩展属性,通过翻译JSON格式的描述信息生成界面组件,并能提供一定的交互功能(如,组件间的联动功能)。

例如有这样一个基本的表单结构:

{
  "label": "用户名",
  "key": "userName",
  "value": null,
  "default": "Stephen",
  "type": "Input" // 这里基于Antd控件给出定义,其他类型如:CheckBox、Select、Group、Tabs以及CustomA等自定义组件
}

Type 常规组件类型(本文以 Ant design 组件为例1),常规的组件如 CheckBox,Select,Input 等,往往我们还需要一些布局组件如 Collapse 折叠面板,Tabs 标签页等。

Type 自定义组件类型,在符合表单模板结构的前提下自行创作,可以是基于业务系统的组件、可以是基于交互的组件,也可以是对基础组件的扩展如 Font 组件自带字体颜色、字体权重等配置项。

二、无限嵌套组件的设计

有了基础的组件表单模板之后,如何才能更自由的通过表单的定义,来组“肆意”合出想要的结构,诸如搭积木一样,只需要足够的基础元素和基础元素类型,就可以无限扩展。

无限嵌套: 实现无限嵌套的功能很多,比较常见的就是树形列表控件,这里也适用了树形结构作为无限嵌套的基础。

扩展之后的表单模板:

{
  "label": "用户名",
  "key": "userName",
  "value": null,
  "default": "Stephen",
  "type": "Input",

  // ...其他属性
  "children": [] // 这里的children是表单自身结构的数组
}

对于树形结构的设计并没有什么新的东西和难度,略有复杂度的点在于数据的回调更新、对树形组件的渲染优化,这些将在接下来的章节会展开。

三、兄弟节点联动问题

兄弟节点联动从业务需求上讲,组件和组件间是有这一定的“约束关系”存在,通过A组件指的更改影响B组件的外观或者取值范围等因素。对于树形组件来说,这种约束关系往往存在于“同一层次”,很少出现跨父组件之间的联动关系,如果有则可以考虑是否是功能划分上的问题或者扩展跨父组件依赖支持

既然是兄弟节点之前的关系问题,那么最好的解决方案是“调度提升”至父组件中,由父组件“协调”子组件之间的关系。另外一种可能的方案是子组件监听目标子组件的值,从而影响自身变化。对于简单需求来讲,可能并没有什么好坏之分,但是,还有一些其他因素值得关注。

分类父组件协调子组件监控
依赖性自上而下自下而上
DAG 检测
组件模式受控组件模式非受控组件模式

本文主要采取父组件“协调”子组件的方式,主要原因是可以更灵活的控制以及充分利用非受控组件的优势,受控以及非受控组件的取舍将在后面讨论。

下面在扩展表单模板之前,我们需要确定实现功能的几个因素:

  • 被依赖的组件的 Key 值
  • 变化函数/求值函数 fn
  • 子组件自身可改变的值或外观

首先对于依赖的组件 Key,我们可以通过定义 deps 来指定被依赖的组件的 key 值,有了 Key 值我们就可以获取到被依赖组件的当前值,如果需要更高级的转换可以使用自定义函数 f 进行转换,最终返回特定的属性值来改变自身。这里举一个常见的例子,A 组件“Show Label”是一个 checkbox 组件,当启用 Show Label 时,B 组件 Input 可以开启编辑模式,否则不允许编辑。

NOTE:这里将文件扩展成 Javascript 是便于显示,从使用意义上来讲单纯的 JSON 数据格式不支持函数的保存,需要手动转换

扩展模版如下:

[
  // A 组件
  {
    label: "Show Label",
    type: "Checkbox",
    key: "enableShowLable",
    default: false
  },
  // B 组件
  {
    label: "Label Content",
    type: "Input",
    key: "enableShowLable",
    disabled: true,
    watcher: {
      deps: ["enableShowLabel"],
      action: props => {
        return {
          disabled: !props.enableShowLable
        };
      }
    }
  }
];

接下来是父组件的设计,详细说明可以从 JS Doc 中查看:

/**
 * 获取子组件所依赖的值,并传递给子组件
 * @param { deps: [], action: Function } watcher - 子组件的watcher属性
 * @param { Array } children - 当前所有兄弟节点
 * @returns {any}
 */
const getDependencyValue = useCallback((watcher, children) => {
  if (watcher?.deps) {
    // Note: only support depend on one property for now.
    const dependencyKey = watcher?.deps?.[0];
    return children?.find(r => r.key === dependencyKey)?.value;
  }
}, []);

/**
 * 获取子组件变更的属性内容
 * @param { Function } action - 子组件的watcher属性
 * @param { string } key - 需要改变自身的属性的Key值,如“disabled”
 * @param { any } value - 当前依赖的节点
 * @returns {any} 更改的值
 */
const invokeDependencyWatcher = (action, key, value) => {
  if (!action) {
    return {};
  }
  const item = action({
    [key]: value
  });
  return item;
};

NOTE: 通过父子组件的取值以及运算来改变自组件自身,这里没有实现的是 DAG 依赖性的检测以及子组件更新自身属性值的“安全范围”

通过以上这些基本完成了一个组件自定义表单组件生成器的基本结构,接下来,探讨一些对于自定义表单的设计和优化。

四、组件状态管理问题

对于“状态管理”这个话题,不想过多的展开,这里需要讨论的是对于原子组件的设计是在组件内部自我维护还是完全依赖外部输入,之间的有哪些优劣势,该如何取舍。

4.1 什么是受控组件

在 HTML 中,表单元素(如 input、 textarea 和 select)通常自己维护 state,并根据用户输入进行更新。而在 React 中,可变状态(mutable state)通常保存在组件的 state 属性中,并且只能通过使用 setState()来更新2 。在大多数情况下,我们推荐使用受控组件来处理表单数据。在一个受控组件中,表单数据是由 React 组件来管理的3

简而言之,对于组件如何使用我们有完全的话语权和自由度。拿 React 组件来讲,受控组件就意味着需要手动绑定 value 值,并在 onChange 之后更新 value 值进行从新绑定,例如:

handleChange(event) {
  this.setState({value: event.target.value});
}

render () {
  return (<input type="text" value={this.state.value} onChange={this.handleChange} />);
}

4.2 什么是非受控组件

因为非受控组件将真实数据储存在 DOM 节点中,所以在使用非受控组件时,有时候反而更容易同时集成 React 和非 React 代码。如果你不介意代码美观性,并且希望快速编写代码,使用非受控组件往往可以减少你的代码量。否则,你应该使用受控组件。3

与受控组件相反,非受控组件仅仅可能关心 defaultValue 的默认值,以及最终输出的变化值,对于内部如何封装处理并不关心,也可能并不关心何时触发的数据变更,而是在需要知道自组件状态值的时候获取值,例如:

<DatePicker
  defaultValue={this.state.time}
  ref={input => (this.input = input)}
/>

4.3 区别和联系

从组件的使用者角度来讲有如下区别4

featureuncontrolledcontrolled
获取控件值
在改变时验证控件值
格式化输入内容
动态改变值
支持外部触发刷新渲染
内部组件刷新是否可控

从组件的设计者角度来讲有如下区别:

featureuncontrolledcontrolled
需要编码维护状态
延迟存储变化状态

总结一下,这里说明了什么是受控组件非受控组件的定义和区别,针对“无限”级联的表单设计,“支持外部触发刷新渲染”, “动态改变值”,“内部组件刷新是否可控”这些因素会制约组件模式的选取,特别是对于处于组件树中非叶子结点的组件,受控组件是更好的选择。当然,对于叶子结点的组件来说,如果业务逻辑复杂并且存在“大量”的临时状态数据来讲(这里指的复杂业务逻辑会产生大量的临时状态,是根据个人设计经验而言,并无确切可度量的指导值,请自行斟酌取舍),封装一个偏向于私有业务的自定义组件更为合适一些。

五、优化数据更新问题

这里提出一个问题,对于组件树来讲,往往是某一个分支的节点变更,而其他分支并未更新,如何才能做到“局部”刷新,而非全局刷新,额外不必要消耗浏览器性能。

这个问题的本质在于当组件监听自身依赖(比如组件输入的 value)值,如何判断其是否“变化”的问题。对于简单类型,如字符串和数字类型,我们可以根据值本身是否发生变化来作为依据,但对于对象类型情况就稍微复杂一些:

  • 对象中包含的值是 Primitive 类型(stirng, number, bigint, boolean, undifined, symbol...)5
  • 对象中包含的值是 Object 类型,层次很深,不是特别容易穷举对象的 Primitive 类型

问题一自然很好解决,对于问题二需要用 Immutable 数据结构来并结合 Immutable Tools 来对比 Object 值是否发生改变。这里拿 ImmutableJS 举例6,同时也可以选择 ReduxJS 选取的 Immutable 数据结构库 ImmerJS7,两者实现的原理略有区别。

import { Map, is } from "immutable";
const map1 = Map({ a: 1, b: 1, c: 1 });
const map2 = Map({ a: 1, b: 1, c: 1 });

// 对比方式
map1 === map2; //false
Object.is(map1, map2); // false
is(map1, map2); // true

到这里,我们有了如何区分依赖值变化的工具和方法,下一步我们需要集合 React 的 memo 来判断是否更新组件自身。

// ... improt related resouce

// 此函数等同于React类组件生命周期的`shouldComponentUpdate`
const componentShouldUpdateFn = (immutablePrev, immutableNext) => {
  return immutablePrev === immutableNext;
};

const MyComponent = memo(
  ({
    nameObj: { firstName: string, lastName: string, middleName: string }
  }) => {
    return <div>{Hello`${nameObj.lastName}`}</div>;
  },
  componentShouldUpdateFn
);

到这里,我们简单实现了如何利用 Immutable + React Memo 来实现组件局部刷新的功能。这里没有写出如何更新数据,可以自行参考 ImmutableJS 或 ImmerJS API。

六、节点数据更新问题

无限极联的组件自身更新,需要更新数据集以便共享状态。核心问题在于如何定位组件在树形节点的位置。

一种可行的方案是当组件被创建时,可以根据组件父子间的 Key 层级,得到子组件的唯一索引 KeyChains,如['group1', 'label', 'showLable'], 对应的树形链如下:

 - group1
  - label
    - showLabel
    - labelContent
 - group2
  ....

在更新某一子组件值的时候,只需要传入 KeyChains 和更改的 Value。

另一种比较常用的方案是将树形结构按照 KeyChains 打平成一层的文件结构,通过转换 KeyChains,到字符串 Key,如‘group1.label.showLabel’的方式快速查找及更新。

总结

这篇文章希望能抛砖引玉,获得更多的建议和思考。对于表单生成组件的设计行业上也有很多成熟的产品和设计思想,这里总结了一些项目上遇到的一些问题和方案,希望能针对具体问题进行更多的思考和探索。

目前,还有一些不足没有补齐,如原子组件的布局设置、自定义原子组件动态注册等,今后还会更新。

参考引用

Footnotes

  1. ant.design/components/…

  2. zh-hans.reactjs.org/docs/forms.…

  3. zh-hans.reactjs.org/docs/uncont… 2

  4. goshakkk.name/controlled-…

  5. developer.mozilla.org/en-US/docs/…

  6. juejin.cn/post/684490…

  7. segmentfault.com/a/119000001…