👁️

Denoで使えるJavaScript仮想DOMライブラリを作った

26 min read

Beako.js

Denoで利用可能な仮想DOMライブラリBeako.jsをアルファリリースしました。Denoで使えるといってもサーバーサイドで使えるわけではなく、Denoでバンドルできるということです。
本記事下部のコメント欄やGithub Issuesで様々なご意見ご支援をいただけると嬉しいです。

Beako.jsとは

https://github.com/ittedev/beako

JavaScriptの仮想DOMライブラリです。ごく少ない数個の関数だけで、データバインディングに対応したカスタムHTML要素を作ることができます。
カスタム要素やShadow DOMは聞いたことがあるけどよく分からないという方は、非常に簡単に導入できますので是非触ってみてください。

Beako.jsの特徴

  • 仮想DOM、テンプレートエンジン、データバインディングに対応したコンポーネントをWebページに好きなように導入できます。
  • Web Components(カスタム要素、Shadow DOM)を利用したライブラリのため、WordPressやShopify等で既に構築されたWebページにも、既存のCSSやJSライブラリからの干渉を極力抑えて導入できます。
  • 仮想DOM、テンプレートエンジン、データバインディング用のそれぞれ独立したツールを備えています。

なぜ今更作ったのか?

仮想DOM・データバインディングのJavaScriptライブラリは様々存在しています。中でもVue.jsは、学習しやすいテンプレートエンジンを備えているため初心者に優しく、それでいて大規模プロジェクトにも利用できる強力なライブラリです。

そう、私はVue.jsが大好きです。

しかし、Vue.jsを今後使っていくうえで少し困ったことになりました。

  1. 2022年1月現在Denoに公式未対応。

私はNode.jsの膨大な依存関係に疲弊しました。今後はDenoを活用していこうと思います。ところがDenoでVue.jsを使おうとすると簡単にはいきません。いずれ公式対応するでしょうが、待つことができませんでした。

  1. Vue3になって監視対象のオブジェクトに変更を加えなくなった。

Vue2までは監視対象のオブジェクトに__ob__プロパティを加えてGetter/Setterを活用してオブジェクトを監視していましたが、Vue3はProxyを使って監視対象に変更を加えずに監視するようになりました。Proxyは監視対象に変更を加えない分効率が良くなり、監視できる項目が増えますが、Proxyを介さずに加えた変更を検知できないというデメリットがあります。

これらの問題点のうち、特に2つ目が重大でした。私が状態管理を行うライブラリを開発しようとしたとき、Vue.jsに状態の変更を通知するには一旦すべてのデータをVue.jsのProxyにしておく必要がありました。このとき、Vue.jsとの依存関係が生まれてしまいます。依存関係を減らしたいのに、依存関係が必要なライブラリを開発しなければならないのは苦痛です。私にはGetter/Setterでオブジェクトを監視するDeno向け仮想DOMライブラリが必要だったのです。

Beako.jsを使ってみる

Beako.jsはDenoとブラウザでの利用をサポートしています。Node.jsも対応予定です。

Deno

mod.tsファイルをインポートして利用します。

script.ts
import { hack } from 'https://deno.land/x/beako/mod.ts'

const data = {
  count: 1
}

setInterval(() => { data.count++ }, 1000)

watch(data)

hack(document.body, `Counter: {{ count }}`, data)

次にHTMLにインポートできるようにバンドルします。タイプエラーが発生しないようにdeno.jsonでブラウザ向けの設定が必要です。

deno.ts
{
  "compilerOptions": {
    "lib": ["deno.unstable", "deno.ns", "ES2021", "DOM", "DOM.Iterable"]
  }
}
deno bundle --config deno.json script.ts > script.bundle.js

バンドルしたファイルはscriptタグにtype="module"属性を付けて読み込むことができます。

index.html
<!DOCTYPE html>
<meta charset="UTF-8">
<body>Loading...</body>
<script type="module" src="script.bundle.js"></script>

この方法は1つのファイルにバンドルする場合に使える方法です。もっと複雑なビルドが必要な場合はDenoのビルドツールについて別記事を書きましたので参考ください。

