抹桥的博客
Language
Home
Archive
About
GitHub
Language
主题色
250
3211 文字
16 分
`React Form` コンポーネントのリファクタリングの考え方

チーム内部のReactコンポーネントライブラリ(ne-rc)にあるFormコンポーネントを最近リファクタリングしたので、その思考プロセスを記録します。

いくつかの事前定義:

用語定義
フォームFormコンポーネント
子フォームFormの下にネストされたInputSelectのような子コンポーネント

まず、Formコンポーネントに対する要件を見てみましょう。

  1. 現在変更されたフォームの状態を取得する
    • すべての必須フォームが入力完了しているか検証する
    • 特定のフォームの変更を外部に通知するメソッド formFieldChange をトリガーする
  2. フォーム全体の状態を外部に提供するメソッドを公開する
    • フォーム全体の最新の状態を提供するメソッド $Form.data を提供する
  3. 送信メソッド
    • フォームが検証を通過したかチェックする
    • 外部に formSubmit メソッドをトリガーする

次に、リファクタリング前とリファクタリング後で、この問題をどのように解決したかを見ていきます。

Before#

現在変更されたフォームの状態を取得する#

変更された子フォームをどのように取得するか#

Reactの親子間通信はpropを介してメソッドを渡す必要があります。Formの下にあるInputのような子フォームの変更を親に通知したい場合、サードパーティのイベント伝達メソッドを使用しないのであれば、親がpropsを介してInputformFieldChange(仮にこの名前とします)メソッドを渡し、子コンポーネントが変更されたときにformFieldChangeを呼び出すことで実現するしかありません。

では、いつこのメソッドを渡すのでしょうか?

特定のページで使用するたびに、各フォームにこのメソッドを登録することはできません。それでは、フォームコンポーネントを使用するたびに、子フォームにこのようなイベントバインディングを行う必要があり、非常に手間がかかります。

そこで当初、私はFormの下にあるchildrenを直接再帰的に走査し、このchildrenが目的のフォームタイプであると判明した場合、formFieldChangeを持つコンポーネントを新しくクローンして元のコンポーネントを置き換える方法を選択しました。

/**
 * 获取 form 下面每一个表单对象,注入属性,并收集起来
 * @param children
 * @returns {*}
 */
function getForms(children) {
  return React.Children.map(children, (el, i) => {
    if (!el) {
      return null;
    }
    switch (el.type) {
      case Input:
        Forms.push(el);
        return React.cloneElement(el, {
          key: i,
          formFieldChange,
          emptyInput,
        });
      case Select:
        Forms.push(el);
        return React.cloneElement(el, {
          key: i,
          formFieldChange,
        });
      case CheckBox:
        Forms.push(el);
        return React.cloneElement(el, {
          key: i,
          formFieldChange,
        });
      default:
        if (el.props && el.props.children instanceof Array) {
          const children = getForms(el.props.children);
          return React.cloneElement(el, {
            key: i,
            children,
          });
        } else {
          return el;
        }
    }
  });
}

これにより、すべての特定の子コンポーネントが登録されたメソッドを受け取ることができます。Inputを例にとると、InputonChangeメソッド内で親からpropsとして渡されたformFieldChangeを呼び出すことで、Formコンポーネントに通知できます。

変更されたフォームデータを収集する。#

前のステップが完了すれば、このステップは比較的簡単です。InputformFieldChangeを呼び出す際に、渡したいデータを引数として渡し、Form内でこの引数を処理することで、現在変更されたフォームの状態データを取得できます。

フォームが入力完了しているか検証する#

前述の通り、各変更されたフォームのデータを収集しました。しかし、現在のFormの下にあるフォームが入力完了しているかを判断するには、まず入力が必要なフォームがいくつあるかを知る必要があり、その後formFieldChangeの際に判断すればよいのです。入力が必要なFieldがいくつあるかを事前に知るにはどうすればよいでしょうか。以前は、Formを使用する際に、すべてのフォームの初期状態を含むデータを初期化する方法を選択しました。

