Alpine.jsライクな軽量フレームワーク「sprae」

に公開

Alpine.jspetite-vue によく似たプログレッシブ・エンハンスメントなフロンドエンドフレームワーク(≒ ポスト jQuery)の「sprae」をざっくり紹介します。「: しか使わない!」という潔さが好きです。超マイナーで、まだバグが多そう。

https://github.com/dy/sprae

以下、Alpine.js との比較で書いています。

使う準備

head 要素から CDN(UMD)でライブラリを読み込み、sprae 関数を呼び出します。sprae 関数の第 1 引数は sprae を使いたい HTML 要素のエレメントです。第 2 引数には、sprae で使う(つまり HTML 側で使いたい)オブジェクトで変数(プロパティ)や関数(メソッド)などを宣言します。

公式(のみ)
<head>
  ...
  <script src="https://unpkg.com/sprae/dist/sprae.umd"></script>
</head>

<body>
  ...
</body>

<script type="module">
  sprae(エレメント, {
    ...
  });
</script>

以下、本記事では body 要素全体で sprae を有効にしたいので、次のように書いているものとします。

sprae(document.body, {
  ...
});

なお、(後述する :with を使うなどして)sprae の変数もメソッドも sprae 関数の第 2 引数で宣言する必要がないときは、第 2 引数を省略できます。ただし、sprae 関数の呼び出し自体を省略することはできません。

sprae(document.body);

コンテントバインディング

テキストコンテントのバインディングは :text ディレクティブです。
Alpine.js と使い方は同じです。petite-vue のような {{ }} は使えません。

sprae
<body>
  <p><span :text="message"></span> Sprae!!</p>
  <p :text="greeting">
</body>

<script type="module">
  sprae(document.body, {
    message: 'Hello',
    get greeting() {
      return this.message + ' World!!'
    }
  });
</script>

特定の HTML 要素配下だけでローカル変数を使いたいときは、x-data と同じ感覚で :with ディレクティブが使えます。同名の変数が存在するときはブロックスコープのルールに従います。

sprae
<body>
  <p :with="{ message: 'Zenn!!' }">
    Hello <span :text="message"></span>
  </p>
</body>

<script type="module">
  sprae(document.body); // これは必要
</script>

以下、サンプルコードの一覧性をよくするために、短いコードでは :with ディレクティブを使っていきます。:with を使う場合も、script[type="module"] 要素に sprae(document.body); は必要なので気を付けてください。

※ただし、:with で宣言した変数はフォーム入力バインディング(:value)でバグがあるようで時々反映されません。

イベントハンドリング

sprae のイベントバインディングは :on<event> ディレクティブです(<event>の部分にはイベント名が入る)。イベントハンドリングは Alpine.js と sprae で一番異なるところですが、sprae のほうが記述に一貫性があります。

イベントハンドラーを別定義する場合

まず、イベントハンドラー(関数)を別の場所で定義してからイベントと紐づけるときは、sprae と Alpine.js はほぼ同じです。:on<event> ディレクティブの値に「関数名」を指定します。関数呼び出しではないので ( ) は付けません。

sprae
<body :with="{
  message: 'Hello',
  greeting() { this.message += ' World!!'; }
}">

  <p :text="message"></p>
  <button :onclick="greeting">押す</button>
  <!-- ↑ :onclick="greeting()" としたらダメ! -->
</body>

一方、Alpine.js は x-on ディレクティブの値に、「イベントと関数の紐づけ」と「イベント発火時に実行する処理を直接書ける」という 2 つの機能があるため、後者の機能として関数呼び出し(つまり ( ) 付き)も受け付けます。

Alpine.js
<body x-data="{
  message: 'Hello',
  greeting() { this.message += ' World!!'; }
}">

  <p x-text="message"></p>
  <button @click="greeting">押す</button>
  <button @click="greeting()">押す</button>
  <!-- ↑ どちらもOK -->
</body>

イベントハンドラーを直接記述する場合

sprae では、:on<event> ディレクティブに処理を直接記述するときは「無名関数(アロー関数式)」で指定します。これは前述の「定義済みの関数をイベントに紐づける」ことと全く同じ意味で、JavaScript 的には sprae のほうが一貫しています。

