🌏

状態管理がめちゃくちゃ楽になるフレームワークStructiveの仕様

に公開

Structiveとは?

StructiveはシングルファイルベースのWebコンポーネントを採用したフレームワークです。より宣言的な記述と状態管理のためのボイラープレートや状態フックを極力なくした構造駆動型テンプレートが特徴になります。
以下に仕様を見ていきますが、覚えることが

  • 状態とUIテンプレートで同じ構造パスを使う
  • 状態の更新は構造パスで行う
  • getterで派生状態を作れる
  • ループ内では暗黙のインデックス($1、$2...)を使う

ぐらいしかなく、非常にシンプルであり学習しやすくなっています。しかし、モダンなフレームワークで求められる宣言的なUIとリアクティブ性をしっかりと備えています。

https://github.com/mogera551/Structive

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

エントリーポイント

エントリーポイントはhtmlで、importmapでエイリアスの指定が必要になります。

<script type="importmap">
{
  "imports": {
    "structive": "path/to/cdn/structive.js",
    "main": "./components/main.st.html"
  }
}
</script>

<app-main></app-main>

<script type="module">
import { config, defineComponents } from "structive";

config.enableMainWrapper = false;
config.enableShadowDom = false;
config.enableRouter = false;
defineComponents({ "app-main": "main"});
</script>

スクリプト

コンポーネントの登録と設定をスクリプトに書きます。<script/>タグにはtype="module"が必要です。

コンポーネントの登録と起点タグ

defineComponentsでコンポーネントのタグ名とコンポーネントファイルの関連付けを登録します。登録したタグ名で起点になるものを<body>の中に記述します。ここでは<app-main></app-main>になります。

各種設定

configでいくつかの項目で調整が必要になります。現在ルーティング未搭載のため場合、enableMainWrapperenableRouterfalseに指定します。コンポーネントでshadowDomを使うかどうかをenableShadowDomに指定します。

予定

ルーティングやオートローディングを近い将来に搭載する予定です。

コンポーネント

コンポーネントはシングルファイルで記述し、UIテンプレート(template)、CSS(style)、状態クラス(script[type=module])で構成されます。UIテンプレートと状態クラスを中心に説明を行っていきます。

<!-- UIテンプレート -->
<template>
</template>

<!-- CSS -->
<style>
</style>

<!-- 状態クラス -->
<script type="module">
</script>

構造パス

UIテンプレート・状態クラスにおいて、データにアクセスするためのパスで、ドット記法、ワイルドカード*を使ってフルパスuser.profile.nameproducts.*.nameのように書きます。UIテンプレートにおいてはワイルドカード*はforブロック内でリストのある要素という意味で使われます。状態クラスでは派生状態を作るためにも使われます。

UIテンプレート

UI部分を記述します。繰り返しのforブロック、条件分岐のifブロック、状態値の埋込、状態値とDOM要素の属性を結びつける属性バインドを行うことができます。それぞれに、構造パスを指定します。UIテンプレートにおいては構造パスはフィルター(|locale:数値にカンマ区切りなど)で加工することができます。

<template>
  <ul>
    {{ for:products }}
      <li>{{ products.*.name }} {{ products.*.price|locale }}</li>
    {{ endfor: }}
  </ul>

  {{ if:user.isLoggedIn }}
    {{ user.profile.nickName }}さんようこそ!
  {{ else: }}
    ログインしてください
  {{ endif: }}

  名前を入力してください:<input type="text" data-bind="value:user.profile.name">

  <button data-bind="onclick.popup">ボタンを押してください</button>
</template>

forブロック

{{ for:リスト }}で開始し、{{ endfor: }}で閉じます。リストの構造パスが返す値は配列型(Array)である必要があります。スコープ変数は使いません。ループ内は構造パスを記述します。インデックス変数は暗黙的に$1が提供されます。多重化ネストでは、$2$3と階層に応じてインデックス変数が提供されます。