export default class Form extends React.Component {
  constructor (props) {
    super(props)
    this.Forms = []
    this.formState = Object.assign({}, {
      isComplete: false,
      isValidate: false,
      errorMsg: '',
      data: {}
    }, this.props.formState)
  }

  static propTypes = {
    onChange: PropTypes.func,
    onSubmit: PropTypes.func,
    formState: PropTypes.object
  }
  // 初始化一个类似这样的对象传递给 Form
  formState: {
    data: {
      realName: {},
      cityId: {},
      email: {},
      relativeName: {},
      relativePhone: {},
      companyName: {}
    }
  }
}

これでこの問題は非常に大雑把に解決されましたが、その過程には多くの問題が存在しました。

特定のコンポーネントタイプ(InputSelectCheckBox)に限定されていたため、拡張性に欠けていました。開発中にカスタムの子フォームのような他のタイプのコンポーネントに遭遇した場合、Formはそのカスタム子フォームのデータを収集できず、解決が比較的困難でした。

そこで、別の実装方法を検討しました。Formは特定の条件を満たすコンポーネントのみを収集し、そのコンポーネントが条件を満たし、対応するインターフェースを実装していれば、Formはすべてを収集・処理できるようにする、というものです。これにより、適用性が大幅に向上しました。

フォーム全体の状態を外部に提供するメソッドを公開する#

外部でFormがトリガーするonChangeイベントを毎回リッスンすることで、Form全体の状態を取得します。

送信メソッド#

フォームが検証を通過したかチェックする#

フォーム全体のデータオブジェクトが既にあるため、検証を行うことは難しいことではありません。検証を通過した場合はformSubmitメソッドを呼び出し、通過しなかった場合はエラー情報をFormstateに追加して外部に通知します。

外部に formSubmit メソッドをトリガーする#

フォームが検証を通過したとき、formSubmitメソッドを外部にトリガーし、送信するデータをformSubmitの引数として外部に渡します。

After#

ここまでは、以前作成したFormコンポーネントのいくつかの考え方で、実際の使用においても基本的なビジネス要件を満たすことができました。

しかし、Form全体の拡張性が低く、他のカスタムコンポーネントをうまく組み込むことができませんでした。そこで、書き直しのアイデアが生まれました。

この書き直されたFormに対する私の考えは、まず使いやすさです。大量の初期設定作業は不要であるべきです。次に、拡張性が高いことです。InputSelectなど、すでに提供されている内部コンポーネントがFormに組み込めるだけでなく、他のビジネスにおける特別な要件でFormに組み込む必要がある場合でも、そのコンポーネントが特定のインターフェースを実装していれば、コンポーネント内部のコードを大幅に修正することなく、非常に簡単に組み込めるようにすることです。

リファクタリングは主に上記の要件1の内容、つまり現在変更されたフォームの状態を取得するに焦点を当てました。

現在のフォームの状態を取得することは、以下のいくつかの点に分解できます。

  • 収集する必要があるすべての子フォーム formFields を取得する
  • Formstateを初期化する
  • フォームの下の子フォームの数またはタイプが変更されたときに、1で作成されたformFieldsを更新する
  • 子フォームの内部状態が変更されたときに、親フォームに通知する

現在変更されたフォームの状態を取得する#

必要なすべての子フォームを取得する#

同様に、childrenを再帰的に走査して収集する必要がある子フォームを取得します。子フォームのtype.nameの命名規則が定義に合致するかどうかで、収集するかどうかを決定します。 コードを直接見てみましょう:

collectFormField = (children) => {
  const handleFieldChange = this.handleFieldChange;

  // 简单粗暴,在 Form 更新的时候直接清空上一次保存的 formFields,全量更新,
  // 避免 formFields 内容或者数量发生变化时 this.formFields 数据不正确的问题
  const FormFields = (this.formFields = []);

  function getChildList(children) {
    return React.Children.map(children, (el, i) => {
      // 只要 Name 以 _Field 开头,就认为是需要 From 管理的组件
      if (!el || el === null) return null;

      const reg = /^_Field/;
      const childName = el.type && el.type.name;
      if (reg.test(childName)) {
        FormFields.push(el);
        return React.cloneElement(el, {
          key: i,
          handleFieldChange,
        });
      } else {
        if (el.props && el.props.children) {
          const children = getChildList(el.props.children);
          return React.cloneElement(el, {
            key: i,
            children,
          });
        } else {
          return el;
        }
      }
    });
  }
};

