[React] 使用 React.cloneElement 特性實作 Wizard 共用組件

透過 React.cloneElement 注入新 props 至組件中的特性,賦予 Wizard 組件中各步驟子組件的操作功能。

前言


最近接觸到的申請表單都是有步驟性,也就是需要依序填寫各個表單資料後才能完成單項申請作業,因此搞個 wizard 組件來避免重複的步驟頁面切換邏輯一再出現了。

 

 

構想說明


希望透過 wizard 自動將轄下 children 依序視為各步驟下須顯示的 step 組件 (如以下範例所示),並依照當前步驟序 stepIndex 狀態來自動呈現對應 step 組件,且每個 step 組件都可以接收到來自 wizard 向下傳遞的 props 來執行各項操作 (例如切換各步驟頁面、接收表單資料),而要完成此功能就要靠 React.cloneElement() 複製目前步驟序 stepIndex 下的 step 組件並注入新 props 的方法特性來實現。以下進行實作說明。

<Wizard>
  <Step1 />
  <Step2 />
  <Step3 />
  <Step4Result />
</Wizard>

 

 

定義步驟序 stepIndex


首先在 wizard 組件中一定會有步驟序 stepIndex 用來紀錄目前步驟的狀態值,可以透過 setStepIndex 調整該狀態;接著定義 go 方法執行切換 stepIndex 防呆邏輯,可透過 goNextgoPrevious 依目前步驟執行「下一步」和「上一步」的畫面切換動作。

 

 

挑出當前步驟畫面組件


主角登場,先來介紹 React.cloneElement 作用,該方法可複製並回傳一個基於 element 的新 React element,這個回傳的 elementprops 將會是原本 elementprops 與新的 props 進行 shallow 合併之後的結果;新的 children 將會取代原先的 children,而原先 elementkeyref 將會被保留。

React.cloneElement( element, [props], [...children] )

 

因此為了讓包覆在 wizard 中的 step1, step2 .. 組件都可以操作 goNextgoPrevious 去執行上下步驟的切換,我們就需要使用 React.cloneElement() 複製目前步驟序 stepIndex 下的 step 組件並注入 goNextgoPrevious 方法來實現,具體實現方式如下:

  1. children 轉為 array 後取出目前 stepIndex 的子組件 step
  2. step 組件複製一份,並合併新 go, goNextgoPrevious 參數至組件中後存放在 currentStepWithProps 中。
  3. 最後將 currentStepWithProps 顯示在 div 區塊中做畫面的呈現。

 

此時只要是包覆在 wizard 中的組件皆可接收到 go, goNextgoPrevious 方法,這樣各步驟組件就可以自行封裝表單邏輯,當滿足此步驟所需獲得資料的條件後即可呼叫 goNext 通知 wizard 進入下一步驟,反之亦可通過 goPrevious 回到上一步頁面。

 

 

共享表單資料


在此之前都只討論到步驟切換流程,但最重要的表單資料 formValue 還沒有討論到;表單資料需要在各個 step 中共享 (後步驟可能會使用前步驟提供的資訊),所以必須放在 wizard 中保存,並注入各 step 中作初始值的顯示,且允許各 step 在進入下一步驟時提交新表單資料至 wizard 中更新,具體實現方式如下:

  1. wizard 中放置 formValue 狀態,讓此狀態在 wizard 及各 step 組件中共享。
  2. step 組件呼叫 goNext 時可提交 newFormValue 更新 formValue 資訊。
  3. step 頁面接收 formValue 給頁面作呈現。

 

所以在 wizard 中的 step 組件可實現:

  1. 取得表單資料 formValue 來設定畫面初始資料
  2. 使用 goNext 進入下步驟,並傳入「新」表單資料給 wizard 更新
  3. 使用 goPrevious 回到上個步驟

 

 

顯示完成比率


為了讓使用者了解表單填寫狀態,因此提供 progress bar 示意此申請流程的完成百分比,所以加上一個橫條做為表示,而計算方式就是目前步驟 stepIndex 與總步驟數 totalStep 相除所得之百分比。

/* ProgressBar.js */

import React from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components'

const Wrapper = styled.div`
  border: thin solid #ccc;
  background: #e6e6e6;
`

const Bar = styled.div`
  height: 4px;
  width: ${props => props.current * 100 / props.total}%; 
  background-color: #9e9e9e;
`

const ProgressBar = ({ current, total }) => {
  return (
    <Wrapper>
      <Bar current={current} total={total} />
    </Wrapper>
  )
}

ProgressBar.propTypes = {
  current: PropTypes.number.isRequired,
  total: PropTypes.number.isRequired
}

export default ProgressBar

 

 

成果驗收


建立三個表單 Step1, Step2, Step3 組件加上結果頁 Step4Result 組件於 Wizard 中。

<Wizard>
  <Step1 />
  <Step2 />
  <Step3 />
  <Step4Result />
</Wizard>

 

結果如下,各 step 會填上不同的資料,往下一個步驟時會記錄當下資料至 wizard 中,所以已填寫資料可在回上一步時重現於表單中,而最終可收集到所有資料並呈現於結果頁中。

 

 

相關代碼


此範例使用到的相關代碼如下,有興趣的朋友可以玩玩。

/* Wizard.js */