CDN

CDNから利用する場合もscriptタグにtype="module"を付けます。

index.html
<!DOCTYPE html>
<meta charset="UTF-8">
<body>Loading...</body>
<script type="module">
  import { watch, hack } from 'https://unpkg.com/beako/beako.js'

  const data = {
    count: 1
  }

  setInterval(() => { data.count++ }, 1000)

  watch(data)

  hack(document.body, `Counter: {{ count }}`, data)
</script>

Beako.jsの使い方

英語が不得手ですのでまずはここに長々と基本の使い方を書きます。

例はすべてDenoで書いています。バンドルせずにブラウザのみ使う場合はimportするファイルをbeako.jsに変更してください。

ライブラリ構成

Beako.jsはデータバインディング、仮想DOM、テンプレートエンジン、Webコンポーネント用の4種類のツールで構成されています。通常、仮想DOMとテンプレートエンジンのツールを直接触ることは少なく、Webコンポーネントのツールを介して利用します。

flowchart BT
  subgraph t1[Web Components]
    a1(compact)
    a2(hack)
    a3(define)
  end
  subgraph t2[Template Engine]
    direction LR
    b1(parse)
    b2(evaluate)
  end
  subgraph t3[Virtual Dom]
    direction LR
    c1(load)
    c2(patch)
  end
  subgraph t4[Data Binding]
    direction LR
    d1(watch)
    d2(receive)
  end
  t2-->t1
  t3-->t1
  style t2 stroke-dasharray: 5 5
  style t3 stroke-dasharray: 5 5
  style b1 stroke-dasharray: 5 2
  style b2 stroke-dasharray: 5 2
  style c1 stroke-dasharray: 5 2
  style c2 stroke-dasharray: 5 2

Webコンポーネント

Beako.jsはWebコンポーネントを利用してコンポーネント指向のWebアプリケーションを構築します。
コンポーネントごとにシャドウツリーを持つため、Shadow DOMを使ったことがない人には学習コストが高いかもしれません。その難しさは特にスタイルシートを用いる場合に顕著です。その代わりにBeako.jsは、WordPressやECサイトのASP等で構築されたページにも、既存のcssやJSライブラリからの干渉を受けずに導入できます。
現時点ではShadow DOMを使わずにBeako.jsを利用することはできず、今後の実装については検討段階です。

compact(template, data)関数

graph LR
   A(Template) & B(Data) ---> C((compact)) ---> D(Component)
   style D fill:#f7eb92,stroke:#c2a136
import { compact } from 'https://deno.land/x/beako/mod.ts'

const component = compact('<p>Hello {{ name }}!</p>', { name: 'beako' })

compact関数はテンプレートとテンプレートに与えるデータを組にして、コンポーネントを作ります。コンポーネントは自身では何もできないオブジェクトで、後に説明するdefine関数、hack関数、または、他のコンポーネント内だけで使えるローカルコンポーネントになることで実体を持ち、画面に描画されます。

define(name, component)関数

graph LR
   A(Name) & D(Component) ---> C((define)) --> E(Custom Element)
   style D fill:#f7eb92,stroke:#c2a136
   style E fill:#ffffff,stroke:#000000
import { compact, define } from 'https://deno.land/x/beako/mod.ts'

const component = compact('<p>Hello {{ name }}!</p>', { name: 'beako' })

define('hello-tag', component)

define関数はコンポーネントをカスタム要素として登録します。nameには必ずハイフンを一つ含む必要があります。
登録されたコンポーネントはhtmlに<hello-tag></hello-tag>と書けば好きな場所に表示できます。

compactを省略した書き方も可能です。

import { define } from 'https://deno.land/x/beako/mod.ts'

define('hello-tag', '<p>Hello {{ name }}!</p>', { name: 'beako' })

hack(target, component)関数

graph LR
   A(Element) & D(Component) ---> C((hack)) --> E(Element linked to Component)
   style D fill:#f7eb92,stroke:#c2a136
   style E fill:#ffffff,stroke:#000000
import { compact, hack } from 'https://deno.land/x/beako/mod.ts'

