◆ elm 風ライブラリで DOM 更新は lit-html を使う
◆ elm 相当の内部と外部を分けて メッセージを送れるようにしてる
◆ URL の変更も対応

liel

lit-html はテンプレート定義から DOM を更新する部分だけを行うライブラリです
使い方の自由度が高く その分 テンプレートを作るときに使う変数の管理や イベントからそれらの状態をどう変更するかなど 迷うところも多いです

個人的に elm の考え方がわかりやすくて好きなので lit-html を使うときの考え方の 1 つとしています
lit-html の html タグ関数は DOM 定義からテンプレートオブジェクトを作るもので コンポーネント機能もなく elm に似ている感じもしますからね

以前もこの組み合わせで似たようなものを作りましたが 最低限のイベント時に model を更新して再描画くらいのものでした
今回は SPA になるように URL 処理や HTTP リクエストなど外部通信とかも考慮しています

lit-html を使う以上 elm に相当する部分もすべて JavaScript です
なので elm ほど厳しい制約もなく 自由にできるものです
ただ タイマーとか非同期処理は外部に分けることにしました

elm ランタイムに相当する update 関数や view 関数を呼び出したり その結果を管理するライブラリ部分が liel.js です
その内部と外部を internal と external と呼ぶことにします
internal が liel.js が管理する elm のコードに相当する部分で external が elm アプリケーションを呼び出す JavaScript に相当する部分です

使い方

基本的な使い方はこういう感じです

const app = liel({
init: (initial_value, url) => [initial_model, emsg],
update: (model, imsg) => [next_model, emsg],
view: (model, v) => v.html``,
})

app.start(initial_value)

liel の引数で init, update, view の 3 つの関数を指定します
返り値の start メソッドを呼び出すと liel の処理が開始します

init

start の引数が init の 1 つめの引数に渡されます
init の 2 つめの引数は new URL(location) の値です
JavaScript だから直接取れるし 別に渡さなくてもよかったのですが せっかくなので入れておきました

init の返り値では model の初期値と emsg というメッセージを返します
model は React でいう state であり アプリケーションの状態です
emsg は elm でいう cmd 相当です
配列形式で external で受け取るものを返します

[{ type: "foo", value: 100 }, { type: "bar", name: "abc" }]

みたいなものです
配列の中身はオブジェクト以外の数値でも何でも良いです
external に送るメッセージが無いなら空配列を返します

view

model を受け取り lit-html のテンプレートオブジェクトを返します
model は init で作ったものや update で更新したものそのものです

2 つめの引数で view ヘルパを受け取ります
v.html が lit-html の html タグ関数になっていて lit-html の import が不要になります
directive を使う場合は directive 単位の import は必要です
v.msg では @click などのリスナに設定する関数を作ります

const view = (model, v) => v.html`
<input .value=${model.name} @input=${v.msg(e => ["input", "name", e.target.value])} >
<button @click=${v.msg(["click_ok"])}>OK</button>
`

v.msg には update 関数に渡す imsg を指定します
配列形式で

["type-name", "param1", "param2"]

みたいなのを想定していますが オブジェクトや数値でもかまいません
ただし 関数の場合は event オブジェクトを受け取り imsg を返す関数として処理されます
input などでは value を取得して update 関数に渡したいことがあるので そういうときに使います

update

model と imsg を受け取って新しい model と emsg を返します

model はイミュータブルにする必要はなく 直接書き換えてもかまいません
elm と違って JavaScript だと深い部分の 1 プロパティだけを書き換えるのって面倒ですしね
Redux 関連では そういう便利ツールもありますが そのためだけに入れるのもどうかなと思いますし

model を直接書き換える場合でも return に model を含めることは必須です
emsg は init のときと同じで external に渡す配列です

internal と external の通信

emsg で external へ送られたものを受け取るには liel の返り値の on でリスナを設定します

const app = liel(...)

app.on(emsg => {
for (const msg of emsg) {
something()
}
})

app.start()

internal へ送るには send を使います
引数が imsg となり update 関数が呼び出されます

const app = liel(...)
app.start()

setInterval(() => {
app.send(["timer", new Date()])
}, 1000)

Command と Subscription

elm のような言語的な制約はないので ランダムがほしいなら普通に internal で Math.random を使います

subscription 機能はありません
タイマーが必要なら external でタイマーを動かして send します
update 関数内でタイマーを動かして model にタイマーの id を保持しておくということもできなくはないです
ただ できるだけ internal のコードでは外部への影響をなくしたいので emsg でタイマーを動かしたいとか止めたいとか送るほうが良いと思います

HTTP リクエスト機能も liel は持っていません
emsg で external に送って external で fetch して結果を send で internal ヘ送ります

URL 管理

SPA 用に URL 管理機能は持っています
URL が変わったときには update 関数が呼び出されて imsg には

["@url_changed", new URL(location)]

の値が渡されます
ここで URL に応じたページ情報などを model にセットします
view ではページに応じた HTML を作れば SPA になります

internal でボタンを押されたときに URL を書き換えたい場合は

{ type: "@url_change", url: "/foo/bar" }

形式の emsg を送ると liel が内部で処理します
pushState して @url_changed の update が発生します

