x-render 系列里的 form-render / form-render-mobile 是表单自动生成领域常用的工具,并且也内置了多种类型的嵌套组件和列表组件。
但是官方文档里对于如何自定义嵌套、表格组件却没有介绍,网上也搜不到类似的文章。所以这篇文章就来详细介绍下如何在 form-render 以及 form-render-mobile(下文简称 fr-mobile)中自定义嵌套和列表组件,同时也会提到 form-render 1.x 和 2.x 的实现起来的区别以及对应的源码指路。
文末会给出完整的 demo 项目仓库地址,需要的可以自取。
form-render 自定义嵌套组件
先看一个基础的嵌套组件 schema(来自官方 demo):
const schema = {type: "object",displayType: "row",properties: {obj: {type: "object",title: "卡片主题",description: "这是一个对象类型",widget: "collapse",column: 3,properties: {input1: {title: "输入框 A",type: "string"},input2: {title: "输入框 B",type: "string"},}}}};const schema = { type: "object", displayType: "row", properties: { obj: { type: "object", title: "卡片主题", description: "这是一个对象类型", widget: "collapse", column: 3, properties: { input1: { title: "输入框 A", type: "string" }, input2: { title: "输入框 B", type: "string" }, } } } };const schema = { type: "object", displayType: "row", properties: { obj: { type: "object", title: "卡片主题", description: "这是一个对象类型", widget: "collapse", column: 3, properties: { input1: { title: "输入框 A", type: "string" }, input2: { title: "输入框 B", type: "string" }, } } } };
其中的 schema.properties.obj
就是一个嵌套组件,form-render 会根据它是否包含 properties 属性来决定要不要当作一个嵌套组件来渲染。而其中的 collapse 则是 form-render 内置的一个收起组件。
如果你使用的是 form-render 2.x,那自定义起来就会简单不少,创建组件,进行自定义操作后渲染 props.children,然后把这个自定义组件提供给 FormRender 的 widgets 即可:
const MyCard = (props) => {return (<div style={{ padding: 16, margin: 16, backgroundColor: '#eee' }}>{props.children}</div>);};function App() {const form = useForm();return (<FormRenderschema={schema}form={form}widgets={{ MyCard }}/>);}const MyCard = (props) => { return ( <div style={{ padding: 16, margin: 16, backgroundColor: '#eee' }}> {props.children} </div> ); }; function App() { const form = useForm(); return ( <FormRender schema={schema} form={form} widgets={{ MyCard }} /> ); }const MyCard = (props) => { return ( <div style={{ padding: 16, margin: 16, backgroundColor: '#eee' }}> {props.children} </div> ); }; function App() { const form = useForm(); return ( <FormRender schema={schema} form={form} widgets={{ MyCard }} /> ); }
当 form-render 根据 schema 把你这个组件识别成一个嵌套组件时,会自动的帮你把内部的表单项渲染好,然后塞到 props.children 里,所以我们的自定义组件直接渲染即可。
然后把 schema 里的 widget 改成对应的自定义组件名字(注意大小写)就行了,效果如下:
但是很多人会遇到一个问题就是,嵌套组件内部的表单项会被包裹进一个新对象(这里是 obj)里,而不是直接平铺在 form 数据的根对象里:
严格意义上来说,这并不是一个问题,因为 schema 里的描述语义就是这样的,你嵌套对象里的值,就应该存放在一个嵌套的对象里,平铺在根对象里反而不符合语义。
但是有时候就是会有这样的需求:我的嵌套组件只是对样式进行了修改,我希望数据结构并不会产生嵌套。因为嵌套就会造成数据处理起来会从简单的循环变成递归。
这种情况也是可以实现的,首先我们需要知道,form-render 里没什么黑魔法,数据结构的嵌套本质上还是 Form.Item 的路径嵌套实现的。所以可以自己去实现 Form.Item 来处理对应的数据层级:
const MyCard2: React.FC<any> = (props) => {return (<Form.Item label="内部组件" name="a123"><Input /></Form.Item>);};const MyCard2: React.FC<any> = (props) => { return ( <Form.Item label="内部组件" name="a123"> <Input /> </Form.Item> ); };const MyCard2: React.FC<any> = (props) => { return ( <Form.Item label="内部组件" name="a123"> <Input /> </Form.Item> ); };
这时候就能发现,嵌套的层级已经消失了,数据如同我们期望的那样保存在根对象下:
但是总不能我们自己再把每个表单项的 schema 处理手动实现一遍吧?确实不用,form-render 中有一个内部函数 RenderCore
,顾名思义,这个函数包含了最核心的表单项渲染功能,可以通过这个路径引入到咱们的组件里来:
import RenderCore from "form-render/es/render-core";import RenderCore from "form-render/es/render-core";import RenderCore from "form-render/es/render-core";
注意!是 render-core,而不是 form-core,别写错了。
RenderCore 应该怎么用?
这个函数的用法很简单,你可以直接 ctrl 点一下看看它的类型:
interface RenderCoreProps {schema: any;rootPath?: any[] | undefined;parentPath?: any[] | undefined;[key: string]: any;}declare const RenderCore: (props: RenderCoreProps) => any;interface RenderCoreProps { schema: any; rootPath?: any[] | undefined; parentPath?: any[] | undefined; [key: string]: any; } declare const RenderCore: (props: RenderCoreProps) => any;interface RenderCoreProps { schema: any; rootPath?: any[] | undefined; parentPath?: any[] | undefined; [key: string]: any; } declare const RenderCore: (props: RenderCoreProps) => any;
我们用到的就是前三个参数,schema 是要渲染的表单项配置,rootPath 代表当前数据的存放根路径,parentPath 代表当前数据的父节点路径。
后两者的含义可能比较模糊啊,不过我们这里不用太深入,只需要知道这两者决定了你数据的存放路径,所以我们两个都传 []
就可以把数据保存在根对象里了:
const MyCard3 = (props) => {return RenderCore({schema: props.schema,rootPath: [],parentPath: []});};const MyCard3 = (props) => { return RenderCore({ schema: props.schema, rootPath: [], parentPath: [] }); };const MyCard3 = (props) => { return RenderCore({ schema: props.schema, rootPath: [], parentPath: [] }); };
很简单对吧,效果也和我们预期的一样,数据被保存到根对象之下:
不过这里有个需要注意的地方,比如我上面的 schema 里指定了 column: 3
,正常情况下应该把表单项渲染成三列,但是用 RenderCore 渲染出来的还是默认的单列样式。原因在于,RenderCore 的作用是 渲染 schema 内部的表单项,所以你需要手动把嵌套组件的 props 处理成对应的样式。
再进一步,你可能会想:现在是调用一次 RenderCore 就把内部所有表单项都渲染出来了,能不能一次只渲染一个表单项?这样就可以实现灵活更高的控制了,比如对表单项进行排序,或者每个表单项都有特定的包裹样式。
这也是可以的,做法也很简单粗暴,就是遍历一下 properties,把每个表单项都分别放在一个 object 配置项里:
import sortProperties from "form-render/es/models/sortProperties";const itemSchema = schema?.items?.properties || {};const fieldSchemas = sortProperties(Object.entries(itemSchema)).map(([dataIndex, item]) => {const fieldSchema = {type: 'object',properties: {[dataIndex]: item}};});import sortProperties from "form-render/es/models/sortProperties"; const itemSchema = schema?.items?.properties || {}; const fieldSchemas = sortProperties(Object.entries(itemSchema)).map(([dataIndex, item]) => { const fieldSchema = { type: 'object', properties: { [dataIndex]: item } }; });import sortProperties from "form-render/es/models/sortProperties"; const itemSchema = schema?.items?.properties || {}; const fieldSchemas = sortProperties(Object.entries(itemSchema)).map(([dataIndex, item]) => { const fieldSchema = { type: 'object', properties: { [dataIndex]: item } }; });
这么一顿操作之后,其实就相当于我们得到了很多个“嵌套组件”配置项,而每个组件里只包含一个表单项。之后我们给 RenderCore 传入指定的 fieldSchemas[index]
即可实现单独渲染某个表单项,效果我们这里就不赘述了,下面讲列表的时候会用到这个操作。
sortProperties
是 form-render 内置的一个纯函数,用于生成明确的排序,推荐使用。
至此,我们已经介绍了由浅入深的几种自定义嵌套组件的方法。但是无论用哪种方法,都应该保证:在实现需求的情况下尽可能的遵守 schema 的描述。
form-render 自定义表格组件
然后来看看怎么自定义表格,先来看一个标准表格的 schema:
const schema = {type: 'object',displayType: 'row',properties: {list: {title: '活动模版',type: 'array',widget: 'TableList',items: {type: 'object',properties: {input1: {title: '输入框 A',type: 'string',},input2: {title: '输入框 B',type: 'string',},input3: {title: '输入框 C',type: 'string',},},},},},};const schema = { type: 'object', displayType: 'row', properties: { list: { title: '活动模版', type: 'array', widget: 'TableList', items: { type: 'object', properties: { input1: { title: '输入框 A', type: 'string', }, input2: { title: '输入框 B', type: 'string', }, input3: { title: '输入框 C', type: 'string', }, }, }, }, }, };const schema = { type: 'object', displayType: 'row', properties: { list: { title: '活动模版', type: 'array', widget: 'TableList', items: { type: 'object', properties: { input1: { title: '输入框 A', type: 'string', }, input2: { title: '输入框 B', type: 'string', }, input3: { title: '输入框 C', type: 'string', }, }, }, }, }, };
和嵌套组件类似,但是内部的表单项配置变成了一个 items 属性,这里可以介绍下相关 schema 规则:
- 如果 schema 包含 items 属性,就会被认为是列表组件(优先度更高)
- 如果 schema 包含 properties 属性,就会被认为是嵌套组件
同样的,这里的 widget TableList 也是 form-render 内置的一个表格组件,其实 form-render 内置了非常多的表格,链接在这里:内置表格组件 (xrender.fun),有符合需求的就不用自己再实现一遍了。
想要自定义表格组件的流程和嵌套组件一样:创建一个组件,塞给 form-render 的 widgets 里,最后给你的 schema 配置项里指定对应的组件名即可。
我们详细看一下组件内部应该怎么实现,比较长啊,你可以跟着下面的介绍回头一步步对着代码看:
const MyList = (compProps) => {const {fields = [],removeItem,addItem,rootPath,schema,renderCore,} = compProps;const itemSchema = schema?.items?.properties || {};const { props = {} } = schema;const { pagination = {}, ...rest } = props;const paginationConfig = pagination && {size: 'small',hideOnSinglePage: true,...pagination,};const columns = sortProperties(Object.entries(itemSchema)).map(([dataIndex, item]) => {return {dataIndex,width: item.width || FIELD_LENGTH,title: item.title,render: (_, field) => {const fieldSchema = {type: 'object',properties: {[dataIndex]: {...itemSchema[dataIndex],fieldCol: { span: 24 },}}};return (<div className='fr-table-cell-content'>{renderCore({parentPath: [field.name],rootPath: [...rootPath, field.name],schema: fieldSchema})}</div>)}};});columns.push({title: '操作',key: '$action',fixed: 'right',align: 'center',width: 80,render: (value, record) => {return (<div>{!props.hideDelete && (<Button type='text' danger onClick={() => removeItem(record.name)}>删除</Button>)}</div>);},});return (<div style={{ width: '100%' }} className="fr-table-list"><Tablescroll={{ x: 'max-content' }}columns={columns}dataSource={fields}rowKey='index'size='small'pagination={paginationConfig}{...rest}/><Button block style={{ marginTop: 8 }} type='dashed' onClick={() => addItem()}>新增</Button></div>);};const MyList = (compProps) => { const { fields = [], removeItem, addItem, rootPath, schema, renderCore, } = compProps; const itemSchema = schema?.items?.properties || {}; const { props = {} } = schema; const { pagination = {}, ...rest } = props; const paginationConfig = pagination && { size: 'small', hideOnSinglePage: true, ...pagination, }; const columns = sortProperties(Object.entries(itemSchema)).map(([dataIndex, item]) => { return { dataIndex, width: item.width || FIELD_LENGTH, title: item.title, render: (_, field) => { const fieldSchema = { type: 'object', properties: { [dataIndex]: { ...itemSchema[dataIndex], fieldCol: { span: 24 }, } } }; return ( <div className='fr-table-cell-content'> { renderCore({ parentPath: [field.name], rootPath: [...rootPath, field.name], schema: fieldSchema }) } </div> ) } }; }); columns.push({ title: '操作', key: '$action', fixed: 'right', align: 'center', width: 80, render: (value, record) => { return ( <div> {!props.hideDelete && ( <Button type='text' danger onClick={() => removeItem(record.name)}> 删除 </Button> )} </div> ); }, }); return ( <div style={{ width: '100%' }} className="fr-table-list"> <Table scroll={{ x: 'max-content' }} columns={columns} dataSource={fields} rowKey='index' size='small' pagination={paginationConfig} {...rest} /> <Button block style={{ marginTop: 8 }} type='dashed' onClick={() => addItem()}> 新增 </Button> </div> ); };const MyList = (compProps) => { const { fields = [], removeItem, addItem, rootPath, schema, renderCore, } = compProps; const itemSchema = schema?.items?.properties || {}; const { props = {} } = schema; const { pagination = {}, ...rest } = props; const paginationConfig = pagination && { size: 'small', hideOnSinglePage: true, ...pagination, }; const columns = sortProperties(Object.entries(itemSchema)).map(([dataIndex, item]) => { return { dataIndex, width: item.width || FIELD_LENGTH, title: item.title, render: (_, field) => { const fieldSchema = { type: 'object', properties: { [dataIndex]: { ...itemSchema[dataIndex], fieldCol: { span: 24 }, } } }; return ( <div className='fr-table-cell-content'> { renderCore({ parentPath: [field.name], rootPath: [...rootPath, field.name], schema: fieldSchema }) } </div> ) } }; }); columns.push({ title: '操作', key: '$action', fixed: 'right', align: 'center', width: 80, render: (value, record) => { return ( <div> {!props.hideDelete && ( <Button type='text' danger onClick={() => removeItem(record.name)}> 删除 </Button> )} </div> ); }, }); return ( <div style={{ width: '100%' }} className="fr-table-list"> <Table scroll={{ x: 'max-content' }} columns={columns} dataSource={fields} rowKey='index' size='small' pagination={paginationConfig} {...rest} /> <Button block style={{ marginTop: 8 }} type='dashed' onClick={() => addItem()}> 新增 </Button> </div> ); };
注意看这个组件里边调用 renderCore 就指定了不同 rootPath 和 parentPath。
上面这个组件实现的效果是这样的:
核心思路其实不复杂,就是拿到把每个表单项的配置拆出来(这里就用到了上面嵌套组件里提到的渲染单个表单项),然后生成对应的表格列配置。然后把各种 props 里传递进来的配置组装一顿,塞给 Table 组件就完事了。
其实就是配置项多了点,导致代码写的比较长。
这里需要注意的是,form-render 里自己对列表组件做了处理,会通过 props 里提供诸如新增一项、删除一项、移动排序之类的方法,还帮你自动生成了很多有用的配置项,我们可以打印一个 props 看一下:
看名字就知道是干什么的,这里就不过多介绍了。
另一个需要注意的地方是 props.renderCore
,这个和上面嵌套组件里提到的 RenderCore 是一样的。只不过这里自动帮你注入进来了,更方便一些。
最后要说的是,这个自定义出来的 MyList 组件,其实就是仿照内置的 TableList 组件写的。如果你想实现其他的自定义列表,可以直接从内置列表组件里找一个类似的,然后去源码里抄一下。源码链接:
x-render/packages/form-render/src/widgets at master · alibaba/x-render · GitHub
另外还有一个小坑,你可能实现完之后发现自己的表格长这样:
每个单元格前面都有个 label。并且用配置项里提供的 fieldCol={{ span: 24 }}
和 labelCol={{ span: 0 }}
也没效果。
实际上内置的表格组件也没解决这个问题,只是用样式把 label 隐藏起来了而已。直接给你 Table 组件(或者外层的 div 组件加个 class: fr-table-list 即可)
return (<div className="fr-table-list"> // 看这里<Table /></div>);return ( <div className="fr-table-list"> // 看这里 <Table /> </div> );return ( <div className="fr-table-list"> // 看这里 <Table /> </div> );
背后的隐藏方式也很简单:
fr-mobile 自定义嵌套组件
OK,讲完了 PC,再讲一下移动端怎么适配自定义组件。
其实基本流程还是一样的,声明组件、传递给 FormRender 的 widgets,然后在 schema 里提供对应的组件名。
区别可能就在于 RenderCore 的引入变了,因为我们是移动端,所以是从 form-render-mobile 取的渲染核心:
import RenderCore from 'form-render-mobile/es/render-core';import RenderCore from 'form-render-mobile/es/render-core';import RenderCore from 'form-render-mobile/es/render-core';
这是一个简单的自定义折叠组件:
export const MyCard = (props) => {return (<AntdCollapsedefaultActiveKey={['1']}><AntdCollapse.Paneltitle={<div style={{ fontWeight: 700 }}>{props?.schema?.title}</div>}key="1">{RenderCore(props)}</AntdCollapse.Panel></AntdCollapse>);};export const MyCard = (props) => { return ( <AntdCollapse defaultActiveKey={['1']} > <AntdCollapse.Panel title={ <div style={{ fontWeight: 700 }}>{props?.schema?.title}</div> } key="1" > {RenderCore(props)} </AntdCollapse.Panel> </AntdCollapse> ); };export const MyCard = (props) => { return ( <AntdCollapse defaultActiveKey={['1']} > <AntdCollapse.Panel title={ <div style={{ fontWeight: 700 }}>{props?.schema?.title}</div> } key="1" > {RenderCore(props)} </AntdCollapse.Panel> </AntdCollapse> ); };
效果如下:
可以看到,这个结构下数据还是直接存在根对象里的,如果你想实现嵌套对象的话,自己设置 RenderCore 的 parentPath 参数即可。
fr-mobile 自定义表格组件
最后是移动端的表格组件,这里其实有个坑:fr-mobile 只会渲染内置的列表组件,不会去我们提供的自定义 widgets 里查对应的组件。这就导致了你就算 schema 里配的 widget 是自己的组件名,渲染时也依旧会用内置的列表组件。
解决办法就是先递归整个 schema,把 schema.type 为 array
的改成 any
。由此把列表降级成普通的表单项来使用我们自定义的表格组件。
相关代码在 render-core/index.tsx,感兴趣的可以自己研究下。
知道了这一点之后,自定义列表组件就没啥难点了,下面就是一个简单的例子,包括如何把列表项内部的表单项双排显示:
const MyList = (props) => {const { schema = {}, readOnly = false } = props;return (<Grid.Item span={24}><Form.Arrayname={[props.id]}renderAdd={!readOnly ? () => (<span>添加</span>) : undefined}onAdd={({ add }) => add()}renderHeader={({ index }, { remove }) => (<div>{schema.title && (<span>{schema.title} {index + 1}</span>)}{!readOnly && (<a onClick={() => remove(index)} style={{ float: 'right' }}>删除</a>)}</div>)}>{fields => fields.map(({ index, key }) => {return (<Grid columns={2} key={key}>{RenderCore({schema: schema.items,parentPath: [index],rootPath: [props.id, index]})}</Grid>);})}</Form.Array></Grid.Item>);};const MyList = (props) => { const { schema = {}, readOnly = false } = props; return ( <Grid.Item span={24}> <Form.Array name={[props.id]} renderAdd={!readOnly ? () => ( <span> 添加 </span> ) : undefined} onAdd={({ add }) => add()} renderHeader={({ index }, { remove }) => ( <div> {schema.title && ( <span>{schema.title} {index + 1}</span> )} {!readOnly && ( <a onClick={() => remove(index)} style={{ float: 'right' }}> 删除 </a> )} </div> )} > {fields => fields.map(({ index, key }) => { return ( <Grid columns={2} key={key}> {RenderCore({ schema: schema.items, parentPath: [index], rootPath: [props.id, index] })} </Grid> ); })} </Form.Array> </Grid.Item> ); };const MyList = (props) => { const { schema = {}, readOnly = false } = props; return ( <Grid.Item span={24}> <Form.Array name={[props.id]} renderAdd={!readOnly ? () => ( <span> 添加 </span> ) : undefined} onAdd={({ add }) => add()} renderHeader={({ index }, { remove }) => ( <div> {schema.title && ( <span>{schema.title} {index + 1}</span> )} {!readOnly && ( <a onClick={() => remove(index)} style={{ float: 'right' }}> 删除 </a> )} </div> )} > {fields => fields.map(({ index, key }) => { return ( <Grid columns={2} key={key}> {RenderCore({ schema: schema.items, parentPath: [index], rootPath: [props.id, index] })} </Grid> ); })} </Form.Array> </Grid.Item> ); };
效果如图:
另外如果你遇到了列表组件之外的表单项被挤到一行里的 bug:
把你 form-render-mobile 的版本升级到 ^1.0.7-beta.1
或者更高就解决了。
form-render 1.x 和 2.x 的区别
如果你项目中是使用的 form-render 1.x 的话,那么有几点需要注意一下。
渲染核心的功能不一致
1.x 里使用的渲染核心叫做 Core
,导入链接为:
import Core from 'form-render/es/form-render-core/src/core'import Core from 'form-render/es/form-render-core/src/core'import Core from 'form-render/es/form-render-core/src/core'
其接受的参数也不一样:
<CorehideTitle={true}displayType="inline"key={index.toString()}id={child}dataIndex={childIndex}/><Core hideTitle={true} displayType="inline" key={index.toString()} id={child} dataIndex={childIndex} /><Core hideTitle={true} displayType="inline" key={index.toString()} id={child} dataIndex={childIndex} />
嵌套组件和列表组件的 props 不一样
比如列表组件中会把列表项当作一个字符串数组传递给列表组件的 children 里(数组元素即上面 Core 传参里的 child)。
如果你在用 1.x 的话,我会首先推荐你尝试升级到 2.x,这个升级并没有想象中的麻烦。不过如果你的项目太大没法升级的话,也可以比着源码照抄,这里指个路:
- 把 x-render 项目 clone 下来,切换到
v1.14.0
tag - 列表组件实现(包含如何调用 Core 组件,其他的列表组件也在这里):packages\form-render\src\form-render-core\src\core\RenderChildren\RenderList\TableList.js
- Core 组件实现:packages\form-render\src\form-render-core\src\core\index.js
示例 demo
相关示例代码已经上传到 github,链接:HoPGoldy/x-render-custom-demo (github.com),用法见 readme。