乐趣区

关于前端:配置式表单渲染器的实现

咱们是袋鼠云数栈 UED 团队,致力于打造优良的一站式数据中台产品。咱们始终保持工匠精力,摸索前端路线,为社区积攒并流传教训价值。。

本文作者:奇铭(掘金)

需要背景

前段时间,离线计算产品接到革新数据同步表单的需要。
一方面,数据同步模块的代码可读性和可维护性比拟差,绝对应的,在数据同步模块开发新性能和定位问题的效率很低;另一方面,整体规划上,心愿在对接新的数据源时,能够不再关怀表单渲染相干问题,从数据源核心新建数据源始终到数据源在数据同步模块的利用全链路的表单都能够通过配置化的形式解决;

数据同步表单

数据同步模块整体上分为四个局部,数据起源表单,同步指标表单,字段映射组件和通道管制表单,

其中前三个局部对应的代码十分凌乱,代码量也很大,单个组件代码 5000+ 行。这里着重说一下数据起源表单和同步指标表单。

数据同步起源和指标表单的次要性能就是收集数据源对应的配置信息,并且依据数据源类型的不同,对应须要渲染的表单项也不雷同。

目前离线计算产品数据同步性能的数据源有多达 50 种,在长时间的迭代过程中,与日俱增就呈现了很多强行复用的代码,这些强行复用的代码外部又蕴含着大量的 if else 逻辑;

另外,数据同步模块的表单外部有很多联动关系,比方:

  • 某个表单项的值变动时,须要发动接口申请,申请的返回值被用作另一个表单项下拉框的数据
  • 某个表单项的值变动时,须要去清空 / 重置其余一些表单项的值
  • 某个表单项的值变动时,须要显示 / 暗藏某个表单项
  • 某个表单项的值变动时,某个表单项的 label 文案、表单项组件(比方从 select 变成 input)等随之发生变化

需要剖析

基于上述需要背景,表单渲染器的外围性能是输出一份配置,输入表单 UI 组件。

基于上述数据同步表单背景,咱们 心愿渲染器能够尽可能排汇掉表单外部的复杂度,也就是说在表单的配置中要可能形容上述的联动关系

那么能够大略得出表单的配置须要形容:

  1. 表单项的根底信息,比方字段名、label、表单组件、校验信息等
  2. 表单项数据之间的联动
  3. 表单项 UI 的联动(管制显示 / 暗藏)
  4. 表单项的值变动时须要触发的副作用(比方调用接口)

表单根底信息形容

这里配置格局应用 JSON 格局,用一个数组形容所有的表单项信息,UI 上表单项的渲染程序即配置数组中表单项配置的程序。表单组件应用 Ant Design Form.

对于表单项根底信息的形容配置,大多能够间接搬用 Ant Design Form Item 的 props,比方 label、rules、tooltip 等属性。这里不多赘述。比拟非凡的是,须要在配置里形容表单项形容的 UI 组件,比方 Select、Input。那么这里应用 widget 字段去形容,另外,组件的形容除了组件名称,还须要形容组件的 props,所以还须要一个 widgetProps 字段去形容组件的属性,比方 placeholder、disabled 等。

那么一个用于抉择数据源的表单项应该这样形容:

{
  "fieldName": "sourceId",
  "label": "数据源",
  "rules": [
    {
      "required": true,
      "message": "请抉择数据源!",
    },
  ],
  "widget": "Select",
  "widgetProps": {
    "placeholder": "请抉择数据源",
    "options": [
      {
        "lable": "数据源 1",
        "value": 1
      }
    ]
  },
}

当然可能会存在某些表单项的 UI 组件有自定义的状况,比方可编辑表格,代码编辑器等。这个时候就须要开发自定义表单组件了,而后把这些组件注入到 formRenderer 中,伪代码如下所示

import {Editor, EditableTable} from './customWigets'

export const getWidets = (widgetsName) => {switch(widgetsName) {
      case 'Editor': {return Editor}
      case 'EditableTable': {return EditableTable}
    }
} 

