以 Business Information表单为例为表单做一次总结(paypal)

场景:用户在paypal中注册新账户并到达Business Information这一步,由此为例,总结下大致逻辑

image.png

第一步: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) {...})
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,755评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,369评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,799评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,910评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,096评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,159评论 3 411
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,917评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,360评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,673评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,814评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,509评论 4 334
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,156评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,882评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,123评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,641评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,728评论 2 351

推荐阅读更多精彩内容