场景:用户在paypal中注册新账户并到达Business Information这一步,由此为例,总结下大致逻辑
第一步:pages/onboard/index.js(分布式表单的入口)主要4大块
- 后端渲染钩子,从后端获取授权以及必要信息存入redux,这里不做赘述不是关键
OnboardPage.getInitialProps = async (context: NextPageContext) => {
const {
req,
store,
query: {
isSignup
},
isServer
} = context
if (req && isServer) {
const session = req.session
const userEmail = session.newSignup ? session.email : session.passport.user.email
store.dispatch(updateEmail(userEmail))
if (session?.passport?.user) {
store.dispatch(initOnboard(req))
}
if (!isSignup) {
store.dispatch(loadKycStatus.request(req))
}
}
return {}
}
- 不多说,初始化一些表单用到的信息
const kyc = useSelector(kycSelector) // 用于获取redux中kyc.onboard的信息
const initialStep = useInitialStep() // 运行useInitialStep函数获取当前表单的步骤
const { i18n } = useTranslation() //用于国际化不赘述
const router = useRouter() //next/router不赘述
- 暴露两个函数onStepChange,handleComplete,统筹分布表单的状态
const onStepChange = useCallback((step: OnboardStep) => {
if (step === OnboardStep.Signup) {
router.replace('/onboard', formatUrl('/signup'), { shallow: true })
} else {
router.replace('/onboard', formatUrl('/kyc/onboard'), { shallow: true })
}
}, [router]) //路由状态机,当router改变的时候运行,替换path
const handleComplete = useCallback(() => {
api.post('/settings/language/update', {
langPrefer: i18n.language
}).catch(() => {
// ignore exception
})
window.location.href = formatUrl('/signupsuccess')
}, [])// 完成与否状态机 ,下层组件调用后,直接切换至/signupsuccess
- 下层组件Wizard 分布式表单核心
<Page background="#F5F4F9">
<Row height={68} padding="0 150px" flex>
<img
src="https://www.paypalobjects.com/digitalassets/c/paypal-ui/logos/svg/paypal-color.svg"
style={{ width: '120px' }}
/>
</Row>
<Row width="1200px" margin="auto" padding="40px 0 65px 0">
<Wizard
kyc={kyc}
initialStep={initialStep}
onComplete={handleComplete}
onStepChange={onStepChange}
/>
</Row>
<MerchantFooter />
</Page>
第二步:composed/Kyc/Wizard.js 分步表单外壳
- 初始化相关变量,其中availableSteps由外层传入的initialAvailableSteps或者默认的DEFAULT_STEPS作为初始值,bizType则为企业类型,由于决定了后续的表单结构因此单独将此变量暴露在外侧,t不说了国际化用
const { t } = useTranslation('kyc')
const [availableSteps, setAvailableSteps] = useState<OnboardStep[]>(initialAvailableSteps || DEFAULT_STEPS)
const [bizType, setBizType] = useState<BIZ_TYPE>(kyc?.businessEntity?.merchantInfo?.businessType ||
kyc?.personalBusiness?.bizInfo?.businessType || BIZ_TYPE.COMPANY_LEGAL_REPRESENTATIVE)
- 设定关于bizType的副作用(effect),这里就可以看出bizType对后续步骤有影响
useEffect(() => {
if (initialAvailableSteps) {
return
}
if (bizType === BIZ_TYPE.SOLE_PROPRIETORSHIP) {
setAvailableSteps([OnboardStep.Signup, OnboardStep.BizInfo, OnboardStep.LegalInfo, OnboardStep.PAU])
} else if (bizType === BIZ_TYPE.PERSONAL_SELLER) {
setAvailableSteps([OnboardStep.Signup, OnboardStep.PersonalSeller])
} else {
setAvailableSteps(DEFAULT_STEPS)
}
}, [bizType])
- 定义切换步骤的工具函数useStep ,最终目的是改变变量currentStep
const {
step: currentStep, // 当前步骤
next, // 步骤移至下一步
prev // 步骤移至下一步
} = useStep(availableSteps, initialStep)
function useStep(availableSteps: OnboardStep[], initialStep?: OnboardStep) {
const initialStepInd = initialStep ? availableSteps.indexOf(initialStep) : 0
const [stepInd, setStepInd] = useState(initialStepInd)
return {
step: inferStep(availableSteps, stepInd),
next: useCallback(() => setStepInd(stepInd + 1), [stepInd]),
prev: useCallback(() => setStepInd(stepInd - 1), [stepInd])
}
} // 切换步骤的主要函数,其实主要是维护了一个内部的step状态机,然后抛出结果step以及一系列工具函数
function inferStep(availableSteps: OnboardStep[], stepInd: number): OnboardStep {
if (stepInd < 0) {
stepInd = 0
}
if (stepInd >= availableSteps.length) {
stepInd = availableSteps.length - 1
}
return availableSteps[stepInd]
} // 主要为了处理边界问题,比如step=-1或者step>available.length等等
- 定义currentStep改变之后的后效(effect),即currentStep改变之后调用onStepChange函数,不再赘述
useEffect(() => {
onStepChange && onStepChange(currentStep)
}, [currentStep])
- 根据currentStep来确定需要渲染的组件,同时确定边界是否为第一步或者最后一步(外层不需要使用,主要给与下面的分步表单作为一些业务逻辑的判断依据)
const currentStepInfo = stepsInfo[currentStep]
const OnboardComponent = currentStepInfo.component
const isFirstStep = availableSteps.indexOf(currentStep) === 0
const isLastStep = availableSteps.indexOf(currentStep) === availableSteps.length - 1
- 提取公用的验证逻辑,即每次在提交表单前都需要去验证信息是否是真实的,函数中主体主要是针对业务的一些判断,不再赘述,输出无非三种形式空,onComplete()以及next(),usePersistentCallback主要起到优化作用,毕竟handlenext函数会给与所有子组件使用
const handleNext = usePersistentCallback(async (data: any) => {
// ekyc
/* const confirmed = await confirmEkyc(data)
if (!confirmed) {
return
} */
if (mode === WizardMode.SIGNUP) {
dispatch(preserveKycData(data))
}
// submit kyc if needed
const needSubmitKyc = mode === WizardMode.EDIT || (mode === WizardMode.SIGNUP && isLastStep)
if (needSubmitKyc) {
const submitResponse = await api.post('/submit/kyc')
// Handle RCL
const rclRejected = takeActionOnRiskCheckCode(submitResponse.data.riskCheckCode)
if (rclRejected) {
return
}
}
// trigger next step
if (isLastStep) {
onComplete()
} else {
next()
}
})
- 在做完上述所有的准备工作之后,开始完成dom部分,WizardContext.Provider存放全局使用的变量,WizardContaienr为style节点,StepPanel左侧步骤组件,ContentColumn为内容布局组件,这几个都不赘述,核心是WizardForm以及OnboardComponent
<WizardContext.Provider value={useMemo(() => ({
mode,
bizType,
setBizType,
isFirstStep,
isLastStep,
prev
}), [mode, bizType, setBizType, isFirstStep, isLastStep, prev])}>
<WizardContaienr orientation={orientation}>
{
currentSteps.length > 1 &&
<StepPanel
width={orientation === 'horizontal' ? 'auto' : '225px'}
steps={currentSteps}
activeStep={availableSteps.indexOf(currentStep)}
orientation={orientation}
/>
}
<ContentColumn
flex
margin="0 0 0 15px"
borderRadius="5px"
orientation={orientation}
>
<Text size="xl2" medium>{t(currentStepInfo.title)}</Text>
{
currentStepInfo.subTitle && <div style={{ marginBottom: 30 }}>{t(currentStepInfo.subTitle)}</div>
}
<WizardForm>
<OnboardComponent
kyc={kyc}
preSubmit={preSubmit}
handleNext={handleNext}
onError={useHandleEkycFailed(EkycErrorCode.INDIVIDUAL)}
/>
</WizardForm>
</ContentColumn>
</WizardContaienr>
</WizardContext.Provider>
- 以下为WizardForm的代码,可见WizardForm其实是一个表单的provider,存储着fieldRegistry和两个工具函数,主要是用于判断表单字段是否显示,useRegistry()主要也是优化作用
import React, { createContext, useRef, useCallback, useMemo } from 'react'
const noop = (field: string) => { /**/ }
export const WizardFormContext = createContext({
registerField: noop,
unregisterField: noop,
fieldRegistry: {}
})
export default function WizardForm (props: any) {
return (
<WizardFormContext.Provider
value={useRegistry()}
{...props}
/>
)
}
export function useRegistry() {
const registryRef = useRef<{[key: string]: boolean}>({})
const registerField = useCallback((field: string) => {
registryRef.current[field] = true
}, [registryRef.current])
const unregisterField = useCallback((field: string) => {
registryRef.current[field] = false
}, [registryRef.current])
return useMemo(() => ({
fieldRegistry: registryRef.current,
registerField,
unregisterField
}), [])
}
- OnboardComponent不做赘述主要是根据步骤渲染相应控件
第三步:具体组件(这里以Business Information为例子,composed/bizinfo/default.js)
- 依旧是一样的方法,首先初始化数据,由于bizinfo的表单设计分为了companyinfo ,vendor以及additonal三块,所以这边kycobj同样是组装形式(loadash组装)
const { mode, bizType } = useContext(WizardContext)
const c2Data = useSelector((state: RootState) => state.app.c2Data)
const kycObj = _.merge(
{},
CompanyInfo.InitialValues,
{ additional: INIT_VALUE_FOR_ADDITIONAL },
extractKycDataFromC2(c2Data),
buildKycData(kyc),
{
businessType: bizType
}
)
// Restore cache data which is input previously
const cachedData = useSelector((state: RootState) => state.onboard.kycFromUser?.businessEntity)
if (mode === WizardMode.SIGNUP) {
_.merge(kycObj, cachedData)
}
- 建立表单的验证逻辑(比如必填,email格式是否合规等等),ExpiryDate字段单独列出可能是判断逻辑复杂,为下面建立formik表单做准备
const validationRules = CompanyInfo.useValidationRules()
const validationSchema = useMemo(() => yup.object(validationRules), [validationRules])
const validateExpiryDate = CompanyInfo.useValidateExpiryDate()
- 新建提交函数submit,这里useAsync起到阻挡的作用,防止bizinfo组件刷新时由于函数重新挂载让整个formik做无味的刷新,其实useAsync中的第一个参数就是handleSubmit,传递给formik的onSubmit钩子即可启用,至于这边的业务逻辑,主要是四步,第一组装数据,第二调用sms验证,第三新建账户(/bizinfo/create接口),如果成功则第四步调用handleNext函数下一步,这个submit动作在所有表单中均是如此,可复用
const {
run: handleSubmit,
loading,
error
} = useAsync(async (formData: any) => {
const clonedFormData = _.cloneDeep(formData)
// Override fields if more accruate data is got from vendor
if (mode === WizardMode.SIGNUP) {
const vendorData = _.omit(CompanyInfo.fromVendorData(bizInfoFromVendor), ['name', 'address'])
Object.assign(clonedFormData, vendorData)
}
// Validate expiry date
validateExpiryDate(clonedFormData)
if (mode === WizardMode.EDIT) {
await smsVerify(formData.verifyCode)
}
const payload = {
...CompanyInfo.toAPIData(clonedFormData),
websiteUrl: clonedFormData.additional.websiteUrl,
isServerAbroad: clonedFormData.additional.isServerAbroad,
icpNumber: clonedFormData.additional.icpNumber,
appLinks: extractAppLinks(formData.additional)
}
await api.post('/bizinfo/create', payload)
await handleNext({ businessEntity: formData })
}, onError)
- 建立formike表单,这边非常简单,一个架子,主要是FormContent组件
<Formik
validateOnChange={true}
initialValues={kycObj}
enableReinitialize
validationSchema={validationSchema}
onSubmit={(data, { setSubmitting }) => {
setSubmitting(true)
handleSubmit(data)
setSubmitting(false)
}}
>
<>
{loading && <FullLoading />}
<FormContent />
<WizardFooter
goForward={handleSubmit}
error={error}
noBack
/>
</>
</Formik>
- FormContent组件, 对一些字段的显隐藏做一些初步的判断,以及定义ocr识别之后的一系列操作(主要是handleLicenseConfirm和handleLicenseNumberChange),这个设计模式在之后的ocr识别也是通用的,代码这边就不贴全了,只贴了最终渲染部分
<CompanyInfo
onLicenseConfirm={handleLicenseConfirm}
onLicenseNumChange={handleLicenseNumberChange}
fields={fields}
mode={mode}
disabledFields={disabledFields}
needPersonalSellerEntry={mode === WizardMode.SIGNUP}
/>
{ (vendorData?.icpInfo || mode !== WizardMode.SIGNUP) && <AdditionalInfoSection data={vendorData?.icpInfo} /> }
CompanyInfo真正的业务逻辑组件,生成表单字段,以及一系列工具函数的定义均集中在这边,虽然是这么说但是CompanyInfo调用的业务代码并不多,大多数已经已经由default.js中执行完毕,该组件只是被动的接受props并做一些渲染层面的控制,这一系列动作主要归功于WizardField和WizardSection这两个控件,之前已经介绍过WizardForm,WizardField其实是WizardForm的唯一触发媒介,WizardField通过外部参数hide控制是否生成formik表单以及是否将字段(fieldname)挂载在WizardForm的registerField中,WizardSection可以理解为WizardField的上一级,但是WizardField并不依附于WizardSection存在,WizardSection更像是个区域划分的控件,一样通过hide判断是否显示和隐藏
另外一点就是其实上两步的几乎所有的优化都是为了最终控件CompanyInfo服务的,由代码可知其实CompanyInfo就是个MemoFuncComponent因此控制ICompanyInfoProps中的值不变就非常重要
interface ICompanyInfoProps {
simple?: boolean
onLicenseConfirm?: (dataFromVendor: any) => void
onLicenseNumChange?: (licenseNumber: string) => void
fields: string[]
nameForPlaceholder?: string
mode?: string
disabledFields?: string[]
needPersonalSellerEntry?: boolean
}
export default function CompanyInfo(props: ICompanyInfoProps) {
return <MemoCompanyInfo {...props} />
}
const MemoCompanyInfo = memo(function CompanyInfoFieldsContainer(props: ICompanyInfoProps) {...})