基本コンセプト
UIと状態は本質的に同じ情報の異なる表現と考え、構造パスをUIと状態の唯一の信頼できるソースとし第一級のオブジェクトとして扱い、Proxyなどの既存技術を駆使して、Webフロントエンド開発が抱える多くの課題を解決する。
ここでいう構造パスとは、データの構造的な位置を指し示すフルパスであり、抽象的に表現するために、リストの要素に対しては、ワイルドーカードを使って表現します。
例
user = { name: "Alice" };
=> user.name
users = [ { name: "Alice" }, { name: "Bob" } ]
=> users.*.name /* AliceやBobを指す、全てという意味ではない、あくまでもデータの位置を示す */
解決する課題
-
状態管理の複雑性への抜本的解決
構造パスが唯一のソースとなることで、UIと状態の一貫性が保証され、複雑な状態管理ロジックを大幅に削減できます。これは、大規模なアプリケーション開発において特に大きなメリットとなります。 -
UIと状態の同期問題の解消
構造パスを通じてUIと状態が密接に紐づくことで、両者の間に発生しがちな同期ズレが原理的に解消されます。密接ではあるのですが、UIと状態は構造パスの契約により関連付けられていて、依存性逆転の法則に則り疎結合とみなせます。 -
状態フック・ボイラープレートの削減と開発者体験の向上
構造パスを第一級のオブジェクトとして扱うことで、データの参照や更新がより直感的になり、冗長なコードを削減できます。Proxyのsetトラップを活用し、状態の変更を検知し更新トリガーとすることができるため、状態フックが不要になります。これにより、開発者はより本質的なロジックに集中できるようになります。 -
仮想DOMの不要化とパフォーマンス向上の可能性
構造パスが直接UI要素と結びつくことで、仮想DOMのような中間層が不要になり、より効率的なDOM操作が可能になります。
UIと状態は構造パスを通して1対1に対応するので、状態を変更すると対応するUIをピンポイントで更新でき、UIを変更すると対応する状態をピンポイントで更新できるようになります。構造パス単位でのピンポイントの更新は、パフォーマンス面で大きな可能性を秘めています。 -
デバッグと推論の容易さ予測可能性の向上
構造パスが明確に定義されることで、アプリケーションの状態変化を追跡しやすくなり、デバッグが容易になるだけでなく、コードの振る舞いを予測しやすくなります。 -
認知負荷の低減
単一の概念(構造パス)でUIと状態を統一的に扱えるため、開発者が覚えるべき概念が減り、学習コストや認知負荷が低減されることが期待できます。シングルコンポーネントファイルのような同じファイル内でUIと状態を記述する場合、構造パスが同じデータを指し示すことは、コンテキストの切り替えを不要とし大きく認知負荷さげることができます。また、チーム開発における共通言語として構造パスを使用することで、チーム内の認知負荷を大きく下げることができます。 -
宣言的表現の向上
構造パスによって、UIがどのようなデータ構造を表現しているのかがより明確になり、宣言的なUI記述がさらに強化されます。特にgetterを使った状態派生は、ワイルドカードを使った抽象的な構造パスを使用することができ、より宣言的に定義することが可能になります。
実装概要
- UIで表現するデータ(条件分岐や繰り返し、テキストの埋込みや属性のバインド)を構造パスで指定するようにします。
- 状態側はクラス管理とし、状態をプロパティ、派生状態をgetter、状態更新をメソッドで行うようにします。
- 派生状態の定義・取得処理や状態更新処理で構造パスを使って参照・更新します。
- 状態管理クラスは、Proxyで拡張され、getトラップで構造パスの解決、setトラップで更新トリガーを検出します。
- UIテンプレートと状態クラスは1つのhtmlファイルで収録され、コンポーネントとして動作します。
- UIテンプレートを解析し、構造パスの対象となるUIを取得する ループを展開時、ループブロック内を同様に解析し、構造パスの対象となるUIを取得する 取得した構造パスとUIはデータバインド情報として管理される
- 更新トリガー発生時、データバインド情報をもとに更新対象が決定される。
- UIのループ展開時にループコンテキストを作成し、スタックされます。
- そのループコンテキストと、ループ内に存在するデータバインドまたはイベントハンドラを紐づけます。
- データバインドはその参照時、イベントハンドラは実行時、紐づいているループコンテキストを参照して構造パスを解決します。
- ネストされたループでもループコンテキストをスタックするので、任意のループコンテキストを参照することができます。
- 親子コンポーネントは、親の状態の部分参照とし、親の構造パスと子の構造パスを関連付け、パス変換で参照・更新を解決する。
- 親子コンポーネントの依存を構造パスの契約とすることで、依存性逆転の法則に則り、疎結合とできる。
- 親子コンポーネントの導入により、分割統治が可能になり、スケーラビリティへの懸念を大幅に払拭できる。
実装イメージ
<template>
<!-- UI -->
<ul>
{{ for:users }}
<li>
{{ users.*.name }}, {{ users.*.ucName }}
{{ if:users.*.isInactive }}
<button data-bind="onclick:activate">activate</button>
{{ endif: }}
</li>
{{ endfor: }}
</ul>
</template>
<script type="module">
export default class {
// 状態、users.*.nameは、Alice、Bobを示す
users = [ { name:"Alice", active:true }, { name:"Bob", active:false } ];
// 派生状態、users.*.ucNameは、ALICE、BOBを示す
// ループ内では、以下の構造パスはループコンテキストにより解決できる
get "users.*.ucName"() {
return this["users.*.name"].toUpperCase();
}
// 派生状態、users.*.isInactiveは、true、falseを示す
// ループ内では、以下の構造パスはループコンテキストにより解決できる
get "users.*.isInactive"() {
return !this["users.*.active"];
}
// 状態更新、構造パスusers.*.activeを更新する
// ループ内では、以下の構造パスはループコンテキストにより解決できる
// ループコンテキストに一致するusers.*.activeのみ変更され、対応するUIが更新される
activate() {
this["users.*.active"] = true;
}
}
</script>
実装イメージ(親子)
親コンポーネント
<template>
<!-- UI -->
<ul>
{{ for:users }}
<user-comp data-bind="state.user: users.*"></user-comp>
{{ endfor: }}
</ul>
</template>
<script type="module">
export default class {
// 状態、users.*.nameは、Alice、Bobを示す
users = [ { name:"Alice", active:true }, { name:"Bob", active:false } ];
// 派生状態、users.*.ucNameは、ALICE、BOBを示す
// ループ内では、以下の構造パスはループコンテキストにより解決できる
get "users.*.ucName"() {
return this["users.*.name"].toUpperCase();
}
// 派生状態、users.*.isInactiveは、true、falseを示す
// ループ内では、以下の構造パスはループコンテキストにより解決できる
get "users.*.isInactive"() {
return !this["users.*.active"];
}
}
</script>
子コンポーネント
<template>
<li>
{{ user.name }}, {{ user.ucName }}
{{ if:user.isInactive }}
<button data-bind="onclick:activate">activate</button>
{{ endif: }}
</li>
</template>
<script type="module">
export default class {
user = {};
activate() {
this["user.active"] = true;
}
}
</script>
制約
- また構造パスが唯一の信頼できるソースであることから、オブジェクトを連想配列のような文字列をキーとするような配列として扱うことは不適です、連想配列はオブジェクトとして扱います。
- UIと状態で同じデータ構造を必要とします。しかしながら、配列のfilter関数やキーバリュー配列への変換やgetterによる派生状態を駆使すれば、おおよそのUI構造を表現することが可能になるはずです。
- 構造パスが唯一の信頼できるソースの原則により、多重ループは必ず元のループの要素である必要があります。無関係の構造パスによる多重ループは作成できません。
- 大規模データの構造パスは長大になりますが、自己記述性が増すことや、コンポーネント内の構造パスは有限であり、規則性もあるため解析しやすく、AIによるサジェストや、IDEのサポート(別途対応が必要)もでき、十分対応可能かと考えます。
- また、コンポーネント分割により、状態の部分移譲を行うことで、パスを局所化し短くすることができます。
- 構造パスが唯一の信頼できるソースの原則の堅持のため、エイリアスやスコープ変数や省略記法などの導入は不適と考えます。
最後に
概念実証レベルのプロダクト(Structive)
https://github.com/mogera551/Structive
ご意見をいただければと思います。