const component = compact('<p>Hello {{ name }}!</p>', { name: 'beako' })

hack('#target', component)

hack関数は既存の要素のシャドウツリーにコンポーネントを展開します。
targetにはシャドウツリーを追加できる要素、またはその要素を特定できるセレクターのいずれかを指定します。

compactを省略した書き方も可能です。

import { hack } from 'https://deno.land/x/beako/mod.ts'

hack('#target', '<p>Hello {{ name }}!</p>', { name: 'beako' })

ローカルコンポーネント

graph LR
   A(Template) & D(Component) ---> C((compact)) --> E(Local Component in Component)
   style D fill:#f7eb92,stroke:#c2a136
   style E fill:#f7eb92,stroke:#c2a136
import { compact } from 'https://deno.land/x/beako/mod.ts'

const local = compact('<p>Hello {{ name }}!</p>', { name: 'beako' })

const component = compact('<hello></hello>', { hello: local })

コンポーネントは他のコンポーネントのデータとして渡すことでローカルコンポーネントになることができます。ローカルコンポーネントの名前にはハイフンは必ずしも必要ではありません。

ローカルコンポーネントが描画されるときは、あらかじめ定義されたbeako-entity要素に変換されます。<hello></hello>は以下と同義です。

<beako-entity component:="hello"></beako-entity>

ここではテンプレート内でのみ利用できる代入演算子:=を使っています(後述)。

テンプレート構文

コンポーネントに与えるテンプレートにはデータによって表示や動作を変えるための特殊な構文があります。