import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import ProgressBar from './ProgressBar'

const Wizard = ({ children, onDone } = { children: [] }) => {
  const [stepIndex, setStepIndex] = useState(1)
  const [formValue, setFormValue] = useState({})

  // total step amount
  const totalSteps = children.length

  // scroll top while step change
  useEffect(() => {
    window.scrollTo(0, 0)
  }, [stepIndex])

  // go next
  const goNext = (newFormValue) => go(stepIndex + 1, newFormValue)

  // go previous
  const goPrevious = () => go(stepIndex - 1)

  // go any step
  const go = (newStepIndex, newFormValue = null) => {
    // update form value
    if (newFormValue) {
      setFormValue(newFormValue)
    }

    // move steps
    if (newStepIndex > 0 && newStepIndex <= totalSteps) {
      setStepIndex(newStepIndex)
      if (newStepIndex === totalSteps) onDone()
    } else if (newStepIndex < 1) {
      setStepIndex(1)
    } else if (newStepIndex > totalSteps) {
      setStepIndex(totalSteps)
    }
  }

  // find current step
  const step = React.Children.toArray(children)[stepIndex - 1]
  const currentStepWithProps = step
    ? React.cloneElement(step, { go, goNext, goPrevious, formValue }) : null

  // render
  return (
    <div className='tp-section'>
      <ProgressBar current={stepIndex} total={totalSteps} />
      {currentStepWithProps}
    </div>

  )
}

Wizard.propTypes = {
  children: PropTypes.node,
  onDone: PropTypes.func
}

export default Wizard
/* step1.js */

import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'

const Step1 = ({ goNext, formValue }) => {
  const [name, setName] = useState('')

  useEffect(() => {
    if (formValue.name) {
      setName(formValue.name)
    }
  }, [formValue.name])

  return (
    <>
      <h3>STEP 1</h3>
      <div className='tp-form'>
        <div className='tp-form__row'>
          <label className='tp-form__label' htmlFor='name'>姓名</label>
          <div className='tp-form__field'>
            <input type='text' id='name' value={name} onChange={e => setName(e.target.value)} />
          </div>
        </div>

        <div className='tp-form__row tp-form__row--right'>
          <button onClick={() => goNext({ name, ...formValue })}>下一步</button>
        </div>
      </div>
    </>
  )
}

Step1.propTypes = {
  goNext: PropTypes.func,
  formValue: PropTypes.object
}

export default Step1
/* Step2.js */
import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'

const Step2 = ({ goNext, goPrevious, formValue }) => {
  const [phone, setPhone] = useState('')

  useEffect(() => {
    if (formValue.phone) {
      setPhone(formValue.phone)
    }
  }, [formValue.phone])

  return (
    <>
      <h3>STEP 2</h3>
      <div className='tp-form'>
        <div className='tp-form__row'>
          <label className='tp-form__label' htmlFor='phone'>電話</label>
          <div className='tp-form__field'>
            <input type='text' id='phone' value={phone} onChange={e => setPhone(e.target.value)} />
          </div>
        </div>

        <div className='tp-form__row tp-form__row--right'>
          <button onClick={() => goNext({ phone, ...formValue })}>下一步</button>
          <button onClick={() => goPrevious()}>上一步</button>
        </div>
      </div>
    </>
  )
}

Step2.propTypes = {
  goNext: PropTypes.func,
  goPrevious: PropTypes.func,
  formValue: PropTypes.object
}

export default Step2
/* Step3.js */

import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'

const Step3 = ({ goNext, goPrevious, formValue }) => {
  const [address, setAddress] = useState('')

  useEffect(() => {
    if (formValue.address) {
      setAddress(formValue.address)
    }
  }, [formValue.address])

  return (
    <>
      <h3>STEP 3</h3>
      <div className='tp-form'>
        <div className='tp-form__row'>
          <label className='tp-form__label' htmlFor='address'>地址</label>
          <div className='tp-form__field'>
            <input type='text' id='address' value={address} onChange={e => setAddress(e.target.value)} />
          </div>
        </div>

        <div className='tp-form__row tp-form__row--right'>
          <button onClick={() => goNext({ address, ...formValue })}>送出</button>
          <button onClick={() => goPrevious()}>上一步</button>
        </div>
      </div>
    </>
  )
}

Step3.propTypes = {
  goNext: PropTypes.func,
  goPrevious: PropTypes.func,
  formValue: PropTypes.object
}

export default Step3
/* Step4Result.js */
import React from 'react'
import PropTypes from 'prop-types'

const Step4Result = ({ formValue, go }) => {
  return (
    <>
      <h3>RESULT</h3>
      <div className='tp-form'>
        <div className='tp-form__row'>
          Success
        </div>
        <div className='tp-form__row'>
          <div className='tp-code'>
            {formValue ? JSON.stringify(formValue, null, 2) : ''}
          </div>
        </div>

        <div className='tp-form__row tp-form__row--right'>
          <button onClick={() => go(1, {})}>重新申請</button>
        </div>
      </div>
    </>
  )
}

Step4Result.propTypes = {
  formValue: PropTypes.object,
  go: PropTypes.func
}

export default Step4Result

 

 


希望此篇文章可以幫助到需要的人

若內容有誤或有其他建議請不吝留言給筆者喔 !