function Form () {
  return (
    <FormRenderer
        getWidets={getWidets}
    />
  )
}

那么目前的构造如图所示

这份配置写到这里的时候,问题呈现了,

  • 无奈在配置中形容 onChange、onSelect 等事件回调函数
  • 相比于 jsx 弱小的表达能力,json 中只能表白根本的数据结构,而没方法间接表白逻辑。
  • 另外,Select 下拉框的数据可能来源于接口,这种状况在业务中相当常见,这里也没方法表白。
  • 不能自定义表单校验器,无奈反对简单的 Tootip 提醒,比方带有 a 标签的 tootip

上述问题产生的根本原因,实际上是 JSON 与 jsx 之间表达能力的差距。然而从另一个角度来讲,正因为 JSON 的表达能力和灵活性不如 jsx,所以在用来形容 UI 时,JSON 更不容易导致凌乱。

咱们先思考如何表白 Select 下拉框的数据来源于接口,这里能够拆解为两个局部:数据获取 获得接口的返回值并在配置项中表白

数据获取

实际上,select 下拉框中的数据也并不一定来源于接口,也可能是来源于其余业务数据,所以在配置项形容数据获取逻辑时,不应该关怀数据的起源。

很显然,数据获取逻辑须要用 js 形容,这里咱们形象出一个 Service 的概念,用于形容 / 申明数据获取逻辑,Service 的申明应用 js,在 JSON 配置中,只须要去形容 Service 的调用逻辑即可
对于 JSON 配置来说,Service 调用须要三个因素:

  • Service 的标识 / 名称,示意哪一个 Service 被触发
  • Service 的触发机会
  • Service 返回的数据如何存储

    Service 的触发机会

    Service 的触发一般来说是因为用户的交互引起的,当然也存在在表单项组件挂载时就须要触发的状况,那么调用机会大略就是以下几种:

  • onMount
  • onChange
  • onSearch
  • onFocus
  • onBlur

    Service 返回的数据如何存储

    这里 Service 返回的数据存储须要能被 UI 获取到,那么须要将返回的数据都保护在 FormRender 外部,这里将存储数据的中央命名为 extraData,那么咱们形容 Service 返回的数据的存储,能够应用一个 fieldInExtraData 的字段,形容以后 service 返回的数据被存储在 extraData 的那个字段中,取值时:extraData[fieldInExtraData]

那么在表单项配置中形容 Service,如下所示

{
  "serviceName": "getSourceList",
  "triggers": ["onMount", "onSearch"],
  "fieldInExtraData": "schemaList"
}

Service 的申明

对于 Service 自身来说,要做的事件就是获取并解决数据而后返回,当然 Service 自身可能须要承受一些参数比方以后 Form 收集到的数据、Service 是被哪个字段触发的、触发机会是什么等等,那么 Service 的格局如下所示

const getSourceList = ({formData, extraData, trigger, triggerFieldName}) => {return Promise((resolve) => {resolve(...)
  })
}

因为 Service 可能是异步的,所以这里 Service 都返回一个 Promise

而后将所有的 Service 都注入到 FormRenderer 中,FormRenderer 依据表单项配置中申明的调用机会去调用 Service,整个数据获取的链路就实现了。

获取 Service 返回值并在配置项中表白

上文中提到,Service 的返回的数据都被存储在 FormRenderer 外部的 extraData 中,个别状况下如果应用 jsx 当然能很容易的取到对应的值,然而在 JSON 中,是没方法表白的。然而咱们能够借鉴 jsx 的插值表达式和 vue 的插值表达式。

<div>{user.name}</div>

在 jsx 中,如果在一对标签外部写了一串字符串,对应的会有两种解析策略,第一种是间接辨认为字符串,第二种如果辨认到花括号,则将其视为 js 表达式。
同理,在 JSON 配置中也能够应用这种形式去取值:

