咱们是袋鼠云数栈 UED 团队,致力于打造优异的一站式数据中台产品。咱们始终保持工匠精力,探究前端路途,为社区积累并传达经验价值。。
本文作者:奇铭()
需求布景
前段时刻,离线核算产品接到改造数据同步表单的需求。 一方面,数据同步模块的代码可读性和可保护性比较差,相对应的,在数据同步模块开发新功用和定位问题的效率很低;另一方面,整体规划上,希望在对接新的数据源时,能够不再关怀表单烘托相关问题,从数据源中心新建数据源一直到数据源在数据同步模块的运用全链路的表单都能够经过装备化的方法处理;
数据同步表单
数据同步模块整体上分为四个部分,数据来历表单,同步方针表单,字段映射组件和通道操控表单,
其中前三个部分对应的代码十分混乱,代码量也很大,单个组件代码 5000+ 行。这儿着重说一下数据来历表单和同步方针表单。
数据同步来历和方针表单的首要功用便是搜集数据源对应的装备信息,并且依据数据源类型的不同,对应需求烘托的表单项也不相同。
现在离线核算产品数据同步功用的数据源有多达 50种,在长时刻的迭代过程中,铢积寸累就呈现了许多强行复用的代码,这些强行复用的代码内部又包含着很多的 if else 逻辑;
别的,数据同步模块的表单内部有许多联动联系,比方:
- 某个表单项的值改变时,需求建议接口恳求,恳求的回来值被用作另一个表单项下拉框的数据
- 某个表单项的值改变时,需求去清空/重置其他一些表单项的值
- 某个表单项的值改变时,需求显现/躲藏某个表单项
- 某个表单项的值改变时,某个表单项的 label 案牍、表单项组件(比方从 select 变成 input ) 等随之产生改变
需求剖析
基于上述需求布景,表单烘托器的中心功用是输入一份装备,输出表单UI组件。
基于上述数据同步表单布景,咱们希望烘托器能够尽或许吸收掉表单内部的杂乱度,也便是说在表单的装备中要能够描绘上述的联动联系
那么能够大约得出表单的装备需求描绘:
- 表单项的根底信息,比方字段名、label、表单组件、校验信息等
- 表单项数据之间的联动
- 表单项UI的联动(操控显现/躲藏)
- 表单项的值改变时需求触发的副作用(比方调用接口)
表单根底信息描绘
这儿装备格局运用 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 提示也是如此。 现在结构如下图所示
表单数据联动
表单数据联动实际上便是当表单中某个表单项值改变时,去重置其他表单项的值,那么要在装备中描绘这种联动联系有两种方法
- 当时字段受哪些字段的影响
- 当时字段的值改变会影响到哪些字段
一般状况下,在代码中描绘这种逻辑时都是选用第二种方法,也便是监听某个字段的值的改变,然后在回调函数中去做对应的数据联动操作。
可是在装备json时,第二种方法就变得不那么友好了,那会让字段装备之间产生更多的耦合,更加友好的方法是在某个字段内表达本字段受到哪些字段的影响,这样做的另一个长处时,当开发者填写或者修正某一个字段的装备时,能够更加聚焦,不必关怀其他字段的装备。
这儿用 dependecies 字段来表达当时字段的值受哪些字段的影响。举个比方,表单中有数据源、schema、table 三个字段,数据源改变时,schema 的值应该被重置;schema 改变时,table 的值会被重置。那么在 json 中应该这样描绘:
[
{fieldName: 'sourceId', dependencies: []},
{fieldName: 'schema', dependencies: ['sourceId']},
{fieldName: 'table', dependencies: ['schema']},
]
对应的依靠联系图:
这儿新的问题产生了,当数据源改变时,table 的值是否要被重置?一般状况下是必定的,那么实际上它们的依靠联系是这样的:
这儿有两种方法来处理这种隐式的依靠联系
- 开发者在装备时显式的声明一切的依靠联系
- 烘托器内部解析依靠联系时,将这种隐式的依靠联系也解析出来
那么怎么挑选运用哪一种方法呢?
假如选用第一种方法,长处是烘托器不再需求关怀这种隐式的依靠联系了,可是在装备时的心智负担或许比较大,很容易呈现漏配依靠联系的状况。
假如选用第二种方法,长处是装备起来心智负担低,可是也有或许呈现 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 等。
思路小结
回忆上文需求剖析中所说的需求完成的功用
- 表单项的根底信息,比方字段名、label、表单组件、校验信息等
- 表单项数据之间的联动
- 表单项UI的联动(操控显现/躲藏)
- 表单项的值改变时需求触发的副作用(比方调用接口)
现在在思路上,都是有上述功用都是能够完成的。除了根底的烘托功用以外,FormRender 要额定完成的功用有
- 内置一个 extraData 存储 Service 回来的数据
- 支撑依据装备在正确的机遇触发 Service
- 支撑函数表达式
- 支撑依据装备在内部处理数据联动逻辑
大体完成
整体上,导出一个 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 的在线编辑器去下降的。别的编写详细的说明文档也能明显下降运用本钱。