テキスト表示 `{{ expression }}

{{ 10 / 2 + 'px' }}

{{}}で囲まれた式を評価し、結果と置き換わります。

代入演算子 attr:="expression"

<img src:="domain + filename">

:=を使って要素の属性を書くと、属性値を式として評価し、結果を属性にセットします。代入演算子を使わずに書いた属性と代入演算子で書いた属性が2つ存在するときは代入演算子の結果が採用されます。

真偽値代入演算子 attr&="expression"

<input type="checkbox" checked&="domain + filename">

&=を使って要素の属性を書くと、属性値を式として評価し、結果がTruthyであれば結果を属性にセットし、Falsyであれば属性を削除します。これは主に真偽値属性に利用されます。同じ名前の属性を他の演算子で定義していた場合、それらは無効化されます。

束縛演算子(参照代入演算子?) attr*="expression"

この機能は検討段階で未実装です。

<child name*="value"></child>

*=を使って要素の属性を書くと、属性値を式として評価せず、参照時に評価するプロパティとして渡します。上記例の場合、子コンポーネントでnameプロパティに値を代入すると、valueも同じ値に更新されます。

イベントハンドラ on...="expression"

<button value="val" onclick="value = event.target.value">Click me</button>

懐かしいonイベントハンドラです。イベントを受け取ったときに属性値を式として評価します。
コンポーネントに与えられたデータの他にeventオブジェクトにアクセスできます。thisキーワードは利用できませんのでご注意ください。

条件分岐 @if="expression" @else

<div @if="isActive"></div>

@if属性の属性値を式として評価し、Truthyなら@if属性を書いたタグを表示します。Falsyのときに@if属性を書いたタグの次に@else属性を書いたタグがあればそちらを表示します。

<div @if="isActive"></div>
<div @else></div>

<div @if="gender === 'woman'"></div>
<div @else @if="ender === 'man'"></div>
<div @else></div>

繰り返し @for="expression" @each="word"

<div @for="{ x: 1, y: 2 }">{{ loop.key }}:{{ loop.value }}</div>

@for属性の属性値を式として評価し、反復可能もしくは列挙可能なときは繰り返しそのタグを描画します。
繰り返し描画するときに、loop変数に繰り返しごとのLoopオブジェクトが入ります。
また、@eachを書くと値をに名前を付ける事ができます。

<div @for="[1, 2, 3]" @each="value">{{ value }}</div>

実験段階として、反復可能でも列挙可能でもないときはエラーとならず1度だけ描画します。

例外処理 @try @catch="word"

この機能は検討段階で未実装です。

<div @try>{{ format(value) }}</div>

<div @try>{{ format(value) }}</div>
<div @catch>{{ error.message }}</div>

<div @try>{{ format(value) }}</div>
<div @catch="err">{{ err.message }}</div>

@try属性がある場合、レンダリング時に発生したエラーをキャッチし、何も描画しません。エラーが発生していないときはそのまま表示されます。@try属性を書いたタグの次に@catch属性を書いたタグがある場合、エラーが発生したときは@catch属性を書いたタグが表示され、error変数を利用できます。

待機処理 @receive="word list"

この機能は検討段階で未実装です。

<div @receive="name, age">{{ name }}: {{ age.toLocaleString() }}</div>

<div @receive="name, age">{{ name }}: {{ age.toLocaleString() }}</div>
<div @yet>Now Loading...</div>

@receive属性がある場合、属性値で指定した変数が存在しない場合、描画しません。これは以下と等価です。

<div @if="name !== undefined && age !== undefined">{{ name }}: {{ age.toLocaleString() }}</div>

グループ化タグ <group></group>

<group @for="[1, 2, 3]">|{{ loop.value }}</group>|
<!--
  結果:
  |1|2|3|
-->

<group @if="isActive">
  <div></div>
  <div></div>
</group>
<div @else></div>

<group>を使うと実際には描画されないタグで囲むことができます。主に@if@forと一緒に用います。

式について

テンプレート内で使える式はJavaScriptの式から関数定義を除いたものです。関数を使いたいときは、テンプレート外で定義してデータとしてコンポーネントに渡してください。
Beako.jsは式をJavaScriptと同じように使えるように目指していますが、まだ実装できていない構文が多くあります。

データバインディング

データの変更を検知してDOMを更新する

watch関数で監視しているオブジェクトがコンポーネントに与えられているとき、オブジェクトに変更が加えられると、仮想DOMを再描画し、変更が発生した部分だけDOMを更新します。

import { watch, compact } from 'https://deno.land/x/beako/mod.ts'

const data = {
  name: 'world'
}

setTimeout(() => { data.name = 'beako' }, 2000)

watch(data)

const component = compact('<p>Hello {{ name }}!</p>', data)

複数のデータをコンポーネントに与える

コンポーネントに与えるデータは、配列形式にすることで、単一のオブジェクトだけでなく、関数や階層化されたオブジェクトを渡すことができます。watchしているオブジェクトとwatchしていないオブジェクトを同時に渡すこともできます。

もし同じ名前のデータがある場合は配列の後ろから検索し、先に見つかったものを採用します。

import { watch, compact } from 'https://deno.land/x/beako/mod.ts'

const data = watch({
  greeting: 'Hello'
})
const user = {
  name: 'Beako'
}

const format = name => 'Ms. ' + name

const component = compact('<p>{{ greeting }} {{ format(user.name) }}!</p>', [data, { format, user }])
// --> Hello Ms. Beako!

プロパティ(属性)を受け取る

コンポーネントのデータには関数またはプロミスを返す関数(async関数)を渡すことができ、async関数の場合はプロミスが解決されるまでテンプレートは描画されません。

import { receive, compact } from 'https://deno.land/x/beako/mod.ts'

const component = compact('<p>Hello {{ name }}!</p>',
  async ({ props }) => {
    const { name, isWoman } = await receive(props, ['name', 'isWoman'])
    console.lo
  }
)

receive関数は、指定したオブジェクトのプロパティがすべてundefinedからundefined以外に変わったときにプロミスを解決し、指定したプロパティを返します。
そのため、上の例は、テンプレートが描画されるときにname属性とisWoman属性が存在していることを保証します。

このような仕組みになっている理由として、HTML要素は生成されると同時に属性が存在していることもあれば、document.createElement()を使う時のように生成されるときにはまだ属性が無いこともあります。属性が無いときにエラーにならないようにしています。

さて、上の例でname属性は文字列ですが、isWoman属性はブール値です。HTMLの属性は通常、文字列以外を受け渡しできません。Beako.jsは要素をオーバーライドしたカスタム要素で任意の型を受け取ることを可能にしています。この任意の型であることと、is、class、part、style、on~属性を省いた属性をプロパティと呼んでいます。

プロパティの変更を検知する

プロパティが変更されたとき、自動的にDOMは更新されますが、その他のデータの更新や外部との通信が必要になることがあるかもしれません。
watch関数は指定したプロパティが変更されるたびにコールバック関数を実行します。

import { watch, compact } from 'https://deno.land/x/beako/mod.ts'

const component = compact('<p>Hello {{ name }}!</p>',
  ({ props }) => {
    watch(props, 'name', (newValue, oldValue) => {
      // 処理
    })
  }
)

コンポーネント間通信

コンポーネント間でデータを共有する

コンポーネント間でデータを共有する最も簡単な方法はコンポーネントに同じデータを渡すことです。。

import { compact } from 'https://deno.land/x/beako/mod.ts'

const data = {
  name: 'world'
}

const component1 = compact('<p>Hello {{ name }}!</p>', data)
const component2 = compact('<p>Welcome {{ name }}!</p>', data)

プロパティの受け渡し

コンポーネントはプロパティから任意の型のデータを受け取ることが出来ました。子コンポーネントにプロパティを与えれば、子コンポーネント側で与えられたプロパティを参照できます。

import { receive, compact } from 'https://deno.land/x/beako/mod.ts'

const counter = watch({
  count: 0
})

setInterval(() => { counter.count++ }, 1000)

const local = compact('<p>Hello {{ counter.count }}!</p>', async ({ props }) => {
  await receive(props, ['counter'])
})

const component = compact('<local counter:="counter"></local>', { local, counter })

スロット

Beako.jsはWebコンポーネントのslotをそのまま利用できます。

例えば次のようにテンプレート内でslotタグを書いて<example-element>を定義します。

import { define } from 'https://deno.land/x/beako/mod.ts'

define('example-element', `<slot name="my-text">Hello world!</slot>`)

次のHTMLを任意の場所に書いてみます。

  <example-element></example-element>
  <example-element>
    <span slot="my-text">Hello beako!</span>
  </example-element>

すると一つ目の<example-element><slot>に記載されていた「Hello world!」が表示され、二つ目の<example-element><slot><span>に置き換わり、「Hello beako!」と表示されます。

@expand-@as

スロットはWebコンポーネント標準の機能ですが、テンプレートエンジン内で使うには機能不足です。Beako.jsのコンポーネントは要素内のHTMLをテンプレートとしてcontentプロパティで受け取ることができます。受け取ったテンプレートは@expand属性を指定した要素をテンプレートに置き換えます。

import { compact } from 'https://deno.land/x/beako/mod.ts'

const local = compact(`<p @for="[1, 2, 3]" @expand="content">{{ loop.value }}</p>`)
const component = compact(`
  <local></local>
  <local>
    <span>{{ loop.value + add }}</span>
  </local>