{
  "fieldName": "sourceId",
  "label": "数据源",
  "widget": "Select",
  "widgetProps": {
    "placeholder": "请抉择数据源",
    "options": "{{extraData.sourceList}}"
  },
  "triggerServices": [
    {
      "serviceName": "getSourceList",
      "triggers": ["onMount", "onSearch"],
      "fieldInExtraData": "sourceList"
    }
  ]
}

函数表达式

上例中,应用一对花括号申明函数表达式,外表上是借鉴了 jsx 的插值表达式,然而其实两者有很大的区别。jsx 的插值表达式是在编译阶段就转化成了 js 表达式。而在 JSON 中的这种自定义的函数表达式要在运行时转换,上述的函数表达式只能被转换为函数执行。即:

"{{extraData.schemaList}}"
// 转化为
const valueGetter = new Function('extraData', 'return extraData.schemaList')

出于平安问题思考,表达式还须要去被放在一个相似沙箱的环境执行,防止表达式外部批改全局环境变量。创立繁难沙箱应用 proxy + with + symbol.unscopables 的形式,这里不开展解说了。最终函数表达式的利用大略是如下模式

function Comp () {return <Select options={valueGetter(extraData)} />
}

到目前为止,曾经有了两个新概念:Service函数表达式,回到上文中提到的问题,咱们曾经解决了 Select 下拉框来源于接口的问题,那么还剩下如下问题:

  • json 中只能表白根本的数据结构,而没方法间接表白逻辑。
  • 无奈在配置中形容 onChange、onSelect 等事件回调函数,也不能自定义表单校验器,
  • 不能自定义表单校验器,无奈反对简单的 Tootip 提醒,比方带有 a 标签的 tootip

json 中没方法表白逻辑的问题,其实曾经能够通过函数表达式来解决了。函数表达式外部反对写任意的 js 表达式,另外,在函数表达式中也能够反对拜访 form 表单数据,有了数据反对和逻辑表达能力反对,绝大多数状况下的曾经可能满足 UI 渲染中的逻辑表白了。

而形容 onChange、onSelect 等事件回调函数能够通过配置 Service 来解决。

自定义表白校验器能够通过函数表达式的变种来解决,能够向 formRenderer 中注入 form 校验器的汇合,而后通过 {{ruleMap.xxx}} 来指定表单项的某一条校验规定的校验器

{
  "fieldName": "sourceId",
  "label": "数据源",
  "rules": [
    {validator: "{{ ruleMap.checkSourceId}}"
    },
  ],
}

tooltip 提醒也是如此。
目前构造如下图所示

表单数据联动

表单数据联动实际上就是当表单中某个表单项值变动时,去重置其余表单项的值,那么要在配置中形容这种联动关系有两种形式

  1. 以后字段受哪些字段的影响
  2. 以后字段的值变动会影响到哪些字段

个别状况下,在代码中形容这种逻辑时都是采纳第二种形式,也就是监听某个字段的值的变动,而后在回调函数中去做对应的数据联动操作。

然而在配置 json 时,第二种形式就变得不那么敌对了,那会让字段配置之间产生更多的耦合,更加敌对的形式是在某个字段内表白本字段受到哪些字段的影响,这样做的另一个益处时,当开发者填写或者批改某一个字段的配置时,能够更加聚焦,不必关怀其余字段的配置。

这里用 dependecies 字段来表白以后字段的值受哪些字段的影响。举个例子,表单中有数据源、schema、table 三个字段,数据源变动时,schema 的值应该被重置;schema 变动时,table 的值会被重置。那么在 json 中应该这样形容:

[{fieldName: 'sourceId', dependencies: []},
    {fieldName: 'schema', dependencies: ['sourceId']},
    {fieldName: 'table', dependencies: ['schema']},
]

对应的依赖关系图:

这里新的问题产生了,当数据源变动时,table 的值是否要被重置?个别状况下是必定的,那么实际上它们的依赖关系是这样的:

这里有两种形式来解决这种隐式的依赖关系

  1. 开发者在配置时显式的申明所有的依赖关系
  2. 渲染器外部解析依赖关系时,将这种隐式的依赖关系也解析进去

那么如何抉择应用哪一种形式呢?

如果采纳第一种形式,长处是渲染器不再须要关怀这种隐式的依赖关系了,然而在配置时的心智累赘可能比拟大,很容易呈现漏配依赖关系的状况。

如果采纳第二种形式,长处是配置起来心智累赘低,然而也有可能呈现 table 的确不依赖 sourceId 的状况,也就是间接依赖不失效的状况

结合实际业务看,目前的业务中,所有的字段之间间接依赖其实都是隐式依赖,也就是须要失效的,这里采纳第二种形式,前文中也提到了,冀望是 formRenderer 能够尽可能的排汇掉表单外部的复杂度。

非凡的表单数据联动

在理论业务中还存在着一些比拟非凡的表单数据联动,比方

  • 抉择数据源时,除了须要收集数据源的 id,还须要收集数据源类型
  • 抉择数据源后,须要将数据源的其余信息展现为表单项,比方下图中的表单

对于这种业务场景,咱们能够了解为 某个表单项的值是由其余表单项的值派生进去的,那么就须要去形容这种派生逻辑。当然,这种派生逻辑能够在业务代码中形容,只须要在数据源变动时,手动的 setFieldValue 就能够了。然而还是上文中提到的冀望,formRenderer 能够尽可能排汇掉复杂度。

解决这种状况,须要新增一个配置项去形容派生逻辑,这里配置项定为 valueDerived,这个配置项的值应该为一个取值表达式,那么以第一个例子为例,配置应该这样子:

[
  {
    "fieldName": "sourceId",
    "label": "数据源",
    "widget": "Select",
    "widgetProps": {
      "placeholder": "请抉择数据源",
      "options": "{{extraData.sourceList}}"
    },
  },
  {
    "fieldName": "sourceType",
    "label": "数据源类型",
    "hidden": true,
    "valueDerived": "{{extraData.sourceList.find(s => s.value === formData.sourceId).type }}",
  },
]

formRenderer 外部依据配置的 valueDerived 去自动更新表单中对应字段的值

表单 UI 联动

表单 UI 联动能够分为两个局部:

  • 表单项 UI 文案、款式等依据数据联动。
  • 表单项 UI 的显示与暗藏

    表单项 UI 文案、款式等依据数据联动

    表单项的 UI 联动在 React 和 JSX 中,都能很轻易、很天然的产生。然而想要在 JSON 中形容,因为 JSON 自身不具备表白逻辑的能力,还是要借助函数表达式。只须要反对对应的配置项能够应用函数表达式就能实现表单项的联动。举个例子:

    [
    {
      "fieldName": "time",
      "label": "{{extraData.type === 1 ?' 开始工夫 ':' 完结工夫 '}}",
      "widget": "Input",
      "widgetProps": {"placeholder":"{{ extraData.type === 1 ?' 请输出开始工夫 ':' 请输出完结工夫 '}}",
      },
    }
    ]

    那么它们理论渲染时等同于以下伪代码

    function Comp (props) {const {fieldName, label, widget, widgetProps, extraData} = props
    
    const form = useFormInstance()
    const formData = form.getFieldsValue()
    
    const tarnsformer = (configItem) => {const fn = new Function('formData', 'extraData', `return $[configItem}`)
      return fn.call(null, formData, extraData)
    }
    
    return 
      <Form.Item
        name=fieldName
        label={tarnsformer(label)}
      >
          <widget  placeholder={tarnsformer(widgetProps.placeholder)}/>
        </Form.Item>
    }

    这样就能做到表单项的文案款式等依据数据变动天然的联动。

表单项的显示与暗藏

表单项的暗藏也能拆分为两种状况

  • 暗藏但不销毁,表单项的值依然会被收集和保留
  • 销毁,不再保留 / 收集表单项的值