sprae
<body :with="{ message: 'Hello' }">
  <p :text="message"></p>
  <button :onclick="() => message += ' World!'">押す</button>
</body>

Alpine.js では無名関数の処理の中身だけを記述することができますが、これがちょっと混乱の元になります。

Alpine.js
<body x-data="{ message: 'Hello' }">
  <p x-text="message"></p>
  <button @click="message += ' World!'">押す</button>
</body>

sprae でも、通常のアロー関数式と同様、{ } を付ければ複数行(式だけでなく文)でも書けます。

sprae
<body :with="{ message: 'Hello' }">
  <p :text="message"></p>
  <button :onclick="() => { message += ' World!'; console.log('世界!'); }">
    押す
  </button>
</body>

Alpine.js のほうは一見「式」しか書けないように見えるので(x-text は実際そう)、if 文とかあると「あ、書けるんだっけ?」となります。

Alpine.js
<body x-data="{ message: 'Hello' }">
  <p x-text="message"></p>
  <button @click="if (message) message += ' World!'">押す</button>
</body>

sprae のほうはアロー関数式のルールに従うので、「文」を書くときは { } で囲う、{ } がなければ「式」で書く…です。

sprae
<body :with="{ message: 'Hello' }">
  <p :text="message"></p>
  <button :onclick="() => { if (message) message += ' World!'; }">
    押す
  </button>
</body>

Event オブジェクト

Alpine.js は x-on ディレクティブの中で、$event と $el という特別な変数(マジック変数)が使えます。$event は addEventListener でも使える Event オブジェクト、$el はイベントを受け取った要素の Element オブジェクトです。

Alpine.js
<body x-data>
  <div @click="console.log($event.offsetX)">0123456789</div>
  <button @click="$el.style.color = 'red'">文字色変化</button>
</body>

sprae の :on<event> ディレクティブにはこうしたマジック変数はなく、addEventListener と同じく、素直に「無名関数の引数が Event オブジェクト」です。イベントを受け取った Element オブジェクトには、Event オブジェクトの target プロパティからアクセスします。記述は長くなりますが、標準の JavaScript と同じ使い方なので覚えることは少なくて済みます。

sprae
<body>
  <div :onclick="e => console.log(e.offsetX)">0123456789</div>
  <button :onclick="e => e.target.style.color = 'red'">文字色変化</button>
  <!-- この例では「変数 e」がEventオブジェクトになる -->
</body>

イベントのオプション

.prevent や.once などのイベントのオプションも基本的なものは用意されています。キーもあります。

sprae
<body>
  <button :onclick.once="() => console.log('やあ')">やあ</button>
</body>

https://github.com/dy/sprae?tab=readme-ov-file#modifiers

属性バインディング

sprae の変数の HTML 属性へのバインディングは、Alpine.js「省略版の属性バインディング」と全く同じです。属性名の頭に : を付けます。

sprae
<body :with="{ url: 'https://zenn.dev/ojk', target: '_brank' }">
  <p><a :href="url" :target="target">記事一覧</a></p>
</body>

: ディレクティブにオブジェクトを渡すことによって、属性の一括バインドもできます(Alpine.js でもできます)。sprae の変数名と属性名(プロパティ名)が同じ場合には省略形で書けます(これは JavaScript の標準機能です)。

sprae
<body :with="{ url: 'https://zenn.dev/ojk', target: '_brank' }">
  <p><a :="{ href: url, target }">記事一覧</a></p>
  <!-- 「target: target」は target とのみ書ける -->
</body>

ただし、一括バインドの場合のみ、sprae の変数をオブジェクトにすると意図どおりに動きません(これも Alpine.js と同じです)。一括バインドのオブジェクトの中で、さらにオブジェクトの入れ子が使えないためです(たぶん)。なお、petite-vue はこれができます。