`, { local, add: 2 })

一つ目の<local><p>を描画して「1 2 3」と表示され、二つ目の<local><p><span>に置き換わり、「3 4 5」と表示されます。展開するcontentプロパティは親コンポーネントのデータと子コンポーネントのデータを両方参照することができます。

プロパティ名となるcontentは固定の名前ですが、受け渡すときに@asを使えば任意の名前にすることができます。<group>と一緒に使うことで複数の要素を渡すこともできます。

import { compact } from 'https://deno.land/x/beako/mod.ts'

const local = compact(`
  <div @expand="header"></div>
  <p @for="[1, 2, 3]" @expand="num">{{ loop.value }}</p>
`)

const component = compact(`
  <local>
    <span @as="num">{{ loop.value + add }}</span>
    <group @as="header">
      <h2>Beako.js</h2>
      <p>Welcome!</p>
    </group>
  </local>
`, { local, add: 2 })

もし、HTML内にslotや@as属性が複数ある場合、渡されるcontentプロパティはそれらを除いたものになります。

スロットと@expand-@asの違い

スロットと@expand-@asの大きな違いは、スロットが親コンポーネントのシャドウツリーで展開された結果を子コンポーネントに渡すのに対して、@expand-@asは子コンポーネントのシャドウツリー内で展開されます。

graph LR
    subgraph tree1[Parent Shadow Tree]
    a1(Slot Template)--Expand-->a2(Element)
    b1("@as Template")
    end
    subgraph tree2[Child Shadow Tree]
    a2-.->a3(View)
    b1-.->b2(Props)--Expand-->b3(View)
    end

このため、スロットには親コンポーネント内のstyleが適用されますが、@expand-@asには子コンポーネントのstyleが適用されます。@expand-@asで親コンポーネント内のstyleを適用するには::part擬似要素を使うことができます。

Beako.jsを活用する

CSSスタイリング

Beako.jsはShadow DOMを使っているため、コンポーネント内の要素をグローバルのCSSからセレクタで選択することができません。コンポーネント内の要素をクラス等で選択するには各コンポーネントの中で一つ一つスタイリングしなければなりません。逆にいえばグローバルの影響をほとんど受けずに好きにスタイリングできます。
なお、colorやfont-size等の継承はコンポーネントにも引き継がれます。
Shadow DOMのスタイリングが初めての場合は、Beako.jsは簡単に練習できますので是非遊んでみてください。

CSSファイルを読み込む

<link>を使えばコンポーネント内でCSSファイルを読み込むことができます。

import { compact } from 'https://deno.land/x/beako/mod.ts'

const component = compact(`
  <link rel="stylesheet" href="./base.css"></link>
  <span class="red">Beako</span>
