◆ (速度じゃなく機能が) 軽いもの 作ってみた

hyperHTML や lit-html では親から子への一方向です
Redux 風な全体で状態を保持するものと合わせて使ってみていたのですが いろいろと辛くなってきたので双方向 Binding したいなと思い始めました
各所で必要な値の最新の状態を常に持ってることになるので使う分にはすごく楽です

Polymer とか双方向 Binding をサポートしてる Framework を使えばそういう機能を使えるのですが Framework を使うほどのことをしたくないです
かと言って自分で作るのはちょっと面倒です
自身を直接更新したとき・親が変わったとき・子が変わったとき これらのタイミングで値と画面を更新する必要があります
兄弟要素でも Binding 設定によっては共通の親要素の変更を通して変わるわけですし考えることが多いです
特に子から親への方向は要素が可変だとけっこう大変です

そうは思いながらもとりあえずそれっぽいものということで作ってみました
DOM の構造が変わることは考えてないので 切り替えは hidden のみで動的にクローンを作って行が増えたりとかは対応してません
この要素を継承させます

class BindingBase extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: "open" })
this.ready = false
}

connectedCallback() {
if (!this.ready) {
this.ready = true

this.initProperty()
this.initDOM()
this.initBinding()
this.initListener()
this.notifyDown()
}

this.update()
}

initProperty() {
this.values = {}
for (const name of this.properties) {
Object.defineProperty(this, name, {
get() {
return this.values[name]
},
set(value) {
this.updateProperty(name, value)
},
})
}
}

get properties() {
return []
}

initDOM() {
this.shadowRoot.innerHTML = this.template
}

get template() {
return ""
}

initBinding() {
const bindings = []
for (const elem of this.shadowRoot.querySelectorAll("*")) {
for (const attribute of elem.attributes) {
if (attribute.name.startsWith("b-")) {
bindings.push({
target: elem,
child_property: attribute.name.substr(2),
parent_property: attribute.value,
})
}
}
}
this.bindings = bindings
}

initListener() {
this.shadowRoot.addEventListener("property-update", eve => {
eve.stopPropagation()
const binding = this.bindings.find(e => e.target === eve.target && e.child_property === eve.detail.property)
if (!binding) return
this[binding.parent_property] = eve.detail.value
})
}

updateProperty(name, value) {
if(this[name] === value) return
this.values[name] = value
this.update()

this.dispatchEvent(new CustomEvent("property-update", { bubbles: true, detail: { property: name, value } }))
for (const binding of this.bindings) {
if (binding.parent_property === name) {
binding.target[binding.child_property] = value
}
}
}

update(){}

notifyDown() {
for (const binding of this.bindings) {
binding.target[binding.child_property] = this[this.parent_property]
}
}
}

このクラスを使った例です

customElements.define(
"binding-elem1",
class extends BindingBase {
get template() {
return `
<p id="p"></p>
<binding-elem2 b-z99="x10" b-a15="y32"></binding-elem2>
<binding-elem3 b-h87="x10" b-v91="t49"></binding-elem3>
`
}

get properties() {
return ["x10", "y32", "t49"]
}

update(){
this.shadowRoot.querySelector("#p").innerHTML = `
be1<br>
x10: ${this.x10}<br>
y32: ${this.y32}<br>
t49: ${this.t49}<br>
`
}
}
)

customElements.define(
"binding-elem2",
class extends BindingBase {
get template() {
return `
<p id="p"></p>
<binding-elem3 b-h87="z99" b-v91="a15"></binding-elem3>
`
}

get properties() {
return ["z99", "a15"]
}

update(){
this.shadowRoot.querySelector("#p").innerHTML = `
be2<br>
z99: ${this.z99}<br>
a15: ${this.a15}<br>
`
}
}
)

customElements.define(
"binding-elem3",
class extends BindingBase {
get template() {
return `<p id="p"></p>`
}

get properties() {
return ["h87", "v91"]
}

update(){
this.shadowRoot.querySelector("#p").innerHTML = `
be3<br>
h87: ${this.h87}<br>
v91: ${this.v91}<br>
`
}
}
)

document.body.innerHTML = `<binding-elem1></binding-elem1>`

Binding に使用するプロパティは properties の getter に書きます
自動で getter/setter をセットしてくれます
setter では updateProperty メソッドを呼び出します

「親から来た通知だから子の方向へ」 とか 「子から来た通知だからそれ以外の子と親へ」 とか考えるのも複雑になるので 来た方向であろうと全方向に流します
受け取ったときに同じ値だと無視するので無限ループにはならないようにしています

bining の定義の方法は

<div b-foo="bar"></div>

となります
「b-」 から始まる属性を作って 「b-」 の後にはその要素 (この例だと div) のプロパティを書きます
値の方にはその要素のプロパティに binding する自身のプロパティ名を書きます
div の foo プロパティへ this.bar が binding されるということです

初期化処理時に内側の全要素の binding 定義を保存して 各要素は properties にあるプロパティが更新されると常にイベントで親方向に通知します
イベントを受け取ったら保存した binding の定義の中にマッチするものがあったら対応する自身のプロパティを更新します
この 「先に binding の定義を保存してしまってる」 ということが後からの可変要素に対応するのが難しい理由です

毎回イベントを受けたタイミングで binding 定義を調べるのもできなくはないですがムダな処理が多くなります
特に子要素への通知では変更されたプロパティが binding される要素を内側全体から毎回探すことになります
属性名がわからず値のみとなるのでクエリセレクタともいかず自力で全部を見て回ることになります
実際には存在しなくてもそれを確認するために探す必要がありますし 一つの変更が DOM のあちこちに伝播するので DOM に存在する要素が多いほど重い処理になります

属性値を逆にして 「b-(自分のプロパティ名)=(子要素のプロパティ名)」 にすればクエリセレクタは使えるようになります
しかし ひとつの親プロパティに対して複数の子プロパティを binding したいことはあっても逆はないはずです
それをできるようにするには属性名の方に子要素のプロパティをもってくるしかありません


子1=親1
子2=親1


子1=親1
子1=親2

あと hyperHTML などは DOM に情報が残らないので親から子方向への通知に使うと 子から親方向への通知のために同じような情報をもう一度書くことになるので 使わないようにしました
プロパティ書き換えたら対応する部分が勝手に変わるのでなくても困らないですから

不足部分

MutationObserver で b-*** プロパティと追加削除される要素を監視して binding の更新と input 系など標準のイベントも binding できるようにしたら実用性あったりするのかなと思ってます