{{ for:users }}
  {{ users.*.profile.name }}
{{ endfor: }}

{{ for:makers }}
  <div>No:{{ $1|inc,1 }} Maker:{{ makers.*.name }}</div>
  {{ for:makers.*.products }}
    <div>
      Product: No:{{ $2|inc,1 }}
      name:{{ makers.*.products.name }},
      price:{{ makers.*.products.*.price|locale }}
    </div>
  {{ endfor: }}
{{ endfor: }}

ifブロック

{{ if:条件 }}で開始し、{{ endif: }}で閉じます。{{ else: }}で偽条件ブロックを定義できます。条件には構造パスを指定します。構造パスが返す値は真偽値(boolean)である必要があります。フィルターで加工できますが、式による演算はできません。

{{ if:user.isLoggedIn }}
  {{ user.profile.nickName }}さんようこそ!
{{ else: }}
  ログインしてください
{{ endif: }}

{{ if:user.age > 18 }} <!-- NG --> 
{{ if:user.age|gt,18 }} <!-- OK --> 

埋込

状態の値をテキストとして{{ }}でテンプレート内に埋め込むことができます。状態の値は構造パスで指定します。構造パスにはフィルターが使えます。

{{ user.profile.nickName }}さんようこそ!
{{ user.profile.nickName|uc }}さんようこそ!<!-- 大文字で表示 -->

{{ for:states }}
  <div>{{ states.*.name }}, {{ states.*.population|locale }}</div>
{{ endfor: }}

属性バインド

状態の値をDOM要素の属性と連動させます。状態の値は構造パスで指定します。構造パスにはフィルターが使えます。一部のプロパティは自動的に双方向にバインドされます。DOM要素のプロパティへの直接バインド、クラス属性への条件付きバインド、属性へのバインド、イベントへのバインドがあります。ループ内でも双方向バインドできます。

  • プロパティへのバインド
    DOM要素のプロパティにバインドします。一部のプロパティ(input要素のvalue、input[type=checkbox]、input[type=radio]のchecked)は自動的に双方向にバインドされます。
<input type="text" data-bind="value:user.profile.name">

{{ for:products }}
  <div>
    name:{{ states.*.name }},
    <input type="text" data-bind="value:states.*.population">
  </div>
{{ endfor: }}
  • クラス属性への条件付きバインド
    バインドする状態値の真偽により、DOM要素のクラス属性に指定したクラスを追加・削除します。
    スタイルシートにクラスを指定しておけば特定条件の装飾が簡単に行えます。
.adult {
  color: red;
}
<div data-bind="class.adult:user.isAdult">
<!-- user.isAdultが真の場合、class属性にadultが追加されます。 -->
  • イベントのバインド
    状態クラスのメソッドとDOM要素のイベントを関連付けます。
    onで始まるイベント名とメソッドを指定します。メソッド名にonをつけておくと状態クラスの中でイベントハンドラが判りやすくなります。
<button data-bind="onclick:onAdd">add</button>
  • 属性のバインド
    DOM要素のsetAttributeを通して行うバインドで、属性値としてバインドしたい場合に使います。SVGタグに属性をセットする場合などに使います。attrと属性名と状態値の構造パス名を指定します。
<polygon data-bind="attr.points:points"></polygon>

状態クラス

状態管理はJavaScriptのクラスで定義し、デフォルトエクスポートします。クラスのプロパティに状態を保存します。クラス名は無名でも構いません。

<script type="module">
export default class {
  fruits = [ { name:"apple" }, { name:"banana" }, { name:"cherry" } ];
  count = 0;
  user = {
    profile: {
      name : "Alice",
      age  : 30,
    }
  };
}
</script>

イベント処理

イベント処理(イベントハンドラ)は、メソッドに書きます。メソッド名は自由ですが、接頭辞としてonを使えば、状態クラス内でイベントハンドラと普通のメソッドの区別が簡単になります。