コンポーネントのclass name_Fieldで始まる限り、それを収集し、handleFieldChangeメソッドを渡します。これにより、カスタムコンポーネントを組み込む際、外側に一層ラップし、class名を_Fieldで始まる形式にすることで、Formによって収集・管理されるようになります。

組み込むコンポーネント内で必要なことは、適切なタイミングでhandleFieldChangeメソッドを呼び出し、渡したいデータを引数として渡すだけです。

なぜ、この非効率的な走査という方法に固執して収集する必要があるのでしょうか。実は、それはコンポーネントの使用を容易にするためです。これにより、参照するたびに子フォームに対して何か操作を行う必要がなくなります。

Formstateを初期化する#

前のステップで全ての子フォームを取得し、次にinitialFormDataStructureを呼び出すことでFormstate.dataの構造を初期化し、同時にFormが変更されたことを外部に通知します。

子フォームの数またはタイプが変更されたとき#

Formの下の子コンポーネントが追加または削除されたとき、Form Dataの構造を適時に更新する必要があります。updateFormDataStructureを呼び出すことで、追加または変更された子フォームを最新の状態に更新し、Formが変更されたことを外部に通知します。

子フォームの内部状態が変更されたとき#

最初のステップで子フォームを収集する際に、すでにhandleFieldChangeが子フォームコンポーネントに注入されているため、呼び出しのタイミングは子フォームが決定します。handleFieldChangeが呼び出されたとき、まずFormstateを更新し、次に子フォームが変更されたことを外部に通知し、同時にFormが変更されたことを外部に通知します。

これで全体の流れはうまくいったように見えますが、実際には多くの問題が存在します。

まず、setStateは非同期プロセスであるため、最新のstaterender後にしか取得できません。このため、1つのライフサイクル内でsetStateを複数回呼び出した場合、2つの呼び出しの間でstateの読み取りが不正確になる可能性が非常に高くなります。(ライフサイクルの詳細については、こちらの記事を参照してください:https://www.w3ctech.com/topic/1596)

そこで、現在の状態における最新のstateを格納するためのcurrentStateという一時変数を作成し、setStateを呼び出すたびにそれを更新するようにしました。

もう一つの問題は、Formが変更されるたびにupdateFormDataStructureが頻繁に呼び出されすぎることです。実際には、子フォームの数またはタイプが変更されたときにのみ、Formstate構造を更新する必要があります。子フォームのタイプが変更されたかどうかを直接比較するのもコストの高い操作であるため、別の妥協案を選択しました。Formの現在の状態にタグを付け、Formが取りうるすべての状態を特定します。

const STATUS = {
  Init: "Init",
  Normal: "Normal",
  FieldChange: "FieldChange",
  UpdateFormDataStructure: "UpdateFormDataStructure",
  Submit: "Submit",
};

このようにすることで、FormSTATUSNormalである場合にのみupdateFormDataStructure操作を実行します。これにより、多くの再レンダリングや無効な外部へのFormChangeイベントのトリガーを省くことができます。

送信方法とFormの状態を外部に公開する方法は以前とほぼ同じで、これでForm全体のリファクタリングは完了です。具体的なプロジェクトでの使用感もなかなか良いです O(∩_∩)O

Formコンポーネントのアドレス: https://github.com/NE-LOAN-FED/NE-Component/tree/master/src/Form

最後に、この記事を読んでいるあなたが何かより良いアイデアをお持ちでしたら、ぜひ教えてください 😛。

この記事は 2017年3月18日 に公開され、2017年3月18日 に最終更新されました。3124 日が経過しており、内容が古くなっている可能性があります。

`React Form` コンポーネントのリファクタリングの考え方
https://blog.kisnows.com/ja-JP/2017/03/18/react-form-rework-thinking/
作者
Kisnows
公開日
2017-03-18
ライセンス
CC BY-NC-ND 4.0