`)

```もし、全てのコンポーネントに適用したいCSSファイルがある場合は、すべてのコンポーネントに`<link>`を書く必要があります。テンプレートはただの文字列なので分けておくことができます。

```ts
import { compact } from 'https://deno.land/x/beako/mod.ts'

const CSS = `<link rel="stylesheet" href="/base.css"></link>`

const component1 = compact(CSS + `<span class="red">Beako</span>`)
const component2 = compact(CSS + `<span class="blue">Beako</span>`)

<style>を使う

コンポーネント内に<style>を持つことができます。

import { compact } from 'https://deno.land/x/beako/mod.ts'

const component = compact(`
  <style>
    .red { color: red; }
  </style>
  <span class="red">Beako</span>
`)

もちろん、<style>内のCSSはデータを挿入できます。

import { compact } from 'https://deno.land/x/beako/mod.ts'

const component = compact(`
  <style>
    .red { color: {{ color }}; }
  </style>
  <span class="red">Beako</span>
`, { color: 'red' })

またはインラインスタイル:

import { compact } from 'https://deno.land/x/beako/mod.ts'

const component = compact(`
  <span style:="'color: ' + color">Beako</span>
`, { color: 'red' })

CSSにデータを挿入できるということは、子コンポーネントがプロパティとして親コンポーネントからスタイルを受け取ることができるということです。例えば次のようにしてコンポーネントごとに色を変えることができます。

import { define } from 'https://deno.land/x/beako/mod.ts'

define('custom-font', `
  <span style:="'color: ' + (color || 'black')"><group @expand="content" /></span>
`)
HTML
  <custom-font color="red">Hello</custom-font>
  <custom-font color="blue">beako</custom-font>

他にもBeako.jsはShadow DOMの::part擬似要素も使うことができます。本記事では紹介しませんが、子コンポーネントのスタイルを変更するときに<style>内で記述が可能となります。

フォーム

代入演算子とイベントハンドラを駆使してフォーム部品の単方向データバインディングが可能です。双方向のデータバインディングについては検討段階です。
通常、フォーム部品のvalue属性やchecked属性は初期値としてのみ機能しますがBeako.jsは属性値を変更すると部品が保持しているフォーム値も同時に更新します。逆にフォーム値を変更した場合に属性値は更新されないのでご注意ください。

テキストボックス

テキストボックスとテキストエリアは次のように簡単に実装できます。

テキストボックス
import { watch, compact } from 'https://deno.land/x/beako/mod.ts'

const data = watch({ value: '' })
  
const component = compact(`
  <input type="text" value:="value" oninput="value = event.target.value">