{{ count }}
<button data-bind="onclick:onIncrement">increment</button>
export default class {
  count = 0;
  onIncrement() {
    this.count++;
  }
}

ループ中のイベントは、引数にインデックス引数が渡ってきます。

{{ for:users }}
  {{ users.*.name }}<button data-bind="onclick:onClick">click</button>
{{ endfor: }}
export default class {
  users = [{ name:"Alice" }, { name:"Bob" }, { name:"Charlie" }];
  onClick(e, $1) {
    alert('click index = ' + $1);
  }
}

ループ中のイベントは、ループコンテキストを引き継ぐため、構造パスを指定できます。

{{ for:users }}
  <div>
    {{ users.*.name }}
    <button data-bind="onclick:onToggle">select</button>
  </div>
{{ endfor: }}
export default class {
  users = [
    { name:"Alice", selected:false },
    { name:"Bob", selected: false },
    { name:"Charlie", selected: false }
  ];
  onToggle(e, $1) {
    this["users.*.selected"] = !this["users.*.selected"];
  }
}

更新トリガー

プロパティの更新がトリガーとなり対応するDOM要素が更新されます。プロパティへのアクセスは構造パスで行います。配列などもミュータブルなpushpopなどを使わず、concattoSplicedを使って新しい配列を作り代入します。双方向バインドされた属性は自動的に値が更新されます。

{{ count }}
<button data-bind="onclick:onIncrement">increment</button>
{{ for:users }}
  <div>
    {{ users.*.name }}
    <button data-bind="onclick:onDelete">delete</button>
  </div>
{{ endfor: }}
export default class {
  count = 0;
  onIncrement() {
    this.count = this.count + 1;
  }

  users = [
    { name:"Alice", selected:false },
    { name:"Bob", selected: false },
    { name:"Charlie", selected: false }
  ];
  onDelete(e, $1) {
    this.users = this.users.toSpliced($1, 1);
  }
}

派生状態の作成

getterを使って、簡単に派生状態が作れます。getter名には構造パスを使います。プロパティと同じようにUIテンプレート内で参照できます。getter内部でも構造パスを参照することができます。
user.profile.nameが変更されると自動的にuser.profile.ucNameも変更されます。内部では自動依存トラッキングが行われています。下記例では、名前のテキストを変更すると、プロパティ、getterが自動で変更がされます。

{{ user.profile.name }} <!-- Alice -->
{{ user.profile.ucName }} <!-- ALICE -->
<input type="text" data-bind="value:user.profile.name">
export default class {
  user = {
    profile: {
      name : "Alice",
      age  : 30,
    }
  };
  get "user.profile.ucName"() {
    return this["user.profile.name"].toUpperCase();
  }
}

ワイルドカードを使った構造パスの派生状態

ワイルドカード*を使った構造パスの派生状態も作ることができます。プロパティと同じようにUIテンプレート内で参照できます。getter内部でも構造パスを参照することができます。リストの要素(users.*)にあたかも仮想のプロパティ(ucName)が作られたのかのように見えます。
users.*.nameが変更されると自動的にusers.*.ucNameも変更されます。内部では自動依存トラッキングが行われています。下記例では、名前のテキストを変更すると、プロパティ、getterが自動で変更がされます。

{{ for:users }}
  {{ users.*.ucName }}, <input type="text" data-bind="value:users.*.name">
{{ endfor: }}
export default class {
  users = [
    { name:"Alice", selected:false },
    { name:"Bob", selected: false },
    { name:"Charlie", selected: false }
  ];
  get "users.*.ucName"() {
    return this["users.*.name"].toUpperCase();
  }
}

開発のまとめ

  • 状態とUIテンプレートで同じ構造パスを使う
  • 状態の更新は構造パスで行う
  • getterで派生状態を作れる
  • ループ内では暗黙のインデックス($1、$2...)を使う

最後に

感想、メッセージをいただけるとありがたいです。

Discussion