フレームワーク対決!レーダーチャートでVueとStructiveを比較しよう
Structiveとは?
StructiveはシングルファイルベースのWebコンポーネントを採用したビルドレスなフレームワークです。一見シンプルですが、構造駆動型のテンプレートや状態管理の簡素化により、宣言的かつリアクティブなUIを手軽に構築できるという特徴があります。
構造駆動型とは
UI・状態・状態派生・状態更新・親子連携で、構造パスを使ってアプリを構築していくスタイルです。構造パスはデータの階層構造上の位置を示すパスを示し、リスト要素についてはワイルドカード*
を使って表現します。
構造パスの例、
users = [ { name:"Alice" }, { name:"Bob" } ]
のようなデータの場合、
users
リストの要素のnameプロパティの構造パスはusers.*.name
となります。
Vueのレーダーチャートのサンプル
Vueの有名なサンプルにレーダーチャートがあります。
レーダーチャートの各軸の値をスライダーで調整でき、軸を追加・削除できます。変化に応じてチャートがリアルタイムに変わるため、インパクトがあります。このサンプルと同じ機能をStructiveで実装し、コードを比較することで、Structiveの特徴を抽出していきたいと思います。これから解説していくStructiveのコードはここにあるものと同じです。
比較してみよう
ファイル構成
両者ともシングルファイルコンポーネントを採用しています。Vueのほうは、3コンポーネント(メイン部分、レーダーチャート部分、軸タイトル部品)に分割されています。Structiveのほうは、リスト要素の状態派生を簡単に扱えるため、まとめて1ファイルで構成しています。
テンプレート概観
どちらもHTMLベースで宣言的な記述になっています。
Vueの場合
チャートとラベル部分をコンポーネント化しています。
Structiveの場合
コンポーネント化せず、単一コンポーネントで記述しています。またfor
ブロック・埋込み・属性バインドに状態と同じ構造パスを使っています。ロジックを極力持ち込ませない構造になっています。
UIに記述するデータパスの違い
<label>{{stat.label}}</label>
<span>{{stat.value}}</span>
<label>{{ stats.*.label }}</label>
<span>{{ stats.*.value }}</span>
for
ループ
Vueの場合
v-for
ディレクティブで記述、ループ内はスコープ変数を使って、データにアクセスしています。軸のタイトル記述をサブコンポーネント化しループさせています。また、ループ中のイベントハンドラの引数にはリスト要素を指定しています。
Structiveの場合
for
ブロックを使用し、ループ中のリスト要素にはスコープ変数を使わずに、ワイルドカード(*
)を使った構造パスを指定しています。また、ループ中のイベントハンドラには状態更新を行う状態クラスのメソッド名のみ指定しています。
<axis-label
v-for="(stat, index) in stats"
:stat="stat"
:index="index"
:total="stats.length"
>
</axis-label>
<div v-for="stat in stats">
<label>{{stat.label}}</label>
<input type="range" v-model="stat.value" min="0" max="100">
<span>{{stat.value}}</span>
<button @click="remove(stat)" class="remove">X</button>
</div>
{{ for:stats }}
<text data-bind="
attr.x:stats.*.labelPoint.x;
attr.y:stats.*.labelPoint.y;
">{{ stats.*.label }}</text>
{{ endfor: }}
{{ for:stats }}
<div>
<label>{{ stats.*.label }}</label>
<input type="range" data-bind="value|number:stats.*.value" min="0" max="100">
<span>{{ stats.*.value }}</span>
<button data-bind="onclick:onRemove" class="remove">X</button>
</div>
{{ endfor: }}
属性バインド・イベントハンドラ
DOMの属性のバインドの仕方を見てます。
Vueの場合
:プロパティ名
でバインドします。双方向の場合、v-modelを使います。イベントハンドラには@
をつけ、引数を指定することができます。SVGタグにも同様のバインドが可能です。
Structiveの場合
属性バインド全てdata-bind
属性に記述します。プロパティ名:状態名
の書式で記述し、inputタグの場合自動で、双方向になります。入力方向に型変換フィルターを指定できます(data-bind="value|number:stats.*.value"
)。イベントハンドラもdata-bind
属性に記述します。修飾子に@preventDefault
を指定してイベントハンドラでのe.preventDefault()
の実行を省略します。タグ内の属性にバインドする場合はattr.属性名:状態名
と指定します。SVGタグにもhtmlタグと同様のバインドが可能です。状態名には構造パスを指定します。
<polygon :points="points"></polygon>
<text :x="point.x" :y="point.y">{{stat.label}}</text>
<input type="range" v-model="stat.value" min="0" max="100">
<button @click="remove(stat)" class="remove">X</button>
<input name="newlabel" v-model="newLabel">
<button @click="add">Add a Stat</button>
<polygon data-bind="attr.points:points"></polygon>
<text data-bind="
attr.x:stats.*.labelPoint.x;
attr.y:stats.*.labelPoint.y;
">{{ stats.*.label }}</text>
<input type="range" data-bind="value|number:stats.*.value" min="0" max="100">
<button data-bind="onclick:onRemove" class="remove">X</button>
<input name="newlabel" data-bind="value:newLabel">
<button data-bind="onclick:onAdd@preventDefault">Add a Stat</button>
状態管理外観
Vueの場合
3つに分かれたSFCファイル内の<script setup>
に変数を使って状態を定義しています。親コンポーネントでref
やreactive
を使ってリアクティブな状態を宣言しています。子コンポーネントではdefineProps
で、使用する状態を定義しています。また、computed
を用いて要素数の変化に応じて座標を自動的に計算しています。イベントハンドラはUIで指定された関数が呼ばれます。関数の引数はUIにて指定された変数が渡されます。
Structiveの場合
1つのSFCファイル内の<script type='module'>
にクラスを使って状態を定義しています。プロパティに状態を宣言し、getterの派生状態を使ってVueと同様なcomputed
を実現していますが、構造パス(stats.*.point
など)を使って定義するのが特徴です。状態更新を行うイベントハンドラはメソッドで定義します。メソッドの引数にはEventオブジェクトと、ループ内からの呼び出しであればループインデックスが引数として渡されます。preventDefault()
はUI側でデータ属性修飾子@preventDefault
を指定することで省略できます。$getAll
などのフレームワークが提供するAPIは、this経由で呼び出すことができます。
状態の宣言
Vueの場合
<script setup>
タグ内にref
・reactive
を用いて変数で宣言します。子コンポーネントではdefineProps
を使って状態を定義しています。
Structiveの場合
<script type="module">
タグ内に状態クラスを定義しexport
します。状態はクラスのプロパティとして宣言します。特にref
・reactive
は必要としません。
<script setup>
const newLabel = ref('')
const stats = reactive([...]);
</script>
<script setup>
const props = defineProps({
stat: Object,
index: Number,
total: Number
})
</script>
<script setup>
const props = defineProps({
stats: Array
})
</script>
<script type="module">
export default class {
newLabel = '';
stats = [...];
}
</script>
状態派生
Vueの場合
computed
を使って自動計算プロパティを定義しています。stats
の変化に応じて多角形の各頂点座標計算points
、各ラベルの表示座標計算point
の再計算を行います。
Structiveの場合
JavaScriptのgetterを使って状態派生となる仮想プロパティを定義します。多角形の各頂点座標プロパティstats.*.point
、各ラベルの表示座標プロパティstats.*.labelPoint
、<polygon/>
タグのpoints
属性設定用のプロパティpoints
、statsのJSON表示プロパティstats.json
。
仮想プロパティで参照している状態を自動で依存追跡し、参照している状態に更新があった場合、自動的に再計算されます。points
ではAPIの$getAll
を呼び出してstats.*.point
の全要素を配列として簡単に取得することができます。stats.json
ではstats.*.value
の更新を自動で追跡できないので、APIの$trackDependency
を使って依存関係を登録しています。ループコンテキスト内(ワイルドカードを使った構造パス)の仮想プロパティの定義では暗黙のインデックス変数($1
)を参照することができます。
Structiveの仮想プロパティ
仮想プロパティの大きな特徴は、任意の階層の構造パスに対して仮想的にプロパティを定義することが可能で、定義の中でも構造パスを参照し計算対象とすることができ、参照している構造パスを依存追跡することで自動計算が実行され、UIで通常の構造パスと同様に使用することができます。ワイルドカード*
を使った抽象的な構造パスも扱えるため、コードの宣言性が飛躍的に高まります。
// 多角形の座標配列を計算し、polygonタグのpoints属性にあうように加工
const points = computed(() => {
const total = props.stats.length
return props.stats
.map((stat, i) => {
const { x, y } = valueToPoint(stat.value, i, total)
return `${x},${y}`
})
.join(' ')
})
// ラベルの座標を計算
const point = computed(() =>
valueToPoint(+props.stat.value + 10, props.index, props.total)
)
export default class {
// JSON表示
get "stats.json"() {
this.$trackDependency("stats.*.value"); // stats.*.valueを追跡対象とする
return JSON.stringify(this.stats);
}
// 各ラベルの座標を定義
get "stats.*.labelPoint"() {
// $1はインデックス変数
return valueToPoint(100 + 10, this.$1, this.stats.length);
}
// 多角形の各点の座標を定義
get "stats.*.point"() {
// $1はインデックス変数
// パス"stats.*.value"を参照できる、参照すると依存追跡対象
return valueToPoint(this["stats.*.value"], this.$1, this.stats.length);
}
// polygonタグのpoints属性に設定できるよう各座標の配列をjoinし書式化
get points() {
// パス"stats.*.point"のすべての要素を$getAll使って取得
// パス"stats.*.point"は依存追跡対象
const points = this.$getAll("stats.*.point", []);
// points属性にあうように書式化
return points.map(p => `${p.x},${p.y}`).join(" ")
}
}
状態更新
要素の追加・削除を行う状態更新処理を見ていきます。
Vueの場合
状態更新のためのイベントハンドラを関数で定義します。UIで指定した変数を引数として使用できます。push
やsplice
を使ったミュータブルな配列操作を行っています。
Structiveの場合
状態更新のためのイベントハンドラを状態クラスのメソッドとして定義します。ループ内にあるイベントハンドラの引数には、ループのインデックス($1
)が渡されますのでインデックス変数をUIから渡さなくてもよいです。処理内ではconcat
やtoSpliced
を使って新しい配列を作成し、状態プロパティへ代入しています。状態プロパティへの代入が更新トリガーとなっています。preventDefault
はUIの属性バインドに修飾子(@preventDefault
)をつけているので不要です。
function add(e) {
e.preventDefault()
if (!newLabel.value) return
stats.push({
label: newLabel.value,
value: 100
})
newLabel.value = ''
}
function remove(stat) {
if (stats.length > 3) {
stats.splice(stats.indexOf(stat), 1)
} else {
alert("Can't delete more!")
}
}
export default class {
onAdd(e) {
if (!this.newLabel) return;
this.stats = this.stats.concat({ label: this.newLabel, value: 100});
this.newLabel = '';
}
onRemove(e, $1) {
if (this.stats.length > 3) {
this.stats = this.stats.toSpliced($1, 1);
} else {
alert("Can't delete more!");
}
}
}
まとめ
ここまで比較してきて、Structiveの特徴が浮かび上がってきたかと思います。
Structiveの特徴を挙げておきます。
- UIではあらゆる場面で構造パスを指定する
-
for
ブロックを使う - 属性バインドは
data-bind
属性に定義する -
for
ブロック内では構造パスにアスタリスク*
を使う - イベントはハンドラのメソッド名だけ指定
- 状態管理はクラスを使って行う
- 状態はプロパティ、状態派生はgetter、状態更新はメソッドで行う
- getterやメソッド内でも、構造パスが使える
- ループ内のメソッドの引数に自動でインデックスが渡ってくるので便利
- 状態派生のgetterは仮想プロパティとして使え、どの階層の構造パスでも指定できる
- ボイラープレートは少なく、状態フックは存在しない
最後に
ご意見、メッセージをいただければありがたいです。
Discussion