`, data)
テキストエリア
import { watch, compact } from 'https://deno.land/x/beako/mod.ts'

const data = watch({ value: '' })
  
const component = compact(`
  <textarea oninput="value = event.target.value">{{ value }}</textarea>
`, data)

セレクトボックス

セレクトボックスは少し複雑です。

セレクトボックス
import { watch, compact } from 'https://deno.land/x/beako/mod.ts'

const data = watch({ value: 'Dog' })

const component = compact('custom-font', `
    <select onchange="value = event.target.value">
      <option value="Cat" selected&="value === 'Cat'">Cat</option>
      <option value="Dog" selected&="value === 'Dog'">Dog</option>
      <option value="Rat" selected&="value === 'Rat'">Rat</option>
    </select>
`, data)

@forを使うことも可能です。

セレクトボックス
import { watch, compact } from 'https://deno.land/x/beako/mod.ts'

const data = watch({ value: 'Dog' })
const animals = ['Cat', 'Dog', 'Rat']

const component = compact(`
  <select onchange="value = event.target.value">
    <option
      @for="animals"
      @each="animal"
      value:="animal"
      selected&="value === animal"
    >{{ animal }}</option>
  </select>
`, [data, { animals }])

value値をindexにすることで文字列以外ともバインディングできます。

セレクトボックス
import { watch, compact } from 'https://deno.land/x/beako/mod.ts'

const data = watch({ value: null })
const animals = [
  { name: 'Cat' }, { name: 'Dog' }, { name: 'Rat' }
]

const component = compact(`
  <select onchange="value = animals[Number(event.target.value)]">
    <option></option>
    <option
      @for="animals"
      @each="animal"
      value:="loop.index"
      selected&="value === animal"
    >{{ animal.name }}</option>
  </select>
`, [data, { animals }])

ラジオボタン

ラジオボタンとデータをバインディングするには次のようにします。

ラジオボタン
import { watch, compact } from 'https://deno.land/x/beako/mod.ts'

const data = watch({ value: '' })
const animals = ['Cat', 'Dog', 'Rat']
  
const component = compact(`
  <label @for="animals" @each="animal">
    <input type="radio"
      value:="loop.index"
      onchange="value = animals[Number(event.target.value)]"
      checked&="value === animal"
    />{{ animal }}
  </label>
`, [data, { animals }])

value値をindexにすることで文字列以外ともバインディングできます。

ラジオボタン
import { watch, compact } from 'https://deno.land/x/beako/mod.ts'

const data = watch({ value: null })
const animals = [
  { name: 'Cat' }, { name: 'Dog' }, { name: 'Rat' }
]
  
const component = compact(`
  <label @for="animals" @each="animal">
    <input type="radio"
      value:="loop.index"
      onchange="value = animals[Number(event.target.value)]"
      checked&="value === animal"
    />{{ animal.name }}
  </label>
`, [data, { animals }])

チェックボックス

Booleanとバインディングするには次のようにします。

チェックボックス
import { watch, compact } from 'https://deno.land/x/beako/mod.ts'

const data = watch({ value: false })
  
const component = compact(`
  <label>
    <input type="checkbox" onchange="value = event.target.checked" checked&="value">{{ value }}
  </label>
`, data)

チェックされている項目を配列にバインディングするには例えば次のようにできます。changeイベントが複雑になりますので関数にしています。

マルチチェックボックス
import { watch, compact } from 'https://deno.land/x/beako/mod.ts'

const data = watch({ values: ['Rat'] })
const animals = ['Cat', 'Dog', 'Rat']

const onChange = event => {
  const value = animals[Number(event.target.value)]
  if (event.target.checked) {
    if (!data.values.includes(value)) {
      data.values.push(value)
      data.values.sort((a, b) => animals.indexOf(a) - animals.indexOf(b))
    }
  } else {
    if (data.values.includes(value)) {
      data.values.splice(data.values.indexOf(value), 1)
    }
  }
}

const component = compact(`
  <label @for="animals" @each="animal">
    <input type="checkbox" value:="loop.index" onchange="onChange(event)" checked&="values.includes(animal)" />{{ animal }}
  </label>
