💻

プログラマーならview定義もJavaScriptでする方が幸せじゃないですか?

2022/11/18に公開約7,500字

仕事でVue2をおそるおそる使っていたのですがこれからどうしようかという話になり、Vue3、React、Svelte、Solidなんかを試してみたんですが、それぞれ雰囲気はつかめたのですがどうももやもやが残りました。それが何なのかよくわからなかったんですが、ふとJavaScriptと同時にJSXとかHTML(テンプレート)といった異なる言語を一緒に扱わなきゃいけないところなんじゃないかと思い、だったら全部JavaScriptでやったらいいんじゃないかと思いついてちょっとやってみましたという話です。

HTMLをJavaScriptで表現する

考えてみればHTMLってプロパティというおまけは付いてるけど単なる木構造ですよね。たとえば

<div>
  <button style="color: grey;">
    Cancel
  </button>
  <button style="background-color: aqua;">
    OK
  </button>
</div>

このデータ構造をJavaScriptの配列とオブジェクトで表現すると

['div', {}, [
  ['button', {style: "color: grey;"}, [
    'Cancel'
  ]],
  ['button', {style: "background-color: aqua;"}, [
    'OK'
  ]],
]]

こんな感じでできちゃいますね。つまりひとつの要素を(1)タグ文字列、(2)プロパティ(object)、(3)子要素の配列、の組で表せば良い。
このデータを解析してcreateElement()とElement.append()でDOMツリーを作るプログラムも特に苦労なく作れそうです。VueやReactはcreateElement相当のAPIでview生成する機能があるのでそれらでも使えるはずということになります。
これで拍子抜けするほど簡単にやりたいことが達成できてしまうんですが、データをこの形で手書きするのはちょっと面倒なので少し改良します。まずタグごとに関数化します。

function div(props, children) {
  const obj = {
    tag: 'div',
    props: props,
    children: children,
  }
  return obj
}

扱いづらいので要素のデータを配列からobjectに変えてますがこんな感じでしょうか。これで

div({}, [
  button({style: "color: grey;"}, ['Cancel',]),
  button({style: "background-color: aqua;"}, ['OK',]),
])

こう書けるようになるのですが、空のプロパティ{}を書かなきゃいけないのとか子要素の[]とか面倒なので

  • プロパティは省略可能とする
  • 子要素は配列ではなく可変長引数として渡す

ということにすると、実装は略してviewは

div(
  button({style: "color: grey;"}, 'Cancel'),
  button({style: "background-color: aqua;"}, 'OK'),
)

とスッキリしました。プロパティの省略は引数の型を判定すればできるようになるのですが、どうせ型判定するなら

  • プロパティは複数渡して良いことにする(マージする)
  • 子要素とプロパティの順番は問わないことにする

とより便利そうです。というのはプログラムの中で何度も使うプロパティに名前をつけて使い回せるようになります。onclickも追加して

const greyFont = {style: 'color: grey;'}
const aquaBack = {style: 'background-color: aqua;'}
const view = [
  div(
    button('Cancel', greyFont, {onclick: () => console.log('cancel clicked')}),
    button('OK', aquaBack, {onclick: () => console.log('ok clicked')}),
  )
]

ここでは1回しか使ってませんがgreyFontやaquaBackといった名前の変数としてstyleを使い回せるようになりました。もちろんclassでも何でも同様にできます。buttonの場合直後に子要素である表記文字列を書けるのもちょっと見やすさ向上になると思います。
さらにタグも変数として切り出せます。

  const greyFont = {style: 'color: grey;'}
  const aquaBack = {style: 'background-color: aqua;'}
  const cancelButton = (...args) => button('Cancel', greyFont, ...args)
  const okButton = (...args) => button('Ok', aquaBack, ...args)
  const view = [
    div(
      cancelButton({onclick: () => console.log('cancel clicked')}),
      okButton({onclick: () => console.log('ok clicked')}),
    )
  ]

buttonのstyleと子要素である表記をまとめてcancelButtonとokButtonという名前をつけ、使う場所ごとに異なるonclickだけ指定して使い回せるようになりました。コンポーネントは言い過ぎだと思うのでaliasと呼ぶことにします。1行で済むのが良いですよね。

ほんのちょっとした思いつきで、複数出てくるモノを変数にまとめて適切に使いまわすというプログラミングでは当たり前のことがviewの定義でもできるようになりました。

配列型のフォーマット

これは好みが分かれるかもしれませんし処理も増えますがdiv(...args)という関数呼び出しの代わりに[div, ...args]と配列で定義しapplyで関数呼び出しすることにすると、こう書けるようになります。

[div,
  [button,
    {style: 'color: grey;', onclick: () => console.log('cancel clicked')},
    'Cancel'
  ],
  [button, 
    {style: 'background-color: aqua;', onclick: () => console.log('ok clicked')},
    'OK'
  ],
]

HTMLと比べると

<div>
  <button style="color: grey;" onclick="console.log('cancel clicked')">
    Cancel
  </button>
  <button style="background-color: aqua;" onclick="console.log('ok clicked')">
    OK
  </button>
</div>

関数呼び出しよりカタチはHTMLに近いです。alias使う例の見た目はこう変わります。

const view = [
  [div,
    [cancelButton, {onclick: () => console.log('cancel clicked')}], 
    [okButton, {onclick: () => console.log('ok clicked')}],
  ]
]

aliasへの引数

aliasは単なる関数なので引数を渡すことももちろんできます。selectはこの記法で普通に書くと