暗藏但不销毁的状况,antd form 自身就有 hidden 配置反对,那么这里只须要反对 hidden 配置应用函数表达式就能够了。

对于表单项的销毁,就须要新增一个字段了,这里命名为 destory,同样通过反对应用函数表达式实现联动,然而这里须要思考一些其余状况。比方从销毁状态变成显示状态时,须要去触发 mount service 等。

思路小结

回顾上文需要剖析中所说的须要实现的性能

  1. 表单项的根底信息,比方字段名、label、表单组件、校验信息等
  2. 表单项数据之间的联动
  3. 表单项 UI 的联动(管制显示 / 暗藏)
  4. 表单项的值变动时须要触发的副作用(比方调用接口)

目前在思路上,都是有上述性能都是能够实现的。除了根底的渲染性能以外,FormRender 要额定实现的性能有

  1. 内置一个 extraData 存储 Service 返回的数据
  2. 反对依据配置在正确的机会触发 Service
  3. 反对函数表达式
  4. 反对依据配置在外部解决数据联动逻辑

大体实现

整体上,导出一个 FormRenderer 组件,上文中提到的 json config、Service 申明、自定义的表单校验器,自定义表单项组件等,都通过 FormRenderer 的 props 传入。

内置 extraData

因为 extraData 外部存储的数据变动可能导致视图更新,那么只能应用 React.Context 或者 state,事实上即便应用 Context 也还是须要申明 state 来触发视图更新,然而 Conetxt 在传递数据时有着独特的劣势,这里间接应用 Context 存储数据。

// 防止闭包问题
export function useExtraData(init: IExtraDataType) {const stateRef = useRef<IExtraDataType>(init);
    const [_, updateState] = useReducer((preState, action) => {
        stateRef.current =
            typeof action === 'function'
                ? {...action(preState) }
                : {...action};
        return stateRef.current;
    }, init);

    return [stateRef, updateState] as const;
}

// 创立 context
const ExtraContext = React.createContext<ExtraContextType>({extraDataRef: { current: {} },
    update: () => void 0,});

应用

import {useExtraData, ExtraContext} from 'extraDataContext.ts'

const FormRenderer: React.FC = () => {const [extraDataRef, updateExtraData] = useExtraData({});
  // ....
  return(
    <ExtraContext.Provider
      value={{extraDataRef, update: updateExtraData}}
    >
      {....}
    </ExtraContext.Provider>
  )     
}

在正确的机会触发 Service

在 JSON 配置中 service 相干形容如下所示

[
  {
    "fieldName": "sourceId",
    "label": "数据源",
    "triggerServices": [
      {
        "serviceName": "getSourceList",
        "triggers": ["onMount", "onSearch"],
        "fieldInExtraData": "sourceList"
      },
      {
        "serviceName": "getSchemaList",
        "triggers": ["onChange"],
        "fieldInExtraData": "schemaList"
      },
    ]
  }
]

triggerServices 曾经很分明直观的形容了,该字段在什么机会应该调用哪个 service,在代码实现上,为了这部分触发逻辑与视图渲染拆散,采纳公布订阅模式。大体流程如下图所示:

这里流程曾经走通了,然而能够发现,renderer 中依然须要去解决订阅的逻辑,Service 触发逻辑与视图渲染逻辑拆散的不够彻底,那么能够持续优化一下,退出一个订阅器去解决这部分逻辑,优化后的逻辑如下图所示:

反对函数表达式

上文中提到了,函数表达式的实现是用 new Function,以及处于平安问题思考须要将函数表达式放到模仿沙箱环境中执行,执行流程如下所示

实现代码如下所示(不蕴含正则解决)

class FnExpressionTransformer {
    private sandboxProxiesMap: WeakMap<ScopeType, InstanceType<typeof Proxy>> =
        new WeakMap();