sprae
<body :with="{ myStyle: { color: 'red' } }">
  <!-- NG(一括バインドではオブジェクトの入れ子が不可) -->
  <p :="{ style: myStyle }">Hello sprae!!</p>
  <p :="{ style: { color: 'red' } }">Hello sprae!!</p>

  <!-- OK(個別の属性バインドならオブジェクト指定も大丈夫) -->
  <p :style="myStyle">Hello sprae!!</p>
  <p :style="{ color: 'red' }">Hello sprae!!</p>
</body>

style 属性と class 属性のバインディング

style 属性と class 属性には、値をオブジェクトで指定できるところも Alpine.js と同じです。

CSS
<style>
  .A { border-bottom: solid 2px navy; }
  .B { border: solid 1px navy; }
</style>
sprae
<body :with="{
  myStyle: { color: 'firebrick', backgroundColor: 'lavender' },
  myClass: { A: true, B: false }
}">

  <p :style="myStyle" :class="myClass">Hello sprae!!</p>

</body>

通常の style 属性や class 属性が指定されている場合は、その設定に追加されます。

sprea
<body :with="{ myStyle: { backgroundColor: 'lavender' } }">
  <p style="color: firebrick" :style="myStyle">Hello sprae!!</p>
</body>

class 属性に関しては、配列で class 名を与えることでも指定できます。与えた class 名だけが有効になります。

sprae
<body :with="{ myClass: ['B'] }">

style 属性に関しては CSS のカスタムプロパティも変更できます。

CSS
<style>
  :root { --bg-color: firebrick; }
  .C { color: white; background-color: var(--bg-color); }
</style>
sprae
<body>
  <p class="C" :style="{ '--bg-color': 'navy' }">Hello sprae!!</p>
  <!-- ↑ firebrick ではなく navy で塗られる -->
</body>

フォーム入力バインディング

sprae のフォーム入力バインディングは :value ディレクティブです。value 属性への単なる属性バインディングに見えますが、双方向バインディングになっています。

なお、:value ディレクティブに関しては :with ディレクティブで定義した変数がリアクティブになりません。script 要素(sprae 関数)のほうで定義した変数を使ってください。

sprae
<body>
  <input :value="message" />
  <p :text="message"></p>
</body>

<script type="module">
  sprae(document.body, {
    message: '何か書いてね'
  });
</script>

ちなみに :value は sprae の変数定義も兼ねているので、script 側で変数を定義しなくても使えます[1]

sprae
<body>
  <input :value="message" value="何か書いてね" />
  <p :text="message"></p>
</body>

<script type="module">
  sprae(document.body); // ここでmessageを定義しなくてもよい
</script>

ラジオボタンやドロップダウンメニューは Alpine.js と同じです。ラジオボタンは個々の input 要素に、ドロップダウンメニューは(個々の option 要素ではなく)select 要素のみに :value ディレクティブを指定します。

sprae
<body>
  <p>
    <input type="radio" name="r" value="dog" :value="animal" /><input type="radio" name="r" value="cat" :value="animal" /><input type="radio" name="r" value="pig" :value="animal" /></p>

  <select :value="animal">
    <option selected disabled value="">選んでね</option>
    <option value="dog"></option>
    <option value="cat"></option>
    <option value="pig"></option>
  </select>

  <p :text="animal"></p>
</body>

<script type="module">
  sprae(document.body, {
    animal: ''
  });
</script>

チェックボックスの場合

チェックボックスのみ、sprae は Alpine.js と振る舞いが異なります。

Alpine.js の場合、変数の初期値を空配列にしておけば、チェックされた input 要素の value 属性の値が「配列の要素」として個別にリストされます。

Alpine.js
<body x-data="{ animal: [] }">
  <p>
    <input type="checkbox" value="dog" x-model="animal" /><input type="checkbox" value="cat" x-model="animal" /><input type="checkbox" value="pig" x-model="animal" /></p>
  <p x-text="animal"></p> <!-- 配列で表示される -->
</body>

sprae の場合、これと同じコードを書いても、:value ディレクティブに指定された変数(ここでは animal)は true か false の値しか取りません。すべてのチェックボックスが同じ変数とバインドされているので、いずれか 1 つをチェックするとすべてにチェックが入ります。

