背景#
仕事の都合で、ここ一ヶ月全く使ったことのない技術スタックで新しいプロジェクトを開発しました。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からすべての値を簡単に取得できる方が、データを field の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 の方法で解決しましたが、かなり面倒でした。