【山竹记账前端-react】22.封装表单组件
大纲链接 §
[toc]
1. 封装表单组件 ⇧
封装表单项,规划表单结构 value + onChange ⇧
form表单主体form-item表单项form-label表单标题form-item-content表单项内容form-item-error表单项错误提示
封装表单项 InputText.tsx ⇧
@/components/form/item/InputText.tsx
inputType类型支持'text' | 'password' | 'email' | 'number'- 外部传参值
value、errorHint - 通过
onChange、onBlur、onFocus在父组件中修改value 在
label标签中设置属性htmlFor,联结输入框id属性1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73import { cn } from 'cn' interface Props { className?: string ifUseLabelTag?: boolean // for 属性,用于内部 input 标签 id 属性联动 labelFor?: string label?: string hasLabelColon?: boolean errorHint?: string hasErrorPlaceholder?: boolean placeholder?: string inputType?: 'text' | 'password' | 'email' | 'number' value?: string onChange?: (value: string) => void onBlur?: () => void onFocus?: () => void } export const InputText: React.FC<Props> = ({ className, label, labelFor, hasLabelColon = true, ifUseLabelTag, errorHint, hasErrorPlaceholder = true, placeholder, inputType = 'text', value, onChange, onBlur, onFocus, }) => { const labelForId = labelFor && `form-item-${inputType}-${labelFor}` const labelChildren = ( <> {label} {hasLabelColon ? ':' : ''} </> ) const labelTag = ifUseLabelTag ? ( <label {...(labelForId ? { htmlFor: labelForId } : {})} className="form-label-sc"> {labelChildren} </label> ) : ( <div className="form-label-sc">{labelChildren}</div> ) return ( <section className={cn('form-item-sc', className)}> {label && labelTag} <div className="form-item-content-sc"> <input id={labelForId} type={inputType} value={value} onChange={(e) => onChange?.(e.target.value)} onBlur={onBlur} onFocus={onFocus} placeholder={placeholder} className={cn( 'form-input-text-sc', errorHint ? 'b-[var(--error-color)]' : 'b-[var(--input-border-color)]' )} /> </div> {(errorHint || hasErrorPlaceholder) && ( <span className="form-item-error-sc w-fit buzz">{errorHint ?? <> </>}</span> )} </section> ) }清晰地划分表单项结构:外层容器
<section className={cn('form-item-sc', className)}>- 标签标题
{label && labelTag} - 表单项主体内容
<div className="form-item-content-sc"><input .../></div> - 错误提示
<span className="form-item-error-sc w-fit buzz">{errorHint ?? <> </>}</span>
- 标签标题
封装表单项 InputEmoji.tsx ⇧
@/components/form/item/InputEmoji.tsx
|
|
- 使用
_setEmoji拦截器setEmoji,更新本地组件的状态的同时,使用onChange改变父组件数据状态 - 清晰地划分表单项结构:外层容器
<section className={cn('form-item-sc', className)}>- 标签标题包含标题、图标和提示信息
- 表单项主体内容
<EmojiSelector .../> - 错误提示
<span className="form-item-error-sc w-fit buzz">{errorHint ?? <> </>}</span>
注意避免 set-state-in-effect ⇧
useEffect同步代码改变状态,会重渲染。避免导致性能下降的重新渲染现象- 使用初始值代替
- 使用派生值代替:某些数值可以从现有的属性或状态中计算得出时,就不要将它们存储在状态中了。相反,应该在渲染过程中进行计算
useRef的情况除外,可用
@/modules/tags/emoji/EmojiUnicodeList.tsx
|
|
改造 TagCreatePage.tsx ⇧
@/pages/TagCreatePage.tsx
|
|
- TODO: 继续将
InputText和InputEmoji相同部分抽取逻辑,封装为FormItem.tsx组件 - TODO: 封装为
TagForm.tsx组件,统一收集表单数据,之后发送数据和服务器交互
改造 SignInPage.tsx ⇧
@/pages/SignInPage.tsx
先使用
InputText.tsx重构邮箱表单项部分1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130import { InputText } from '@/components/form/item/InputText' import { GradientBanner } from '@/components/GradientBanner' import { SvgIcon } from '@/components/SvgIcon' import { TopNav } from '@/components/TopNav' import { request } from '@/lib/request/request' import { type FormRules, hasError } from '@/lib/validator/formValidate' import { type SignInFormData, useSignInStore } from '@/stores/useSignInStore' import { cn } from 'cn' import { type FormEventHandler } from 'react' import { useNavigate } from 'react-router-dom' interface Props { className?: string } const signInRules: FormRules<SignInFormData> = [ { key: 'email', type: 'required', required: true, message: '请输入邮箱地址' }, { key: 'email', type: 'pattern', regexp: /^.+@.+$/, message: '邮箱地址格式不正确' }, { key: 'code', type: 'required', required: true, message: '请输入验证码' }, { key: 'code', type: 'length', min: 6, max: 6, message: '请输入六位证码' }, ] as const export const SignInPage: React.FC<Props> = ({ className }) => { const Icon = <SvgIcon name="back-arrow" className="size-[24px]" /> const { data: lastData, error: lastError, setData, clearError, revalidateFormItems, } = useSignInStore() const onChangeEmail = (value: string) => { setData({ ...lastData, email: value, }) } const onChangeCode = (e: React.ChangeEvent<HTMLInputElement>) => { setData({ ...lastData, code: e.target.value, }) } const nav = useNavigate() const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => { e.preventDefault() revalidateFormItems<SignInFormData>(lastData, signInRules, lastError) // 注意过期的闭包 lastData、lastError 都是闭包,不能直接使用,需要用 useSignInStore.getState() 获取最新状态 const freshError = useSignInStore.getState().error const freshData = useSignInStore.getState().data if (!hasError(freshError)) { await request.post('/api/v1/session', freshData) // TODO 保存 JWT 作为登录凭证 nav('/home') } } return ( <div className={cn('container w-screen h-screen relative', className)}> <GradientBanner> <TopNav title="登录" icon={Icon} /> </GradientBanner> {/* logo */} <section className="text-center pt-[40px] pb-[1em]"> <SvgIcon name="mangosteen" className="inline w-[64px] h-[68px]" /> <h1 className="text-[2em] color-[var(--app-name-text)] font-bold">山竹记账</h1> </section> {/* 表单 */} <form onSubmit={onSubmit} className="form-sc"> {/* 邮箱 */} <InputText label="邮箱地址" labelFor="email-address" inputType="email" placeholder="请输入邮箱,然后点击发送验证码" ifUseLabelTag={true} errorHint={lastError.email && lastError.email.join(',')} value={lastData.email} onChange={onChangeEmail} onBlur={() => revalidateFormItems<SignInFormData>({ email: lastData.email }, signInRules, lastError) } onFocus={() => clearError('email')} /> {/* 验证码 */} <section className="form-item-sc"> <label htmlFor="sms-code" className="form-label-sc"> 验证码: </label> <div className="form-item-content-sc"> <input type="text" id="sms-code" value={lastData.code} onChange={onChangeCode} onBlur={() => revalidateFormItems<SignInFormData>({ code: lastData.code }, signInRules, lastError) } onFocus={() => clearError('code')} placeholder="六位数字" className="form-input-text-sc w-[9em]" /> <button type="button" className="grow-1 form-btn-sc"> 发送验证码 </button> </div> { <span className="form-item-error-sc"> {(lastError.code && lastError.code.join(',')) ?? <> </>} </span> } </section> {/* 登录 */} <section className="form-item-sc mt-[100px]"> <button type="submit" className="primary-btn"> 登录 </button> </section> </form> </div> ) }
继续封装
@/components/form/item/InputValidateCodeSend.tsx组件
|
|
继续改造
@/pages/SignInPage.tsx
|
|
- TODO: 继续封装
SignInForm.tsx组件
封装泛用组件 FormItem.tsx ⇧
提取表单相关公共类型
@/components/form/item/FormItemType.ts
|
|
- 每个表单项类型都集成公共类型
CommonFormItemTypeProps - 约束类型
T extends 'input'推导类型{ type?: 'input'; inputType: InputType } & CommonFormItemTypeProps - 约束类型
T extends 'emoji'推导类型{ type?: 'emoji' } & CommonFormItemTypeProps - 约束类型
T extends 'validateCodeSend'推导类型{ type?: 'validateCodeSend'; onClickSendCode?: () => void } & CommonFormItemTypeProps - 方便后续添加不同表单项类型
已知
InputText、InputValidateCodeSend、InputEmoji三个组件的公共部分可提取为组件
|
|
- 声明泛用组件
export const FormItem = <T extends FormItemType>({...}: Props<T>) => {} Props中的formItemType: T调用时给定具体类型Props中的formItemTypeProps: FormItemTypeProps<T>推断表单项组件参数的具体类型
改造
@/pages/TagCreatePage.tsx
|
|
改造
@/pages/SignInPage.tsx
|
|
2. 封装表单组件2 ⇧
3. 表单验证 ⇧
4. 消除 key 警告 ⇧
5. 新页面:编辑标签 ⇧
6. $3 ⇧
·未完待续·
参考文章
- 初始代码:https://github.com/FrankFang/aLK6px
- 提交历史(5个提交):https://github.com/FrankFang/XJJO9L/commits/master
相关文章
- 无
- 作者: Joel
- 文章链接:
- 版权声明
- 非自由转载-非商用-非衍生-保持署名