sprae
<body>
  <p>
    <input type="checkbox" value="dog" :value="animal" /><input type="checkbox" value="cat" :value="animal" /><input type="checkbox" value="pig" :value="animal" /></p>
  <!-- ↑犬・猫・豚のどれを選んでも全input要素にチェックが入る -->

  <p :text="animal"></p>
  <!-- ↑trueかfalseとなる -->
</body>

<script type="module">
  sprae(document.body, {
    animal: []
  });
</script>

チェックボックスで sprae の :value 値にバインドされるのは「checked 属性」の値(真偽値)です。そのため、以下のようにして個別に受け取り用の変数を用意します。通常の value 属性は設定しても(現状のところ)無視されます。

sprae
<body>
  <p>
    <input type="checkbox" :value="animal.dog" /><input type="checkbox" :value="animal.cat" /><input type="checkbox" :value="animal.pig" /></p>
  <p :text="[animal.dog, animal.cat, animal.pig]"></p>
</body>

<script type="module">
  sprae(document.body, {
    animal: { dog: false, cat: false, pig: false }
  });
</script>

実際のところ、チェックボックスの値を「配列」として取得したいということはあまりないかと思います。必要ならば、次のようなゲッターを書けばよいでしょう。

sprae
get animalList() {
  let list = [];
  for (const [key, value] of Object.entries(this.animal)) {
    if (value) list.push(key);
  }
  return list;
}

条件付きレンダリング

sprae の条件付きレンダリングには :if ディレクティブと :else ディレクティブが用意されています。Alpine.js のように x-show と x-if の 2 種類はなく、sprae の :if の内部処理は x-if 相当(DOM ツリーの操作)です。

Alpine.js は template 要素にしか x-if を付けられません。

Alpine.js
<body x-data="{ hima: false }">
  <p><input type="checkbox" x-model="hima" /></p>
  <template x-if="hima">
    <p>働け</p>
  </template>
  <template x-if="!hima">
    <p>休め</p>
  </template>
</body>

sprae は template 要素以外にも :if が書けます。また、:else もあるので、上記の Alpine.js のサンプルコードと同じものを短く書けます。

sprae
<body>
  <p><input type="checkbox" :value="hima" /></p>
  <p :if="hima">働け</p>
  <p :else>休め</p>
</body>

<script type="module">
  sprae(document.body);
</script>

petite-vue の v-else-if に当たるディレクティブはありませんが、1 つの HTML 要素内に<tag :else :if="条件式"> と書けば同様のことができます。ただ、現状(バージョン 11)にはバグがあるようで、sprae の変数経由で条件式の値を変えると正しく動きません。

リストレンダリング

sprae のリストレンダリングは :each ディレクティブです。:if と同様、Alpine.js は template 要素にしか x-for を書けませんが、sprae の:each は template 要素以外にも書けます。

以下、それぞれの例を示します。

配列

配列の場合は for...of 文と同じ形で書きます。

Alpine.js
<body x-data="{ list: [1, 2, 3, 'dar!'] }">
  <ul>
    <template x-for="item in list">
      <li x-text="item"></li>
    </template>
  </ul>
</body>

sprae の :each は template 要素以外にも使えるので、Alpine.js と同じことが短く書けます。

sprae
<body :with="{ list: [1, 2, 3, 'dar!'] }">
  <ul>
    <li :each="item in list" :text="item" />
  </ul>
</body>

変数を 2 つ指定するとインデックスが取れます。このとき、(item, idx) in list などと ( ) で囲ってはいけません。

sprae
<body :with="{ list: ['犬', '猫', '豚'] }">
  <ul>
    <li :each="item, idx in list" :text="`${idx + 1} - ${item}`" />
  </ul>
</body>

数字もいけます。

sprae
<body>
  <ul>
    <li :each="idx in 10" :text="idx" />
  </ul>
</body>

オブジェクト

Alpine.js と同じく、オブジェクトに対しては変数を 2 つ指定することで「値」と「プロパティ名(キー名)」の順番で取れます。

