Skip to content

Commit 0d2d208

Browse files
committed
fix(Form): optimize field interception logic
1 parent 5a6d3dd commit 0d2d208

File tree

5 files changed

+102
-59
lines changed

5 files changed

+102
-59
lines changed

packages/components/form/FormItem.tsx

Lines changed: 44 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1+
import { get, isEqual, isFunction, isObject, isString, set, unset } from 'lodash-es';
12
import React, { forwardRef, ReactNode, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
23
import {
34
CheckCircleFilledIcon as TdCheckCircleFilledIcon,
45
CloseCircleFilledIcon as TdCloseCircleFilledIcon,
56
ErrorCircleFilledIcon as TdErrorCircleFilledIcon,
67
} from 'tdesign-icons-react';
7-
import { get, isEqual, isFunction, isObject, isString, set, unset } from 'lodash-es';
88

99
import useConfig from '../hooks/useConfig';
1010
import useDefaultProps from '../hooks/useDefaultProps';
1111
import useGlobalIcon from '../hooks/useGlobalIcon';
1212
import { useLocaleReceiver } from '../locale/LocalReceiver';
13-
import { READONLY_SUPPORTED_COMP, ValidateStatus } from './const';
13+
import { NATIVE_INPUT_COMP, TD_READONLY_COMP, ValidateStatus } from './const';
1414
import { formItemDefaultProps } from './defaultProps';
1515
import { useFormContext, useFormListContext } from './FormContext';
1616
import { parseMessage, validate as validateModal } from './formModel';
@@ -490,41 +490,53 @@ const FormItem = forwardRef<FormItemInstance, FormItemProps>((originalProps, ref
490490
<div className={`${classPrefix}-form__controls-content`}>
491491
{React.Children.map(children, (child, index) => {
492492
if (!child) return null;
493+
if (!React.isValidElement(child)) return child;
493494

494-
let ctrlKey = 'value';
495-
if (React.isValidElement(child)) {
496-
if (child.type === FormItem) {
497-
return React.cloneElement(child, {
498-
// @ts-ignore
499-
ref: (el) => {
500-
if (!el) return;
501-
innerFormItemsRef.current[index] = el;
502-
},
503-
});
504-
}
505-
if (typeof child.type === 'object') {
506-
ctrlKey = ctrlKeyMap.get(child.type) || 'value';
507-
}
508-
const childProps = child.props as any;
509-
// @ts-ignore
510-
const readOnlyKey = READONLY_SUPPORTED_COMP.includes(child?.type?.displayName) ? 'readonly' : 'readOnly';
495+
const childType = child.type;
496+
const isCustomComp = typeof childType === 'object' || typeof childType === 'function';
497+
// @ts-ignore
498+
const childName = isCustomComp ? childType.displayName : childType;
499+
500+
if (childName === 'FormItem') {
511501
return React.cloneElement(child, {
512-
disabled: disabledFromContext,
513-
[readOnlyKey]: readonlyFromContext,
514-
...childProps,
515-
[ctrlKey]: formValue,
516-
onChange: (value: any, ...args: any[]) => {
517-
const newValue = valueFormat ? valueFormat(value) : value;
518-
updateFormValue(newValue, true, true);
519-
childProps?.onChange?.call?.(null, value, ...args);
520-
},
521-
onBlur: (value: any, ...args: any[]) => {
522-
handleItemBlur();
523-
childProps?.onBlur?.call?.(null, value, ...args);
502+
// @ts-ignore
503+
ref: (el) => {
504+
if (!el) return;
505+
innerFormItemsRef.current[index] = el;
524506
},
525507
});
526508
}
527-
return child;
509+
510+
const childProps = child.props as any;
511+
const readOnlyKey = TD_READONLY_COMP.includes(childName) ? 'readonly' : 'readOnly';
512+
const commonProps = {
513+
disabled: disabledFromContext,
514+
[readOnlyKey]: readonlyFromContext,
515+
...child.props,
516+
};
517+
518+
if (!isCustomComp && !NATIVE_INPUT_COMP.includes(childName)) {
519+
return React.cloneElement(child, commonProps);
520+
}
521+
522+
let ctrlKey = 'value';
523+
if (isCustomComp) {
524+
ctrlKey = ctrlKeyMap.get(childType) || 'value';
525+
}
526+
527+
return React.cloneElement(child, {
528+
...commonProps,
529+
[ctrlKey]: formValue,
530+
onChange: (value: any, ...args: any[]) => {
531+
const newValue = valueFormat ? valueFormat(value) : value;
532+
updateFormValue(newValue, true, true);
533+
childProps?.onChange?.call?.(null, value, ...args);
534+
},
535+
onBlur: (value: any, ...args: any[]) => {
536+
handleItemBlur();
537+
childProps?.onBlur?.call?.(null, value, ...args);
538+
},
539+
});
528540
})}
529541
{renderSuffixIcon()}
530542
</div>

packages/components/form/const.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export const enum ValidateStatus {
55
VALIDATING = 'validating',
66
}
77

8-
export const READONLY_SUPPORTED_COMP = [
8+
export const TD_READONLY_COMP = [
99
'AutoComplete',
1010
'Cascader',
1111
'Checkbox',
@@ -21,3 +21,8 @@ export const READONLY_SUPPORTED_COMP = [
2121
'Textarea',
2222
'TreeSelect',
2323
];
24+
25+
/**
26+
* 原生支持 value 属性的组件
27+
*/
28+
export const NATIVE_INPUT_COMP = ['input', 'textarea', 'select', 'progress'];

packages/components/form/form.md

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030

3131
创建 Form 实例,用于管理所有数据状态。
3232

33-
3433
### Form.useWatch
3534

3635
用于直接获取 form 中字段对应的值。
@@ -52,34 +51,64 @@ const Demo = () => {
5251

5352
## FAQ
5453

55-
### 为什么被 FormItem 包裹的组件 value、defaultValue 没有效果?
54+
### 为什么被 FormItem 包裹的组件 `value``defaultValue` 没有效果?
55+
56+
Form 的设计初衷是自动托管表单字段的 `value``onChange`,FormItem 会向包裹的第一个组件注入状态,即内部组件自身的 `defaultValue``value` 将被拦截,不会生效。如果需要设置初始值,应该使用 FormItem 的 `initialData`
57+
58+
```js
59+
<FormItem name="ui" label="组件库" initialData="TDesign">
60+
<Input />
61+
</FormItem>
62+
```
63+
64+
如果第一层组件不支持 `value` 属性,会导致 Form 无法接管组件的行为:
65+
66+
```js
67+
// ❌ div 的 value 无意义,Form 部分 API 失效
68+
<FormItem name="ui" label="组件库" >
69+
<div style={{ border: '1px dotted blue', padding: 5 }}>
70+
<Input />
71+
</div>
72+
</FormItem>
73+
```
5674

57-
Form 组件设计的初衷是为了解放开发者配置大量的 `value``onChange` 受控属性,所以 Form.FormItem 被设计成需要拦截嵌套组件的受控属性,如需定义初始值请使用 `initialData` 属性。
75+
如果想要自定义排版样式,可以考虑下面的写法:
5876

59-
由于 Form.FormItem 只会拦截第一层子节点的受控属性,所以如不希望 Form.FormItem 拦截受控属性希望自行管理 state 的话,可以在 Form.FormItem 下包裹一层 `div` 节点脱离 Form.FormItem 的代理,但同时也会失去 Form 组件的校验能力。
77+
```js
78+
// ✅ value 自动会传递给 input,Form 相关 API 正常
79+
const CustomInput = (props) => (
80+
<div style={{ border: '1px dotted blue', padding: 5 }}>
81+
<Input {...props} />
82+
</div>
83+
);
84+
85+
<FormItem name="ui" label="组件库" initialData="TDesign">
86+
<CustomInput />
87+
</FormItem>
88+
```
6089

6190
### 我只想要 Form 组件的布局效果,校验能力我自己业务来实现可以吗?
6291

63-
可以的,Form 的校验能力只跟 `name` 属性关联,不指定 Form.FormItem 的 `name` 属性是可以当成布局组件来使用的,甚至可以实现各种嵌套自定义内容的布局效果。
92+
可以的,Form 的校验能力只跟 `name` 属性关联,不指定 FormItem 的 `name` 属性是可以当成布局组件来使用的,甚至可以实现各种嵌套自定义内容的布局效果。
6493

6594
```js
6695
// 可以单独使用 FormItem 组件
67-
<Form.FormItem label="姓名">
96+
<FormItem label="姓名">
6897
<div>可以任意定制内容</div>
6998
<Input />
7099
<div>可以任意定制内容</div>
71-
</Form.FormItem>
100+
</FormItem>
72101
```
73102

74-
### getFieldsValue 返回的数据如何支持嵌套数据结构?
103+
### `getFieldsValue` 返回的数据如何支持嵌套数据结构?
75104

76105
`name` 设置成数组形式可以支持嵌套数据结构。
77106

78107
```js
79108
// ['user', 'name'] => { user: { name: '' } }
80-
<Form.FormItem label="姓名" name={['user', 'name']}>
109+
<FormItem label="姓名" name={['user', 'name']}>
81110
<Input />
82-
</Form.FormItem>
111+
</FormItem>
83112
```
84113

85114
## API
Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
import { useState, useEffect, useMemo, useRef } from 'react';
2-
import { get, isUndefined } from 'lodash-es';
1+
import { useEffect, useState } from 'react';
2+
import { get, isEqual } from 'lodash-es';
3+
4+
import noop from '../../_util/noop';
5+
import { HOOK_MARK } from './useForm';
6+
37
import type { NamePath } from '../type';
48
import type { InternalFormInstance } from './interface';
5-
import { HOOK_MARK } from './useForm';
6-
import noop from '../../_util/noop';
79

810
export default function useWatch(name: NamePath, form: InternalFormInstance) {
911
const [value, setValue] = useState<any>();
10-
const valueStr = useMemo(() => JSON.stringify(value), [value]);
11-
const valueStrRef = useRef(valueStr);
1212

13-
// eslint-disable-next-line
13+
// eslint-disable-next-line no-underscore-dangle
1414
const isValidForm = form && form._init;
1515

1616
useEffect(() => {
@@ -21,22 +21,18 @@ export default function useWatch(name: NamePath, form: InternalFormInstance) {
2121
const cancelRegister = registerWatch(() => {
2222
const allFieldsValue = form.getFieldsValue?.(true);
2323
const newValue = get(allFieldsValue, name);
24-
const nextValueStr = JSON.stringify(newValue);
25-
26-
// Compare stringify in case it's nest object
27-
if (valueStrRef.current !== nextValueStr) {
28-
valueStrRef.current = nextValueStr;
29-
setValue(nextValueStr);
24+
if (!isEqual(value, newValue)) {
25+
setValue(newValue);
3026
}
3127
});
3228

3329
const allFieldsValue = form.getFieldsValue?.(true);
3430
const initialValue = get(allFieldsValue, name);
35-
setValue(JSON.stringify(initialValue));
31+
setValue(initialValue);
3632

3733
return cancelRegister;
3834
// eslint-disable-next-line react-hooks/exhaustive-deps
3935
}, []);
4036

41-
return isUndefined(value) ? value : JSON.parse(value);
37+
return value;
4238
}

packages/tdesign-react/site/plugins/plugin-tdoc/transforms.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ export default {
4444
}
4545

4646
// 替换成对应 demo 文件
47-
source = source.replace(/\{\{\s+(.+)\s+\}\}/g, (demoStr, demoFileName) => {
47+
// 只匹配独立行的 {{ }} 模式,避免影响普通代码块中的内容
48+
source = source.replace(/^[ \t]*\{\{\s+(.+?)\s+\}\}[ \t]*$/gm, (demoStr, demoFileName) => {
4849
const tsxDemoPath = path.resolve(resourceDir, `./_example/${demoFileName}.tsx`);
4950

5051
if (!fs.existsSync(tsxDemoPath)) {

0 commit comments

Comments
 (0)