概要
素のJSでもReactみたいにHTML中にインラインでJSと連携させてDOMを生成したい。
問題と問題がどう解決するか
素のJSでは、HTML文字列と、JSの設定が分離してしまう。
class MyComponent extends HTMLElement {
#root;
constructor() {
super();
this.#root = this.attachShadow({ mode: "closed" });
this.#root.innerHTML = `
<div>
<button id="button">Button</button>
</div>
`;
this.#root.querySelector("#button").onclick = () =>
this.#handleClick();
}
#handleClick() {
alert("Click");
}
}
customElements.define("my-component", MyComponent);
HTML文字列での属性値設定は文字列しか設定することができず、関数などの非文字列なJSの値を設定できないからだ。
要素に id
などを指定しておき、DOMを生成した後で querySelector
などで特定し、
要素のプロパティに値を設定してやる必要がある。
これは煩雑だ。
本記事の対応を行うと、
class MyComponent extends HTMLElement {
#root;
constructor() {
super();
this.#root = this.attachShadow({ mode: "closed" });
this.#root.replaceChildren(html`
<div>
<button onclick=${() => this.#handleClick()}>Button</button>
</div>
`);
}
#handleClick() {
alert("Click");
}
}
と、HTML文字列の途中にJSをインラインで書けるようになる。
id
などで後で要素を特定してからJSで触る必要がなくなる。
基本
HTML文字列はそのままではJSとは連携できない。
そのため、生成したDOMを基本として扱っていく。
生成したDOMは、DocumentFragment
として扱うと汎用性が高そうだ。
DocumentFragment
については、ここ が参考になる。
DOMは基本的に DocumentFragment
として扱うようにしよう。
HTML文字列を DocumentFragment
に変換するのには、タグ付きテンプレートリテラルを使おう。
タグ付きテンプレートリテラルについては、ここ が参考になる。
見た目も自然で、文字列と非文字列が入り乱れるDOM生成と相性が良い。
まずは、受け取ったHTML文字列から DocumentFragment
を生成する、
最も単純なタグ付きテンプレートリテラル向けの関数 html
を作ってみよう。
function html(strs, ...vals) {
const text =
vals
.map((v, i) => {
const s = strs[i];
return s + v;
})
.join("") + strs.at(-1);
const template = document.createElement("template");
template.innerHTML = text;
const fragment = document.importNode(template.content, true);
return fragment;
}
単純に文字列部分と値部分をひとつなぎの文字列、text
にしている。
交互にサンドイッチになっているので、strs
のほうが1つ要素が多く、最後にそれだけ追加している。
それを、template
要素の innerHTML
に設定して、content
で DocumentFragment
を取り出している。
template
要素を使う以外の方法もあるのだが、
この方法は、table
要素内部を部分生成する場合などでもうまく動作する。
また、単純に content.clone()
せずに document.importNode(...)
しているのは、
内部にカスタム要素が含まれていた場合に、こうしないと実体化されないからだ。
利用するコードは、
this.#root.innerHTML = `
<div>
<button id="button">Button</button>
</div>
`;
と書いていた部分が、
this.#root.replaceChildren(html`
<div>
<button id="button">Button</button>
</div>
`);
と書くようになる。
ほとんど同じに見えるが、HTML文字列の設定ではなく、DocumentFragment
の設定に変更されていて、
この段階でもやっていることは大きく変化している。
属性への設定をJSからのプロパティ設定にする
HTML文字列での属性値は文字列しか扱えない。
(例外はあるが)基本的には関数を設定したりオブジェクトを設定したりはできない。
属性に対応するプロパティへのJSでのインラインでの設定を可能にしていく。
具体的には、最初に記載したように、
this.#root.replaceChildren(html`
<div>
<button onclick=${() => this.#handleClick()}>Button</button>
</div>
`);
と書けるようになる。
これを実現するために、タグ付きテンプレートリテラル用関数 html
を以下のように拡張する。
function html(strs, ...vals) {
const attrs = [];
const text =
vals
.map((v, i) => {
// ...
if (s.endsWith("=") && typeof v !== "string") {
const name = s.match(/ (\w+)=$/)[1];
attrs.push({ name, value: v });
return (
s.slice(0, -`${name}=`.length) +
`data-${name}="${attrs.length - 1}"`
);
}
// ...
})
// ...
attrs.forEach((attr, i) => {
const el = fragment.querySelector(`[data-${attr.name}="${i}"]`);
el[attr.name] = attr.value;
el.removeAttribute(`data-${attr.name}`);
});
// ...
}
値の直前の文字列の末尾が =
かつ、値が非文字列だった場合は、直前の文字列から属性名を拾い、
自身の属性値とセットで属性配列に保管していく。
HTML文字列側には、ダミーのデータ属性を文字列で設定しておく。
具体的には、onclick=${...}
となっている箇所は -data-onclick="(対応する配列インデクス)"
に置き換える。
これを、DocumentFragment
化後に、保管しておいた属性配列で forEach
して、
ダミーのデータ属性をたよりに querySelector
で特定し、属性に対応するプロパティにJSで値を代入していく。
プロパティへの代入が終わったら、ダミーで設定したデータ属性は不要になるので削除しておく。
これでHTML文字列の途中で、属性に対応するプロパティをJSで指定できるようになった。
また、この実装により、カスタム要素に文字列以外の値を自然に設定できるようにもなった。
class MyButton extends HTMLElement {
// ...
set props(props) {
this.#props = props;
}
}
customElements.define("my-button", MyButton);
class MyComponent extends HTMLElement {
#root;
#input;
constructor() {
super();
this.#root = this.attachShadow({ mode: "closed" });
this.#root.replaceChildren(html`
<div>
<my-button
props=${{ text: "Button", onClick: () => this.#handleClick() }}
></my-button>
</div>
`);
}
// ...
}
これによって、通常、カスタム要素では属性では文字列しか設定できないが、
一見属性値を設定しているように見せつつプロパティへの設定を行うことで、
カスタム要素に外部から自然な形でオブジェクトなどの非文字列を設定できるようになった。
この例では、セッターを使っているので props
セッター関数の呼び出しが行われるが、
公開プロパティでも問題はないはずだ。
HTML文字列中にインラインで DocumentFragment
を挟み込めるようにする
HTML文字列を記載していると、対応箇所のカスタム要素を作るほどでもない、
Reactでいうと状態を持たない関数コンポーネント程度のものを記載したくなることがある。
HTMLで完結するのであればHTML文字列を返す関数にする手もあるのだが、JSとの連携が含まれていると、
HTML文字列だとできない、なので DocumentFragment
を返す関数を作ることになる。
そうすると html
関数をタグ付きテンプレートリテラルと呼び出した場合の値部分に、
DocumentFragment
を指定可能にしたくなる。
具体的には、
function createButton(props) {
return html`<button onclick=${() => props.onClick()}>Button</button>`;
}
という DoucmentFragment
を生成する関数を、
this.#root.replaceChildren(html`
<div>
${createButton({ onClick: () => this.#handleClick() })}
</div>
`);
みたいにHTML文字列の途中に書きたい。
(少し逸れるが、createButton
のような実装は、html
により随分簡潔に書けるようになった)
html
関数を以下のように拡張する(関連箇所以外は省略)
function html(strs, ...vals) {
// ...
const frags = [];
const preserveFrag = (frag) => {
if (frag.firstElementChild === null) {
return frag.textContent;
}
frags.push(frag);
const el = document.createElement(frag.firstElementChild.tagName);
el.setAttribute("id", `_${frags.length - 1}`);
return el.outerHTML;
};
const text =
vals
.map((v, i) => {
// ...
if (v instanceof DocumentFragment) {
return s + preserveFrag(v);
}
// ...
})
// ...
frags.forEach((frag, i) => {
const el = fragment.querySelector(`#_${i}`);
el.replaceWith(frag);
});
// ...
}
もし、vals
の要素が、DocumentFragment
だった場合、
frags
配列に保持しておき、HTML文字列には、ダミー要素のタグを配置する。
このダミー要素のタグ名は、DocumentFragment
の最初の要素のものにする。
何故かというと、table
関連要素など、包含関係に制約がある要素があるため、
ダミーとはいえ、最低限、配置可能なタグにしないといけないからだ。
なお、要素が1つも存在しない場合は、後続処理が行えないし、
JSは関与していないので、そのまま文字列化したものを返してしまえばよい。
また、タグは空要素の可能性があり、その場合は閉じタグを書いてはいけない。
なので、document.createElement
して、outerHTML
を取り出すことで、
閉じタグの必要性をブラウザに判断させて、タグ文字列を生成している。
具体的には、div
なら <div></div>
になるし、input
なら <input>
になってくれる。
このダミー要素には、配列のインデクスに相当する値を id
として指定しておく。
DOM化された後は、id
によりダミー要素を特定し、
元々指定していた本来の DocumentFragment
に Element.replaceWith
で置き換えていく。
これで、HTML文字列中にインラインで、DocumentFrament
を挟み込めるようになった。
JSで要素を参照できるようにする
ここまでで、非文字列をインラインで挟み込めるようになったが、それでも要素を直接JSで見に行きたいときもある。
Reactでも要素をフォーカスしたい場合にはやはり直接要素を見に行っている。
ReactのCallback Refsという仕組みを真似しよう。
Callback Refsについては、ここ に情報がある。
属性名が ref
かつ、属性値が関数だった場合、
属性値を代入するのではなく、その要素を引数にして属性値の関数を呼び出すという変更を行うのみだ。
function html(strs, ...vals) {
// ...
attrs.forEach((attr, i) => {
const el = fragment.querySelector(`[data-${attr.name}="${i}"]`);
if (attr.name === "ref" && typeof attr.value === "function") {
attr.value(el);
} else {
el[attr.name] = attr.value;
}
// ...
});
// ...
}
これで、生成時に特定の input
要素を focus
したい、みたいな状況で、
id
属性を使わずに対応できるようになった。
class MyComponent extends HTMLElement {
#root;
#input;
constructor() {
super();
this.#root = this.attachShadow({ mode: "closed" });
this.#root.replaceChildren(html`
<div>
<input ref=${(el) => this.#input = el}/>
</div>
`);
}
connectedCallback() {
this.#input.focus();
}
}
配列に対応する
this.#root.replaceChildren(html`
<div>
${[...Array(3)].map(() =>
createButton({ onClick: () => this.#handleClick() })
)}
</div>
`);
みたいに、複数の DocumentFragment
を map
で生成するような場合を考えると、
値部分が DocumentFragment
の配列に対応しているとうれしい。
function html(strs, ...vals) {
// ...
const text =
vals
.map((v, i) => {
// ...
if (Array.isArray(v)) {
const frag = html(Array(v.length + 1).fill(""), ...v);
return s + preserveFrag(frag);
}
// ...
})
// ...
}
のように、html
関数を、タグ付きテンプレートリテラルとしてでなく普通の関数として呼び出す。
html
関数の vals
部分は、今回でいえば DocumentFragment
のような非文字列が入っている場合に対応しているので、
そのまま利用できてしまう。
文字列部分は必要ないので、配列の個数より1つ大きい空文字の配列を生成して渡している。
これで、配列を1つの DocumentFragment
に変換できる。
あとは、v
が、DocumentFragment
の場合と同じ処理にしてしまえばよい。
undefined
に対応する
値が undefined
の場合は無視するようにしておくと良い。
function html(strs, ...vals) {
// ...
if (v === undefined) {
return s;
}
// ...
}
こうしておけば、
this.#root.replaceChildren(html`
<div>
${this.#isLoading ? "<div>Loading...</div>" : ""}
</div>
`);
と書くところが、
this.#root.replaceChildren(html`
<div>
${this.#isLoading && "<div>Loading...</div>"}
</div>
`);
とちょっと簡潔に書けるようになる。
(実際には、ローディング表示非表示のような状況に対しては、全書き換えはの勿体ないので、
要素をメンバ保持しておいて、個別で hidden
設定する気がするので例があまりよくないが)
最終的な html
関数
export default function html(strs, ...vals) {
const attrs = [];
const frags = [];
const preserveFrag = (frag) => {
if (frag.firstElementChild === null) {
return frag.textContent;
}
frags.push(frag);
const el = document.createElement(frag.firstElementChild.tagName);
el.setAttribute("id", `_${frags.length - 1}`);
return el.outerHTML;
};
const text =
vals
.map((v, i) => {
const s = strs[i];
if (s.endsWith("=") && typeof v !== "string") {
const name = s.match(/ (\w+)=$/)[1];
attrs.push({ name, value: v });
return (
s.slice(0, -`${name}=`.length) +
`data-${name}="${attrs.length - 1}"`
);
}
if (v instanceof DocumentFragment) {
return s + preserveFrag(v);
}
if (Array.isArray(v)) {
const frag = html(Array(v.length + 1).fill(""), ...v);
return s + preserveFrag(frag);
}
if (v === undefined) {
return s;
}
return s + v;
})
.join("") + strs.at(-1);
const template = document.createElement("template");
template.innerHTML = text;
const fragment = document.importNode(template.content, true);
attrs.forEach((attr, i) => {
const el = fragment.querySelector(`[data-${attr.name}="${i}"]`);
if (attr.name === "ref" && typeof attr.value === "function") {
attr.value(el);
} else {
el[attr.name] = attr.value;
}
el.removeAttribute(`data-${attr.name}`);
});
frags.forEach((frag, i) => {
const el = fragment.querySelector(`#_${i}`);
el.replaceWith(frag);
});
return fragment;
}
まとめ
素のJSで書くときに、id
などを指定しておき querySelector
で要素を取得してJSとして連携する必要があった処理が、
HTML文字列中でインラインでJSで連携できるようになった。