SPA 対応 elm 風 lit-html ライブラリ
- カテゴリ:
- JavaScript
- つくった
- コメント数:
- Comments: 0
◆ elm 風ライブラリで DOM 更新は lit-html を使う
◆ elm 相当の内部と外部を分けて メッセージを送れるようにしてる
◆ URL の変更も対応
◆ 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 }
}