📘

フレームワーク対決!レーダーチャートでVueとStructiveを比較しよう

に公開

Structiveとは?

StructiveはシングルファイルベースのWebコンポーネントを採用したビルドレスなフレームワークです。一見シンプルですが、構造駆動型のテンプレートや状態管理の簡素化により、宣言的かつリアクティブなUIを手軽に構築できるという特徴があります。

構造駆動型とは

UI・状態・状態派生・状態更新・親子連携で、構造パスを使ってアプリを構築していくスタイルです。構造パスはデータの階層構造上の位置を示すパスを示し、リスト要素についてはワイルドカード*を使って表現します。
構造パスの例、
users = [ { name:"Alice" }, { name:"Bob" } ]のようなデータの場合、
usersリストの要素のnameプロパティの構造パスはusers.*.nameとなります。

https://github.com/mogera551/Structive

https://github.com/mogera551/Structive-Example

Vueのレーダーチャートのサンプル

Vueの有名なサンプルにレーダーチャートがあります。
レーダーチャートの各軸の値をスライダーで調整でき、軸を追加・削除できます。変化に応じてチャートがリアルタイムに変わるため、インパクトがあります。このサンプルと同じ機能をStructiveで実装し、コードを比較することで、Structiveの特徴を抽出していきたいと思います。これから解説していくStructiveのコードはここにあるものと同じです。

比較してみよう

ファイル構成

両者ともシングルファイルコンポーネントを採用しています。Vueのほうは、3コンポーネント(メイン部分、レーダーチャート部分、軸タイトル部品)に分割されています。Structiveのほうは、リスト要素の状態派生を簡単に扱えるため、まとめて1ファイルで構成しています。

テンプレート概観

どちらもHTMLベースで宣言的な記述になっています。

Vueの場合

チャートとラベル部分をコンポーネント化しています。

Structiveの場合

コンポーネント化せず、単一コンポーネントで記述しています。またforブロック・埋込み・属性バインドに状態と同じ構造パスを使っています。ロジックを極力持ち込ませない構造になっています。

UIに記述するデータパスの違い

Vue
<label>{{stat.label}}</label>
<span>{{stat.value}}</span>
Structive
<label>{{ stats.*.label }}</label>
<span>{{ stats.*.value }}</span>

forループ

Vueの場合

v-forディレクティブで記述、ループ内はスコープ変数を使って、データにアクセスしています。軸のタイトル記述をサブコンポーネント化しループさせています。また、ループ中のイベントハンドラの引数にはリスト要素を指定しています。

Structiveの場合

forブロックを使用し、ループ中のリスト要素にはスコープ変数を使わずに、ワイルドカード(*)を使った構造パスを指定しています。また、ループ中のイベントハンドラには状態更新を行う状態クラスのメソッド名のみ指定しています。

Vue
<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>
Structive
{{ 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タグと同様のバインドが可能です。状態名には構造パスを指定します。

Vue
<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>
Structive
<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>に変数を使って状態を定義しています。親コンポーネントでrefreactiveを使ってリアクティブな状態を宣言しています。子コンポーネントでは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>タグ内にrefreactiveを用いて変数で宣言します。子コンポーネントではdefinePropsを使って状態を定義しています。

Structiveの場合

<script type="module">タグ内に状態クラスを定義しexportします。状態はクラスのプロパティとして宣言します。特にrefreactiveは必要としません。

Vue
<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>
Structive
<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で通常の構造パスと同様に使用することができます。ワイルドカード*を使った抽象的な構造パスも扱えるため、コードの宣言性が飛躍的に高まります。

Vue
// 多角形の座標配列を計算し、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)
)
Structive
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で指定した変数を引数として使用できます。pushspliceを使ったミュータブルな配列操作を行っています。

Structiveの場合

状態更新のためのイベントハンドラを状態クラスのメソッドとして定義します。ループ内にあるイベントハンドラの引数には、ループのインデックス($1)が渡されますのでインデックス変数をUIから渡さなくてもよいです。処理内ではconcattoSplicedを使って新しい配列を作成し、状態プロパティへ代入しています。状態プロパティへの代入が更新トリガーとなっています。preventDefaultはUIの属性バインドに修飾子(@preventDefault)をつけているので不要です。

Vue
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!")
  }
}
Structive
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