Form 组件
更新时间:2023-02-22 15:57:53标签:reactformrxjsweb前端
Form.tsx
1import {2 useMemo, useState, ReactNode, useRef, forwardRef, ForwardRefExoticComponent,3 PropsWithoutRef, RefAttributes, useImperativeHandle, useCallback, FormEvent, CSSProperties,4} from 'react';5import { Field } from './Field';6import { FormContext, FormContextInterface, FormValuesContext, FormValuesMap, FormValues } from './Context';7import type { Observable } from 'rxjs';8import { concatMap, forkJoin, of, throwError } from 'rxjs';9import { Status } from './interface';10import { trailed, combineClass } from '@shared/utils';11import { FormItem } from './Item';1213export interface FormInstance<T=any> {14 setItem: (name: string, value: any) => void;15 reset: (...fields: string[]) => void;16 clearValidity: (...fields: string[]) => void;17 validate: (...fields: string[]) => Observable<T>;18 readonly values: FormValuesMap;19 toJSON: FormContextInterface['toJSON'];20}21export interface FormProps {22 className?: string;23 style?: CSSProperties;24 children?: ReactNode;25 onChange?: (values: FormValues) => void;26 onSubmit?: (instance: FormInstance) => void;27}28interface FormComponent extends ForwardRefExoticComponent<PropsWithoutRef<FormProps> & RefAttributes<FormInstance | undefined>> {29 Field: typeof Field;30 Item: typeof FormItem;31}32const Form = forwardRef<FormInstance, FormProps>((props, ref) => {3334 const { children, onChange, onSubmit, className, style } = props;3536 const [formValues, setFormValues] = useState<FormValuesMap>(new Map());37 const formValuesRef = useRef(formValues);3839 const onChangeRef = useRef(onChange);40 onChangeRef.current = onChange;4142 const context = useMemo<FormContextInterface>(() => {43 let formValues: FormValuesMap = formValuesRef.current;44 const toJSON = () => {45 return Array.from(formValues.entries()).reduce((prev, currentValue) => {46 const [key, val] = currentValue;47 Object.assign(prev, { [key]: val });48 return prev;49 }, {} as FormValues);50 };51 return {52 get values() {53 return formValues;54 },55 toJSON,56 setItem: trailed((callback) => {57 return (name, value, options) => {58 const emit = options?.emit ?? false;59 const remove = options?.remove ?? false;60 formValues = new Map(formValues.entries());61 if (remove) {62 formValues.delete(name);63 } else {64 formValues.set(name, value);65 }66 setFormValues(formValues);67 if (emit) callback(() => onChangeRef.current?.(toJSON()));68 };69 }),70 reductions: new Map(),71 invalidations: new Map(),72 validators: new Map(),73 status: new Map(),74 };75 }, []);7677 const reset: FormInstance['reset'] = useCallback((...fields) => {78 if (fields.length) {79 fields.forEach(key => context.reductions.get(key)?.());80 } else {81 context.reductions.forEach(v => v());82 }83 }, [context]);8485 const validate: FormInstance['validate'] = useCallback((...fields) => {86 let values = context.toJSON();87 let obs: Observable<Status>[];88 if (fields.length) {89 obs = fields.map(key => context.validators.get(key)).filter(Boolean).map(v => v!());90 values = fields.reduce((previousValue, key) => Object.assign(previousValue, { [key]: values[key] }), {} as FormValues);91 } else {92 obs = Array.from(context.validators.values()).map(v => v());93 }94 return forkJoin(obs).pipe(95 concatMap(results => {96 if (results.every(v => v.state === 'valid')) {97 return of(values);98 }99 return throwError(() => results.filter(v => v.state === 'invalid'));100 }),101 );102 }, [context]);103104 const setItem: FormInstance['setItem'] = useCallback((...args) => {105 return context.setItem(...args);106 }, [context]);107108 const clearValidity: FormInstance['clearValidity'] = useCallback((...args) => {109 if (args.length) {110 args.forEach(name => context.invalidations.get(name)?.());111 } else {112 context.invalidations.forEach(v => v());113 }114 }, [context]);115116 const instance: FormInstance = useMemo(() => {117 return { setItem, reset, validate, clearValidity, values: formValues, toJSON: context.toJSON };118 }, [formValues, reset, setItem, validate, context, clearValidity]);119120 const handleSubmit = useCallback((e: FormEvent) => {121 e.preventDefault();122 onSubmit?.(instance);123 }, [instance, onSubmit]);124125 useImperativeHandle(ref, () => {126 return instance;127 });128129 return (130 <form onSubmit={handleSubmit} className={combineClass(className)} style={style}>131 <FormValuesContext.Provider value={formValues}>132 <FormContext.Provider value={context}>{ children }</FormContext.Provider>133 </FormValuesContext.Provider>134 </form>135 );136}) as FormComponent;137138Form.displayName = 'Form';139Form.Field = Field;140Form.Item = FormItem;141142export { Form };
Field.tsx
1import {2 cloneElement, FunctionComponent, ReactElement,3 useCallback, useContext, useEffect, useMemo, useRef,4} from 'react';5import { FormContext, FormValuesContext } from './Context';6import { Rule, Status } from './interface';7import {8 concat, concatMap, from, isObservable, of, take,9 skipWhile, switchMap, takeLast, tap, throwError, skip,10} from 'rxjs';11import type { Observable } from 'rxjs';12import { useStore } from '@shared/stores';13import { useSubject } from '@shared/hooks/observable';1415function restore() {16 //17}18export interface FieldProps {19 name: string;20 children: ReactElement;21 defaultValue?: any;22 rules?: Rule[];23 onChange?: (e: any) => void;24 onStatusChange?: (status: Status) => void;25}26interface FieldComponent extends FunctionComponent<FieldProps> {}27const Field: FieldComponent = (props) => {28 const { children, name, defaultValue, rules = [], onStatusChange, onChange: fieldChange } = props;29 const context = useContext(FormContext);30 const values = useContext(FormValuesContext);3132 const refs = useRef({ context, defaultValue, rules, name });33 refs.current.context = context;34 refs.current.rules = rules;3536 const [status, store] = useStore<Status>(() => ({ state: 'initial', name }));3738 const nullVal = useMemo(() => {39 if (children.type === 'input') {40 return '';41 }42 return undefined;43 }, [children]);4445 const value = useMemo(() => {46 return values?.get(name) ?? nullVal;47 }, [name, nullVal, values]);4849 const status$ = useSubject<any>(action => action.pipe(50 tap(() => store.set(Object.assign({}, store.state, { state: 'pending', name }))),51 switchMap(value => {52 if (value === restore) {53 store.set({ state: 'initial', name });54 return of(null).pipe(skip(1));55 }56 return validate(refs.current.rules, value);57 }),58 tap(() => store.set({ state: 'valid', name })),59 store.capture(err => ({ state: 'invalid', name, message: err })),60 ), [name, store]);6162 const onChange = useCallback((e: any) => {63 const val = toFormValue(e);64 status$?.next(val);65 context?.setItem(name, val, { emit: true });66 fieldChange?.(val);67 }, [fieldChange, context, name, status$]);6869 const clearValidity = useCallback(() => {70 status$?.next(restore);71 }, [status$]);7273 const reset = useCallback(() => {74 const { context, defaultValue, name } = refs.current;75 const value = context?.values.get(name) ?? nullVal;76 const next = toFormValue(defaultValue) ?? nullVal;77 clearValidity();78 if (value !== next) {79 context?.setItem(name, next, { emit: true });80 }81 }, [clearValidity, nullVal]);8283 const validateField = useCallback(() => {84 status$?.next(value); // The status will be updated to pending synchronously85 return store.state$.pipe(86 skipWhile(v => v.state === 'pending'),87 take(1),88 );89 }, [status$, value, store]);9091 useEffect(() => {92 context?.validators.set(name, validateField);93 return () => {94 context?.validators.delete(name);95 };96 }, [context, name, validateField]);9798 useEffect(() => {99 context?.invalidations.set(name, clearValidity);100 return () => {101 context?.invalidations.delete(name);102 };103 }, [context, name, clearValidity]);104105 useEffect(() => {106 context?.reductions.set(name, () => {107 reset();108 });109 return () => {110 context?.reductions.delete(name);111 };112 }, [context, name, reset]);113114 useEffect(() => {115 context?.status.set(name, status);116 return () => {117 context?.status.delete(name);118 };119 }, [context, status, name]);120121 useEffect(() => {122 const { context } = refs.current;123 if (refs.current.name !== name) {124 context?.setItem(refs.current.name, null, { emit: true, remove: true });125 }126 refs.current.name = name;127 reset();128 }, [reset, name]);129130 useEffect(() => {131 onStatusChange?.(status);132 }, [onStatusChange, status]);133134 return cloneElement(children, {135 onChange,136 value,137 });138};139140export { Field };141142function validate(rules: Rule[], value: any) {143 return concat(144 ...rules.map(v => {145 let obs: Observable<null | string>;146 if (typeof v === 'function') {147 const ret = v(value);148 if (isObservable(ret)) {149 obs = ret;150 } else if (ret instanceof Promise) {151 obs = from(ret);152 } else {153 obs = of(ret);154 }155 } else {156 obs = v.required && !value ? of(v.message ?? '') : of(null);157 }158 obs = obs.pipe(159 concatMap(res => res === null ? of(null) : throwError(() => res))160 );161 return obs as Observable<null>;162 }),163 of(null),164 ).pipe(takeLast(1));165}166167function toFormValue(e: any) {168 if (e && e.target && Object.hasOwn(e.target, 'value')) {169 return e.target.value;170 }171 return e;172}
Item.tsx
1import { cloneElement, CSSProperties, FC, ReactNode, useCallback, useState } from 'react';2import { Field, FieldProps } from './Field';3import { Status } from './interface';4import styled from 'styled-components';5import { combineClass } from '@shared/utils';67const Wrapper = styled.div`8 &:not(:last-child){9 margin-bottom: 8px;10 }11 .error-tip{12 margin: 3px 0 0 0;13 font-size: .8em;14 color: var(--color-error);15 }16`;1718interface FormItemProps extends FieldProps {19 className?: string;20 style?: CSSProperties;21 prefix?: ReactNode;22}23const FormItem: FC<FormItemProps> = (props) => {2425 const { onStatusChange, children, className, style, prefix, ...extra } = props;2627 const [status, setStatus] = useState<Status>();2829 const handleStatusChange = useCallback((status: Status) => {30 onStatusChange?.(status);31 setStatus(status);32 }, [onStatusChange]);3334 return (35 <Wrapper className={className} style={style}>36 <div className={'form-control'}>37 { prefix }38 <Field {...extra} onStatusChange={handleStatusChange}>39 {40 cloneElement(children, {41 className: combineClass(children.props.className, {42 'status-error': status?.state === 'invalid',43 }),44 })45 }46 </Field>47 </div>48 {49 typeof status?.message === 'string' ?50 <p className={'error-tip'}>{ status.message }</p> :51 null52 }53 </Wrapper>54 );55};5657export { FormItem };
Context.ts
1import { createContext } from 'react';2import type { Observable } from 'rxjs';3import { Status } from './interface';45export type FormValues = Record<string, any>;67export type FormValuesMap = Map<string, any>;89export const FormValuesContext = createContext<FormValuesMap | null>(null);1011export interface FormContextInterface {12 readonly values: FormValuesMap;13 setItem: (name: string, value: any, options?: {14 emit?: boolean;15 remove?: boolean;16 }) => void;17 toJSON: () => FormValues;18 reductions: Map<string, () => void>;19 invalidations: Map<string, () => void>;20 validators: Map<string, () => Observable<Status>>;21 status: Map<string, Status>;22}2324export const FormContext = createContext<FormContextInterface | null>(null);
interface.ts
1import type { Observable } from 'rxjs';23export interface FormControlled<T> {4 value?: T;5 onChange?(e: T): void;6}78interface RuleRequired {9 required?: boolean;10 message?: string;11}12interface RuleValidator {13 (value: any): null | string | Promise<null | string> | Observable<null | string>;14}15export type Rule = RuleRequired | RuleValidator;1617export interface Status {18 state: 'valid' | 'invalid' | 'pending' | 'initial';19 name: string;20 message?: string;21}