◆ lit-html はまだ 1.0 でそうにないし
◆ hyperHTML はなんか使いづらそう
◆ lit-html が正式リリースされるまで使うように自分に必要な機能だけ入れたものを作ってみた

やっぱり vdom 的なものほしい

以前 WebComponents 難しいと書いたときのイベントをルートから伝えるという方法を少しやってみたのですが やっぱりイマイチでした
これまでのグローバルにあちこちにイベントを付けて 好き勝手 DOM を書き換えるのに比べたら 自分のコンポーネントの内側のみであり 常にルートの要素を経由してトップダウンになるので流れはわかりやすくなります
それでもこのイベントが起きたらあそこ書き換えて この場合はあそこのリセットも必要で~ とか考えることが多くて漏れが起きやすくコードの量も多くなります
それに DOM を自由に触れる分 この処理はどこでやるかみたいのがはっきりせず直接 JavaScript で処理する時に考えなくていい部分で変に時間が取られます

vdom を使って現時点の状態からその状態であるべき HTML の形だけ作って実際の DOM 操作は自動でやらせるとしてしまったほうが色々楽に思いました
特にリスナ設定や更新処理でクエリセレクタを書くのが多くなると HTML 中に書けるのがすごく楽に感じます

避けてた理由

これまで避けてた理由ですが重めのページだと DOM を直接しないと重くなりそうというのが一番です
しかし試してみた感じ DOM 自体の更新処理は非常に高速です

Twitter の 1 ツイートみたいな名前・本文・時刻くらいの情報を入れたブロックを数千入れて更新や削除やソートしても基本 0.1 秒未満でした
JSON データを元にテンプレートストリングで埋め込みながら 1000 件分作って append しても DOM 更新部分は 0.1 秒未満でした
極端な話毎回 HTML を 0 から作って document.body.innerHTML を置き換えても問題なさそうです
遅いのはレンダリングで 書き換えたものがほんの少しでもそれによって実際のサイズの再計算が必要になるなどでここがとても重いです
500 件程度でも 1, 2 秒かかることもありました
flexbox や grid など柔軟性あるものだと特に遅くなります

例えばマウス動かすごとについてくるブロック要素を作ったとして mousemove イベントごとに全体の状態を更新して全体に伝えるというのは遅そうですが 実際は変更がなければそこは何もされないわけですし 変更の有無を === で比較するくらいは無視できる程度のコストです
試しに 5 万件比較しても 2 ms 程度でした
マウスについてくるブロック要素の処理でイベントが伝わる要素なんてどう考えても 100 もないですし mousemove のたびに状態を書き換えて自動で変更が必要な箇所を見つけて変更するという方法でも別に問題なさそうな気がしてきました
DOM 更新の時間をほんの少し高速化してもその何十倍もしくは何百倍もかかるレンダリングのほうが同じならほぼ意味ないですしね

lit-html 使いたいけど

まだ開発中

vdom 系のツールでも HTML 風に書けてシンプルで扱いやすい lit-html が使いたいです
ですが README にも書かれてるように開発中のステータスです
もうすぐ 1.0 リリースらしいので 6 月くらいからほぼ毎日チェックしてるのですが まだまだ先そうです
そこまで活発というほどでもなく 実際にコミットしてる人は少なめですし 毎日のようにコミットあるわけでもないです
最近 属性名の規則が変わる大きな変更もありましたし まだ 1.0 に向けて追加予定の機能もあるようです
それにバグのタグがついているのに残ったままの issues もけっこうあります
この調子だと秋くらいになるのかなぁ

その場限りで使う分には全然困らないのですが 長期間使われるコードを書くにはまだ使うのは難しいと思います
まだ互換性のない変更もありそうですし

hyperHTML

そんな中見つけたのが hyperHTML です
lit-html と同じ template string を使ってテンプレートを作り必要な箇所のみ更新してくれるライブラリです
lit-html との比較を見るに高機能そうです

ただ ドキュメントをみてもいまいち使い方が頭に入ってきません
bind とか wire とかよくわからない物が多くて lit-html のテンプレートと親要素を指定して render 関数に渡すっていうシンプルさに比べると扱いづらいです
機能が多いと言っても私としては lit-html のものだけで十分なので 機能が多くてわかりづらいのは求めてません
ただでさえ 関連ツールみたいな viper とか native とか色々な種類があって把握が大変そうなのに hyper だけでこうも分かりづらいのはあまり使う気が起きないです
lit-html が 1.0 出たらそっち使うと思うのでそこまでこっちを使い込みたいと思いませんし 最低限の使い方だけで使うとなんかトラブル起きそうな予感もします

