From 4b38e84c185e04e06f01bd79203e098e7964a791 Mon Sep 17 00:00:00 2001 From: "Y." Date: Mon, 20 Apr 2026 15:08:18 +0800 Subject: [PATCH 1/6] fix(Form): fix onValuesChange invalid --- src/form/Form.tsx | 6 ++++-- src/form/FormItem.tsx | 10 ++++++++++ src/form/hooks/interface.ts | 1 + src/form/hooks/useForm.ts | 4 ++++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/form/Form.tsx b/src/form/Form.tsx index 8b9380bda..5b56d9a09 100644 --- a/src/form/Form.tsx +++ b/src/form/Form.tsx @@ -246,8 +246,10 @@ const Form = forwardRefWithStatics( } function onFormItemValueChange(changedValue: Record) { - const allFields = formRef.current.getFieldsValue(true); - onValuesChange(changedValue, allFields); + requestAnimationFrame(() => { + const allFields = form.getFieldsValue?.(true) ?? {}; + onValuesChange(changedValue, allFields); + }); } return ( diff --git a/src/form/FormItem.tsx b/src/form/FormItem.tsx index c8a290466..ac6a6176a 100644 --- a/src/form/FormItem.tsx +++ b/src/form/FormItem.tsx @@ -58,6 +58,7 @@ const FormItem: React.FC = (props) => { resetType: resetTypeFromContext, rules: rulesFromContext, errorMessage, + onFormItemValueChange, } = formContext; const { @@ -85,6 +86,7 @@ const FormItem: React.FC = (props) => { const hasInit = useRef(false); const contextRef = useRef(null); const rulesMemoStr = useMemo(() => JSON.stringify(rules), [rules]); + const shouldEmitChangeRef = useRef(false); const shouldShowErrorMessage = useMemo(() => { if (isBoolean(freeShowErrorMessage)) return freeShowErrorMessage; @@ -353,6 +355,13 @@ const FormItem: React.FC = (props) => { }; }, [context, form.store, formContext, name]); + // 监听 formValue 变化,触发 onValuesChange + useEffect(() => { + if (typeof name === 'undefined' || !shouldEmitChangeRef.current) return; + const fieldValue = { [name]: formValue }; + onFormItemValueChange?.(fieldValue); + }, [formValue, name, onFormItemValueChange]); + // 监听规则变化 useEffect(() => { if (!hasInit.current) { @@ -418,6 +427,7 @@ const FormItem: React.FC = (props) => { onChange: (value: any, ...args) => { const newValue = cloneDeep(value); lodashSet(form?.store, name, newValue); + shouldEmitChangeRef.current = true; setFormValue(newValue); (children as React.ReactElement).props?.onChange?.call?.(null, value, ...args); }, diff --git a/src/form/hooks/interface.ts b/src/form/hooks/interface.ts index d06bd8419..b508ef58c 100644 --- a/src/form/hooks/interface.ts +++ b/src/form/hooks/interface.ts @@ -3,4 +3,5 @@ export type Store = Record; export interface InternalFormInstance { isInit?: boolean; store?: Store; + getFieldsValue?: (multiple?: boolean) => Store; } diff --git a/src/form/hooks/useForm.ts b/src/form/hooks/useForm.ts index 1997e16af..52fe4b8bf 100644 --- a/src/form/hooks/useForm.ts +++ b/src/form/hooks/useForm.ts @@ -7,6 +7,10 @@ class FormStore { public getForm = (): InternalFormInstance => ({ isInit: true, store: this.store, + getFieldsValue: (multiple?: boolean) => { + if (multiple) return this.store; + return { ...this.store }; + }, }); } From c454c3630659ac68adcd94e25c179e7ae497aeab Mon Sep 17 00:00:00 2001 From: "Y." Date: Mon, 20 Apr 2026 17:03:27 +0800 Subject: [PATCH 2/6] chore(Form): enhance FormInstanceFunctions --- src/form/Form.tsx | 349 +++++++++++++++------- src/form/FormContext.tsx | 4 +- src/form/FormItem.tsx | 157 ++++++++-- src/form/const.ts | 17 +- src/form/defaultProps.ts | 1 + src/form/form.en-US.md | 12 +- src/form/form.md | 12 +- src/form/hooks/interface.ts | 16 +- src/form/hooks/useForm.ts | 88 +++++- src/form/type.ts | 58 +++- test/snap/__snapshots__/csr.test.jsx.snap | 105 ++++++- 11 files changed, 638 insertions(+), 181 deletions(-) diff --git a/src/form/Form.tsx b/src/form/Form.tsx index 5b56d9a09..cc716eb7b 100644 --- a/src/form/Form.tsx +++ b/src/form/Form.tsx @@ -1,30 +1,28 @@ -import React, { ForwardedRef, useImperativeHandle, useRef } from 'react'; +import React, { ForwardedRef, useEffect, useImperativeHandle, useRef, useMemo } from 'react'; import classNames from 'classnames'; -import { isArray, isBoolean, isEmpty, isFunction } from 'lodash-es'; +import { get, cloneDeep, merge, set, isFunction, isArray, isBoolean, isEmpty, isObject } from 'lodash-es'; import noop from '../_util/noop'; import forwardRefWithStatics from '../_util/forwardRefWithStatics'; import { - Data, FormInstanceFunctions, FormResetParams, FormValidateMessage, FormValidateParams, FormValidateResult, TdFormProps, - ValidateResultList, + NamePath, + FieldData, } from './type'; import { FormResetEvent, FormSubmitEvent, StyledProps } from '../common'; -import FormItem, { FormItemValidateResult } from './FormItem'; +import FormItem from './FormItem'; import { formItemDefaultProps } from './defaultProps'; import useDefaultProps from '../hooks/useDefaultProps'; import { usePrefixClass } from '../hooks/useClass'; import useConfig from '../hooks/useConfig'; -import useForm from './hooks/useForm'; +import useForm, { HOOK_MARK } from './hooks/useForm'; import { FormContext } from './FormContext'; import { FormItemContext } from './const'; -type Result = FormValidateResult; - export interface FormProps extends TdFormProps, StyledProps { children?: React.ReactElement[] | React.ReactElement; } @@ -41,6 +39,21 @@ export const requestSubmit = (target: HTMLFormElement) => { target.removeChild(submitter); }; +function needValidate(name: NamePath, fields?: string[]) { + if (!fields || !Array.isArray(fields)) return true; + return fields.some((item) => String(item) === String(name)); +} + +function formatValidateResult(validateResultList: any[]) { + const result = validateResultList.reduce((r, err) => Object.assign(r || {}, err), {}); + Object.keys(result).forEach((key) => { + if (result[key] === true) { + delete result[key]; + } + }); + return isEmpty(result) ? true : result; +} + const Form = forwardRefWithStatics( (props: FormProps, ref: ForwardedRef) => { const { form: globalFormConfig } = useConfig(); @@ -52,15 +65,16 @@ const Form = forwardRefWithStatics( labelWidth, labelAlign, colon, - requiredMark = globalFormConfig.requiredMark, - requiredMarkPosition = globalFormConfig.requiredMarkPosition, + requiredMark = globalFormConfig?.requiredMark, + requiredMarkPosition = globalFormConfig?.requiredMarkPosition, scrollToFirstError, showErrorMessage, resetType, rules, - errorMessage = globalFormConfig.errorMessage, + errorMessage = globalFormConfig?.errorMessage, preventSubmitDefault, disabled, + readonly, children, id, onSubmit: onSubmitCustom, @@ -69,51 +83,185 @@ const Form = forwardRefWithStatics( onValuesChange = noop, } = useDefaultProps(props, formItemDefaultProps); const submitParams = useRef>({}); - const resetParams = useRef>({}); + const resetParams = useRef>({}); const formRef = useRef(null); + const formMapRef = useRef(new Map()); // 收集所有包含 name 属性 formItem 实例 const [form] = useForm(); const formContentClass = classNames(formClass, className); + // 获取单个字段值 + function getFieldValue(name: NamePath) { + if (!name) return null; + const formItemRef = formMapRef.current.get(String(name)); + if (formItemRef?.current) { + return formItemRef.current.getValue?.(); + } + return get(form?.store, name); + } + + // 获取一组字段名对应的值,当调用 getFieldsValue(true) 时返回所有值 + function getFieldsValue(nameList: string[] | boolean) { + const fieldsValue: Record = {}; + + if (nameList === true) { + // 倒序遍历,嵌套数组子节点先添加,导致外层数据被覆盖 + const entries = Array.from(formMapRef.current.entries()); + for (let i = entries.length - 1; i >= 0; i--) { + const [name, formItemRef] = entries[i]; + if (formItemRef?.current) { + const value = formItemRef.current.getValue?.(); + set(fieldsValue, name, value); + } + } + // 合并 store 中的值 + merge(fieldsValue, cloneDeep(form?.store)); + } else { + if (!Array.isArray(nameList)) { + console.error('Form', 'The parameter of "getFieldsValue" must be an array'); + return {}; + } + for (let i = 0; i < nameList.length; i++) { + const name = nameList[i]; + const formItemRef = formMapRef.current.get(String(name)); + if (formItemRef?.current) { + const value = formItemRef.current.getValue?.(); + set(fieldsValue, name, value); + } else { + const storeValue = get(form?.store, name); + set(fieldsValue, name, storeValue); + } + } + } + return cloneDeep(fieldsValue); + } + + // 递归处理嵌套对象,将叶子节点设置到对应的 FormItem + function setNestedValue(obj: Record, prefix = '') { + Object.keys(obj).forEach((key) => { + const value = obj[key]; + const fullPath = prefix ? `${prefix}.${key}` : key; + + if (isObject(value) && !Array.isArray(value)) { + // 递归处理嵌套对象 + setNestedValue(value as Record, fullPath); + } else { + // 叶子节点,尝试设置到 FormItem + const formItemRef = formMapRef.current.get(fullPath); + if (formItemRef?.current) { + formItemRef.current.setValue?.(cloneDeep(value)); + } else if (form?.store) { + set(form.store, fullPath, value); + } + } + }); + } + + // 设置表单字段值 + function setFieldsValue(fields: Record = {}) { + setNestedValue(fields); + } + + // 设置多组字段状态 + function setFields(fields: FieldData[] = []) { + if (!Array.isArray(fields)) throw new TypeError('The parameter of "setFields" must be an array'); + + fields.forEach((field) => { + const { name, ...restFields } = field; + const formItemRef = formMapRef.current.get(String(name)); + formItemRef?.current?.setField?.(restFields); + }); + } + + function getValidateMessage(fields?: Array) { + if (typeof fields !== 'undefined' && !isArray(fields)) { + throw new TypeError('The parameter of "getValidateMessage" must be an array'); + } + + const formItemRefs = + typeof fields === 'undefined' + ? [...formMapRef.current.values()] + : fields.map((name) => formMapRef.current.get(String(name))).filter(Boolean); + + const message: Record = {}; + + formItemRefs.forEach((formItemRef: any) => { + const item = formItemRef?.current?.getValidateMessage?.(); + if (isEmpty(item)) return; + const nameKey = formItemRef?.current?.name; + const key = Array.isArray(nameKey) ? nameKey.toString() : String(nameKey); + message[key] = item; + }); + + if (isEmpty(message)) return; + + return message; + } + useImperativeHandle(ref, () => ({ validate, submit, reset, - clearValidate, - setValidateMessage, + clearValidate: clearValidate as any, + setValidateMessage: setValidateMessage as any, validateOnly, + currentElement: () => formRef.current!, + getCurrentElement: () => formRef.current!, + getFieldValue, + getFieldsValue: getFieldsValue as any, + getValidateMessage: getValidateMessage as any, + setFields, + setFieldsValue, })); - function needValidate(name: string | number, fields: string[] | undefined) { - if (!fields || !isArray(fields)) return true; - return fields.indexOf(`${name}`) !== -1; - } + // form 初始化后清空队列 + useEffect(() => { + form?.getInternalHooks?.(HOOK_MARK)?.flashQueue?.(); + }, [form]); - function formatValidateResult(validateResultList: FormItemValidateResult[]) { - const result = validateResultList.reduce((r, err) => Object.assign(r || {}, err), {}); - Object.keys(result).forEach((key) => { - if (result[key] === true) { - delete result[key]; - } - }); - return isEmpty(result) ? true : result; - } + // 使用 useMemo 缓存 context 值 + const formContextValue = useMemo( + () => ({ + disabled, + readonly, + form, + labelWidth, + labelAlign, + colon, + requiredMark, + requiredMarkPosition, + errorMessage, + showErrorMessage, + resetType, + rules, + formMapRef, + onFormItemValueChange, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + disabled, + readonly, + form, + labelWidth, + labelAlign, + colon, + requiredMark, + requiredMarkPosition, + errorMessage, + showErrorMessage, + resetType, + rules, + ], + ); - async function validate(param?: FormValidateParams): Promise { + async function validate(param?: FormValidateParams) { const { fields, trigger = 'all', showErrorMessage } = param || {}; - const list = React.Children.toArray(children) + const list = [...formMapRef.current.values()] .filter( - (child: React.ReactElement) => - React.isValidElement(child) && - isFunction(child.props.validate) && - needValidate(String(child.props.name), fields), + (formItemRef) => + isFunction(formItemRef?.current?.validate) && needValidate(formItemRef?.current?.name, fields), ) - .map((child: React.ReactElement) => { - if (React.isValidElement(child)) { - return child.props.validate(trigger, showErrorMessage); - } - return null; - }); + .map((formItemRef) => formItemRef?.current?.validate(trigger, showErrorMessage)); const arr = await Promise.all(list); const result = formatValidateResult(arr); onValidate?.({ @@ -124,46 +272,40 @@ const Form = forwardRefWithStatics( // 校验不通过时,滚动到第一个错误表单 function scrollTo(selector: string) { - const doms = formRef.current.getElementsByClassName(selector); - const dom = doms[0]; + const doms = formRef.current?.getElementsByClassName(selector); + const dom = doms?.[0]; const behavior = scrollToFirstError; if (behavior && dom) { dom.scrollIntoView({ behavior }); } } - function getFirstError(result: Result) { + function getFirstError(result: FormValidateResult) { if (isBoolean(result)) { return ''; } - const [firstKey] = Object.keys(result); - if (scrollToFirstError) { + const keys = Object.keys(result); + const [firstKey] = keys; + if (scrollToFirstError && firstKey) { const tmpClassName = `${formClass}-item__${firstKey}`; scrollTo(tmpClassName); } - const resArr = result[firstKey] as ValidateResultList; + const resArr = result[firstKey]; if (!isArray(resArr)) { return ''; } - return result?.[Object.keys(result)?.[0]]?.[0]?.message || ''; + return resArr?.[0]?.message || ''; } async function validateOnly(params?: Omit) { const { fields, trigger = 'all' } = params || {}; - const list = React.Children.toArray(children) + const list = [...formMapRef.current.values()] .filter( - (child: React.ReactElement) => - React.isValidElement(child) && - isFunction(child.props.validateOnly) && - needValidate(String(child.props.name), fields), + (formItemRef) => + isFunction(formItemRef?.current?.validateOnly) && needValidate(formItemRef?.current?.name, fields), ) - .map((child: React.ReactElement) => { - if (React.isValidElement(child)) { - return child.props.validateOnly(trigger); - } - return null; - }); + .map((formItemRef) => formItemRef?.current?.validateOnly?.(trigger)); const arr = await Promise.all(list); return formatValidateResult(arr); } @@ -180,12 +322,14 @@ const Form = forwardRefWithStatics( firstError, }); }); - submitParams.current = undefined; + submitParams.current = {}; } async function submit(params?: Pick) { - submitParams.current = params; - requestSubmit(formRef.current); + submitParams.current = params || {}; + if (formRef.current) { + requestSubmit(formRef.current); + } } function onReset(e?: FormResetEvent) { @@ -193,82 +337,61 @@ const Form = forwardRefWithStatics( e.preventDefault(); e.stopPropagation(); } - React.Children.toArray(children) - .filter( - (child: React.ReactElement) => - React.isValidElement(child) && - isFunction(child.props.resetField) && - needValidate(String(child.props.name), resetParams.current?.fields as string[]), - ) - .forEach((child: React.ReactElement) => { - if (React.isValidElement(child)) { - child.props.resetField(resetParams.current?.type); + const params = resetParams.current; + resetParams.current = {}; + [...formMapRef.current.values()].forEach((formItemRef) => { + if (isFunction(formItemRef?.current?.resetField)) { + const name = formItemRef?.current?.name; + if (needValidate(name, params?.fields as string[])) { + formItemRef?.current?.resetField(params?.type); } - }); - resetParams.current = undefined; + } + }); + // 重置后清空 store + if (form?.store) { + form.store = {}; + } onResetCustom?.({ e }); } - function reset(params?: FormResetParams) { - (resetParams.current as any) = params; - formRef.current.reset(); + function reset(params?: FormResetParams) { + resetParams.current = params || {}; + formRef.current?.reset(); } function clearValidate(fields?: Array) { - React.Children.toArray(children).forEach((child: React.ReactElement) => { - if ( - React.isValidElement(child) && - isFunction(child.props.resetHandler) && - needValidate(String(child.props.name), fields) - ) { - child.props.resetHandler(); + [...formMapRef.current.values()].forEach((formItemRef) => { + if (isFunction(formItemRef?.current?.resetHandler) && needValidate(formItemRef?.current?.name, fields)) { + formItemRef?.current?.resetHandler(); } }); } - function setValidateMessage(validateMessage: FormValidateMessage) { + function setValidateMessage(validateMessage: FormValidateMessage) { const keys = Object.keys(validateMessage); if (!keys.length) return; - const list = React.Children.toArray(children) - .filter( - (child: React.ReactElement) => - React.isValidElement(child) && - isFunction(child.props.setValidateMessage) && - keys.includes(`${child.props.name}`), - ) - .map((child: React.ReactElement) => { - if (React.isValidElement(child)) { - return child.props.setValidateMessage(validateMessage[`${child.props.name}`]); + + [...formMapRef.current.values()].forEach((formItemRef) => { + const name = formItemRef?.current?.name; + if (isFunction(formItemRef?.current?.setValidateMessage) && name) { + const nameKey = Array.isArray(name) ? name.toString() : String(name); + if (keys.includes(nameKey)) { + const msg = validateMessage[nameKey]; + formItemRef?.current?.setValidateMessage(msg); } - return null; - }); - Promise.all(list); + } + }); } function onFormItemValueChange(changedValue: Record) { requestAnimationFrame(() => { - const allFields = form.getFieldsValue?.(true) ?? {}; + const allFields = getFieldsValue(true); onValuesChange(changedValue, allFields); }); } return ( - +
>; onFormItemValueChange: (changedValue: Record) => void; } diff --git a/src/form/FormItem.tsx b/src/form/FormItem.tsx index ac6a6176a..cb7c5e8d9 100644 --- a/src/form/FormItem.tsx +++ b/src/form/FormItem.tsx @@ -49,6 +49,7 @@ const FormItem: React.FC = (props) => { form, colon, disabled: disabledFromContext, + readonly: readonlyFromContext, requiredMark: requiredMarkFromContext, requiredMarkPosition, labelAlign: labelAlignFromContext, @@ -58,6 +59,7 @@ const FormItem: React.FC = (props) => { resetType: resetTypeFromContext, rules: rulesFromContext, errorMessage, + formMapRef, onFormItemValueChange, } = formContext; @@ -81,10 +83,11 @@ const FormItem: React.FC = (props) => { const [resetValidating, setResetValidating] = useState(false); const [needResetField, setNeedResetField] = useState(false); const [freeShowErrorMessage, setFreeShowErrorMessage] = useState(undefined); - const [formValue, setFormValue] = useState(lodashGet(form?.store, name)); + const [formValue, setFormValue] = useState(name ? lodashGet(form?.store, name) : undefined); const initialValue = useRef(''); const hasInit = useRef(false); const contextRef = useRef(null); + const formItemRef = useRef(null); const rulesMemoStr = useMemo(() => JSON.stringify(rules), [rules]); const shouldEmitChangeRef = useRef(false); @@ -186,8 +189,8 @@ const FormItem: React.FC = (props) => { return style; }, [labelWidth, labelAlign]); - const errorMessages = useMemo( - () => errorMessage ?? globalFormConfig.errorMessage, + const errorMessages = useMemo( + () => errorMessage ?? globalFormConfig?.errorMessage, [errorMessage, globalFormConfig], ); @@ -199,35 +202,39 @@ const FormItem: React.FC = (props) => { }, []); const getEmptyValue = useCallback((): ValueType => { - const value = lodashGet(form.store, name); + const value = name ? lodashGet(form?.store, name) : undefined; if (typeof value === 'string') return ''; if (Array.isArray(value)) return []; if (typeof value === 'object') return {}; return undefined; - }, [form.store, name]); + }, [form?.store, name]); const resetField = useCallback( async (resetType: 'initial' | 'empty' | undefined = resetTypeFromContext): Promise => { - if (!name) { + if (!name || !form?.store) { return null; } + let resetValue; if (resetType === 'empty') { - lodashSet(form.store, name, getEmptyValue()); + resetValue = getEmptyValue(); + lodashSet(form.store, name, resetValue); } else if (resetType === 'initial') { - lodashSet(form.store, name, initialValue.current); + resetValue = initialValue.current; + lodashSet(form.store, name, resetValue); } + setFormValue(resetValue); if (resetValidating) { setNeedResetField(true); } else { resetHandler(); } }, - [resetTypeFromContext, name, resetValidating, form.store, getEmptyValue, initialValue, resetHandler], + [resetTypeFromContext, name, resetValidating, form?.store, getEmptyValue, resetHandler, initialValue], ); const analysisValidateResult = useCallback( async (trigger: ValidateTriggerType): Promise => { - const value = lodashGet(form.store, name); + const value = name ? lodashGet(form?.store, name) : undefined; const result: AnalysisValidateResult = { successList: [], errorList: [], @@ -241,18 +248,31 @@ const FormItem: React.FC = (props) => { return result; } result.allowSetValue = true; - result.resultList = await validate(value, result.rules); + // 处理自定义校验规则的 context 参数 + const rulesWithContext = result.rules.map((rule) => { + if (rule.validator) { + return { + ...rule, + validator: (val: ValueType) => { + const context = { formData: form?.store || {}, name: String(name) }; + return rule.validator!(val, context); + }, + }; + } + return rule; + }); + result.resultList = await validate(value, rulesWithContext); result.errorList = result.resultList .filter((item) => item.result !== true) .map((item) => { const newItem = { ...item }; Object.keys(newItem).forEach((key) => { - if (!newItem.message && errorMessages[key]) { - const compiled = lodashTemplate(errorMessages[key]); + if (!newItem.message && errorMessages?.[key as keyof FormErrorMessage]) { + const compiled = lodashTemplate(errorMessages[key as keyof FormErrorMessage] as string); const labelName = isString(label) ? label : name; newItem.message = compiled({ name: labelName, - validate: newItem[key], + validate: newItem[key as keyof typeof newItem], }); } }); @@ -264,7 +284,7 @@ const FormItem: React.FC = (props) => { ) as SuccessListType[]; return result; }, - [form.store, name, innerRules, errorMessages, label], + [form?.store, name, innerRules, errorMessages, label], ); const validateHandler = useCallback( @@ -292,9 +312,12 @@ const FormItem: React.FC = (props) => { } setResetValidating(false); - return { - [`${name}`]: innerErrorList?.length === 0 ? true : resultList, - } as FormItemValidateResult; + const result = {} as FormItemValidateResult; + if (name !== undefined) { + (result as Record)[String(name)] = + innerErrorList?.length === 0 ? true : resultList; + } + return result; }, [analysisValidateResult, needResetField, resetHandler, name], ); @@ -302,9 +325,12 @@ const FormItem: React.FC = (props) => { const validateOnly = useCallback( async (trigger: ValidateTriggerType): Promise> => { const { errorList: innerErrorList, resultList } = await analysisValidateResult(trigger); - return { - [name]: innerErrorList.length === 0 ? true : resultList, - } as FormItemValidateResult; + const result = {} as FormItemValidateResult; + if (name !== undefined && innerErrorList) { + (result as Record)[String(name)] = + innerErrorList.length === 0 ? true : resultList; + } + return result; }, [analysisValidateResult, name], ); @@ -317,12 +343,52 @@ const FormItem: React.FC = (props) => { setErrorList(validateMessage.map((item) => ({ ...item, result: false }))); }, []); + // 获取校验信息 + const getValidateMessage = useCallback(() => { + if (errorList.length === 0) return []; + return errorList.map((item) => ({ + type: item.type || 'error', + message: item.message, + })); + }, [errorList]); + + // 获取当前值 + const getValue = useCallback(() => (name ? lodashGet(form?.store, name) : undefined), [form?.store, name]); + + // 设置值 + const setValue = useCallback( + (value: any) => { + if (name && form?.store) { + lodashSet(form.store, name, cloneDeep(value)); + } + setFormValue(value); + }, + [form?.store, name], + ); + + // 设置字段状态 + const setField = useCallback( + (fieldData: { value?: unknown; status?: string; validateMessage?: { type?: string; message?: string } }) => { + const { value, status, validateMessage } = fieldData; + if (typeof value !== 'undefined') { + setValue(value); + } + if (status) { + // 可以扩展状态处理逻辑 + } + if (validateMessage) { + setValidateMessage([validateMessage as FormItemValidateMessage]); + } + }, + [setValue, setValidateMessage], + ); + const handleBlur = useCallback(async () => { await validateHandler('blur'); }, [validateHandler]); // 创建 context 对象 - const context: FormItemContext = useMemo( + const context = useMemo( () => ({ name, resetHandler, @@ -330,17 +396,45 @@ const FormItem: React.FC = (props) => { validate: validateHandler, validateOnly, setValidateMessage, + getValidateMessage, + getValue, + setValue, + setField, value: formValue, }), - [name, resetHandler, resetField, validateHandler, validateOnly, setValidateMessage, formValue], + [ + name, + resetHandler, + resetField, + validateHandler, + validateOnly, + setValidateMessage, + getValidateMessage, + getValue, + setValue, + setField, + formValue, + ], ); + // 注册到 formMapRef + useEffect(() => { + if (!name || !formMapRef) return; + const nameKey = Array.isArray(name) ? name.join('.') : String(name); + const mapRef = formMapRef.current; + formItemRef.current = { current: context }; + mapRef.set(nameKey, formItemRef.current); + return () => { + mapRef.delete(nameKey); + }; + }, [name, formMapRef, context]); + useEffect(() => { - if (initialValue.current || !name) { + if (initialValue.current || !name || !form?.store) { return; } initialValue.current = lodashGet(form.store, name); - }, [form.store, name]); + }, [form?.store, name]); // 生命周期 useEffect(() => { @@ -353,12 +447,13 @@ const FormItem: React.FC = (props) => { formContext.children = formContext.children.filter((ctx) => ctx !== contextRef.current); } }; - }, [context, form.store, formContext, name]); + }, [context, formContext]); // 监听 formValue 变化,触发 onValuesChange useEffect(() => { if (typeof name === 'undefined' || !shouldEmitChangeRef.current) return; - const fieldValue = { [name]: formValue }; + const fieldValue: Record = {}; + fieldValue[String(name)] = formValue; onFormItemValueChange?.(fieldValue); }, [formValue, name, onFormItemValueChange]); @@ -413,7 +508,7 @@ const FormItem: React.FC = (props) => {
- {colon && t(locale.colonText)} + {colon && locale?.colonText && t(locale.colonText)}
@@ -424,9 +519,13 @@ const FormItem: React.FC = (props) => { ...(children as React.ReactElement).props, value: formValue, disabled: disabledFromContext, + readonly: readonlyFromContext, onChange: (value: any, ...args) => { + if (readonlyFromContext) return; const newValue = cloneDeep(value); - lodashSet(form?.store, name, newValue); + if (name && form?.store) { + lodashSet(form.store, name, newValue); + } shouldEmitChangeRef.current = true; setFormValue(newValue); (children as React.ReactElement).props?.onChange?.call?.(null, value, ...args); diff --git a/src/form/const.ts b/src/form/const.ts index 801406c11..6a37e3aed 100644 --- a/src/form/const.ts +++ b/src/form/const.ts @@ -42,15 +42,24 @@ export interface AnalysisValidateResult { export interface FormItemContext { name: TdFormItemProps['name']; resetHandler: () => void; - resetField: (resetType?: 'initial' | 'empty') => void; + resetField: (resetType?: 'initial' | 'empty' | undefined) => Promise; validate: ( trigger: ValidateTriggerType, showErrorMessage?: boolean, ) => Promise>; - validateOnly: (trigger: ValidateTriggerType) => Promise>; + validateOnly: (trigger: ValidateTriggerType) => Promise>; setValidateMessage: (validateMessage: FormItemValidateMessage[]) => void; + getValidateMessage: () => { type: string; message?: string }[]; + getValue: () => unknown; + setValue: (value: any) => void; + setField: (fieldData: { + value?: unknown; + status?: string; + validateMessage?: { type?: string; message?: string }; + }) => void; disabled?: boolean; - onChange?: (value: any, ...args) => void; - onBlur?: (value: any, ...args) => void; + readonly?: boolean; + onChange?: (value: any, ...args: any[]) => void; + onBlur?: (value: any, ...args: any[]) => void; value?: any; } diff --git a/src/form/defaultProps.ts b/src/form/defaultProps.ts index 500dc7caa..4cf5189ad 100644 --- a/src/form/defaultProps.ts +++ b/src/form/defaultProps.ts @@ -12,6 +12,7 @@ export const formDefaultProps: TdFormProps = { labelAlign: 'right', labelWidth: '81px', preventSubmitDefault: true, + readonly: undefined, requiredMark: undefined, resetType: 'empty', showErrorMessage: true, diff --git a/src/form/form.en-US.md b/src/form/form.en-US.md index 7df62a2e7..9e10e1cc6 100644 --- a/src/form/form.en-US.md +++ b/src/form/form.en-US.md @@ -16,6 +16,7 @@ id | String | undefined | native id attribute of the form,which supports bein labelAlign | String | right | options: left/right/top | N labelWidth | String / Number | '81px' | \- | N preventSubmitDefault | Boolean | true | \- | N +readonly | Boolean | undefined | \- | N requiredMark | Boolean | true | \- | N requiredMarkPosition | String | left | Display position of required symbols。options: left/right | N resetType | String | empty | options: empty/initial | N @@ -35,7 +36,14 @@ name | params | return | description className | String | - | className of component | N style | Object | - | CSS(Cascading Style Sheets),Typescript: `React.CSSProperties` | N clearValidate | `(fields?: Array)` | \- | required +currentElement | \- | `HTMLFormElement` | \- +getCurrentElement | \- | `HTMLFormElement` | \- +getFieldValue | `(field: NamePath) ` | `unknown` | required +getFieldsValue | `(nameList: string[] \| boolean)` | `getFieldsValue` | required。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts)。
`interface getFieldsValue{ (nameList: true): T; (nameList: any[]): Record;}`
+getValidateMessage | `(fields?: Array)` | `Array \| void` | required reset | `(params?: FormResetParams)` | \- | required。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts)。
`interface FormResetParams { type?: 'initial' \| 'empty'; fields?: Array }`
+setFields | `(fields: FieldData[])` | \- | required。Typescript: `(fields: FieldData[]) => void` `interface FieldData { name: NamePath; value?: unknown, status?: string, validateMessage?: { type?: string, message?: string } }`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts) +setFieldsValue | `(field: Data)` | \- | required setValidateMessage | `(message: FormValidateMessage)` | \- | required。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts)。
`type FormValidateMessage = { [field in keyof FormData]: FormItemValidateMessage[] }`

`interface FormItemValidateMessage { type: 'warning' \| 'error'; message: string }`
submit | `(params?: { showErrorMessage?: boolean })` | \- | required validate | `(params?: FormValidateParams)` | `Promise>` | required。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts)。
`interface FormValidateParams { fields?: Array; showErrorMessage?: boolean; trigger?: ValidateTriggerType }`

`type ValidateTriggerType = 'blur' \| 'change' \| 'submit' \| 'all'`
@@ -55,7 +63,7 @@ help | TNode | - | Typescript: `string \| TNode`。[see more ts definition](http label | TNode | '' | Typescript: `string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N labelAlign | String | - | options: left/right/top | N labelWidth | String / Number | - | \- | N -name | String | - | \- | N +name | String / Number / Array | - | Typescript: `NamePath` `type NamePath = string \| number \| Array`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts) | N requiredMark | Boolean | undefined | \- | N rules | Array | - | Typescript: `Array` | N showErrorMessage | Boolean | undefined | \- | N @@ -80,7 +88,7 @@ telnumber | Boolean | - | \- | N trigger | String | change | Typescript: `ValidateTriggerType` | N type | String | error | options: error/warning | N url | Boolean / Object | - | Typescript: `boolean \| IsURLOptions` `import { IsURLOptions } from 'validator/es/lib/isURL'`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts) | N -validator | Function | - | Typescript: `CustomValidator` `type CustomValidator = (val: ValueType) => CustomValidateResolveType \| Promise` `type CustomValidateResolveType = boolean \| CustomValidateObj` `interface CustomValidateObj { result: boolean; message: string; type?: 'error' \| 'warning' \| 'success' }` `type ValueType = any`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts) | N +validator | Function | - | Typescript: `CustomValidator` `type CustomValidator = (val: ValueType, context?: { formData: Data , name: string }) => CustomValidateResolveType \| Promise` `type CustomValidateResolveType = boolean \| CustomValidateObj` `interface CustomValidateObj { result: boolean; message: string; type?: 'error' \| 'warning' \| 'success' }` `type ValueType = any`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts) | N whitespace | Boolean | - | \- | N ### FormErrorMessage diff --git a/src/form/form.md b/src/form/form.md index be87cd336..419017aec 100644 --- a/src/form/form.md +++ b/src/form/form.md @@ -16,6 +16,7 @@ id | String | undefined | 表单原生的id属性,支持用于配合非表单 labelAlign | String | right | 表单字段标签对齐方式:左对齐、右对齐、顶部对齐。可选项:left/right/top | N labelWidth | String / Number | '81px' | 可以整体设置label标签宽度,默认为81px | N preventSubmitDefault | Boolean | true | 是否阻止表单提交默认事件(表单提交默认事件会刷新页面),设置为 `true` 可以避免刷新 | N +readonly | Boolean | undefined | 是否整个表单只读 | N requiredMark | Boolean | true | 是否显示必填符号(*),默认显示 | N requiredMarkPosition | String | left | 表单必填符号(*)显示位置。可选项:left/right | N resetType | String | empty | 重置表单的方式,值为 empty 表示重置表单为空,值为 initial 表示重置表单数据为初始值。可选项:empty/initial | N @@ -35,7 +36,14 @@ onValuesChange | Function | | TS 类型:`(changedValues: Record)` | \- | 必需。清空校验结果。可使用 fields 指定清除部分字段的校验结果,fields 值为空则表示清除所有字段校验结果。清除邮箱校验结果示例:`clearValidate(['email'])` +currentElement | \- | `HTMLFormElement` | 获取 form dom 元素 +getCurrentElement | \- | `HTMLFormElement` | 获取 form dom 元素 +getFieldValue | `(field: NamePath) ` | `unknown` | 必需。获取单个字段值 +getFieldsValue | `(nameList: string[] \| boolean)` | `getFieldsValue` | 必需。获取一组字段名对应的值,当调用 getFieldsValue(true) 时返回所有表单数据。[详细类型定义](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts)。
`interface getFieldsValue{ (nameList: true): T; (nameList: any[]): Record;}`
+getValidateMessage | `(fields?: Array)` | `Array \| void` | 必需。获取校验结果,当调用 getValidateMessage() 时返回所有校验结果 reset | `(params?: FormResetParams)` | \- | 必需。重置表单,表单里面没有重置按钮`
csr test src/form/_example/horizontal.tsx 1`] = ` > 请输入用户名
+
+ 只能输入8个字符英文 +
@@ -54821,7 +54826,7 @@ exports[`csr snapshot test > csr test src/form/_example/horizontal.tsx 1`] = `
csr test src/form/_example/horizontal.tsx 1`] = `
+
+ 长度大于6个字符 +
@@ -54906,7 +54916,7 @@ exports[`csr snapshot test > csr test src/form/_example/horizontal.tsx 1`] = `
csr test src/form/_example/horizontal.tsx 1`] = `
+
+ 不能为空 +
@@ -55103,7 +55118,7 @@ exports[`csr snapshot test > csr test src/form/_example/horizontal.tsx 1`] = `
csr test src/form/_example/horizontal.tsx 1`] = `
+
+ 分数过低会影响整体评价 +
@@ -55229,7 +55249,7 @@ exports[`csr snapshot test > csr test src/form/_example/horizontal.tsx 1`] = `
csr test src/form/_example/horizontal.tsx 1`] = `
+
+ 不能为空 +
@@ -55411,7 +55436,7 @@ exports[`csr snapshot test > csr test src/form/_example/index.tsx 1`] = `
csr test src/form/_example/index.tsx 1`] = ` > 请输入用户名
+
+ 只能输入8个字符英文 +
@@ -55470,7 +55500,7 @@ exports[`csr snapshot test > csr test src/form/_example/index.tsx 1`] = `
csr test src/form/_example/index.tsx 1`] = `
+
+ 长度大于6个字符 +
@@ -55555,7 +55590,7 @@ exports[`csr snapshot test > csr test src/form/_example/index.tsx 1`] = `
csr test src/form/_example/index.tsx 1`] = `
+
+ 不能为空 +
@@ -55752,7 +55792,7 @@ exports[`csr snapshot test > csr test src/form/_example/index.tsx 1`] = `
csr test src/form/_example/index.tsx 1`] = `
+
+ 分数过低会影响整体评价 +
@@ -55878,7 +55923,7 @@ exports[`csr snapshot test > csr test src/form/_example/index.tsx 1`] = `
csr test src/form/_example/index.tsx 1`] = `
+
+ 不能为空 +
@@ -55960,7 +56010,7 @@ exports[`csr snapshot test > csr test src/form/_example/vertical.tsx 1`] = `
csr test src/form/_example/vertical.tsx 1`] = ` > 请输入用户名
+
+ 只能输入8个字符英文 +
@@ -56017,7 +56072,7 @@ exports[`csr snapshot test > csr test src/form/_example/vertical.tsx 1`] = `
csr test src/form/_example/vertical.tsx 1`] = `
+
+ 长度大于6个字符 +
@@ -56100,7 +56160,7 @@ exports[`csr snapshot test > csr test src/form/_example/vertical.tsx 1`] = `
csr test src/form/_example/vertical.tsx 1`] = `
+
+ 不能为空 +
@@ -56293,7 +56358,7 @@ exports[`csr snapshot test > csr test src/form/_example/vertical.tsx 1`] = `
csr test src/form/_example/vertical.tsx 1`] = `
+
+ 分数过低会影响整体评价 +
@@ -56417,7 +56487,7 @@ exports[`csr snapshot test > csr test src/form/_example/vertical.tsx 1`] = `
csr test src/form/_example/vertical.tsx 1`] = `
+
+ 不能为空 +
From 773be382996b43801fdd99414c761e5ae35d6d53 Mon Sep 17 00:00:00 2001 From: "Y." Date: Mon, 20 Apr 2026 19:05:59 +0800 Subject: [PATCH 3/6] feat: update demo --- src/form/_example/horizontal.tsx | 11 +-- src/form/_example/vertical.tsx | 11 +-- test/snap/__snapshots__/csr.test.jsx.snap | 105 ++++------------------ 3 files changed, 17 insertions(+), 110 deletions(-) diff --git a/src/form/_example/horizontal.tsx b/src/form/_example/horizontal.tsx index f8d7f6917..2f9754ee5 100644 --- a/src/form/_example/horizontal.tsx +++ b/src/form/_example/horizontal.tsx @@ -1,11 +1,10 @@ -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { useMemo, useRef } from 'react'; import { Button, Form, Input, Radio, RadioGroup, Rate, Stepper, Textarea } from 'tdesign-mobile-react'; import { BrowseOffIcon } from 'tdesign-icons-react'; const RATE_GAP = 8; export default function HorizontalForm({ disabled }) { const formRef = useRef(null); - const isInit = useRef(false); const rules = useMemo( () => ({ name: [{ validator: (val: any) => val?.length === 8, message: '只能输入8个字符英文' }], @@ -19,14 +18,6 @@ export default function HorizontalForm({ disabled }) { [], ); - useEffect(() => { - if (isInit.current) { - return; - } - formRef.current?.setValidateMessage(rules); - isInit.current = true; - }, [rules]); - const onReset = () => { console.log('===onReset'); }; diff --git a/src/form/_example/vertical.tsx b/src/form/_example/vertical.tsx index fffddb9d0..436dc2967 100644 --- a/src/form/_example/vertical.tsx +++ b/src/form/_example/vertical.tsx @@ -1,11 +1,10 @@ -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { useMemo, useRef } from 'react'; import { Button, Form, Input, Radio, RadioGroup, Rate, Stepper, Textarea } from 'tdesign-mobile-react'; import { BrowseOffIcon } from 'tdesign-icons-react'; const RATE_GAP = 8; export default function VerticalForm({ disabled }) { const formRef = useRef(null); - const isInit = useRef(false); const rules = useMemo( () => ({ name: [{ validator: (val: any) => val?.length === 8, message: '只能输入8个字符英文' }], @@ -19,14 +18,6 @@ export default function VerticalForm({ disabled }) { [], ); - useEffect(() => { - if (isInit.current) { - return; - } - formRef.current?.setValidateMessage(rules); - isInit.current = true; - }, [rules]); - const onReset = () => { console.log('===onReset'); }; diff --git a/test/snap/__snapshots__/csr.test.jsx.snap b/test/snap/__snapshots__/csr.test.jsx.snap index cca1f4de8..f22cc5c25 100644 --- a/test/snap/__snapshots__/csr.test.jsx.snap +++ b/test/snap/__snapshots__/csr.test.jsx.snap @@ -54762,7 +54762,7 @@ exports[`csr snapshot test > csr test src/form/_example/horizontal.tsx 1`] = `
csr test src/form/_example/horizontal.tsx 1`] = ` > 请输入用户名
-
- 只能输入8个字符英文 -
@@ -54826,7 +54821,7 @@ exports[`csr snapshot test > csr test src/form/_example/horizontal.tsx 1`] = `
csr test src/form/_example/horizontal.tsx 1`] = `
-
- 长度大于6个字符 -
@@ -54916,7 +54906,7 @@ exports[`csr snapshot test > csr test src/form/_example/horizontal.tsx 1`] = `
csr test src/form/_example/horizontal.tsx 1`] = `
-
- 不能为空 -
@@ -55118,7 +55103,7 @@ exports[`csr snapshot test > csr test src/form/_example/horizontal.tsx 1`] = `
csr test src/form/_example/horizontal.tsx 1`] = `
-
- 分数过低会影响整体评价 -
@@ -55249,7 +55229,7 @@ exports[`csr snapshot test > csr test src/form/_example/horizontal.tsx 1`] = `
csr test src/form/_example/horizontal.tsx 1`] = `
-
- 不能为空 -
@@ -55436,7 +55411,7 @@ exports[`csr snapshot test > csr test src/form/_example/index.tsx 1`] = `
csr test src/form/_example/index.tsx 1`] = ` > 请输入用户名
-
- 只能输入8个字符英文 -
@@ -55500,7 +55470,7 @@ exports[`csr snapshot test > csr test src/form/_example/index.tsx 1`] = `
csr test src/form/_example/index.tsx 1`] = `
-
- 长度大于6个字符 -
@@ -55590,7 +55555,7 @@ exports[`csr snapshot test > csr test src/form/_example/index.tsx 1`] = `
csr test src/form/_example/index.tsx 1`] = `
-
- 不能为空 -
@@ -55792,7 +55752,7 @@ exports[`csr snapshot test > csr test src/form/_example/index.tsx 1`] = `
csr test src/form/_example/index.tsx 1`] = `
-
- 分数过低会影响整体评价 -
@@ -55923,7 +55878,7 @@ exports[`csr snapshot test > csr test src/form/_example/index.tsx 1`] = `
csr test src/form/_example/index.tsx 1`] = `
-
- 不能为空 -
@@ -56010,7 +55960,7 @@ exports[`csr snapshot test > csr test src/form/_example/vertical.tsx 1`] = `
csr test src/form/_example/vertical.tsx 1`] = ` > 请输入用户名
-
- 只能输入8个字符英文 -
@@ -56072,7 +56017,7 @@ exports[`csr snapshot test > csr test src/form/_example/vertical.tsx 1`] = `
csr test src/form/_example/vertical.tsx 1`] = `
-
- 长度大于6个字符 -
@@ -56160,7 +56100,7 @@ exports[`csr snapshot test > csr test src/form/_example/vertical.tsx 1`] = `
csr test src/form/_example/vertical.tsx 1`] = `
-
- 不能为空 -
@@ -56358,7 +56293,7 @@ exports[`csr snapshot test > csr test src/form/_example/vertical.tsx 1`] = `
csr test src/form/_example/vertical.tsx 1`] = `
-
- 分数过低会影响整体评价 -
@@ -56487,7 +56417,7 @@ exports[`csr snapshot test > csr test src/form/_example/vertical.tsx 1`] = `
csr test src/form/_example/vertical.tsx 1`] = `
-
- 不能为空 -
From 4041df41436ccd229be9faf78c94bded1eafbee4 Mon Sep 17 00:00:00 2001 From: "Y." Date: Thu, 23 Apr 2026 14:28:59 +0800 Subject: [PATCH 4/6] feat(Form): add form and initialData props --- src/form/Form.tsx | 344 ++++---------------- src/form/FormContext.tsx | 10 +- src/form/FormItem.tsx | 583 ++++++++++++++++++---------------- src/form/const.ts | 41 +-- src/form/defaultProps.ts | 1 + src/form/form.en-US.md | 4 + src/form/form.md | 4 + src/form/hooks/interface.ts | 34 +- src/form/hooks/useForm.ts | 48 ++- src/form/hooks/useInstance.ts | 308 ++++++++++++++++++ src/form/hooks/useWatch.ts | 37 +++ src/form/index.ts | 1 + src/form/type.ts | 19 ++ 13 files changed, 831 insertions(+), 603 deletions(-) create mode 100644 src/form/hooks/useInstance.ts create mode 100644 src/form/hooks/useWatch.ts diff --git a/src/form/Form.tsx b/src/form/Form.tsx index cc716eb7b..2e1fe83d9 100644 --- a/src/form/Form.tsx +++ b/src/form/Form.tsx @@ -1,30 +1,21 @@ import React, { ForwardedRef, useEffect, useImperativeHandle, useRef, useMemo } from 'react'; import classNames from 'classnames'; -import { get, cloneDeep, merge, set, isFunction, isArray, isBoolean, isEmpty, isObject } from 'lodash-es'; import noop from '../_util/noop'; import forwardRefWithStatics from '../_util/forwardRefWithStatics'; -import { - FormInstanceFunctions, - FormResetParams, - FormValidateMessage, - FormValidateParams, - FormValidateResult, - TdFormProps, - NamePath, - FieldData, -} from './type'; -import { FormResetEvent, FormSubmitEvent, StyledProps } from '../common'; +import { FormInstanceFunctions, TdFormProps } from './type'; +import { StyledProps } from '../common'; import FormItem from './FormItem'; -import { formItemDefaultProps } from './defaultProps'; +import { formDefaultProps } from './defaultProps'; import useDefaultProps from '../hooks/useDefaultProps'; import { usePrefixClass } from '../hooks/useClass'; import useConfig from '../hooks/useConfig'; import useForm, { HOOK_MARK } from './hooks/useForm'; +import useWatch from './hooks/useWatch'; +import useInstance from './hooks/useInstance'; import { FormContext } from './FormContext'; -import { FormItemContext } from './const'; export interface FormProps extends TdFormProps, StyledProps { - children?: React.ReactElement[] | React.ReactElement; + children?: React.ReactNode; } export const requestSubmit = (target: HTMLFormElement) => { @@ -39,21 +30,6 @@ export const requestSubmit = (target: HTMLFormElement) => { target.removeChild(submitter); }; -function needValidate(name: NamePath, fields?: string[]) { - if (!fields || !Array.isArray(fields)) return true; - return fields.some((item) => String(item) === String(name)); -} - -function formatValidateResult(validateResultList: any[]) { - const result = validateResultList.reduce((r, err) => Object.assign(r || {}, err), {}); - Object.keys(result).forEach((key) => { - if (result[key] === true) { - delete result[key]; - } - }); - return isEmpty(result) ? true : result; -} - const Form = forwardRefWithStatics( (props: FormProps, ref: ForwardedRef) => { const { form: globalFormConfig } = useConfig(); @@ -65,6 +41,7 @@ const Form = forwardRefWithStatics( labelWidth, labelAlign, colon, + initialData, requiredMark = globalFormConfig?.requiredMark, requiredMarkPosition = globalFormConfig?.requiredMarkPosition, scrollToFirstError, @@ -81,138 +58,37 @@ const Form = forwardRefWithStatics( onValidate, onReset: onResetCustom, onValuesChange = noop, - } = useDefaultProps(props, formItemDefaultProps); - const submitParams = useRef>({}); - const resetParams = useRef>({}); + } = useDefaultProps(props, formDefaultProps); + const formRef = useRef(null); const formMapRef = useRef(new Map()); // 收集所有包含 name 属性 formItem 实例 - const [form] = useForm(); + const floatingFormDataRef = useRef>({}); // 储存游离值的 formData + const [form] = useForm(props.form); // 内部与外部共享 form 实例,外部不传则内部创建 const formContentClass = classNames(formClass, className); - // 获取单个字段值 - function getFieldValue(name: NamePath) { - if (!name) return null; - const formItemRef = formMapRef.current.get(String(name)); - if (formItemRef?.current) { - return formItemRef.current.getValue?.(); - } - return get(form?.store, name); - } - - // 获取一组字段名对应的值,当调用 getFieldsValue(true) 时返回所有值 - function getFieldsValue(nameList: string[] | boolean) { - const fieldsValue: Record = {}; - - if (nameList === true) { - // 倒序遍历,嵌套数组子节点先添加,导致外层数据被覆盖 - const entries = Array.from(formMapRef.current.entries()); - for (let i = entries.length - 1; i >= 0; i--) { - const [name, formItemRef] = entries[i]; - if (formItemRef?.current) { - const value = formItemRef.current.getValue?.(); - set(fieldsValue, name, value); - } - } - // 合并 store 中的值 - merge(fieldsValue, cloneDeep(form?.store)); - } else { - if (!Array.isArray(nameList)) { - console.error('Form', 'The parameter of "getFieldsValue" must be an array'); - return {}; - } - for (let i = 0; i < nameList.length; i++) { - const name = nameList[i]; - const formItemRef = formMapRef.current.get(String(name)); - if (formItemRef?.current) { - const value = formItemRef.current.getValue?.(); - set(fieldsValue, name, value); - } else { - const storeValue = get(form?.store, name); - set(fieldsValue, name, storeValue); - } - } - } - return cloneDeep(fieldsValue); - } - - // 递归处理嵌套对象,将叶子节点设置到对应的 FormItem - function setNestedValue(obj: Record, prefix = '') { - Object.keys(obj).forEach((key) => { - const value = obj[key]; - const fullPath = prefix ? `${prefix}.${key}` : key; - - if (isObject(value) && !Array.isArray(value)) { - // 递归处理嵌套对象 - setNestedValue(value as Record, fullPath); - } else { - // 叶子节点,尝试设置到 FormItem - const formItemRef = formMapRef.current.get(fullPath); - if (formItemRef?.current) { - formItemRef.current.setValue?.(cloneDeep(value)); - } else if (form?.store) { - set(form.store, fullPath, value); - } - } - }); - } - - // 设置表单字段值 - function setFieldsValue(fields: Record = {}) { - setNestedValue(fields); - } - - // 设置多组字段状态 - function setFields(fields: FieldData[] = []) { - if (!Array.isArray(fields)) throw new TypeError('The parameter of "setFields" must be an array'); - - fields.forEach((field) => { - const { name, ...restFields } = field; - const formItemRef = formMapRef.current.get(String(name)); - formItemRef?.current?.setField?.(restFields); - }); - } - - function getValidateMessage(fields?: Array) { - if (typeof fields !== 'undefined' && !isArray(fields)) { - throw new TypeError('The parameter of "getValidateMessage" must be an array'); - } - - const formItemRefs = - typeof fields === 'undefined' - ? [...formMapRef.current.values()] - : fields.map((name) => formMapRef.current.get(String(name))).filter(Boolean); - - const message: Record = {}; - - formItemRefs.forEach((formItemRef: any) => { - const item = formItemRef?.current?.getValidateMessage?.(); - if (isEmpty(item)) return; - const nameKey = formItemRef?.current?.name; - const key = Array.isArray(nameKey) ? nameKey.toString() : String(nameKey); - message[key] = item; - }); - - if (isEmpty(message)) return; - - return message; - } + // 使用 useInstance 获取所有实例方法(对齐桌面端架构) + const formInstance = useInstance( + { + ...props, + scrollToFirstError, + preventSubmitDefault, + onSubmit: onSubmitCustom, + onValidate, + onReset: onResetCustom, + onValuesChange, + }, + formRef, + formMapRef, + floatingFormDataRef, + form, + formClass, + ); - useImperativeHandle(ref, () => ({ - validate, - submit, - reset, - clearValidate: clearValidate as any, - setValidateMessage: setValidateMessage as any, - validateOnly, - currentElement: () => formRef.current!, - getCurrentElement: () => formRef.current!, - getFieldValue, - getFieldsValue: getFieldsValue as any, - getValidateMessage: getValidateMessage as any, - setFields, - setFieldsValue, - })); + // 关键:将实例方法同步到 form 对象上,使外部通过 form.getFieldsValue() 等可直接调用 + useImperativeHandle(ref, () => formInstance as unknown as FormInstanceFunctions); + Object.assign(form, formInstance); + form?.getInternalHooks?.(HOOK_MARK)?.setForm?.(formInstance); // form 初始化后清空队列 useEffect(() => { @@ -228,14 +104,17 @@ const Form = forwardRefWithStatics( labelWidth, labelAlign, colon, + initialData, requiredMark, requiredMarkPosition, + scrollToFirstError, errorMessage, showErrorMessage, resetType, rules, formMapRef, - onFormItemValueChange, + floatingFormDataRef, + onFormItemValueChange: formInstance.onFormItemValueChange, }), // eslint-disable-next-line react-hooks/exhaustive-deps [ @@ -245,8 +124,10 @@ const Form = forwardRefWithStatics( labelWidth, labelAlign, colon, + initialData, requiredMark, requiredMarkPosition, + scrollToFirstError, errorMessage, showErrorMessage, resetType, @@ -254,140 +135,53 @@ const Form = forwardRefWithStatics( ], ); - async function validate(param?: FormValidateParams) { - const { fields, trigger = 'all', showErrorMessage } = param || {}; - const list = [...formMapRef.current.values()] - .filter( - (formItemRef) => - isFunction(formItemRef?.current?.validate) && needValidate(formItemRef?.current?.name, fields), - ) - .map((formItemRef) => formItemRef?.current?.validate(trigger, showErrorMessage)); - const arr = await Promise.all(list); - const result = formatValidateResult(arr); - onValidate?.({ - validateResult: result, - }); - return result; - } - - // 校验不通过时,滚动到第一个错误表单 - function scrollTo(selector: string) { - const doms = formRef.current?.getElementsByClassName(selector); - const dom = doms?.[0]; - const behavior = scrollToFirstError; - if (behavior && dom) { - dom.scrollIntoView({ behavior }); - } - } - - function getFirstError(result: FormValidateResult) { - if (isBoolean(result)) { - return ''; - } - - const keys = Object.keys(result); - const [firstKey] = keys; - if (scrollToFirstError && firstKey) { - const tmpClassName = `${formClass}-item__${firstKey}`; - scrollTo(tmpClassName); - } - const resArr = result[firstKey]; - if (!isArray(resArr)) { - return ''; + function onResetHandler(e?: React.FormEvent) { + if (preventSubmitDefault && e) { + e.preventDefault(); + e.stopPropagation(); } - return resArr?.[0]?.message || ''; - } - - async function validateOnly(params?: Omit) { - const { fields, trigger = 'all' } = params || {}; - const list = [...formMapRef.current.values()] - .filter( - (formItemRef) => - isFunction(formItemRef?.current?.validateOnly) && needValidate(formItemRef?.current?.name, fields), - ) - .map((formItemRef) => formItemRef?.current?.validateOnly?.(trigger)); - const arr = await Promise.all(list); - return formatValidateResult(arr); + [...formMapRef.current.values()].forEach((formItemRef) => { + formItemRef?.current?.resetField?.(); + }); + form?.getInternalHooks?.(HOOK_MARK)?.notifyWatch?.([]); + form.store = {}; + floatingFormDataRef.current = {}; + onResetCustom?.({}); } - function onSubmit(e?: FormSubmitEvent) { + function onSubmit(e?: React.FormEvent) { if (preventSubmitDefault && e) { e.preventDefault(); e.stopPropagation(); } - validate(submitParams.current).then((r) => { + formInstance.validate().then((r) => { const firstError = getFirstError(r); onSubmitCustom?.({ validateResult: r, firstError, }); }); - submitParams.current = {}; - } - - async function submit(params?: Pick) { - submitParams.current = params || {}; - if (formRef.current) { - requestSubmit(formRef.current); - } } - function onReset(e?: FormResetEvent) { - if (preventSubmitDefault && e) { - e.preventDefault(); - e.stopPropagation(); + function getFirstError(result: any) { + if (typeof result === 'boolean') { + return ''; } - const params = resetParams.current; - resetParams.current = {}; - [...formMapRef.current.values()].forEach((formItemRef) => { - if (isFunction(formItemRef?.current?.resetField)) { - const name = formItemRef?.current?.name; - if (needValidate(name, params?.fields as string[])) { - formItemRef?.current?.resetField(params?.type); - } + const keys = Object.keys(result); + const [firstKey] = keys; + if (scrollToFirstError && firstKey) { + const tmpClassName = `${formClass}-item__${firstKey}`; + const doms = formRef.current?.getElementsByClassName(tmpClassName); + const dom = doms?.[0]; + if (dom) { + dom.scrollIntoView({ behavior: scrollToFirstError as ScrollBehavior }); } - }); - // 重置后清空 store - if (form?.store) { - form.store = {}; } - onResetCustom?.({ e }); - } - - function reset(params?: FormResetParams) { - resetParams.current = params || {}; - formRef.current?.reset(); - } - - function clearValidate(fields?: Array) { - [...formMapRef.current.values()].forEach((formItemRef) => { - if (isFunction(formItemRef?.current?.resetHandler) && needValidate(formItemRef?.current?.name, fields)) { - formItemRef?.current?.resetHandler(); - } - }); - } - - function setValidateMessage(validateMessage: FormValidateMessage) { - const keys = Object.keys(validateMessage); - if (!keys.length) return; - - [...formMapRef.current.values()].forEach((formItemRef) => { - const name = formItemRef?.current?.name; - if (isFunction(formItemRef?.current?.setValidateMessage) && name) { - const nameKey = Array.isArray(name) ? name.toString() : String(name); - if (keys.includes(nameKey)) { - const msg = validateMessage[nameKey]; - formItemRef?.current?.setValidateMessage(msg); - } - } - }); - } - - function onFormItemValueChange(changedValue: Record) { - requestAnimationFrame(() => { - const allFields = getFieldsValue(true); - onValuesChange(changedValue, allFields); - }); + const resArr = result[firstKey]; + if (!Array.isArray(resArr)) { + return ''; + } + return resArr?.[0]?.message || ''; } return ( @@ -398,13 +192,13 @@ const Form = forwardRefWithStatics( style={style} className={formContentClass} onSubmit={(e) => onSubmit(e)} - onReset={(e) => onReset(e)} + onReset={(e) => onResetHandler(e)} > {children}
); }, - { FormItem }, + { useForm, useWatch, FormItem }, ); export default Form; diff --git a/src/form/FormContext.tsx b/src/form/FormContext.tsx index e203e292f..0a2ed989c 100644 --- a/src/form/FormContext.tsx +++ b/src/form/FormContext.tsx @@ -1,6 +1,6 @@ -import { createContext, useContext, MutableRefObject } from 'react'; +import React, { createContext, useContext, MutableRefObject } from 'react'; import { TdFormProps } from './type'; -import { FormItemContext } from './const'; +import { FormItemInstance } from './const'; import { InternalFormInstance } from './hooks/interface'; export interface FormContextType { @@ -9,16 +9,18 @@ export interface FormContextType { labelAlign?: TdFormProps['labelAlign']; contentAlign?: TdFormProps['contentAlign']; colon?: TdFormProps['colon']; + initialData?: TdFormProps['initialData']; requiredMark?: TdFormProps['requiredMark']; requiredMarkPosition?: TdFormProps['requiredMarkPosition']; + scrollToFirstError?: TdFormProps['scrollToFirstError']; rules?: TdFormProps['rules']; errorMessage?: TdFormProps['errorMessage']; resetType?: TdFormProps['resetType']; - children?: FormItemContext[]; form?: InternalFormInstance; disabled?: TdFormProps['disabled']; readonly?: TdFormProps['readonly']; - formMapRef?: MutableRefObject>; + formMapRef?: MutableRefObject>>; + floatingFormDataRef?: MutableRefObject>; onFormItemValueChange: (changedValue: Record) => void; } diff --git a/src/form/FormItem.tsx b/src/form/FormItem.tsx index cb7c5e8d9..ce2f5a541 100644 --- a/src/form/FormItem.tsx +++ b/src/form/FormItem.tsx @@ -1,30 +1,36 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import classNames from 'classnames'; import { cloneDeep, get as lodashGet, isArray, isBoolean, + isEqual, isFunction, isNumber, + isObject, isString, set as lodashSet, template as lodashTemplate, } from 'lodash-es'; import { ChevronRightIcon } from 'tdesign-icons-react'; import { validate } from './formModel'; +import { HOOK_MARK } from './hooks/useForm'; import { AllValidateResult, Data, + FieldData, FormErrorMessage, + FormInstanceFunctions, FormItemValidateMessage, FormRule, ValidateTriggerType, ValueType, TdFormItemProps, + TdFormProps, } from './type'; -import { AnalysisValidateResult, ErrorListType, FormItemContext, SuccessListType } from './const'; +import { AnalysisValidateResult, ErrorListType, FormItemInstance, SuccessListType } from './const'; import { usePrefixClass } from '../hooks/useClass'; import useConfig from '../hooks/useConfig'; import { useLocaleReceiver } from '../locale/LocalReceiver'; @@ -32,12 +38,12 @@ import { useFormContext } from './FormContext'; import { StyledProps } from '../common'; export interface FormItemProps extends TdFormItemProps, StyledProps { - children?: React.ReactNode; + children?: React.ReactNode | React.ReactNode[] | ((form: FormInstanceFunctions) => React.ReactElement); } export type FormItemValidateResult = { [key in keyof T]: boolean | AllValidateResult[] }; -const FormItem: React.FC = (props) => { +const FormItem = forwardRef((props, ref) => { const [locale, t] = useLocaleReceiver('form'); const { form: globalFormConfig } = useConfig(); const formClass = usePrefixClass('form'); @@ -59,7 +65,9 @@ const FormItem: React.FC = (props) => { resetType: resetTypeFromContext, rules: rulesFromContext, errorMessage, + initialData: initialDataFromContext, formMapRef, + floatingFormDataRef, onFormItemValueChange, } = formContext; @@ -67,6 +75,7 @@ const FormItem: React.FC = (props) => { arrow = false, for: htmlFor = '', help, + initialData, label, labelAlign = labelAlignFromContext, labelWidth = labelWidthFromContext, @@ -74,22 +83,56 @@ const FormItem: React.FC = (props) => { name, requiredMark = requiredMarkFromContext, rules = [], + shouldUpdate, showErrorMessage, children, } = props; + // 计算默认初始数据:优先级 floatingFormData > FormItem.initialData > Form.initialData > store 中的值 + const defaultInitialData = useMemo(() => { + // 先检查 floatingFormData + if (name && floatingFormDataRef?.current) { + const floatingValue = lodashGet(floatingFormDataRef.current, name); + if (typeof floatingValue !== 'undefined') { + return floatingValue; + } + } + // FormItem 自身的 initialData 优先级最高 + if (typeof initialData !== 'undefined') { + return initialData; + } + // Form 级别的 initialData + if (name && initialDataFromContext) { + const contextValue = lodashGet(initialDataFromContext, name); + if (typeof contextValue !== 'undefined') { + return contextValue; + } + } + // store 中的已有值 + if (name) { + return lodashGet(form?.store, name); + } + return undefined; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const [errorList, setErrorList] = useState([]); const [successList, setSuccessList] = useState([]); const [resetValidating, setResetValidating] = useState(false); const [needResetField, setNeedResetField] = useState(false); const [freeShowErrorMessage, setFreeShowErrorMessage] = useState(undefined); - const [formValue, setFormValue] = useState(name ? lodashGet(form?.store, name) : undefined); - const initialValue = useRef(''); - const hasInit = useRef(false); - const contextRef = useRef(null); - const formItemRef = useRef(null); - const rulesMemoStr = useMemo(() => JSON.stringify(rules), [rules]); - const shouldEmitChangeRef = useRef(false); + const [formValue, setFormValue] = useState(defaultInitialData); + const [, forceUpdate] = useState({}); // 用于 shouldUpdate 强制渲染 + + const formItemRef = useRef(null); // 当前 formItem 实例 + const shouldEmitChangeRef = useRef(false); // onChange 冒泡开关 + const shouldValidate = useRef(false); // 校验开关 + const valueRef = useRef(formValue); // 当前最新值 + + const snakeName = useMemo(() => { + if (name === undefined) return ''; + return Array.isArray(name) ? name.join('.') : String(name); + }, [name]); const shouldShowErrorMessage = useMemo(() => { if (isBoolean(freeShowErrorMessage)) return freeShowErrorMessage; @@ -194,281 +237,267 @@ const FormItem: React.FC = (props) => { [errorMessage, globalFormConfig], ); + // 更新 form 表单字段(对齐桌面端 updateFormValue) + const updateFormValue = (newVal: any, doValidate = true, shouldEmitChange = false) => { + const { setPrevStore } = form?.getInternalHooks?.(HOOK_MARK) || {}; + setPrevStore?.(form?.getFieldsValue?.(true)); + shouldEmitChangeRef.current = shouldEmitChange; + shouldValidate.current = doValidate; + valueRef.current = newVal; + const fieldValue = name ? lodashGet(form?.store, name) : undefined; + if (isEqual(fieldValue, newVal)) return; + if (name && form?.store) { + lodashSet(form.store, name, newVal); + } + setFormValue(newVal); + }; + // 方法定义 - const resetHandler = useCallback(() => { + function resetHandler() { setNeedResetField(false); setErrorList([]); setSuccessList([]); - }, []); - - const getEmptyValue = useCallback((): ValueType => { - const value = name ? lodashGet(form?.store, name) : undefined; - if (typeof value === 'string') return ''; - if (Array.isArray(value)) return []; - if (typeof value === 'object') return {}; - return undefined; - }, [form?.store, name]); + } - const resetField = useCallback( - async (resetType: 'initial' | 'empty' | undefined = resetTypeFromContext): Promise => { - if (!name || !form?.store) { - return null; - } - let resetValue; - if (resetType === 'empty') { - resetValue = getEmptyValue(); - lodashSet(form.store, name, resetValue); - } else if (resetType === 'initial') { - resetValue = initialValue.current; - lodashSet(form.store, name, resetValue); - } - setFormValue(resetValue); - if (resetValidating) { - setNeedResetField(true); - } else { - resetHandler(); - } - }, - [resetTypeFromContext, name, resetValidating, form?.store, getEmptyValue, resetHandler, initialValue], - ); + function getResetValue(resetType: TdFormProps['resetType']): ValueType { + if (resetType === 'initial') { + return defaultInitialData; + } + let emptyValue: ValueType; + if (Array.isArray(formValue)) { + emptyValue = []; + } else if (isObject(formValue)) { + emptyValue = {}; + } else if (isString(formValue)) { + emptyValue = ''; + } + return emptyValue; + } + + function resetField(type?: TdFormProps['resetType']) { + if (typeof name === 'undefined') return; + const resetType = type || resetTypeFromContext; + const resetValue = getResetValue(resetType); + // reset 不校验 + updateFormValue(resetValue, false); + if (resetValidating) { + setNeedResetField(true); + } else { + resetHandler(); + } + } - const analysisValidateResult = useCallback( - async (trigger: ValidateTriggerType): Promise => { - const value = name ? lodashGet(form?.store, name) : undefined; - const result: AnalysisValidateResult = { - successList: [], - errorList: [], - rules: [], - resultList: [], - allowSetValue: false, - }; - result.rules = - trigger === 'all' ? innerRules : innerRules.filter((item) => (item.trigger || 'change') === trigger); - if (innerRules.length && !result.rules?.length) { - return result; - } - result.allowSetValue = true; - // 处理自定义校验规则的 context 参数 - const rulesWithContext = result.rules.map((rule) => { - if (rule.validator) { - return { - ...rule, - validator: (val: ValueType) => { - const context = { formData: form?.store || {}, name: String(name) }; - return rule.validator!(val, context); - }, - }; - } - return rule; - }); - result.resultList = await validate(value, rulesWithContext); - result.errorList = result.resultList - .filter((item) => item.result !== true) - .map((item) => { - const newItem = { ...item }; - Object.keys(newItem).forEach((key) => { - if (!newItem.message && errorMessages?.[key as keyof FormErrorMessage]) { - const compiled = lodashTemplate(errorMessages[key as keyof FormErrorMessage] as string); - const labelName = isString(label) ? label : name; - newItem.message = compiled({ - name: labelName, - validate: newItem[key as keyof typeof newItem], - }); - } - }); - return newItem as ErrorListType; - }); - // 仅有自定义校验方法才会存在 successList - result.successList = result.resultList.filter( - (item) => item.result === true && item.message && item.type === 'success', - ) as SuccessListType[]; + async function analysisValidateResult(trigger: ValidateTriggerType): Promise { + const value = name ? lodashGet(form?.store, name) : undefined; + const result: AnalysisValidateResult = { + successList: [], + errorList: [], + rules: [], + resultList: [], + allowSetValue: false, + }; + result.rules = trigger === 'all' ? innerRules : innerRules.filter((item) => (item.trigger || 'change') === trigger); + if (innerRules.length && !result.rules?.length) { return result; - }, - [form?.store, name, innerRules, errorMessages, label], - ); - - const validateHandler = useCallback( - async ( - trigger: ValidateTriggerType, - showErrorMessage?: boolean, - ): Promise> => { - setResetValidating(true); - setFreeShowErrorMessage(showErrorMessage); - - const { - successList: innerSuccessList, - errorList: innerErrorList, - resultList, - allowSetValue, - } = await analysisValidateResult(trigger); - - if (allowSetValue) { - setSuccessList(innerSuccessList || []); - setErrorList(innerErrorList || []); - } - // 重置处理 - if (needResetField) { - resetHandler(); - } - setResetValidating(false); - - const result = {} as FormItemValidateResult; - if (name !== undefined) { - (result as Record)[String(name)] = - innerErrorList?.length === 0 ? true : resultList; + } + result.allowSetValue = true; + // 处理自定义校验规则的 context 参数 + const rulesWithContext = result.rules.map((rule) => { + if (rule.validator) { + return { + ...rule, + validator: (val: ValueType) => { + const context = { formData: form?.store || {}, name: String(name) }; + return rule.validator!(val, context); + }, + }; } - return result; - }, - [analysisValidateResult, needResetField, resetHandler, name], - ); + return rule; + }); + result.resultList = await validate(value, rulesWithContext); + result.errorList = result.resultList + .filter((item) => item.result !== true) + .map((item) => { + const newItem = { ...item }; + Object.keys(newItem).forEach((key) => { + if (!newItem.message && errorMessages?.[key as keyof FormErrorMessage]) { + const compiled = lodashTemplate(errorMessages[key as keyof FormErrorMessage] as string); + const labelName = isString(label) ? label : name; + newItem.message = compiled({ + name: labelName, + validate: newItem[key as keyof typeof newItem], + }); + } + }); + return newItem as ErrorListType; + }); + // 仅有自定义校验方法才会存在 successList + result.successList = result.resultList.filter( + (item) => item.result === true && item.message && item.type === 'success', + ) as SuccessListType[]; + return result; + } + + async function validateHandler( + trigger: ValidateTriggerType, + showErrorMsg?: boolean, + ): Promise> { + setResetValidating(true); + setFreeShowErrorMessage(showErrorMsg); + + const { + successList: innerSuccessList, + errorList: innerErrorList, + resultList, + allowSetValue, + } = await analysisValidateResult(trigger); + + if (allowSetValue) { + setSuccessList(innerSuccessList || []); + setErrorList(innerErrorList || []); + } + // 重置处理 + if (needResetField) { + resetHandler(); + } + setResetValidating(false); - const validateOnly = useCallback( - async (trigger: ValidateTriggerType): Promise> => { - const { errorList: innerErrorList, resultList } = await analysisValidateResult(trigger); - const result = {} as FormItemValidateResult; - if (name !== undefined && innerErrorList) { - (result as Record)[String(name)] = - innerErrorList.length === 0 ? true : resultList; - } - return result; - }, - [analysisValidateResult, name], - ); + const result = {} as FormItemValidateResult; + if (name !== undefined) { + (result as Record)[snakeName] = + innerErrorList?.length === 0 ? true : resultList; + } + return result; + } + + async function validateOnly(trigger: ValidateTriggerType): Promise> { + const { errorList: innerErrorList, resultList } = await analysisValidateResult(trigger); + const result = {} as FormItemValidateResult; + if (name !== undefined && innerErrorList) { + (result as Record)[snakeName] = + innerErrorList.length === 0 ? true : resultList; + } + return result; + } - const setValidateMessage = useCallback((validateMessage: FormItemValidateMessage[]) => { + function setValidateMessage(validateMessage: FormItemValidateMessage[]) { if (!validateMessage && !isArray(validateMessage)) return; if (validateMessage.length === 0) { setErrorList([]); } setErrorList(validateMessage.map((item) => ({ ...item, result: false }))); - }, []); + } - // 获取校验信息 - const getValidateMessage = useCallback(() => { + function getValidateMessage() { if (errorList.length === 0) return []; return errorList.map((item) => ({ type: item.type || 'error', message: item.message, })); - }, [errorList]); + } - // 获取当前值 - const getValue = useCallback(() => (name ? lodashGet(form?.store, name) : undefined), [form?.store, name]); + function setField(field: Omit) { + const { value, status, validateMessage } = field; + if (typeof value !== 'undefined') { + // 手动设置 status 则不需要校验,交给用户判断 + updateFormValue(value, typeof status === 'undefined', true); + } + if (status) { + // 可以扩展状态处理逻辑 + } + if (validateMessage) { + setValidateMessage([validateMessage as FormItemValidateMessage]); + } + } - // 设置值 - const setValue = useCallback( - (value: any) => { - if (name && form?.store) { - lodashSet(form.store, name, cloneDeep(value)); - } - setFormValue(value); - }, - [form?.store, name], - ); + // blur 下触发校验 + function handleItemBlur() { + const filterRules = innerRules.filter((item) => item.trigger === 'blur'); + if (filterRules.length) { + validateHandler('blur'); + } + } - // 设置字段状态 - const setField = useCallback( - (fieldData: { value?: unknown; status?: string; validateMessage?: { type?: string; message?: string } }) => { - const { value, status, validateMessage } = fieldData; - if (typeof value !== 'undefined') { - setValue(value); - } - if (status) { - // 可以扩展状态处理逻辑 - } - if (validateMessage) { - setValidateMessage([validateMessage as FormItemValidateMessage]); - } - }, - [setValue, setValidateMessage], - ); + // shouldUpdate: 注册自定义更新回调 + useEffect(() => { + if (!shouldUpdate || !form) return; - const handleBlur = useCallback(async () => { - await validateHandler('blur'); - }, [validateHandler]); - - // 创建 context 对象 - const context = useMemo( - () => ({ - name, - resetHandler, - resetField, - validate: validateHandler, - validateOnly, - setValidateMessage, - getValidateMessage, - getValue, - setValue, - setField, - value: formValue, - }), - [ - name, - resetHandler, - resetField, - validateHandler, - validateOnly, - setValidateMessage, - getValidateMessage, - getValue, - setValue, - setField, - formValue, - ], - ); + const { getPrevStore, registerWatch } = form?.getInternalHooks?.(HOOK_MARK) || {}; + + const cancelRegister = registerWatch?.(() => { + const currStore = form?.getFieldsValue?.(true) || {}; + let updateFlag = shouldUpdate as boolean; + if (isFunction(shouldUpdate)) + updateFlag = (shouldUpdate as (prev: any, cur: any) => boolean)(getPrevStore?.(), currStore); + + if (updateFlag) forceUpdate({}); + }); + + return cancelRegister; + }, [shouldUpdate, form]); // 注册到 formMapRef useEffect(() => { - if (!name || !formMapRef) return; - const nameKey = Array.isArray(name) ? name.join('.') : String(name); + if (typeof name === 'undefined') return; + if (!formMapRef?.current) return; + const mapRef = formMapRef.current; - formItemRef.current = { current: context }; - mapRef.set(nameKey, formItemRef.current); - return () => { - mapRef.delete(nameKey); - }; - }, [name, formMapRef, context]); - useEffect(() => { - if (initialValue.current || !name || !form?.store) { - return; - } - initialValue.current = lodashGet(form.store, name); - }, [form?.store, name]); + // 注册实例 + mapRef.set(snakeName, formItemRef); - // 生命周期 - useEffect(() => { - if (formContext?.children) { - formContext.children.push(context); + // 初始化 + if (typeof defaultInitialData !== 'undefined' && form?.store) { + lodashSet(form.store, name, defaultInitialData); } - contextRef.current = context; + setFormValue(defaultInitialData); + return () => { - if (formContext?.children) { - formContext.children = formContext.children.filter((ctx) => ctx !== contextRef.current); - } + mapRef.delete(snakeName); }; - }, [context, formContext]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [snakeName]); - // 监听 formValue 变化,触发 onValuesChange + // 合并后的 formValue 变化 useEffect(对齐桌面端) useEffect(() => { - if (typeof name === 'undefined' || !shouldEmitChangeRef.current) return; - const fieldValue: Record = {}; - fieldValue[String(name)] = formValue; - onFormItemValueChange?.(fieldValue); - }, [formValue, name, onFormItemValueChange]); + if (typeof name === 'undefined') return; - // 监听规则变化 - useEffect(() => { - if (!hasInit.current) { - // 仅在用户有交互后进行校验 - if (formValue) { - hasInit.current = true; - } - return; + // value 变化通知 watch 事件 + form?.getInternalHooks?.(HOOK_MARK)?.notifyWatch?.(name); + + // 控制是否需要校验 + if (!shouldValidate.current) return; + + if (shouldEmitChangeRef.current) { + const fieldValue: Record = {}; + fieldValue[snakeName] = formValue; + onFormItemValueChange?.(fieldValue); } - validateHandler('change'); - // eslint-disable-next-line - }, [formValue, name, rulesMemoStr]); + + const filterRules = innerRules.filter((item) => (item.trigger || 'change') === 'change'); + if (filterRules.length) { + validateHandler('change'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formValue, snakeName]); + + // 暴露 ref 实例方法(对齐桌面端) + const instance: FormItemInstance = { + name, + value: formValue, + initialData, + getValue: () => cloneDeep(valueRef.current), + setValue: (newVal: any) => updateFormValue(newVal, true, true), + setField, + validate: validateHandler, + validateOnly, + resetField, + setValidateMessage, + getValidateMessage, + resetValidate: resetHandler, + }; + useImperativeHandle(ref, (): FormItemInstance => instance); + useImperativeHandle(formItemRef, (): FormItemInstance => instance); // 渲染函数 const renderRightIconContent = () => { @@ -503,6 +532,10 @@ const FormItem: React.FC = (props) => { return
{extraNode}
; }; + // 支持函数渲染 children(对齐桌面端) + if (isFunction(children)) + return (children as (form: FormInstanceFunctions) => React.ReactElement)(form as FormInstanceFunctions); + return (
@@ -512,31 +545,31 @@ const FormItem: React.FC = (props) => {
- { - // 受控模式下,children 应该是 input 并传递 value/onChange - React.isValidElement(children) - ? React.cloneElement(children as React.ReactElement, { - ...(children as React.ReactElement).props, - value: formValue, - disabled: disabledFromContext, - readonly: readonlyFromContext, - onChange: (value: any, ...args) => { - if (readonlyFromContext) return; - const newValue = cloneDeep(value); - if (name && form?.store) { - lodashSet(form.store, name, newValue); - } - shouldEmitChangeRef.current = true; - setFormValue(newValue); - (children as React.ReactElement).props?.onChange?.call?.(null, value, ...args); - }, - onBlur: (value: any, ...args: any[]) => { - handleBlur(); - (children as React.ReactElement).props?.onBlur?.call?.(null, value, ...args); - }, - }) - : children - } + {React.Children.map(children, (child) => { + if (!child) return null; + + // Fragment 或非 React Element 直接返回 + if (!React.isValidElement(child) || child.type === React.Fragment) return child; + + const childProps = child.props as any; + + return React.cloneElement(child as React.ReactElement, { + ...childProps, + value: formValue, + disabled: disabledFromContext, + readOnly: readonlyFromContext, + onChange: (value: any, ...args: any[]) => { + if (readonlyFromContext) return; + const newValue = cloneDeep(value); + updateFormValue(newValue, true, true); + childProps?.onChange?.call?.(null, value, ...args); + }, + onBlur: (value: any, ...args: any[]) => { + handleItemBlur(); + childProps?.onBlur?.call?.(null, value, ...args); + }, + }); + })}
{renderHelpNode()} {renderExtraNode()} @@ -545,6 +578,8 @@ const FormItem: React.FC = (props) => { {renderRightIconContent()}
); -}; +}); + +FormItem.displayName = 'FormItem'; export default FormItem; diff --git a/src/form/const.ts b/src/form/const.ts index 6a37e3aed..79113113e 100644 --- a/src/form/const.ts +++ b/src/form/const.ts @@ -1,10 +1,11 @@ -import { FormItemValidateResult } from './FormItem'; import { AllValidateResult, - Data, + FieldData, FormItemValidateMessage, + FormInstanceFunctions, FormRule, - TdFormItemProps, + NamePath, + TdFormProps, ValidateResultType, ValidateTriggerType, } from './type'; @@ -39,27 +40,17 @@ export interface AnalysisValidateResult { allowSetValue: boolean; } -export interface FormItemContext { - name: TdFormItemProps['name']; - resetHandler: () => void; - resetField: (resetType?: 'initial' | 'empty' | undefined) => Promise; - validate: ( - trigger: ValidateTriggerType, - showErrorMessage?: boolean, - ) => Promise>; - validateOnly: (trigger: ValidateTriggerType) => Promise>; - setValidateMessage: (validateMessage: FormItemValidateMessage[]) => void; - getValidateMessage: () => { type: string; message?: string }[]; - getValue: () => unknown; - setValue: (value: any) => void; - setField: (fieldData: { - value?: unknown; - status?: string; - validateMessage?: { type?: string; message?: string }; - }) => void; - disabled?: boolean; - readonly?: boolean; - onChange?: (value: any, ...args: any[]) => void; - onBlur?: (value: any, ...args: any[]) => void; +export interface FormItemInstance { + name?: NamePath; value?: any; + initialData?: any; + getValue?: () => any; + setValue?: (newVal: any) => void; + setField?: (field: Omit) => void; + validate?: (trigger?: ValidateTriggerType, showErrorMessage?: boolean) => Promise>; + validateOnly?: (trigger?: ValidateTriggerType) => Promise>; + resetField?: (type?: TdFormProps['resetType']) => void; + setValidateMessage?: (message: FormItemValidateMessage[]) => void; + getValidateMessage?: FormInstanceFunctions['getValidateMessage']; + resetValidate?: () => void; } diff --git a/src/form/defaultProps.ts b/src/form/defaultProps.ts index 4cf5189ad..91bf7a0fe 100644 --- a/src/form/defaultProps.ts +++ b/src/form/defaultProps.ts @@ -23,5 +23,6 @@ export const formItemDefaultProps: TdFormItemProps = { arrow: false, label: '', requiredMark: undefined, + shouldUpdate: false, showErrorMessage: undefined, }; diff --git a/src/form/form.en-US.md b/src/form/form.en-US.md index 9e10e1cc6..149690e45 100644 --- a/src/form/form.en-US.md +++ b/src/form/form.en-US.md @@ -12,7 +12,9 @@ colon | Boolean | false | \- | N contentAlign | String | left | options: left/right | N disabled | Boolean | undefined | \- | N errorMessage | Object | - | Typescript: `FormErrorMessage` | N +form | Object | - | Typescript: `FormInstanceFunctions` | N id | String | undefined | native id attribute of the form,which supports being used in conjunction with non-form buttons through the form attribute to trigger form events | N +initialData | Object | - | \- | N labelAlign | String | right | options: left/right/top | N labelWidth | String / Number | '81px' | \- | N preventSubmitDefault | Boolean | true | \- | N @@ -60,12 +62,14 @@ arrow | Boolean | false | \- | N contentAlign | String | - | options: left/right | N for | String | - | \- | N help | TNode | - | Typescript: `string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N +initialData | String / Number / Object / Array | - | Typescript: `InitialData` `type InitialData = any`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts) | N label | TNode | '' | Typescript: `string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N labelAlign | String | - | options: left/right/top | N labelWidth | String / Number | - | \- | N name | String / Number / Array | - | Typescript: `NamePath` `type NamePath = string \| number \| Array`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts) | N requiredMark | Boolean | undefined | \- | N rules | Array | - | Typescript: `Array` | N +shouldUpdate | Boolean / Function | false | Typescript: `boolean \| ((prevValue, curValue) => boolean)` | N showErrorMessage | Boolean | undefined | \- | N ### FormRule diff --git a/src/form/form.md b/src/form/form.md index 419017aec..c36fda573 100644 --- a/src/form/form.md +++ b/src/form/form.md @@ -12,7 +12,9 @@ colon | Boolean | false | 是否在表单标签字段右侧显示冒号 | N contentAlign | String | left | 表单内容对齐方式:左对齐、右对齐。可选项:left/right | N disabled | Boolean | undefined | 是否禁用整个表单 | N errorMessage | Object | - | 表单错误信息配置,示例:`{ idcard: '请输入正确的身份证号码', max: '字符长度不能超过 ${max}' }`。TS 类型:`FormErrorMessage` | N +form | Object | - | 经 `Form.useForm()` 创建的 form 控制实例。TS 类型:`FormInstanceFunctions` | N id | String | undefined | 表单原生的id属性,支持用于配合非表单内的按钮通过form属性来触发表单事件 | N +initialData | Object | - | 表单初始数据,重置时所需初始数据,优先级小于 FormItem 设置的 initialData | N labelAlign | String | right | 表单字段标签对齐方式:左对齐、右对齐、顶部对齐。可选项:left/right/top | N labelWidth | String / Number | '81px' | 可以整体设置label标签宽度,默认为81px | N preventSubmitDefault | Boolean | true | 是否阻止表单提交默认事件(表单提交默认事件会刷新页面),设置为 `true` 可以避免刷新 | N @@ -60,12 +62,14 @@ arrow | Boolean | false | 是否显示右侧箭头 | N contentAlign | String | - | 表单内容对齐方式,优先级高于 Form.contentAlign。可选项:left/right | N for | String | - | label 原生属性 | N help | TNode | - | 表单项说明内容。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N +initialData | String / Number / Object / Array | - | 表单初始数据,重置时所需初始数据。TS 类型:`InitialData` `type InitialData = any`。[详细类型定义](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts) | N label | TNode | '' | 字段标签名称。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N labelAlign | String | - | 表单字段标签对齐方式:左对齐、右对齐、顶部对齐。默认使用 Form 的对齐方式,优先级高于 Form.labelAlign。可选项:left/right/top | N labelWidth | String / Number | - | 可以整体设置标签宽度,优先级高于 Form.labelWidth | N name | String / Number / Array | - | 表单字段名称。TS 类型:`NamePath` `type NamePath = string \| number \| Array`。[详细类型定义](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts) | N requiredMark | Boolean | undefined | 是否显示必填符号(*),优先级高于 Form.requiredMark | N rules | Array | - | 表单字段校验规则。TS 类型:`Array` | N +shouldUpdate | Boolean / Function | false | TS 类型:`boolean \| ((prevValue, curValue) => boolean)` | N showErrorMessage | Boolean | undefined | 校验不通过时,是否显示错误提示信息,优先级高于 `Form.showErrorMessage` | N ### FormRule diff --git a/src/form/hooks/interface.ts b/src/form/hooks/interface.ts index 4d89387cc..f621645d8 100644 --- a/src/form/hooks/interface.ts +++ b/src/form/hooks/interface.ts @@ -1,21 +1,23 @@ -import { FieldData, FormValidateMessage, FormResetParams } from '../type'; +import type { FormInstanceFunctions, NamePath } from '../type'; export type Store = Record; -export type NamePath = string | number | Array; -export interface InternalFormInstance { - isInit?: boolean; +export type WatchCallBack = (values: Store, namePathList: NamePath) => void; + +export interface InternalHooks { + notifyWatch: (name: NamePath) => void; + registerWatch: (callback: WatchCallBack) => () => void; + getPrevStore: () => Store; + setPrevStore: (store: Store) => void; + flashQueue: () => void; + setForm: (form: any) => void; +} + +/** + * @internal + */ +export interface InternalFormInstance extends FormInstanceFunctions { + _init?: boolean; store?: Store; - getFieldsValue?: (nameList: string[] | boolean) => Store; - getFieldValue?: (name: NamePath) => unknown; - setFieldsValue?: (data: Record) => void; - setFields?: (fields: FieldData[]) => void; - getValidateMessage?: (fields?: Array) => Record | void; - reset?: (params?: FormResetParams) => void; - submit?: (params?: { showErrorMessage?: boolean }) => void; - validate?: (params?: any) => Promise; - validateOnly?: (params?: any) => Promise; - clearValidate?: (fields?: Array) => void; - setValidateMessage?: (message: FormValidateMessage) => void; - getInternalHooks?: (key: string) => any; + getInternalHooks?: (secret: string) => InternalHooks | null; } diff --git a/src/form/hooks/useForm.ts b/src/form/hooks/useForm.ts index 9149d464e..d7ff518c8 100644 --- a/src/form/hooks/useForm.ts +++ b/src/form/hooks/useForm.ts @@ -1,5 +1,6 @@ import { useRef, useState } from 'react'; -import { InternalFormInstance } from './interface'; +import type { NamePath } from '../type'; +import type { InternalFormInstance, InternalHooks, Store, WatchCallBack } from './interface'; export const HOOK_MARK = 'TD_FORM_INTERNAL_HOOKS'; @@ -7,7 +8,9 @@ export type Task = { args: any[]; name: string }; // TODO 后续将所有实例函数迁移到 FormStore 内统一管理 class FormStore { - private store: Record = {}; + private prevStore: Store = {}; + + private store: Store = {}; private forceRootUpdate: () => void; @@ -25,10 +28,10 @@ class FormStore { }; public getForm = (): InternalFormInstance => ({ - isInit: true, + _init: true, store: this.store, - getFieldsValue: undefined, - getFieldValue: undefined, + getFieldsValue: null, + getFieldValue: null, setFieldsValue: (...args: any[]) => { this.taskQueue.push({ args, name: 'setFieldsValue' }); }, @@ -50,12 +53,32 @@ class FormStore { setValidateMessage: (...args: any[]) => { this.taskQueue.push({ args, name: 'setValidateMessage' }); }, - validate: undefined, - validateOnly: undefined, + validate: null, + validateOnly: null, getInternalHooks: this.getInternalHooks, }); - private getInternalHooks = (key: string) => { + private watchList: WatchCallBack[] = []; + + private registerWatch: InternalHooks['registerWatch'] = (callback) => { + this.watchList.push(callback); + + return () => { + this.watchList = this.watchList.filter((fn) => fn !== callback); + }; + }; + + private notifyWatch = (namePath: NamePath = []) => { + if (this.watchList.length) { + const values = (this as any).getFieldsValue?.(true) || {}; + + this.watchList.forEach((callback) => { + callback(values, namePath); + }); + } + }; + + private getInternalHooks = (key: string): InternalHooks | null => { if (key === HOOK_MARK) { return { setForm: (formInstance: any) => { @@ -64,6 +87,12 @@ class FormStore { }); }, flashQueue: this.flashQueue, + notifyWatch: this.notifyWatch, + registerWatch: this.registerWatch, + getPrevStore: () => this.prevStore, + setPrevStore: (store: Store) => { + this.prevStore = store; + }, }; } return null; @@ -74,7 +103,8 @@ export default function useForm(form?: InternalFormInstance) { const formRef = useRef(Object.create({})); const [, forceUpdate] = useState({}); - if (!formRef.current.isInit) { + // eslint-disable-next-line no-underscore-dangle + if (!formRef.current._init) { if (form) { formRef.current = form; // Reset store when reopening diff --git a/src/form/hooks/useInstance.ts b/src/form/hooks/useInstance.ts new file mode 100644 index 000000000..5f916bb23 --- /dev/null +++ b/src/form/hooks/useInstance.ts @@ -0,0 +1,308 @@ +import React from 'react'; +import { cloneDeep, get, isArray, isBoolean, isEmpty, isFunction, isObject, merge, set } from 'lodash-es'; + +import type { + FormResetParams, + FormValidateMessage, + FormValidateParams, + FormValidateResult, + NamePath, + TdFormProps, + FieldData, +} from '../type'; +import type { InternalFormInstance } from './interface'; + +// 检测是否需要校验 默认全量校验 +function needValidate(name: NamePath, fields?: string[]) { + if (!fields || !Array.isArray(fields)) return true; + return fields.some((item) => String(item) === String(name)); +} + +// 整理校验结果 +function formatValidateResult(validateResultList: any[]) { + const result = validateResultList.reduce((r, err) => Object.assign(r || {}, err), {}); + Object.keys(result).forEach((key) => { + if (result[key] === true) { + delete result[key]; + } + }); + return isEmpty(result) ? true : result; +} + +export default function useInstance( + props: TdFormProps & { onSubmit?: any; onValidate?: any; onReset?: any; onValuesChange?: any }, + formRef: React.RefObject, + formMapRef: React.MutableRefObject>, + floatingFormDataRef: React.MutableRefObject>, + form: InternalFormInstance, + classPrefix: string, +) { + const { scrollToFirstError, onSubmit: onSubmitCustom, onValidate, onReset: onResetCustom, onValuesChange } = props; + + // 获取第一个错误表单 + function getFirstError(result: FormValidateResult) { + if (isBoolean(result)) { + return ''; + } + const keys = Object.keys(result); + const [firstKey] = keys; + if (scrollToFirstError && firstKey) { + scrollTo(`.${classPrefix}-form-item__${firstKey}`); + } + const resArr = result[firstKey]; + if (!isArray(resArr)) { + return ''; + } + return resArr?.[0]?.message || ''; + } + + // 校验不通过时,滚动到第一个错误表单 + function scrollTo(selector: string) { + const dom = formRef.current?.querySelector?.(selector); + const behavior = scrollToFirstError as ScrollBehavior; + if (behavior && dom) { + dom.scrollIntoView({ behavior }); + } + } + + // 对外方法,获取单个字段值 + function getFieldValue(name: NamePath) { + if (!name) return null; + const formItemRef = formMapRef.current.get(String(name)); + if (formItemRef?.current) { + return formItemRef.current.getValue?.(); + } + return get(floatingFormDataRef.current, name) ?? get(form?.store, name); + } + + // 对外方法,获取一组字段名对应的值,当调用 getFieldsValue(true) 时返回所有值 + function getFieldsValue(nameList: string[] | boolean) { + const fieldsValue: Record = {}; + + if (nameList === true) { + // 先用 floatingFormDataRef 作为基础 + merge(fieldsValue, cloneDeep(floatingFormDataRef.current)); + + // 倒序遍历,嵌套数组子节点先添加,导致外层数据被覆盖 + const entries = Array.from(formMapRef.current.entries()); + for (let i = entries.length - 1; i >= 0; i--) { + const [name, formItemRef] = entries[i]; + if (formItemRef?.current) { + const value = formItemRef.current.getValue?.(); + set(fieldsValue, name, value); + } + } + // 合并 store 中的值 + merge(fieldsValue, cloneDeep(form?.store)); + } else { + if (!Array.isArray(nameList)) { + console.error('Form', 'The parameter of "getFieldsValue" must be an array'); + return {}; + } + for (let i = 0; i < nameList.length; i++) { + const name = nameList[i]; + const formItemRef = formMapRef.current.get(String(name)); + if (formItemRef?.current) { + const value = formItemRef.current.getValue?.(); + set(fieldsValue, name, value); + } else { + const floatingValue = get(floatingFormDataRef.current, name); + if (typeof floatingValue !== 'undefined') { + set(fieldsValue, name, floatingValue); + } else { + const storeValue = get(form?.store, name); + set(fieldsValue, name, storeValue); + } + } + } + } + return cloneDeep(fieldsValue); + } + + // 递归处理嵌套对象,将叶子节点设置到对应的 FormItem + function setNestedValue(obj: Record, prefix = '') { + Object.keys(obj).forEach((key) => { + const value = obj[key]; + const fullPath = prefix ? `${prefix}.${key}` : key; + + if (isObject(value) && !Array.isArray(value)) { + setNestedValue(value as Record, fullPath); + } else { + const formItemRef = formMapRef.current.get(fullPath); + if (formItemRef?.current) { + formItemRef.current.setValue?.(cloneDeep(value)); + } else { + // 没有对应 FormItem 时,存到 floatingFormDataRef + set(floatingFormDataRef.current, fullPath, value); + if (form?.store) { + set(form.store, fullPath, value); + } + } + } + }); + } + + // 对外方法,设置表单字段值 + function setFieldsValue(fields: Record = {}) { + setNestedValue(fields); + } + + // 对外方法,设置多组字段状态 + function setFields(fields: FieldData[] = []) { + if (!Array.isArray(fields)) throw new TypeError('The parameter of "setFields" must be an array'); + + fields.forEach((field) => { + const { name, ...restFields } = field; + const formItemRef = formMapRef.current.get(String(name)); + formItemRef?.current?.setField?.(restFields); + }); + } + + // 对外方法,校验函数 + async function validate(param?: FormValidateParams): Promise> { + const { fields, trigger = 'all', showErrorMessage } = param || {}; + const list = [...formMapRef.current.values()] + .filter( + (formItemRef) => isFunction(formItemRef?.current?.validate) && needValidate(formItemRef?.current?.name, fields), + ) + .map((formItemRef) => formItemRef?.current?.validate(trigger, showErrorMessage)); + const arr = await Promise.all(list); + const result = formatValidateResult(arr); + onValidate?.({ + validateResult: result, + }); + return result; + } + + // 对外方法,纯净校验函数 + async function validateOnly(params?: Omit): Promise> { + const { fields, trigger = 'all' } = params || {}; + const list = [...formMapRef.current.values()] + .filter( + (formItemRef) => + isFunction(formItemRef?.current?.validateOnly) && needValidate(formItemRef?.current?.name, fields), + ) + .map((formItemRef) => formItemRef?.current?.validateOnly?.(trigger)); + const arr = await Promise.all(list); + return formatValidateResult(arr); + } + + // 对外方法,手动提交表单 + function submit(params?: { showErrorMessage?: boolean }) { + validate(params ? { showErrorMessage: params.showErrorMessage } : undefined).then((r) => { + const firstError = getFirstError(r); + onSubmitCustom?.({ + validateResult: r, + firstError, + }); + }); + } + + // 对外方法,重置对应 formItem 的数据 + function reset(params?: FormResetParams) { + if (typeof params === 'undefined') { + [...formMapRef.current.values()].forEach((formItemRef) => { + formItemRef?.current?.resetField?.(); + }); + } else { + const { type, fields = [] } = params; + [...formMapRef.current.values()].forEach((formItemRef) => { + if (isFunction(formItemRef?.current?.resetField)) { + const name = formItemRef?.current?.name; + if (needValidate(name, fields as string[])) { + formItemRef?.current?.resetField(type); + } + } + }); + } + // 重置后清空 store 和 floatingFormData + if (form?.store) { + // eslint-disable-next-line no-param-reassign + form.store = {}; + } + // eslint-disable-next-line no-param-reassign + floatingFormDataRef.current = {}; + onResetCustom?.({}); + requestAnimationFrame(() => { + const fieldValue = getFieldsValue(true); + onValuesChange?.(fieldValue, fieldValue); + }); + } + + // 对外方法,清除校验结果 + function clearValidate(fields?: Array) { + [...formMapRef.current.values()].forEach((formItemRef) => { + if (isFunction(formItemRef?.current?.resetValidate) && needValidate(formItemRef?.current?.name, fields)) { + formItemRef?.current?.resetValidate(); + } + }); + } + + // 对外方法,设置 formItem 的错误信息 + function setValidateMessage(validateMessage: FormValidateMessage) { + const keys = Object.keys(validateMessage); + if (!keys.length) return; + + [...formMapRef.current.values()].forEach((formItemRef) => { + const name = formItemRef?.current?.name; + if (isFunction(formItemRef?.current?.setValidateMessage) && name) { + const nameKey = Array.isArray(name) ? name.toString() : String(name); + if (keys.includes(nameKey)) { + const msg = validateMessage[nameKey]; + formItemRef?.current?.setValidateMessage(msg); + } + } + }); + } + + // 对外方法,获取 formItem 的错误信息 + function getValidateMessage(fields?: Array) { + if (typeof fields !== 'undefined' && !isArray(fields)) { + throw new TypeError('The parameter of "getValidateMessage" must be an array'); + } + + const formItemRefs = + typeof fields === 'undefined' + ? [...formMapRef.current.values()] + : fields.map((name) => formMapRef.current.get(String(name))).filter(Boolean); + + const message: Record = {}; + + formItemRefs.forEach((formItemRef: any) => { + const item = formItemRef?.current?.getValidateMessage?.(); + if (isEmpty(item)) return; + const nameKey = formItemRef?.current?.name; + const key = Array.isArray(nameKey) ? nameKey.toString() : String(nameKey); + message[key] = item; + }); + + if (isEmpty(message)) return; + + return message; + } + + // 表单字段值变化回调 + function onFormItemValueChange(changedValue: Record) { + requestAnimationFrame(() => { + const allFields = getFieldsValue(true); + onValuesChange?.(changedValue, allFields); + }); + } + + return { + submit, + reset, + validate, + validateOnly, + clearValidate, + setFields, + setFieldsValue, + setValidateMessage, + getValidateMessage, + getFieldValue, + getFieldsValue, + onFormItemValueChange, + currentElement: () => formRef.current!, + getCurrentElement: () => formRef.current!, + }; +} diff --git a/src/form/hooks/useWatch.ts b/src/form/hooks/useWatch.ts new file mode 100644 index 000000000..57c8b844a --- /dev/null +++ b/src/form/hooks/useWatch.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; +import { get, isEqual } from 'lodash-es'; + +import noop from '../../_util/noop'; +import { HOOK_MARK } from './useForm'; + +import type { NamePath } from '../type'; +import type { InternalFormInstance } from './interface'; + +export default function useWatch(name: NamePath, form: InternalFormInstance) { + const [value, setValue] = useState(); + + // eslint-disable-next-line no-underscore-dangle + const isValidForm = form && form._init; + + useEffect(() => { + if (!isValidForm) return; + + const { registerWatch = noop } = form.getInternalHooks?.(HOOK_MARK) || {}; + + const cancelRegister = registerWatch((allFieldsValue: any) => { + const newValue = get(allFieldsValue, name); + if (!isEqual(value, newValue)) { + setValue(newValue); + } + }); + + const allFieldsValue = form.getFieldsValue?.(true); + const initialValue = get(allFieldsValue, name); + setValue(initialValue); + + return cancelRegister; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return value; +} diff --git a/src/form/index.ts b/src/form/index.ts index a997b477b..d0e3f3f38 100644 --- a/src/form/index.ts +++ b/src/form/index.ts @@ -4,6 +4,7 @@ import { TdFormItemProps, TdFormProps } from './type'; import './style/index.js'; export * from './type'; +export * from './hooks/interface'; export type FormProps = TdFormProps; export type FormItemProps = TdFormItemProps; diff --git a/src/form/type.ts b/src/form/type.ts index 551947472..1644b80d7 100644 --- a/src/form/type.ts +++ b/src/form/type.ts @@ -27,10 +27,18 @@ export interface TdFormProps { * 表单错误信息配置,示例:`{ idcard: '请输入正确的身份证号码', max: '字符长度不能超过 ${max}' }` */ errorMessage?: FormErrorMessage; + /** + * 经 `Form.useForm()` 创建的 form 控制实例 + */ + form?: FormInstanceFunctions; /** * 表单原生的id属性,支持用于配合非表单内的按钮通过form属性来触发表单事件 */ id?: string; + /** + * 表单初始数据,重置时所需初始数据,优先级小于 FormItem 设置的 initialData + */ + initialData?: object; /** * 表单字段标签对齐方式:左对齐、右对齐、顶部对齐 * @default right @@ -174,6 +182,10 @@ export interface TdFormItemProps { * 表单项说明内容 */ help?: TNode; + /** + * 表单初始数据,重置时所需初始数据 + */ + initialData?: InitialData; /** * 字段标签名称 * @default '' @@ -199,6 +211,11 @@ export interface TdFormItemProps { * 表单字段校验规则 */ rules?: Array; + /** + * null + * @default false + */ + shouldUpdate?: boolean | ((prevValue, curValue) => boolean); /** * 校验不通过时,是否显示错误提示信息,优先级高于 `Form.showErrorMessage` */ @@ -417,6 +434,8 @@ export type ValidateTriggerType = 'blur' | 'change' | 'submit' | 'all'; export type Data = { [key: string]: any }; +export type InitialData = any; + export type NamePath = string | number | Array; export interface IsDateOptions { From cd1e04b63af97be5763be3df48921d4cb3c47d47 Mon Sep 17 00:00:00 2001 From: "Y." Date: Thu, 23 Apr 2026 17:31:50 +0800 Subject: [PATCH 5/6] fix(FormItem): fix disabled invalid for form components --- src/form/FormItem.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/form/FormItem.tsx b/src/form/FormItem.tsx index ce2f5a541..41981cf54 100644 --- a/src/form/FormItem.tsx +++ b/src/form/FormItem.tsx @@ -554,10 +554,10 @@ const FormItem = forwardRef((props, ref) => { const childProps = child.props as any; return React.cloneElement(child as React.ReactElement, { - ...childProps, - value: formValue, disabled: disabledFromContext, readOnly: readonlyFromContext, + ...childProps, + value: formValue, onChange: (value: any, ...args: any[]) => { if (readonlyFromContext) return; const newValue = cloneDeep(value); From a0c31772998c2ffc1227f32939e87c55795a3309 Mon Sep 17 00:00:00 2001 From: "Y." Date: Thu, 23 Apr 2026 17:33:42 +0800 Subject: [PATCH 6/6] fix(FormItem): fixed invalid className and style props --- src/form/FormItem.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/form/FormItem.tsx b/src/form/FormItem.tsx index 41981cf54..fc4bdfc6a 100644 --- a/src/form/FormItem.tsx +++ b/src/form/FormItem.tsx @@ -85,6 +85,8 @@ const FormItem = forwardRef((props, ref) => { rules = [], shouldUpdate, showErrorMessage, + className, + style, children, } = props; @@ -193,6 +195,7 @@ const FormItem = forwardRef((props, ref) => { `${formClass}--${labelAlign || 'right'}`, `${formClass}-item__${name}`, ], + className, { [`${formClass}__item-with-help`]: help }, ); const formItemWrapperClasses = classNames([`${formItemClass}-wrap`, `${formItemClass}--${labelAlign}`]); @@ -537,7 +540,7 @@ const FormItem = forwardRef((props, ref) => { return (children as (form: FormInstanceFunctions) => React.ReactElement)(form as FormInstanceFunctions); return ( -
+