スキップしてメイン コンテンツに移動

アイデアスケッチ、JavaScript だけで宣言的 UI を読みやすく記述する

Cycle.js が同じ発想の記法を採用していました。この記事に新規性はゼロですね…(2023/09/30)

宣言的 UI を JavaScript だけで、人間に読みやすく表現するアイデアです。Vue の作者による 6KB の petite-vue を眺めていたら頭に浮かびました。実装はありません、悪しからず!

はじめに

これまでも JavaScript の関数呼び出しで UI 構造を表現していくことは、度々取り組んできて、これが上手くいくことは分かっています。直近では、JavaScirpt の関数呼び出しで HTML 構造を定義するテンプレートライブラリを書いて WebViewPanel を使う VSCode 拡張の開発に使っています。(*1)

しかし、プライベートで宣言的 UI に向く Web アプリを作る動機が弱い(*2)ので、宣言的 UI ライブラリ自体に疎かったりします。例えば、この記事を書くための調べごとの最中に Svelte を知りました。(*3)

*1 WebView パネルを使う VSCode 拡張は、SPA でも実装できますが、初回表示の HTML はバックエンド(nodejs)側で生成し、以降は WebView 側で CSR する、ハイブリッドレンダリングにしました。この Webview と nodejs で UI 関連のコードを共通化する為に、テンプレートライブラリを自作しました。UX への貢献は微量だと思いますが、こういう機会でもないと、ハイブリッドレンダリングに挑戦しないので。

*2 一方で業務向け開発では、宣言的 UI に適した案件が多いと思います。

ブラウザの提供する文書閲覧機能とフォーム部品をほぼそのまま使ってデータを表示して操作するアプリケーション開発は、宣言的 UI ライブラリとの相性が抜群だと思います。

React や Vue 等に関心が低いのは、React と同時期に Virtual DOM を備えた DOM フレームワークの開発に取り組んでいて、ふーんと横目に見たまま来てしまった、というのがあります。しかし VSCode 拡張で Webview アプリに取り組むと、正に宣言的 UI ライブラリに頼るべきものでした。

*3 僕には Svelte の「ビルド時に負荷を移すコンパイラ」というアプローチが決定的に思えて、記事を残す意欲が少し萎えたのですが。コンパイル結果をほぼ Vanilla JS 状態に出来るなら、宣言的 UI ライブラリの軽量版が登場する動機の大半を解消していると思います。

いずれ Svelte を触ってみて、この方向を進めるか、Svelte で満足するか、判断しますね。

商品リストをクリックするとカートの数字が増えていくだけのモックアプリ

draw.io で編集する

商品リスト(products.json)を fetch して表示し、クリックするとカートの数字が増えていく、というアプリケーションのモックを考えてみます。

商品リストは、SPA で実行する場合は XMLHTTPRequest(fetch) で取得します。SSR する場合は fs で読み込んでリストを完成させ、併せてクリックイベントハンドラを <script> で埋め込みます。

products.json は次のようなものとします。

[
  { "name" : "pen", "price" : 110 },
  { "name" : "apple", "price" : 162 },
  { "name" : "pineapple", "price" : 436 }
]

以上のモックを JavaScript だけで宣言的に書いてみる

JavaScript の関数呼び出しを使って、HTML 的な構造を表現しています。.vue 的なアプローチでなくとも、生の JavaScirpt の表現力で UI 構造とスクリプトをひと纏めにしてみました。

scope(
  section( // <section>
    Icon('cart'), // <svg>
    span('{{count}}') // scope.data.count
  ),
  section( // <section>
    ul() // <ul>
      .each(
        'products', // scope.data.products
        li( // <li>
          b('{{name}}'), // products[i].name
          span('{{price}}') // products[i].price
        ).on(
          'click',
          () => {
            this.setData('count', this.getData('count') + 1);
          }
        )
      )
  )
).initData(
  'count', 0, 'w' // read / write
).onDOMReady(
  () =>
    fetch('products.json').then(
      (response) =>
        this.initData('products', response.json, 'r')
    )
);
  1. getter, setter はあえて使いませんでした。
  2. Vue の "computed" 的なものは要りますね。
  3. li インスタンスの getData, setData は、自身と対になる product オブジェクトのデータを探し、続いてスコープのデータを探す。

SPA またはアプリ開発中の簡易実行

上記 JavaScript を次の HTML の中に記入すれば、SPA として実行できる、とします。

<script src="spa.js"></script>
<div v-scope>
  <script>
    // document.currentScript から [v-scope] 要素を求める
    // scope( ... );
  </script>
</div>

グローバルが section, ul といった関数(というか Virtual DOM のコンストラクタ)で汚染されているのが前提になるので、やっぱり簡易実行用途ですね…

SSR された HTML はこんな感じ

Web サーバ内にある products.json を使って SSR した HTML が次です。

onDOMReady ハンドラが解決されるまでに構築出来た HTML を配信して、.on('click') などのハンドラは回収して、<script> タグに埋め込んでいます。

このくらい単純でしたら、僕でも SSR のコードを書けそうな気もしますが…。ある程度複雑なアプリに耐えうる SSR が書ける自信やモチベーションは今のところ無しです。

<script src="ssr.js"></script>
<div v-scope>
  <section>
    <svg>...</svg>
    <span v-bind="count">0</span>
  </section>
  <section>
    <ul v-each="products">
      <li v-click="func-xxxxx"><b v-bind="name">pen</b><span v-bind="price">110</span></li>
      <li v-click="func-xxxxx"><b v-bind="name">apple</b><span v-bind="price">162</span></li>
      <li v-click="func-xxxxx"><b v-bind="name">pineapple</b><span v-bind="price">436</span></li>
    </ul>
  </section>
  <script>
    init(
      {
        data: {
          count: 0,
          products: [
            {name: 'pen', price: 110},
            {name: 'apple', price: 162},
            {name: 'pineapple', price: 436}
          ]
        },
        xxxxx: () => {
          this.setData('count', this.getData('count') + 1);
        }
      }
    );
  </script>
</div>