背景#
仕事の関係で、ここ一ヶ月全く使ったことのない技術スタックで新しいプロジェクトを開発しました。React や Storybook はそれほど難しくなく、基本的に一日で使いこなせましたが、Formily は半月以上かかってようやく入門できました。作業の一区切りがついたので、これらの経験をまとめてみました。
プロジェクトの理由から、私が Formily を使用する方法は必ずしもベストプラクティスではなく、正しいとも限りません。フォームに関しての使用はほぼゼロで、主なシーンは@formily/antd-v5と自作コンポーネントの使用に集中しています。
基礎#
Formily はアリババが開発した、Schema でフォームを記述するオープンソースフレームワークですが、私たちのプロジェクトでそれを使用する理由はフォームを構築するためではなく、動的にページを構築するためです。
Formily には 3 つの重要な概念があります:Form、Field、Schema。
- Form は全体のフォームのコアで、そのインスタンスはフォームの検証、値、フィールド、フィールドの連動などの内容を取得および設定するために使用できます。
- Field は Form の構成要素であり、フィールドです。例えば、フォームに
keyword
という名前の入力ボックスを設定した場合、入力ボックスの内容を変更するたびに、form.values.keyword
も変動します。 - Schema は Field のデータを記述するもので、簡単に言えば ReactNode と同等と考えられます。具体的に Schema がどのような内容を含むことができるかは公式文書を参照してください。
- Schema をコンポーネントに変換する過程で、Formily はデフォルトでコンポーネントの
value
とonChange
を代理しますので、Formily コンポーネントを開発する際にはこの点を考慮する必要があります。
- Schema をコンポーネントに変換する過程で、Formily はデフォルトでコンポーネントの
簡単なページから始める#
まずは簡単な例から始めましょう。
上記のコードは ==「枠付きの div の中に入力ボックスともう一つの 2 つの入力ボックスを持つコンテナが入っている」== というシーンを作成しています。コードからいくつかの結論を得ることができます:
- Schema の階層構造は HTML の階層構造に相当し、その中の
properties
はchildren
のような役割を果たします。 createForm
ではフォームのいくつかの初期状態を定義できますか?x-component
は現在の位置でレンダリングする要素を表し、ネイティブのタグやカスタムコンポーネントをサポートします。その中のカスタムコンポーネントはcreateSchemaField
で登録する必要があります。x-component-props
ではコンポーネントの props を定義し、x-decorator
ではこのコンポーネントをラップするコンポーネントを定義します。
フォームを簡単に操作してみると、Schema と実際のフォーム値のマッピング関係がわかります。
異なるフィールドの値を管理する場合、schema のtype
は非常に重要です。
type
がvoid
の場合、フォームはこのレベルのパスを無視します。type
がobject
の場合、そのフィールドは子フィールドを持つオブジェクトになります。type
が基本型の場合、そのフィールドは具体的な値を表します。
当初、Card + Tabs 機能を組み合わせたコンポーネントを封装するつもりでしたが、Formily のメカニズムでは、フィールド自体が自分の値(Tabs の activeKey)を表すことも、子フィールドを持つオブジェクトになることもできないため、最終的にこのアプローチは断念しました。
関数処理#
JSON Schema を使用することにこだわる場合、Schema に関数を埋め込む方法はあまり合理的ではありません。Schema の中で、Formily は{{}}
の文字列を関数として処理するため、書き方を変える必要があります。
// ...
input: {
type: "string",
"x-component": "Input",
"x-component-props": {
onClick: `{{ (event) => { console.log(event) } }}`
}
}
// ...
同様に、ReactNode を必要とする props もこのように処理しますが、React.createElement
を使用する必要があります。
import { createElement } from "react";
import { Input } from '@formily/antd-v5'
import { SearchOutlined } from '@ant-design/icons';
// ...
const SchemaField = createSchemaField({
components: {
Input
},
scope: {
createElement,
SearchOutlined
}
})
// ...
input: {
type: "string",
"x-component": "Input",
"x-component-props": {
suffix: `{{ createElement(SearchOutlined) }}`
}
}
// ...
フィールドの連動#
この部分の内容は公式文書に詳しく書かれているので、ここでは詳述しません。
データの受け渡し#
当時のページには連動ロジックがあり、トップナビゲーションバーで時間を切り替えると、下の各グラフがデータを同期して更新される必要がありました。私たちの解決方法は以下の通りで、必ずしもベストプラクティスではありません。
コンポーネント開発プロセス#
Formily コンポーネントは通常の React コンポーネントとはかなり異なります。@formily/antd-v5
を公式の推奨プラクティスとして考えるなら、以前の考え方でコンポーネントを開発することはできません。
antd の Table を例にとると、columns
やdataSource
はコンポーネントに渡される props の一部として扱われますが、Formily ではデータはデフォルト設定のprops.value
によってのみ処理されるべきです。したがって、開発時にはレンダリングデータとprops.value
の変換を適切に行う必要があります。既存のコンポーネントと接続する際には、公式のmapPropsメソッドを使用してマッピングすることもできます。
データをどこに置いても機能を実現することは可能ですが、
form.values
からすべての値を簡単に取得できる方が、データをフィールドのcomponentProps
に分散させるよりも便利です。
それに加えて、useForm
、useField
、useFieldSchema
をうまく活用することで、親子フィールドの内容を簡単に取得できます。
いくつかの落とし穴#
ReactNode を Schema に変換#
{{createElement}}
の方法で ReactNode を渡すことはできますが、カスタムコンポーネントの場合は、事前にコンポーネントを scope に渡す必要があります。動的な Schema の場合、これを必要に応じてインポートするのは難しいです。
私が当時行った方法は、コンポーネント内部でインターセプトを行うことでした。useFieldSchema
を使って Schema を取得し、対応する属性が Schema オブジェクトであるかどうかを判断し、そうであればRecursionField
を返して Schema をレンダリングします。
インターセプトを書いた後、一見すると問題はなさそうでしたが、antd の Table の sortIcon で困惑しました。レンダリングされたアイコンはクリック後に全く状態が変化しませんでした。
私の個人的な推測では、これは Formily のレンダリングメカニズムに関係していると思います。RecursionField
のレンダリング結果はキャッシュされており、関数を再実行して新しい props を渡すだけでは再レンダリングをトリガーするには不十分です。
onChange#
これも Table のフィルタリングに関連しています。元のプロセスでは、表のヘッダーをクリックしてソートすると、テーブルのonChange
イベントがトリガーされてソート情報を取得します。
しかし、さまざまな書き方を試みた後でも、このonChange
をトリガーできないことがわかりました。ソースコードを見た後、公式がonChange
を空の関数で上書きしていることに気づきました。この機能は完全に必要ないのでしょうか?
ビジネスの観点からは解決策はありませんでしたが、最終的に patch の方法で解決しました。非常に面倒でした。