作った

lit-html を待っていてもまだ先になりそうだし hyperHTML を使うのもなんかなぁ とどうしようかと考えていたら 「考えたり待ってる時間あれば自分でつくれるんじゃない」 と思ったので勢いで作ってみました
昼過ぎに思い至って夜に完成したクオリティなのでコード的にはあんまりキレイくないですしバグがありそうです
また lit-html が正式リリースしたらそっち使うつもりなので自分が必要なものだけしか入れてません
コード量は 250 行弱の短さです

パーシャルテンプレートはないのでネスト不可能です
リピート機能もありません
こういう機能は必要ならそこはコンポーネントに分けて コンポーネントがリピートする部分を管理すればいいという考えです

ネストがないとテンプレートの可変部分が動的に増減しないので単純に作れます

lit-html の最近変わった変更に合わせてプロパティ代入は 「.」 から始まる属性名です
逆にイベントリスナの設定は DOM 標準に合わせて 「onclick」 などです
ここは hyperHTML 側に合わせてます

hyperHTML は自己終了タグに対応していてこういうことをするために正規表現で頑張ってるみたいですが こっちは DocumentFragment を構築して 属性含めて一度すべての Node を辿るだけです

また

`<div class="${"abc"} ${"def"}"></div>`

みたいな 1 属性に複数可変箇所にも対応しています

また

`
<h1>${{static: config.title}}</h1>
<div class="${{static: style.class1}}">${value.text}</div>
`

みたいな非可変部分にも対応しています
個人的に作るものに 実行時には変更されないけどリテラルにはできなくて変数から埋め込む文字列がすごく多いです
それらも変更対象として毎回チェックってなんか無駄だなぁと思っていたので 可変箇所としてみなさない埋め込みにも対応させました
「static」 というキーのあるオブジェクトを埋め込むとそこは固定の文字列になります
埋め込みなのでコード上は毎回別のデータに変更できますが 1 度目の実行時に固定されて 2 度目の実行時に別の値を入れても DOM の方は変更されません

独自仕様あるけど

あとで lit-html リリースしたらそっちにするといいつつあまり仕様は合わせてません
合わせたところで大きな変更があったら結局変更の必要がありますからね

どうせ変えるなら lit-html 使ってもいいのですが 一番の問題はバグがあったときの対処です
自分で作ってるのならまぁどうにかできますが 外部のライブラリだと原因を見つけるまでが大変で直すのに一苦労です
特に lit-html の場合は typescript ですし
報告したら公式側で対処してくれそうですが今の issues をみてるとすぐ対応されるとは言えなそうです

コード

const key = `xtpl${~~(Math.random() * 1000000)}lptx`
const cache = new WeakMap()

function xtpl(strs, ...values) {
const new_strs = [strs[0]]
const new_values = []
for (let i = 1; i < strs.length; i++) {
const str = strs[i]
const value = values[i - 1]
if (value && value.static) {
new_strs[new_strs.length - 1] += "" + value.static + str
} else {
new_strs.push(str)
new_values.push(value)
}
}

return { key: strs, strs: new_strs, values: new_values }
}

function render(element, xtpl, nocache) {
let template = cache.get(element)
if (!template || template.key !== xtpl.key || nocache) {
template = build(xtpl)
cache.set(element, template)
element.innerHTML = ""
element.append(template.fragment)
}
template.update(xtpl.values)
}