オリジン内のリンクの a タグがクリックされたときも自動でハンドルして pushState を行い update 関数を呼び出します

ただ liel 内では完全独立してる機能ですし 自動でやる分 細かな制御はできません
コード量も 20 行程度なので ライブラリ部分としては含めずに external に直接書く方が良かったかなという気もしています

実際に使って簡単なものを作ってみました
作るページの機能はこういうのです

  • ページを開いたときに JSON ファイルを fetch する
  • /list ページで取得したデータの一覧表示
  • /edit?id=1 ページで id の項目を編集
  • 存在しないページや /edit で存在しない id を指定するとエラー画面
  • リセットボタンで再 fetch して変更をリセットする


[index.html]
<!DOCTYPE html>
<meta charset="utf-8"/>

<script type="module" src="external.js"></script>

[external.js]
import app from "./internal.js"

app.on((emsg) => {
for (const m of emsg) {
if (m.type === "fetch") {
fetch(m.url)
.then((res) => res.json())
.then((result) => {
app.send("got-data", result)
})
}
}
})

app.start()

[internal.js]
import liel from "./liel.js"

const init = (ini, url) => {
const page = urlToPage(url)
return [
{
page,
items: [],
},
[{ type: "fetch", url: "/data.json" }],
]
}

const update = (model, imsg) => {
switch (imsg.shift()) {
case "@url_changed": {
const [url] = imsg
model.page = urlToPage(url)
return [model, []]
}
case "got-data": {
const [items] = imsg
model.items = items
return [model, []]
}
case "input": {
const [index, name, value] = imsg
model.items[index][name] = value
return [model, []]
}
case "click_ok": {
const emsg = [{ type: "@url_change", url: "/list" }]
return [model, emsg]
}
case "reset": {
const emsg = [{ type: "fetch", url: "/data.json" }]
return [model, emsg]
}
}
}

const view = (model, v) => {
switch (model.page.name) {
case "list":
return viewList(model, v)
case "edit": {
const index = model.items.findIndex((x) => x.id === model.page.edit_id)
if (index >= 0) {
return viewEdit(model.items[index], index, v)
} else {
return view404(model, v)
}
}
default:
return view404(model, v)
}
}

const viewList = (model, v) => {
return v.html`
<div>
<h1>LIST</h1>
${model.items.map(
(item) => v.html`
<div>
<a href="/edit?id=${item.id}">
<b>${item.title}</b>
<span>${item.message}</span>
</a>
</div>
`
)}
<button @click=${v.msg(["reset"])}>Reset</button>
</div>
`
}

const viewEdit = (item, index, v) => {
return v.html`
<div>
<h1>Edit</h1>
<div>
<label>Title</label>
<input .value=${item.title} @input=${v.msg((e) => ["input", index, "title", e.target.value])} />
</div>
<div>
<label>Message</label>
<input .value=${item.message} @input=${v.msg((e) => ["input", index, "message", e.target.value])} />
</div>
<div>
<button @click=${v.msg(["click_ok"])}>OK</button>
</div>
</div>
`
}

const view404 = (_, v) => {
return v.html`
<h1>404</h1>
`
}

const urlToPage = (url) => {
const page = {
name: { "/list": "list", "/edit": "edit" }[url.pathname],
}
if (page.name === "edit") {
page.edit_id = ~~url.searchParams.get("id")
}

return page
}

export default liel({
init,
update,
view,
})

取得する JSON データはこういうのです

[data.json]
[
{ "id": 101, "title": "foo", "message": "aaaaaaaaaaaa" },
{ "id": 102, "title": "bar", "message": "bbbbbbbbbbbbbbbb" },
{ "id": 103, "title": "baz", "message": "ccccccc" }
]

liel.js のライブラリの部分はこういうのです

[liel.js]
import { html, render } from "https://unpkg.com/lit-html@1.4.1/lit-html.js?module"

export default ({ init, update, view, root }) => {
root = root || document.body
let model = null
const listeners = new Set()

listeners.add((emsg) => {
for (const m of emsg) {
if (m.type === "@url_change") {
history.pushState(null, "", m.url)
send("@url_changed", new URL(location))
}
}
})

root.addEventListener("click", (event) => {
const a = event.target.closest("a")
if (a && a.origin === location.origin && a.target !== "_blank") {
event.preventDefault()
history.pushState(null, "", a.href)
send("@url_changed", new URL(location))
}
})

window.addEventListener("popstate", () => {
send("@url_changed", new URL(location))
})

const onUpdated = ([new_model, emsg]) => {
model = new_model
render(view(model, v), root)
for (const listener of listeners) {
listener(emsg)
}
}

const start = (ini) => {
const next = init(ini, new URL(location))
onUpdated(next)
}

const msg = (a) => (event) => {
const imsg = typeof a === "function" ? a(event) : a
const next = update(model, imsg)
onUpdated(next)
}

const v = { html, msg }

const on = (fn) => {
listeners.add(fn)
}

const off = (fn) => {
listeners.delete(fn)
}

const send = (...imsg) => {
const next = update(model, imsg)
onUpdated(next)
}

return { on, off, send, start }
}