チーム内部のReactコンポーネントライブラリ(ne-rc)にあるForm
コンポーネントを最近リファクタリングしたので、その思考プロセスを記録します。
いくつかの事前定義:
用語 | 定義 |
---|---|
フォーム | Form コンポーネント |
子フォーム | Form の下にネストされたInput やSelect のような子コンポーネント |
まず、Form
コンポーネントに対する要件を見てみましょう。
- 現在変更されたフォームの状態を取得する
- すべての必須フォームが入力完了しているか検証する
- 特定のフォームの変更を外部に通知するメソッド
formFieldChange
をトリガーする
- フォーム全体の状態を外部に提供するメソッドを公開する
- フォーム全体の最新の状態を提供するメソッド
$Form.data
を提供する
- フォーム全体の最新の状態を提供するメソッド
- 送信メソッド
- フォームが検証を通過したかチェックする
- 外部に
formSubmit
メソッドをトリガーする
次に、リファクタリング前とリファクタリング後で、この問題をどのように解決したかを見ていきます。
Before
現在変更されたフォームの状態を取得する
変更された子フォームをどのように取得するか
Reactの親子間通信はprop
を介してメソッドを渡す必要があります。Form
の下にあるInput
のような子フォームの変更を親に通知したい場合、サードパーティのイベント伝達メソッドを使用しないのであれば、親がprops
を介してInput
にformFieldChange
(仮にこの名前とします)メソッドを渡し、子コンポーネントが変更されたときに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
を例にとると、Input
のonChange
メソッド内で親からprops
として渡されたformFieldChange
を呼び出すことで、Form
コンポーネントに通知できます。
変更されたフォームデータを収集する。
前のステップが完了すれば、このステップは比較的簡単です。Input
がformFieldChange
を呼び出す際に、渡したいデータを引数として渡し、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: {}
}
}
}
これでこの問題は非常に大雑把に解決されましたが、その過程には多くの問題が存在しました。
特定のコンポーネントタイプ(Input
、Select
、CheckBox
)に限定されていたため、拡張性に欠けていました。開発中にカスタムの子フォームのような他のタイプのコンポーネントに遭遇した場合、Form
はそのカスタム子フォームのデータを収集できず、解決が比較的困難でした。
そこで、別の実装方法を検討しました。Form
は特定の条件を満たすコンポーネントのみを収集し、そのコンポーネントが条件を満たし、対応するインターフェースを実装していれば、Form
はすべてを収集・処理できるようにする、というものです。これにより、適用性が大幅に向上しました。
フォーム全体の状態を外部に提供するメソッドを公開する
外部でForm
がトリガーするonChange
イベントを毎回リッスンすることで、Form
全体の状態を取得します。
送信メソッド
フォームが検証を通過したかチェックする
フォーム全体のデータオブジェクトが既にあるため、検証を行うことは難しいことではありません。検証を通過した場合はformSubmit
メソッドを呼び出し、通過しなかった場合はエラー情報をForm
のstate
に追加して外部に通知します。
外部に formSubmit
メソッドをトリガーする
フォームが検証を通過したとき、formSubmit
メソッドを外部にトリガーし、送信するデータをformSubmit
の引数として外部に渡します。
After
ここまでは、以前作成したForm
コンポーネントのいくつかの考え方で、実際の使用においても基本的なビジネス要件を満たすことができました。
しかし、Form
全体の拡張性が低く、他のカスタムコンポーネントをうまく組み込むことができませんでした。そこで、書き直しのアイデアが生まれました。
この書き直されたForm
に対する私の考えは、まず使いやすさです。大量の初期設定作業は不要であるべきです。次に、拡張性が高いことです。Input
やSelect
など、すでに提供されている内部コンポーネントがForm
に組み込めるだけでなく、他のビジネスにおける特別な要件でForm
に組み込む必要がある場合でも、そのコンポーネントが特定のインターフェースを実装していれば、コンポーネント内部のコードを大幅に修正することなく、非常に簡単に組み込めるようにすることです。
リファクタリングは主に上記の要件1の内容、つまり現在変更されたフォームの状態を取得するに焦点を当てました。
現在のフォームの状態を取得することは、以下のいくつかの点に分解できます。
- 収集する必要があるすべての子フォーム
formFields
を取得する Form
のstate
を初期化する- フォームの下の子フォームの数またはタイプが変更されたときに、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
メソッドを呼び出し、渡したいデータを引数として渡すだけです。
なぜ、この非効率的な走査という方法に固執して収集する必要があるのでしょうか。実は、それはコンポーネントの使用を容易にするためです。これにより、参照するたびに子フォームに対して何か操作を行う必要がなくなります。
Form
のstate
を初期化する
前のステップで全ての子フォームを取得し、次にinitialFormDataStructure
を呼び出すことでForm
のstate.data
の構造を初期化し、同時にForm
が変更されたことを外部に通知します。
子フォームの数またはタイプが変更されたとき
Form
の下の子コンポーネントが追加または削除されたとき、Form Data
の構造を適時に更新する必要があります。updateFormDataStructure
を呼び出すことで、追加または変更された子フォームを最新の状態に更新し、Form
が変更されたことを外部に通知します。
子フォームの内部状態が変更されたとき
最初のステップで子フォームを収集する際に、すでにhandleFieldChange
が子フォームコンポーネントに注入されているため、呼び出しのタイミングは子フォームが決定します。handleFieldChange
が呼び出されたとき、まずForm
のstate
を更新し、次に子フォームが変更されたことを外部に通知し、同時にForm
が変更されたことを外部に通知します。
これで全体の流れはうまくいったように見えますが、実際には多くの問題が存在します。
まず、setState
は非同期プロセスであるため、最新のstate
はrender
後にしか取得できません。このため、1つのライフサイクル内でsetState
を複数回呼び出した場合、2つの呼び出しの間でstate
の読み取りが不正確になる可能性が非常に高くなります。(ライフサイクルの詳細については、こちらの記事を参照してください:https://www.w3ctech.com/topic/1596)
そこで、現在の状態における最新のstate
を格納するためのcurrentState
という一時変数を作成
し、setState
を呼び出すたびにそれを更新するようにしました。
もう一つの問題は、Form
が変更されるたびにupdateFormDataStructure
が頻繁に呼び出されすぎることです。実際には、子フォームの数またはタイプが変更されたときにのみ、Form
のstate
構造を更新する必要があります。子フォームのタイプが変更されたかどうかを直接比較するのもコストの高い操作であるため、別の妥協案を選択しました。Form
の現在の状態にタグを付け、Form
が取りうるすべての状態を特定します。
const STATUS = {
Init: "Init",
Normal: "Normal",
FieldChange: "FieldChange",
UpdateFormDataStructure: "UpdateFormDataStructure",
Submit: "Submit",
};
このようにすることで、Form
のSTATUS
がNormal
である場合にのみ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 日が経過しており、内容が古くなっている可能性があります。