4
3

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-02-11

概要

素の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 に設定して、contentDocumentFragment を取り出している。

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 によりダミー要素を特定し、
元々指定していた本来の DocumentFragmentElement.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>
    `);

みたいに、複数の DocumentFragmentmap で生成するような場合を考えると、
値部分が 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で連携できるようになった。

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3