Lit-htmlでWeb ComponentのDOM変更を簡単に行う方法

こんにちは！ちゅらデータぬおーすてぃんやいびーん！

# 概要
Googleが開発している<a href="https://lit.dev/docs/libraries/standalone-templates/">Lit-html</a>を使って、前回作ったフォーム部品のDOM変更をより簡単に実行する方法を紹介します。

前回の記事はこちら：

https://qiita.com/tronicboy/items/4ba393e3d79afbab1406

# 背景
これ前のWeb Componentの記事では、１００％ Vanilla（つまり、npmの第三者パッケージを使わない）Web Componentを作ってきました。

しかし、このようなやり方では、EventListenerを追加する、DOMの変更を行うなど、コードが冗長になりがちなのです。

そこで、lit-htmlというパッケージを使うと、ほぼほぼVanillaのまま、HTMLを書きながら、値を代入したり、EventListenerをつけたりすることが簡単にできます。

しかも、DOMの変更も、lit-htmlに行なってもらえるのです。

実は、lit-htmlは<a href="https://lit.dev/">lit</a>の一部の機能を切り離したもので、Web Componentsとの相性も抜群です。

また、VS Codeを使っている人なら、以下のLit-html用のプラグインをインストールすれば、VS CodeのIntellisenseがHTML・CSSに対して使えるようになります。

https://marketplace.visualstudio.com/items?itemName=runem.lit-plugin

lit-htmlを使うことで、DOMをいじるわずらしさがなくなりつつ、VanillaのWeb Componentのメリットも満喫できます。

## プロジェクトへの追加

以下のコマンドを実行して追加します。
```
yarn add lit-html
```

そしたら、package.jsonが以下のようになります。

```json:package.json
{
  "name": "webpack-wc",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "devDependencies": {
    "@babel/core": "^7.18.2",
    "@babel/preset-env": "^7.18.2",
    "@babel/preset-typescript": "^7.17.12",
    "babel-loader": "^8.2.5",
    "css-loader": "^6.7.1",
    "html-loader": "^3.1.0",
    "html-webpack-plugin": "^5.5.0",
    "to-string-loader": "^1.2.0",
    "typescript": "^4.7.3",
    "webpack": "^5.73.0",
    "webpack-cli": "^4.9.2"
  },
  "dependencies": {
    "lit-html": "^2.2.5"
  }
}
```

これまでのwebpack設定を変えずにビルドできます！

## 前回のラジオ入力をリファクタリングしてみる
<a href="https://qiita.com/tronicboy/items/4ba393e3d79afbab1406">前回で作ったラジオ入力のWeb Component</a>では、index.ts、template.html、styles.cssに分かれていました。
今回、template.htmlは無くなります。

```typescript:wc-true-false-radio/index.ts
import styles from "./styles.css";

import { html, render } from "lit-html";

export default class WcTrueFalseRadio extends HTMLElement {
  #value = true;
  #name = "wc-radio";
  #labelText = "";
  #trueRadio: HTMLInputElement;
  #falseRadio: HTMLInputElement;
  #hiddenInput = document.createElement("input");

  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    if (!this.shadowRoot) throw Error("Shadow root not supported.");
    const styleElement = document.createElement("style");
    styleElement.innerHTML = styles;
    this.shadowRoot.append(styleElement);
  }

  get value(): boolean {
    return this.#value;
  }
  set value(newValue: any) {
    this.#value = Boolean(newValue); // ラジオ入力の値に変化が生じたら、DOMにもその結果を自動的に反映させる
    this.#render();
  }

  connectedCallback() {
    const labelText = this.getAttribute("label"); // index.htmlで指定したlabelの値を取得する
    if (!labelText) throw TypeError("label attribute must be defined.");
    this.#labelText = labelText;
    const inputName = this.getAttribute("name"); // Light DOMのフォームに入れる\<input name="">で使います。
    if (!inputName) throw TypeError("name attribute must be defined.");
    this.#name = inputName;
    this.#render();
    this.#trueRadio = this.shadowRoot!.querySelector<HTMLInputElement>("input#true")!;
    this.#falseRadio = this.shadowRoot!.querySelector<HTMLInputElement>("input#false")!;
    this.#hiddenInput = this.querySelector("input")!;
  }

  disconnectedCallback() {
    this.#falseRadio.removeEventListener("click", this.#handleRadioInput); // EventListenerを外すことは基本的に不要ですが、メモリリークを意識することがGood Practiceです。
    this.#trueRadio.removeEventListener("click", this.#handleRadioInput);
  }

  #handleRadioInput: EventListener = (event) => {
    const target = event.currentTarget;
    if (!(target instanceof HTMLInputElement)) throw Error("This listener must be used with an Input Element.");
    this.value = Boolean(Number(target.value));
  };

  #render() {
    if (!this.shadowRoot) throw Error("Cannot render Shadow DOM if not attached.");
    const shadowDOMTemplate = html`
      <span id="label">${this.#labelText}</span>
      <div id="input-group">
        <label for="true">はい</label>
        <input
          @click=${this.#handleRadioInput}
          type="radio"
          name="radio"
          id="true"
          checked
          .checked=${this.value}
          value="1"
          aria-describedby="label"
        />
        <label for="false">いいえ</label>
        <input
          @click=${this.#handleRadioInput}
          type="radio"
          name="radio"
          id="false"
          value="0"
          .checked=${!this.value}
          aria-describedby="label"
        />
        <slot name="hidden-input"></slot>
      </div>
    `;
    render(shadowDOMTemplate, this.shadowRoot); // なければ、Shadow DOMに追加、あれば、変わった箇所を変更する

    const lightDOMTemplate = html`
      <input type="hidden" slot="hidden-input" name=${this.#name} .value=${String(Number(this.value))} />
    `;
    render(lightDOMTemplate, this); // Light DOMに入ります。
  }
}
```

このようになります。lit-htmlのテンプレート記号の解説は以下の通り
| 記号 | 意味 |
| --- | --- |
| \@click | Vanilla JavaScriptのonclickに値します。\@〜でありとあらゆるEventListenerを追加することができます。|
| ?checked | checkedというAttributeをBooleanとして追加するかどうかを決めるもの。${}の中の変数がtrueだったら、\<input checked>になります。falseだったら、\<input>に。 checkedじゃなくても、任意のAttributeでも使えます。 |
| .value | \<input>のValueを設定するものですが、attributeとしてのValueではありません。DOMで見えるattributeとして指定したい時は、.valueではなく、valueを使用します。 |

参考のために元々あったtemplate.htmlを載せておきましょうね。これが不要になったのです。
```html
<span id="label"></span>
<div id="input-group">
  <label for="true">はい</label>
  <input type="radio" name="radio" id="true" checked value="1" aria-describedby="label">
  <label for="false">いいえ</label>
  <input type="radio" name="radio" id="false" value="0" aria-describedby="label">
  <slot name="hidden-input"></slot>
</div>
```

一度、renderが実行されれば、次から実行される時は違ったところのみを変えるようにします。ReactのようなVirtual DOMではありませんし、<strong>同じタグ名のものがあってもReactのようにkeyで特定して変更するような仕組みではないのでご注意ください。</strong>

# まとめ
以上、lit-htmlを単独でWeb Componentで使う方法をご紹介しました。これはあくまでも提案であり、他の使い方ももちろんあるかと思います。

筆者は、このlit-htmlはとても便利なツールだと思いますので、使って便利そうな部品では導入すればいいかと思います。

今度、litを使ったWeb Componentをご紹介したいと思います！