在一个老项目中,已经有
antd
的Form
了,怎么进行两者的整合?
这是我一开始做项目时的一个痛点。因为我们是基于多组合作的,有些组件必须要公用,因为涉及到后期调整。但是人家已经用了antd
的Form
了。
我想借这个话题,来具体分享下我们实际工作中formily的一些应用及痛点,以及一些有意思的bug
吧。
一些基本认知
写法
在formily的世界里面,有三种写法:
- Markup Schema
- JSON Schema
- 纯JSX
通常我自己在项目里面不会用到Markup Schema
,理由是它的结构限死了,如果要中间在插入一段div的写法,是没法做到的。
纯JSX的写法
import { Form, FormItem, Input } from '@formily/antd';
import { createForm } from '@formily/core';
import { Field } from '@formily/react';
import React, { useMemo } from 'react';
function Test() {
const form = useMemo(
() =>
createForm({
validateFirst: true, // 只显示第一个错误
initialValues: {}, // 初始值
effects() {
// 事件处理
},
}),
[]
);
return (
<Form form={form} layout="vertical">
<Field name="test" title="测试" component={[Input, { placeholder: '请输入' }]} decorator={[FormItem]} />
<Field name="xx" hidden />
</Form>
);
}
export default Test;
我在上面的代码中加了一个hidden
的Field
,但其实一般情况下是不用加的。有些特殊场景下是需要,譬如说我们监听一个字段的变化,但是这个字段并没有出现在UI中,那这样就无法监听到。即:
setValues进去的字段,不等于会映射到
Field
里面去。
JSON Schema的写法
import { Form, FormItem, Input } from '@formily/antd';
import { createForm } from '@formily/core';
import { createSchemaField, Field, ISchema } from '@formily/react';
import React, { useMemo } from 'react';
const SchemaField = createSchemaField({
components: {
Input,
FormItem,
},
scope: {
// 作用域,函数的相关处理
},
});
const jsonSchema: ISchema = {
type: 'object',
properties: {
test: {
'x-component': 'Input',
title: '测试',
'x-component-props': {},
'x-decorator': 'FormItem',
'x-decorator-props': {},
},
},
};
function Test() {
const form = useMemo(
() =>
createForm({
validateFirst: true, // 只显示第一个错误
initialValues: {}, // 初始值
effects() {
// 事件处理
},
}),
[]
);
return (
<Form form={form} layout="vertical">
<SchemaField schema={jsonSchema} />
</Form>
);
}
export default Test;
可以说,JSON Schema
开启了一个新的时代:低代码,就是我们完全可以上面的jsonSchema内容存储到后端,前端只要关心业务逻辑的处理就行了。
Field分类
- VoidField
- Field
- ObjectField
- ArrayField
VoidField
可以简单的理解成div
这种容器,就是和表单项没关系的,单纯用来UI布局的。所以问题来了,如果尝试给这个元素加一个value
的props,是否能拿到该value
呢?
// xx: {
// type: 'void',
// 'x-component': 'MyComponent',
// }
form.setFieldState('xx', field => {
field.componentProps = {
...field.componentProps,
value: '1111'
}
})
在MyComponent
这个组件里面,props.value
是undefined
。
Field
Field就是一个普通表单项Wrapper,通常我们自定义组件,其实和antd
的Form
自定义组件一样,只要接收value
和onChange
即可。
它比较强大的点,在于Field
可以嵌套Field
,当然嵌套了之后,路径是进行叠加的,比如说前者的path是a
,后者的path是b
,那么后一个真实的路径是a.b
,当然我们可以重写Field
的basePath
来重新指定。
不过在ArrayField里面,就不能使用basePath
了,因为会出现在添加、删除这些操作时,出现bug
。之前之所以这么用,是因为场景是:Input
旁边是Select
控件,然后我当时的写法是Input
的addonAfter
属性是下拉。但其实这个地方应该是要写成两个控件是并排
的。
ObjectField
顾名思议,就是可以理解成对象的Field
。场景会有以下三种:
- 多个相同前缀的组合,都是放在一起的,可以变成一起。那样的话,未来前缀调整了,改一处就行了
- 子表单
- 动态添加属性(但一般不常见)
ArrayField
这在实际工作中比较常见。譬如说动态的table
,亦或是动态的list
。
它给field
赋值了两个常用的方法:
- push 新增一条数据
- remove 移除一条数据,根据索引
那么问题来了,下面这种怎么处理?
批量删除,就需要我们取点巧,譬如说倒着删
:
/**
* 删除选中的行
* @param field ArrayField
* @param params object
* @returns undefined
*/
export const removeField = (
field: ArrayField,
{
selectedRowKeys,
setSelectedRows,
setSelectedRowKeys,
}: {
selectedRowKeys: any[];
setSelectedRowKeys: (keys: any[]) => void;
setSelectedRows: (rows: any[]) => void;
}
) => {
if (selectedRowKeys.length === 0) {
message.error('请至少选择一条数据');
return;
}
selectedRowKeys.sort((a, b) => b - a);
selectedRowKeys.forEach(index => {
field.remove(index);
});
setSelectedRows([]);
setSelectedRowKeys([]);
};
一些前提工作
处理错误
mouseover的时候才显示错误
为什么要这么做?首先就是让它出现在下面一行,在table
的表单项是不太合适的,然后@formily/antd
的Form
提供了popover
的模式,它的方式是错误时,出现气泡。
但是针对toB的项目,大量的表单项,如果60%都是必填项,一开始直接点击保存,会有大量的气泡层出来,且滚动条滚动时,一直固定在那儿,贼丑。
所以我们的方案是:不显示error message,只是把框变红,鼠标hover的时候,显示error message
。
做法是这样的:
入口处调用:
formilyErrorHelper(theme['ant-prefix']); // 传进去antd的前缀
上面的formilyErrorHelper
方法的实现思路,大家简单看一下下面的图片就能理解了:
鼠标移上去的时候,复制错误的div
结构到doument
元素下,移开的时候,移除那个dom元素。
滚动到指定位置
document
.querySelector(`.${antPrefix}-formily-item.${antPrefix}-formily-item-error`)
?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
重写max
的规则
其实我不太喜欢max
这个规则,主要是来自于它的不确定性,一会儿是长度,一会儿是最大值。然后这个取决于这个字段的类型是字符串,还是数字。
大家都知道,前端和后端,有些字段的类型可能是不一样的,所以这时候设置的max就容易出问题了。那么我索性直接让这个max
成为最大长度
的代名词,不管是什么类型。
const isValidateEmpty = (value: any) => {
if (isArr(value)) {
for (let i = 0; i < value.length; i++) {
if (isValid(value[i])) return false;
}
return true;
} else {
//compat to draft-js
if (value?.getCurrentContent) {
/* istanbul ignore next */
return !value.getCurrentContent()?.hasText();
}
return isEmpty(value);
}
};
registerValidateRules({
// 重写max的规则
// @ts-ignore
max(value, rule) {
if (isValidateEmpty(value)) return '';
const length = (value + '').length;
const max = Number(rule.max);
return length > max ? rule.message : '';
},
});
禁用态能复制
这个功能还是比较带有业务性的,不具有通用性,但能给小伙伴们一些想法吧。
譬如这样的下拉框:
作为使用方来说,有些情况下,想快速复制,都没办法。因此我们这边搞了一个方式,就是鼠标移入到label
上时,会出来一个复制
,点击就可以copy。
实现代码:
import I18N from '@/utils/I18N';
import { message } from '@dzg/common-utils';
// eslint-disable-next-line no-undef
let delay: NodeJS.Timeout;
// eslint-disable-next-line no-undef
let interval: NodeJS.Timeout;
function copy(text: string) {
var textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
try {
var successful = document.execCommand('copy');
// var msg = successful ? 'successful' : 'unsuccessful';
if (successful) {
message.success(I18N.utils.CopyHelper.copiedTo);
}
} catch (err) {
console.log(err);
}
document.body.removeChild(textArea);
}
function addCopyButton(target: HTMLElement, text: string, antPrefix: string) {
let formItemLabel = target.querySelector(`.${antPrefix}-formily-item-label`) as HTMLElement;
if (formItemLabel.querySelector('input')) {
return;
}
let copyButton = document.createElement('a');
copyButton.className = 'form-item-copy-button';
copyButton.style.fontSize = '12px';
// copyButton.style.lineHeight = '20px';
copyButton.style.marginLeft = '10px';
copyButton.text = I18N.HeaderInfo.BusinessNoFee.copy;
copyButton.onclick = () => {
copy(text);
};
if (!formItemLabel.querySelector('.form-item-copy-button')) {
formItemLabel.appendChild(copyButton);
}
target.addEventListener(
'mouseleave',
() => {
if (copyButton.parentNode === target.querySelector(`.${antPrefix}-formily-item-label`)) {
clearInterval(interval);
requestAnimationFrame(() => {
(target.querySelector(`.${antPrefix}-formily-item-label`) as HTMLElement).removeChild(copyButton);
});
}
},
{ once: true }
);
}
export const formilyCopyHelper = (antPrefix: string) => {
document.addEventListener('mouseover', event => {
if (delay) {
clearTimeout(delay);
}
const func = () => {
if (interval) {
clearInterval(interval);
}
const target = event.target as HTMLElement;
if (target) {
const formItem = (((event as any).path as HTMLElement[]) || []).find(
item =>
item.className &&
item.className.split &&
item.className.split(' ').includes(`${antPrefix}-formily-item`) &&
item.className.split(' ').includes(`${antPrefix}-formily-item-layout-vertical`) &&
!item.className.split(' ').includes('formily-item-no-copy')
);
if (formItem && window.getComputedStyle(formItem, null).flexDirection === 'row') {
//内部不存在其他formItem的formItem
if (!formItem.querySelector(`.${antPrefix}-formily-item`)) {
let formItemLabel = formItem.querySelector(`.${antPrefix}-formily-item-label`);
if (formItemLabel) {
let formItemContent = formItem.querySelector(`.${antPrefix}-formily-item-control-content`);
//输入框group
if (formItemContent?.querySelector(`.${antPrefix}-input-group-wrapper`)) {
let input = formItemContent?.querySelector(`.${antPrefix}-input`) as HTMLInputElement;
if (input.disabled && input.value) {
addCopyButton(formItem, input.value, antPrefix);
}
}
//下拉框和autoComplete
else if (formItemContent?.querySelector(`.${antPrefix}-select`)) {
let select = formItemContent?.querySelector(`.${antPrefix}-select`);
if (select?.classList.value.includes(`${antPrefix}-select-disabled`)) {
if (select?.classList.value.includes(`${antPrefix}-select-multiple`)) {
//多选下拉框
let selectionItemContents = formItemContent?.querySelectorAll(
`.${antPrefix}-select-selection-item-content`
);
let content = Array.prototype.slice
.call(selectionItemContents)
.map(item => item.innerHTML)
.join(',');
addCopyButton(formItem, content, antPrefix);
} else {
let selectionItem = formItemContent?.querySelector(`.${antPrefix}-select-selection-item`);
let selectionSearchInput = formItemContent?.querySelector(
`.${antPrefix}-select-selection-search-input`
) as HTMLInputElement;
if (selectionItem && selectionItem.innerHTML) {
addCopyButton(formItem, selectionItem.innerHTML, antPrefix);
} else if (selectionSearchInput && selectionSearchInput.value) {
addCopyButton(formItem, selectionSearchInput.value, antPrefix);
}
}
}
}
//数字输入框
else if (formItemContent?.querySelector(`.${antPrefix}-input-number-input`)) {
let input = formItemContent?.querySelector(`.${antPrefix}-input-number-input`) as HTMLInputElement;
if (input.disabled && input.value) {
addCopyButton(formItem, input.value, antPrefix);
}
}
//输入框和时间输入框
else if (formItemContent?.querySelector(`.${antPrefix}-input`)) {
let input = formItemContent?.querySelector(`.${antPrefix}-input`) as HTMLInputElement;
if (input.disabled && input.value) {
addCopyButton(formItem, input.value, antPrefix);
}
}
}
}
}
}
};
delay = setTimeout(func, 150);
});
};
同formilyErrorHelper
,上面的formilyCopyHelper
也是在入口处调用一下就行了。(当然这个方法可能还是需要补全的,只是考虑了大部分的组件,有些可能没考虑到)
其实这个只能满足大部分的场景,当场景是Table
时,禁用态的复制,似乎不太好处理,此时要么就是加title
,要么变成不用复制的阅读态
(不然会很丑)。
阅读态可复制
这个功能也是比较带有业务性的,不具有通用性,但是其他小伙伴也可能会碰到类似的需求。
老实说,我个人觉得@formily/antd
除了最基础的一些容器组件(如FormItem
、Grid
),其他的在业务层面应用还是有点浅(这个浅是指preview
这一块上面,按我的理解,大多数是需要定制开发的)
我们的需求是这样的:
所以我们得要自己实现一套针对阅读态的组件。
与antd
的Form
共存
一些思考
对于antd的组件来说,如果接收formily
的onChange
,其实也是改了formily
里面的值。
然后如果组件不接收onChange
,那一般会接收外部
传进去的form
属性,然后去操作setFields
、setFieldsValue
、getFieldsValue
等函数。
那么我们只需要重写那些函数,在里面除了对antd
本身的数据操作之外,还有要对formily
的数据也要操作。
/**
* 获取合并后的表单对象
* 外部这个return值,一般只关注submit和setValues这两个方法
* @param form formily表单对象
* @returns obj
*/
export const getMergeForm = (form: IForm<any>) => {
const [antdForm] = AntdForm.useForm();
return Object.assign({}, antdForm, {
setFields: (values: any[]) => {
antdForm.setFields(values);
for (let i = 0; i < values.length; i++) {
let { name, value } = values[i];
if (Array.isArray(name)) {
name = name.join('.');
}
form.setValuesIn(name, value);
}
},
setFieldsValue: (values: any) => {
antdForm.setFieldsValue(values);
for (let key in values) {
let namePath = key;
if (Array.isArray(namePath)) {
namePath = namePath.join('.');
}
form.setValuesIn(namePath, values[namePath]);
}
},
getFieldsValue: () => {
return { ...toJS(form.values), ...antdForm.getFieldsValue() };
},
getFieldValue: (path: string) => {
return form.getValuesIn(path) || antdForm.getFieldValue(path);
},
// 提交数据,antd的表单要通过校验
submit: (): Promise<any> => {
return Promise.all([antdForm.validateFields(), form.submit()]);
},
setValues: (values: any) => {
antdForm.setFieldsValue(values);
form.setValues(values);
},
});
};
当然没那么简单
实际上在具体应用的时候,还是碰到了一些问题,那么解决方案,通常会自己在外层重新包裹一个组件进行处理。
譬如:
import { ShipperConsignee } from '@dzg/business-component';
import React from 'react';
/**
* 收发通
* @param props 属性
* @returns JSX
*/
const WrapperShipperConsignee = (props: any) => {
return (
<ShipperConsignee
{...props}
value={{ transceiverList: props.value }}
onChange={value => {
props.onChange(value.transceiverList);
(window as any).isChange = true;
}}
/>
);
};
export default WrapperShipperConsignee;
上面的例子是对value
和onChange
做了二次处理,主要来应对一些特殊的场景。
还有类似这样的:
import React from 'react';
import { observer, useField } from '@formily/react';
import HsCode from '@dzg/business-component/es/goods-info/HsCode';
import CommonFormWrapper from '../CommonFormWrapper';
/**
* WrapperHsCode
* @param props 组件属性
* @returns JSX
*/
function WrapperHsCode(props: any) {
const field = useField();
const disabled = field.disabled || false;
return <CommonFormWrapper wrapper={HsCode} {...props} disabled={disabled} />;
}
export default observer(WrapperHsCode);
import { Form } from 'antd';
import React from 'react';
/**
* 公共form包裹组件器
* @param props 组件属性
* @returns JSX
*/
function CommonFormWrapper(props: any) {
const { wrapper, form, ...restProps } = props;
const InnerComp = wrapper;
return (
<Form form={form} style={{ width: '100%' }}>
<InnerComp {...restProps} form={form} />
</Form>
);
}
export default CommonFormWrapper;
上面的例子是给组件包一层antd
的Form
,来解决一些三方包里面只有antd的FormItem
,导致在处理值有问题。
当然以上的场景只能遇魔杀魔、遇神杀神
了,大前提还是不要影响别人的现有代码。
在实际的自定义组件中,useField、useForm、useFormEffects这三个在复杂场景中,还是比较常用的。当然了,别忘记,组件要嵌套
observer
,不然没法进行响应式变化。
useFormilyForm
这是我在实际业务中抽象的一个hook。拿我们的页面来说,就是头部、中间、右侧栏信息。
中间是一个表单项。头部的数据、中间的数据和右侧的数据,基本都是一个接口里面的(当然有些页面不是在一个接口里面),那么对于中间的表单项来说,无非是需要传入表单数据
,然后中间的那个组件需要往外抛一个获取表单数据
的方法(因为保存按钮在头部)。
import { createForm, Form, onFieldInputValueChange, onFormMount } from '@formily/core';
import { createSchemaField } from '@formily/react';
import { useImperativeHandle, useMemo, useState } from 'react';
import { getMergeForm } from '../utils';
import { SchemaComponents, SchemaScope } from '../utils/schemaHelper';
import _ from 'lodash';
/**
* formily form的通用处理
* @param ref Ref
* @param initialValues 初始值
* @param formEffect 事件处理回调
* @param initFormData 初始化表单数据回调
* @returns 包含表单对象、antd表单对象、SchemaField的大对象
*/
function useFormilyForm(
ref: any,
initialValues = {},
formEffect?: () => void,
initFormData?: (form: Form<any>, data: any, reduxData: any) => void
) {
// 用来判断是否已经加载了数据
const [hasLoadData, setHasLoadData] = useState(false);
const [loadData, setLoadData] = useState<any>({});
const form: Form<any> = useMemo(
() =>
createForm({
validateFirst: true,
initialValues,
effects() {
onFormMount(form => {
// @ts-ignore
window.__MyForm__ = form; // 给自己留个后门,比如说做个chrome插件,给页面赋值,如果必填项太多,可以做一个初始数据
});
onFieldInputValueChange('*', () => {
// 在切换不同的头部tab时,需要判断当前表单页是否被修改,如果有修改,则要弹窗提示用户是否切走
(window as any).isChange = true;
});
formEffect && formEffect();
},
}),
[]
);
const mergedAntdForm = getMergeForm(form);
useImperativeHandle(ref, () => ({
// 历史原因,需要区分出是表单的数据,还是redux的数据
initFormData: (data: any, reduxData: any, key?: string) => {
let finalData = data;
if (key) {
finalData = _.get(data, key);
}
initFormData && initFormData(form, finalData, reduxData);
mergedAntdForm.setValues(finalData);
setHasLoadData(true);
setLoadData({
...finalData,
...reduxData,
});
},
// 往外抛一个方法
getFormData: () => {
return ((mergedAntdForm.submit() as any) as Promise<any>).then(([, data2]) => {
return data2;
});
},
setFields: mergedAntdForm.setFields,
}));
const SchemaField = createSchemaField({
// @ts-ignore
components: SchemaComponents, // 所有的组件
scope: {
form: mergedAntdForm,
...SchemaScope,
},
});
return {
form,
mergedAntdForm,
SchemaField,
hasLoadData,
loadData,
};
}
export default useFormilyForm;
一些注意点
校验出错的时候,也要滚动antd
的错误项里面去(如果有错的话)。
/**
* 将错误滚动到可视区范围内
* @return undefined
*/
export const scrollIntoView = () => {
const antPrefix = theme['ant-prefix'];
document
.querySelector(`.${antPrefix}-form-item.${antPrefix}-form-item-has-error`)
?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
document
.querySelector(`.${antPrefix}-formily-item.${antPrefix}-formily-item-error`)
?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
};
浅淡Modal的Form
@formily/antd
提供了一个FormDialog
组件,其实我个人是觉得有些些鸡肋
,当然有人会觉得很好,这个个人口味吧。
我说一下点:
- 其实没必要共享context,应该是内外部相互隔离的,只有在点击
确定
的时候,Modal
内的数据才会同步到外部
,当点击取消
的时候,放弃Modal
内已经变更的数据。 - 能够传进去一个标识,用来控制这个表单到底是
edit
,还是disabled
,还有preview
所以我自己实现了一个withFormilyModal
的高阶函数,实现的有点丑陋
,就不放出来了。。其实写法上参考了这个哥们写的:React通用解决方案——表单容器
一些有意思的bug
disabled的情况下,placeholder不应该显示
话说这个点,我觉得其实应该写到HTML规范里面去。
我目前的做法是通过css
来简单处理一下。
input[disabled]::placeholder {
opacity: 0;
}
textarea[disabled]::placeholder {
opacity: 0;
}
/* 针对antd的下拉框处理 */
.@{ant-prefix}-select-disabled .@{ant-prefix}-select-selection-placeholder {
opacity: 0;
}
在联动的时候,如果验证不通过,就不要再联动了
需求如下:
列表里面有长宽高、体积,当长、宽、高变化时,如果长、宽、高校验不通过(比如说小数点超过3位,或者正整数超过多少位),那么体积就不应该再被计算了。
onFieldInputValueChange('goods.detailList.*.*(length,width,high)', async field => {
const lengthField: any = field.query('.length').take();
const widthField: any = field.query('.width').take();
const highField: any = field.query('.high').take();
const volume: any = field.query('.volume').take();
// 长、宽、高进行校验
await lengthField.validate();
await widthField.validate();
await highField.validate();
if (
(lengthField.validateStatus && lengthField.validateStatus === 'error') ||
(widthField.validateStatus && widthField.validateStatus === 'error') ||
(highField.validateStatus && highField.validateStatus === 'error')
) {
return;
}
if (isNumber(lengthField.value) && isNumber(widthField.value) && isNumber(highField.value)) {
let result = accMul(
Number(lengthField.value) / 100,
Number(widthField.value) / 100,
Number(highField.value) / 100
);
if (result < 0.001) {
result = 0.001;
}
if (!_.isFinite(result)) {
volume.value = '';
} else {
volume.value = result.toFixed(3) + '';
}
volume.validate(); // 触发校验
} else {
if (!volume.value) {
volume.value = '';
}
}
});
});
其实这个算是一种场景,可以进行async await
的,另外有一种场景是说render
一个jsx
,但是里面的内容渲染,需要做一个处理,就是校验没通过时,就用上一次的值。
const renderTips = (field: IArrayField) => {
const value = field.value;
let validateResult = true;
for (let i = 0; i < value.length; i++) {
const item = value[i];
if (!/^[1-9](\d+)?$/.test(item.count)) {
validateResult = false;
break;
}
if (validateWeight(item.weight)) {
validateResult = false;
break;
}
if (validateWeight(item.volume)) {
validateResult = false;
break;
}
}
if (validateResult) {
totalDataRef.current = toJS(value);
}
const sumData = getSumData(totalDataRef.current);
const content = (
<>
总件数: {sumData.count || 0} 件 总毛重: {sumData.weight || 0} KGS 总体积: {sumData.volume || 0} CBM
</>
);
return <Alert message={content} type="info" showIcon />;
};
但老实说,这样写,有一个风险在于这边的校验规则和schema
的规则写了两套,尽管可以写一个公共的方法,但是还是有一定的修改风险点吧。我暂时没有想到更好的方案,也许哪天想出了验证器引擎
这样的玩意,再来做分享吧。
required的框,输几个空格,没有提示算bug
这个可以在校验规则里面加入whitespace
但其实加的多了,也比较烦。。所以可以参考我上面的重写max
规则的方式,来重写required
规则。(大前提是后期修改)
数字输入框允许敲入空格
我一开始拿到这个bug
,整个是懵的。。后来看了NumberPicker
之后,发出一声感慨:
table多行的时候,自动滚动到最后一行
就是自增的table
,在出来10条后,再新增
,就要出现滚动条,然后每次新增,滚动条就要到底部。
/**
* 增加一行
* @param field ArrayField
* @param tableId tableId
* @returns undefined
*/
export const addField = (field: ArrayField, tableId: string) => {
field.push({});
scrollToEnd(tableId);
};
/**
* 滚动到最后一行
* @param tableId tableId
* @returns undefined
*/
export const scrollToEnd = (tableId: string) => {
setTimeout(() => {
let d = document.querySelector(`#${tableId} .${theme['ant-prefix']}-table-body`);
if (d) {
d.scrollTop = d.scrollHeight;
}
}, 0);
};