素のJSでReactみたいにDOMを生成

## 概要

素のJSでもReactみたいにHTML中にインラインでJSと連携させてDOMを生成したい。

## 問題と問題がどう解決するか

素のJSでは、HTML文字列と、JSの設定が分離してしまう。

```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` などで特定し、
要素のプロパティに値を設定してやる必要がある。
これは煩雑だ。

本記事の対応を行うと、

```js
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` については、[ここ](https://developer.mozilla.org/ja/docs/Web/API/DocumentFragment) が参考になる。
DOMは基本的に `DocumentFragment` として扱うようにしよう。

HTML文字列を `DocumentFragment` に変換するのには、タグ付きテンプレートリテラルを使おう。
タグ付きテンプレートリテラルについては、[ここ](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Template_literals#%E3%82%BF%E3%82%B0%E4%BB%98%E3%81%8D%E3%83%86%E3%83%B3%E3%83%97%E3%83%AC%E3%83%BC%E3%83%88) が参考になる。
見た目も自然で、文字列と非文字列が入り乱れるDOM生成と相性が良い。

まずは、受け取ったHTML文字列から `DocumentFragment` を生成する、
最も単純なタグ付きテンプレートリテラル向けの関数 `html` を作ってみよう。

```js
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(...) `しているのは、
内部にカスタム要素が含まれていた場合に、こうしないと実体化されないからだ。

利用するコードは、

```js
    this.#root.innerHTML = `
      <div>
        <button id="button">Button</button>
      </div>
    `;
```

と書いていた部分が、

```js
    this.#root.replaceChildren(html`
      <div>
        <button id="button">Button</button>
      </div>
    `);
```

と書くようになる。

ほとんど同じに見えるが、HTML文字列の設定ではなく、`DocumentFragment` の設定に変更されていて、
この段階でもやっていることは大きく変化している。

## 属性への設定をJSからのプロパティ設定にする

HTML文字列での属性値は文字列しか扱えない。
（例外はあるが）基本的には関数を設定したりオブジェクトを設定したりはできない。

属性に対応するプロパティへのJSでのインラインでの設定を可能にしていく。

具体的には、最初に記載したように、

```js
    this.#root.replaceChildren(html`
      <div>
        <button onclick=${() => this.#handleClick()}>Button</button>
      </div>
    `);
```

と書けるようになる。

これを実現するために、タグ付きテンプレートリテラル用関数 `html` を以下のように拡張する。

```js
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で指定できるようになった。

また、この実装により、カスタム要素に文字列以外の値を自然に設定できるようにもなった。

```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` を指定可能にしたくなる。

具体的には、

```js
function createButton(props) {
  return html`<button onclick=${() => props.onClick()}>Button</button>`;
}
```

という `DoucmentFragment` を生成する関数を、

```js
    this.#root.replaceChildren(html`
      <div>
        ${createButton({ onClick: () => this.#handleClick() })}
      </div>
    `);
```

みたいにHTML文字列の途中に書きたい。
（少し逸れるが、`createButton` のような実装は、`html` により随分簡潔に書けるようになった）

`html` 関数を以下のように拡張する（関連箇所以外は省略）

```js
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については、[ここ](https://reactjs.org/docs/refs-and-the-dom.html#callback-refs) に情報がある。

属性名が `ref` かつ、属性値が関数だった場合、
属性値を代入するのではなく、その要素を引数にして属性値の関数を呼び出すという変更を行うのみだ。

```js
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` 属性を使わずに対応できるようになった。

```js
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();
  }
}
```

## 配列に対応する

```js
    this.#root.replaceChildren(html`
      <div>
        ${[...Array(3)].map(() =>
          createButton({ onClick: () => this.#handleClick() })
        )}
      </div>
    `);
```

みたいに、複数の `DocumentFragment` を `map` で生成するような場合を考えると、
値部分が `DocumentFragment` の配列に対応しているとうれしい。

```js
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` の場合は無視するようにしておくと良い。

```js
function html(strs, ...vals) {
  // ...
        if (v === undefined) {
          return s;
        }
  // ...
}
```

こうしておけば、

```js
    this.#root.replaceChildren(html`
      <div>
        ${this.#isLoading ? "<div>Loading...</div>" : ""}
      </div>
    `);
```

と書くところが、

```js
    this.#root.replaceChildren(html`
      <div>
        ${this.#isLoading && "<div>Loading...</div>"}
      </div>
    `);
```

とちょっと簡潔に書けるようになる。

（実際には、ローディング表示非表示のような状況に対しては、全書き換えはの勿体ないので、
要素をメンバ保持しておいて、個別で `hidden` 設定する気がするので例があまりよくないが）

## 最終的な `html` 関数

https://github.com/htsnul/html

```js
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で連携できるようになった。