function build(xtpl) {
const t = document.createElement("template")
t.innerHTML = xtpl.strs.join(key)
const fragment = t.content
const values = []

for (const node of walkTree(fragment)) {
if (node.nodeType === 1) {
const needs_fixes = []
for (const attr of node.attributes) {
let fixed_name = attr.name
if (attr.name === key) {
// <div ${"xxx"}></div>
fixed_name = null
values.push({
node,
value: {},
type: "attributes",
})
needs_fixes.push({
attr: attr.name,
})
} else if (attr.name.includes(key)) {
// <div data-${"xxx"}-${"yyy"}="100"></div>
const parts = attr.name.split(key)
const mid_values = []
for (let i = 0; i < parts.length - 1; i++) {
mid_values[i] = xtpl.values[values.length]
values.push({ type: "none" })
}
fixed_name = alternateJoin(parts, mid_values)
needs_fixes.push({
attr: attr.name,
fixed_name,
})
}

// attribute that starts with `.` means property
// attribute that starts with `on` and not include `-` means event
const attr_type =
fixed_name === null ? "attributes" :
fixed_name.startsWith(".") ? "property" :
(fixed_name.startsWith("on") && !fixed_name.includes("-")) ? "event" :
"attribute"

const parts = attr.value.split(key)

// throw if <div ${"a"}="1"></div>
if (attr_type === "attributes" && attr.value !== "")
throw new Error("'attributes' attribute cannot have attribute value.")

if (attr_type === "property" || attr_type === "event") {
// throw if <div onclick="xx${x => {}}yy"></div>
if (!(parts.length === 2 && parts[0] === "" && parts[1] === "")) {
throw new Error("'property' and 'event' attribute cannot contain string.")
}

const real_name = attr_type === "event" ? fixed_name.substr(2) : fixed_name.substr(1)

values.push({
node,
type: attr_type,
value: null,
name: real_name,
})
needs_fixes.push({
attr: attr.name,
})
}

if (attr_type === "attribute") {
const mid_values = []
for (let i = 0; i < parts.length - 1; i++) {
mid_values[i] = ""
values.push({
node,
type: attr_type,
parts,
mid_values,
values_index: i,
value: key,
attr: fixed_name,
})
}
}
}
// fix attribute names
for (const item of needs_fixes) {
const value = node.getAttribute(item.attr)
node.removeAttribute(item.attr)
if (item.fixed_name) {
node.setAttribute(item.fixed_name, value)
}
}
} else if (node.nodeType === 3) {
// text node
const idx = node.textContent.indexOf(key)
if (idx === 0) {
node.splitText(key.length)
} else if (idx > 0) {
node.splitText(idx)
}

if (node.textContent === key) {
values.push({
node,
type: "text",
value: key,
})
}
}
}

const update = new_values => {
for (let i = 0; i < values.length; i++) {
const value = values[i]
const new_value = new_values[i]
if (value.value !== new_values) {
switch (value.type) {
case "attribute":
{
value.mid_values[value.values_index] = new_value
value.node.setAttribute(value.attr, alternateJoin(value.parts, value.mid_values))
}
break
case "attributes":
{
const attr_diff = diff(value.value, new_value)
for (const name of Object.keys(attr_diff.out)) {
value.node.removeAttribute(name)
}
for (const [name, item] of Object.entries(attr_diff.in)) {
value.node.setAttribute(name, item)
}
for (const [name, val] of Object.entries(attr_diff.mod)) {
value.node.setAttribute(name, item.to)
}
}
break
case "property":
{
value.node[value.name] = new_value
}
break
case "event":
{
value.node.removeEventListener(value.name, value.value)
value.node.addEventListener(value.name, new_value)
}
break
case "text":
{
value.node.textContent = new_value
}
break
}
value.value = new_value
}
}
}

return {
key: xtpl.key,
fragment,
update,
}
}

function diff(from, to) {
const f = new Set(Object.keys(from))
const t = new Set(Object.keys(to))
const ret = {
in: {},
out: {},
mod: {},
}
for (const key of new Set([...f, ...t])) {
const has_from = f.has(key)
const has_to = t.has(key)
const from_value = from[key]
const to_value = to[key]
if (has_from && has_to) {
if (f.get(key) !== t.get(key)) {
ret.mod[key] = { from: from_value, to: to_value }
}
} else if (has_from) {
ret.out[key] = from_value
} else if (has_to) {
ret.in[key] = to_value
}
}
return ret
}

function alternateJoin(a, b) {
return a.reduce((a, e, i) => a + b[i - 1] + e)
}

function* walkTree(root) {
yield root
for (const child of root.childNodes) {
yield* walkTree(child)
}
}

export { render, xtpl }

使い方は lit-html に近いです
render 関数に 親要素と xtpl というタグで作ったテンプレートを渡します

function template(values) {
return xtpl`
<div id="${values.id}" class="${values.class1} ${values.class2} z" >
***${values.a}++${values.b}##
</div>
<div data-${values.foo}-${values.bar}="${values.value}" ${values.options}>
***${values.c}++${values.d}##
</div>
<div .prop="${values.data}" onclick="${values.listener}">
${{static: "click me"}}
</div>
`
}

render(
document.body,
template({
id: "id",
class1: "x",
class2: "y",
foo: "foo",
bar: "bar",
value: "va",
options: {
name: "b",
},
a: "100",
b: "200",
c: "300",
d: "400",
data: [1, 2, 3],
listener() {
console.log("old", this.prop)
},
})
)