    private createProxy(scopeObj: ScopeType) {
        /** 存储创立的 proxy 防止反复创立 */
        if (this.sandboxProxiesMap.has(scopeObj)) {return this.sandboxProxiesMap.get(scopeObj);
        }
        const scope = {
            extraData: scopeObj.extraDataRef,
            formData: scopeObj.formData,
            Math: Math,
            Date: Date,
        };
        const proxy = new Proxy(scope, {has() {return true;},
            get(target, prop) {if (prop === Symbol.unscopables) return undefined;
                if (prop === 'extraData') {return target[prop]['current'];
                }
                return target[prop];
            },
        });
        this.sandboxProxiesMap.set(scopeObj, proxy);
        return proxy;
    }

    transform = (code: string): TransformedFnType => {return (scope: ScopeType) => {const proxy = this.createProxy(scope);
            const fnBody = `with(scope) {return ${code} }`;
            const fn = new Function('scope', fnBody);
            return fn(proxy);
        };
    };
}

比方在 label 配置中应用了函数表达式

[
  {
    "fieldName": "name",
    "label": "{{extraData.xxx ?' 用户名 ':' 昵称 '}}"
  }
]

那么通过转换后,就是等同于以下函数

function lableValue (scope) {return scope.extraData.xxx;} 

利用:

<FormItem
  name={name}
  label={lableValue({ formData, extraData})}
>
{/* xxxx */}
</FormItem>

反对依据配置在外部解决数据联动逻辑

与上文中 service 触发逻辑一样,将这部分联动的逻辑通过公布订阅与视图渲染逻辑拆散。然而相比于 service 触发逻辑,这里多了剖析依赖的步骤
比方,有如下 json 配置

[{fieldName: 'schema', dependencies: []},
   {fieldName: 'table', dependencies: ['schema']},
   {fieldName: 'partition', dependencies: ['schema', 'table']},
   {fieldName: 'coprate', dependencies: ['table', 'partition']}
]

那么生成的依赖关系图就应该是:

[{fieldName: 'schema', isField: true]},
  {fieldName: 'table', isField: true},
  {fieldName: 'partition', isField: true},
  {fieldName: 'coprate', isField: true},
  {fieldName: 'schema', dependBy: 'table', isRelation: true},
  {fieldName: 'schema', dependBy: 'partition', isRelation: true},
  {fieldName: 'table', dependBy: 'partition', isRelation: true},
  {fieldName: 'table', dependBy: 'coprate', isRelation: true},
  {fieldName: 'partition', dependBy: 'coprate', isRelation: true},
]

生成上述依赖关系后,剩下的流程与触发 service 的流程相似,在这里不多做赘述了。

总结

回顾本文结尾需要剖析局部中提到的须要实现的性能,到目前为止,仿佛都实现了。然而其实很容易就产生一些疑难,这个货色它好用吗?

作为开发者,我很难主观的评估它好不好用。不过集体认为针对好不好用这个问题,还是有一些客观条件去评估的

  • 稳定性
  • 可维护性
  • 应用老本

对于稳定性, 目前还没有在实在业务场景去落地,目前看不出。然而最近正在将局部业务中表单迁徙到 FormRenderer,目前给我的感觉不是很稳固,常常须要去批改 FormRenderer 的源码去修复一些小 bug,或者是让它的某些体现更符合实际的业务场景。贴一个 TODO LIST

这只是一部分,很多批改也没有记录在这下面。

可维护性,只针对于文章结尾提到的需要背景来说来说,应用 FormRenderer,显然比已有的代码更容易保护。

应用老本,这个老本要分为多方面来讲,首先是学习应用 FormRenderer 的老本,这个老本显然要比间接用 JSX 和 Antd 去开发表单的老本要高的多,使用者不仅须要理解 Antd 表单的应用,也须要相熟 FormRenderer 的应用。其次是保护老本,我个人感觉它的保护老本会比数据同步的表单的保护老本要低。最初是开发成本,相比于开发组件,这个表单的开发成本次要体现在没有主动提醒以及纠错,然而这个问题是能够通过开发一个相似 PlayGround 的在线编辑器去升高的。另外编写具体的阐明文档也能显著升高应用老本。

退出移动版