Alpine.js の場合は template 要素の配下に「ルート要素」と呼ばれる HTML 要素を 1 つしか取れないので、dl リストの場合などは無用な div 要素が入ってしまいます。

Alpine.js(オブジェクト)
<body x-data="{ obj: { dog: '犬', cat: '猫', pig: '豚' } }">
  <dl>
    <template x-for="(value, key) in obj">
      <div>
        <dt x-text="key"></dt>
        <dd x-text="value"></dd>
      </div>
    </template>
  </dl>
</body>

sprae は同じことをシンプルに書けます。in の前の変数を ( ) で囲ってはいけないことに注意してください。

sprae
<body :with="{ obj: { dog: '犬', cat: '猫', pig: '豚' } }">
  <dl>
    <template :each="value, key in obj">
      <dt :text="key"></dt>
      <dd :text="value"></dd>
    </template>
  </dl>
</body>

その他

エレメントの参照

Alpine.js では、x-ref ディレクティブと $refs 変数を使って、(フレームワークのスコープ内にある)任意の HTML 要素のエレメントを参照することができます。

Alpine.js
<body x-data>
  <p x-ref="psan">こんにちは、世界</p>
  <ul>
    <li x-text="$refs.psan.textContent"></li>
  </ul>
  <button @click="$refs.psan.style.color = 'red' ">赤化</button>
</body>

sprae では、:ref ディレクティブに指定した文字列がそのままその HTML 要素のエレメントを指す「変数」となります。

sprae
<body>
  <p :ref="psan">こんにちは、世界</p>
  <ul>
    <li :text="psan.textContent"></li>
  </ul>
  <button :onclick="() => psan.style.color = 'red'">赤化</button>
</body>

また、その HTML 要素自体を参照したいときは、:ref ディレクティブに無名関数を渡したときの引数として自身のエレメントが受け取れます。

sprae
<body>
  <p :ref="el => el.style.color = 'red'">こんにちは、世界</p>
</body>

マウント時にさせたい処理

Alpine.js には、x-if ディレクティブで HTML 要素が表示(DOM ツリーにマウント)されたときに実行したい処理を、x-init ディレクティブに記述することができます。

Alpine.js
<body x-data="{ show: false }">
  <button
    @click="show = !show"
    x-text="show ? '非表示にする' : '表示する'">
  </button>
  <template x-if="show">
    <p x-init="console.log('hi!')">表示されています。</p>
  </template>
  <template x-if="!show">
    <p style="color: gray" x-init="console.log('bye!')">表示されていません…</p>
  </template>
</body>

sprae では、同じ HTML 要素に :if ディレクティブに続けて :ref ディレクティブを置くことで、その HTML 要素をマウントしたときに実行したい処理が書けます。このとき、:ref には無名関数(あるいは別途定義した関数)を指定します。:else ディレクティブでも同様です。

sprae
<body :with="{ open: false }">
  <button
    :onclick="() => open = !open"
    :text="open ? '非表示にする' : '表示する'">
  </button>
  <p :if="open" :ref="() => console.log('hi!')">
    表示されています。
  </p>
  <p :else style="color: gray" :ref="() => console.log('bye!')">
    表示されていません…
  </p>
</body>

なお、sprae のガイドにはアンマウントの処理も書かれていますが、現状、私の手元では正しく動きません。

data 属性と aria 属性へのバインディング

:data ディレクティブと:aria ディレクティブにオブジェクトを渡すことで、それぞれ data 属性と aria 属性値を指定できます。data 属性に関してはキャメルケースはケバブケースに置き換わります。

sprae
<input :data="{foo: 1, barBaz: true}" />
<!-- <input data-foo="1" data-bar-baz /> -->

シグナル

Preact などの Signal が使えます。これが一番の売りのようてす。

https://github.com/dy/sprae?tab=readme-ov-file#signals

JSX

ディレクティブの接頭語を変更して JSX と組み合わせられるそうです。

https://github.com/dy/sprae?tab=readme-ov-file#jsx

脚注
  1. :value ディレクティブで定義されただけの変数を script 要素の sprae の関数定義内で this を付けて使うこともできます。 ↩︎

Discussion