render(
document.body,
template({
id: "qqq",
class1: "zzzz",
class2: "vvvv",
foo: "iop",
bar: "bnm",
value: "98",
options: {
new: "value",
},
a: "900",
b: "800",
c: "700",
d: "600",
data: [10, 20],
listener() {
console.log("new", this.prop)
},
})
)

DOM 全体を作り直さないので 1 回目の render のあとに適当な属性をつけたり プロパティを追加した後に 2 つ目の render を実行してもユーザが手動で設定した部分は競合しない限りそのままです

作ってみて必要だったのかな?と思う部分もあって 「data-${values.foo}-${values.bar}」 の部分と 「${values.options}」 の部分です
属性名も埋め込み可能です が static 同様初回に確定して変更はできません

options の方は属性名の部分埋め込みではなく属性名が埋め込み値そのものです
この場合はオブジェクトとして属性をまとめて設定できます
1 回目の render では {name: "b"} なので name 属性に b という値が設定されます
2 回目の render では {new: "value"} なので new 属性に value という値が設定されます
1 回目で設定された name 属性は消されます

ただこの機能はオブジェクト自身に変更がないとダメで 中身だけ変わっても変更を検知しないので効果なしです
毎回

const old_value = {id: "x", class: "a bc d"}
const new_value = {...old, id: "y"}

みたいに新しいオブジェクトにしていれば大丈夫ですが 結構手間です
必要な属性はそれぞれ書いておけば良くて 状態に応じて追加されたり削除されたりってあまりないので使う必要ないかもしれません

hidden, disabled を切り替えたりなら 「.hidden=${false}」 とか 「.disabled=${true}」 とかプロパティとして true/false 設定すればいいですし

repeat

リピート機能はコンポーネント側でやれば良いと書いたのでやってみた例です

customElements.define("x-page", class extends HTMLElement {
constructor(){
super()
this.attachShadow({mode: "open"})

this.h1 = "example"
this.items = [
{id: 1, name: "x", message: "y"},
{id: 2, name: "a", message: "b"},
{id: 3, name: "s", message: "t"},
]
}

connectedCallback(){
this.render()
}

render(){
render(this.shadowRoot, xtpl`
<h1>${this.h1}</h1>
<x-container key-name="id" template-component="x-item" .items=${this.items}></x-container>
`)
}
})


customElements.define("x-container", class extends HTMLElement {
constructor(){
super()
this.attachShadow({mode: "open"})
this._items = []
this.map = {}
}

connectedCallback(){
this.render()
}

render(){
const template_component = this.template_component
const id = this.key_name
if(!id || !template_component) return
for(const item of this._items){
const key = item[id]
if(!this.map[key]){
const new_elem = document.createElement(template_component)
this.map[key] = new_elem
}
const elem = this.map[key]
elem.data = item
this.shadowRoot.append(elem)
}
const first_item = this._items[0]
const first_elem = first_item ? this.map[first_item[id]] : null
let elem = null
while((elem = this.shadowRoot.firstElementChild) && elem !== first_elem){
elem.remove()
}
}

rerender(){
this.shadowRoot.innerHTML = ""
this.render()
}

get key_name(){
return this.getAttribute("key-name")
}

set key_name(value){
this.setAttribute("key-name", value)
this.rerender()
}

get items(){
return this._items
}

set items(value){
this._items = value
this.render()
}

get template_component(){
return this.getAttribute("template-component")
}

set template_component(value){
this.setAttribute("template-component", value)
this.rerender()
}
})

customElements.define("x-item", class extends HTMLElement {
constructor(){
super()
this.attachShadow({mode: "open"})
}

connectedCallback(){
this.render()
}

get data(){
return this._data
}

set data(value){
this._data = value
this.render()
}

render(){
render(this.shadowRoot, xtpl`
<div>${this.data.name}「${this.data.message}」</div>
`)
}
})

document.body.innerHTML = `<x-page></x-page>`

可変個数の x-item を作りたいとします
x-container の items に可変個数にしたいデータの配列を渡します
items の配列のそれぞれが一意となるキーが必要なので key-name にそのキー名を設定します
データが変える場合はこうします

const p = document.querySelector("x-page")
p.items = [
{id: 1, name: "x", message: "cc"},
{id: 5, name: "y", message: "dd"},
{id: 3, name: "z", message: "zz"},
]
p.render()

items を変えて再度 render を実行します
x-container は items が更新されると自動で必要な箇所のみ更新します