[select,
  [option, 'sel1', {value: 'v1'}],
  [option, 'sel2', {value: 'v2', selected: true}],
  [option, 'sel3', {value: 'v3'}],
]

ですがoptionとかvalueの記述を減らしたいならこんな感じでしょうか。

//alias
const shortSelect = (lst, ...args) => [select,
  ...lst.map(x => [option, x[0], {value: x[1], selected: x[2]}]),
  ...args
]
// view
[shortSelect,
  [['sel1', 'a1'], ['sel2', 'a2', 1], ['sel3', 'a3']], greyFont
]

aliasに対する引数は固定引数になってしまうのがちょっと残念です。普通のタグの様にプロパティで渡せるようにするには複数プロパティをマージする処理を公開してalias内で使えるようにする必要があります。aliasに対するプロパティは大文字始まりというルールも足して、

// alias
const shortSelect2 = (...args) => {
  const { props } = collectArgs(args) // argsから複数プロパティをマージして返すAPI
  return [select,
    ...props.Options.map(x => [option, x[0], {value: x[1], selected: x[2]}]),
    ...args
  ]
}
// view
[shortSelect2, greyFont,
  { Options: [['sel1', 'p1'], ['sel2', 'p2', 1], ['sel3', 'p3'],] },
]

greyFontを前に持ってくることができるようになりましたがこれはちょっとやりすぎでしょうか。

まとめるとview定義を

  • JavaScriptを使う
  • 一つのタグを、プロパティと子要素を可変長引数として受け取る関数とする
  • プロパティは複数渡せる(マージする)
  • プロパティと子要素は順不同に渡せる

という仕様にするとviewの部品化が簡単にできて便利になるんじゃないかという話でした。実際コードは書いたのですがつい余計な機能を入れてちょっと膨れてしまいました(300行くらい)。上記だけに絞れば数時間もあれば書ける程度の処理だと思うので、よくわからない他人のコード(でハマるリスク)よりそれぞれ一から書いていただく方が良いかもしれません。

まとめてdemo

Vueで使う

Vueはレンダー関数という機能をサポートしており、テンプレートの代わりにレンダー関数を呼び出すことでview生成ができます(HTMLの代わりにcreateElement()で画面を生成するのと似ていますが先に子要素を作ってレンダー関数に引数で渡す必要があるという違いがあります)。
JavaScriptで定義したviewもcreateElement()の替わりにVueのレンダー関数を呼び出せばVueで使えるはずです。簡単なtodoアプリっぽいものを作り確認したところ

  • valueのバインディング
  • スタイルのバインディング
  • 条件付きレンダリング
  • リストレンダリング
  • イベントハンドリング
  • コンポーネント呼び出し
  • $refs

については特に問題なく動作する様でした。他の機能の確認はしていませんがレンダー関数の仕組みが単純なだけにもしかしたらそのまま実用できちゃうかも?と思える感触です。

Reactで使う

ReactもVueのレンダー関数と同様の機能をサポートしています。Vueと同じ確認を行ったところ同じレベルで動作はしたのですが、残念ながらWarning: Each child in a list should have a unique "key" prop.という警告が沢山出てしまいます。リストレンダリングでは適切にkeyを設定しているつもりなのですがどうもリストレンダリングではないところで出ているようでよくわからない現象でした。Reactに不慣れなこともありそれ以上の調査はできていません。 しました(warninの原因はレンダー関数への子要素の渡し方がVueは配列であるのに対しReactは可変長引数だったためでした。2022/11/20)。

類似ライブラリ

hyperscriptのことは後から知りました。多分これに合わせて各フレームワークでレンダー関数相当の機能の仕様が決められていてVue、React、Solidなどで同じ仕組みでview生成できるようになっているという、老舗ライブラリだと思われます。こちらは複数プロパティではないので多分aliasみたいなことはできないと思います(一方idやclassを簡単に付与できる様になっています)。

HTMLのフォーマット変換

PythonでHTMLからこのフォーマットに変換するプログラムを書いたので貼っておきます。pip3 install beautifulsoup4すると使えます。標準入力にHTMLを渡すと変換した文字列を表示します。

html2vammp.py
import sys
import bs4

INDENT = '  '
if True: # 配列フォーマット
    FMT_TOP = '[{},'
    FMT_END = '],'
    FMT_ALL = '[{}],'
else: # 関数フォーマット
    FMT_TOP = '{}('
    FMT_END = '),'
    FMT_ALL = '{}(),'

def parse(tag: bs4.PageElement, ofs: int) -> str:
    if isinstance(tag, bs4.element.Tag):
        done = False
        head = f'{INDENT*ofs}' + FMT_TOP.format(tag.name)
        if tag.attrs:
            done = True
            print(head)
            print(f'{INDENT*(ofs+1)}{tag.attrs},')
        if tag.children:
            for child in tag.children:
                if not done:
                    done = True
                    print(head)
                if isinstance(child, str):
                    child = child.strip()
                    if child:
                        print(f"{INDENT*(ofs+1)}'{child}',")
                parse(child, ofs + 1)
        if done:
            print(f'{INDENT*ofs}' + FMT_END)
        else:
            print(f'{INDENT*ofs}' + FMT_ALL.format(tag.name))

def html2vammp(src: str):
    soup = bs4.BeautifulSoup(src, 'html.parser')
    parse(soup, 0)

if __name__ == '__main__':
    src = sys.stdin.read()
    html2vammp(src)

Discussion

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