`, [data, { animals, onChange }])

可変フォーム

フォーム部品をただ設置してもデータバインディング+仮想DOMの恩恵を受けているとは言い難いです。フォーム部品を切り替えたり増やしたり削除したりできると嬉しいです。

チェックすると現れるフォーム
import { watch, compact } from 'https://deno.land/x/beako/mod.ts'

const data = watch({
  isActive: false,
  email: ''
})
  
const component = compact(`
  <p>
    <label>
      <input type="checkbox" onchange="isActive = event.target.checked" checked&="isActive"> Subscribe
    </label>
  </p>
  <p @if="isActive">
    <input type="email" value:="email" oninput="email = event.target.value" placeholder="e.g. email@example.com">
    <button onclick="alert(email)">Send</button>
  </p>
`, data)

追加・削除が可能なテキストボックス
import { watch, compact } from 'https://deno.land/x/beako/mod.ts'

const data = watch({
  texts: ['']
})
  
const component = compact(`
  <p @for="texts">
    <input type="text" value:="loop.value" oninput="texts[loop.index] = event.target.value">
    <button onclick="texts.splice(loop.index, 1))">Remove</button>
  </p>
  <p>
    <button onclick="texts.push('')">Add</button>
  </p>
`, data)

フォームの使用感について

フォーム部品に関してBeako.jsは他のHTML要素と比べて、属性で値を上書きこと以外は何もしていません。そのため、event.targetやchecked属性等を駆使した、SSR + Vanilla JSに近い使用感になっています。これは正直言って使い勝手が良くなく、本望ではないのですが、Vue.jsのような双方向データバインディングはどんなフォームにも対応しているわけではなく、にもかかわらずBeako.jsに実装すると、内部のソースが複雑化するのではないかと感じています。複雑になるということは良い手段では無いと考えています。ではそもそもなぜフォーム部品の実装が難しくなるかというと、根本的にはHTMLのフォームがそもそも貧弱だからではないか・・・と予想しています。Beako.jsはそのままに、双方向データバインディングできるコンポーネントを別ライブラリとして用意したらどうだろうかというのが今の考えです。
例えばこのような感じです。

<email value*="email" />
<radiogroup value*="email">
  <radio @for="values" value:="loop.value">{{ loop.value }}</radio>
</radiogroup>

ここについてはまだ検討段階です。

Beako.jsの名前の由来

Beako.jsという名前の由来は、特徴的な関数であるwatch()から、
watchする人 → 目 → バックベアード → ベア子
となっています。単にBeakoだとベア子そのものになってしまうので.jsを付けるようにしています。

今後の予定

基本仕様は完成しているので2022年1月中リリースを目指します。
もしよければフィードバックをいただけると嬉しいです。

正式リリースまで

  1. Refを実装 - Beako.jsのライフサイクルとイベント
  2. コンポーネントのイベントをリッスンおよび、ライフサイクルイベント(connected、disconnected、destroyed)を実装 - Beako.jsのライフサイクルとイベント
  3. canvasおよびsvg対応
  4. テストとバグフィックス

実装予定

  1. 参照渡し演算子
  2. コンポーネントコンストラクタ内でwatch、receiveのオブジェクトを省略
  3. テンプレートエンジンを使わずに仮想DOMだけでコンポーネントを使えるようにする。

検討段階

  • 双方向データバインディング可能なカスタム要素
  • 単一ファイルコンポーネント的なもの(テンプレートリテラル内でテンプレートリテラルが使いづらいので何とかしたい。シンタックスハイライトを使いたい。)
  • Shadow DOMを使わずにコンポーネントを表示する
  • is属性を使ったビルトイン要素のコンポーネント化
  • @try @catch文
  • @receive文
  • hackした要素のプロパティ変更を検知する
  • テンプレート外で展開されたコンポーネントのinnerHTMLの変更を検知する
  • 任意の要素を仮想DOMでパッチを当てない要素にできるようにする

Discussion

ログインするとコメントできます