「haunted」 - React hooks APIのWeb Component実装

# hauntedとは

最近何かと話題に上る「React hooks」ですが、React以外でも「hook」したいですよね。

[haunted](https://github.com/matthewp/haunted)は、標準のWeb Componentで「hook」する為のライブラリです。

ライセンスは「BSD-2-Clause」です。[^1]

[^1]: 2019/09/19 現在

読みは、「ホーンテッド」？かと。~~浦安のハツカネズミの生息地にそんなマンションがあったような~~

執筆時点で、バージョンは「4.5.4」です。

## 使い方

`haunted`はデフォルトで`lit-html`を使う前提になっています。

以下は、[github](https://github.com/matthewp/haunted)にあるコードです。

`〇〇.html`で保存し、ブラウザで開けば動きます。（僕の場合、Goole Chrome 76.0.3809.132）

カスタムエレメントを定義する場合の例です。

```html
<!doctype html>
<html lang="en">

<my-counter></my-counter>

<script type="module">
  import { html } from 'https://unpkg.com/lit-html/lit-html.js';
  import { component, useState } from 'https://unpkg.com/haunted/haunted.js';

  function Counter() {
    const [count, setCount] = useState(0);

    return html`
      <div id="count">${count}</div>
      <button type="button" @click=${() => setCount(count + 1)}>Increment</button>
    `;
  }

  customElements.define('my-counter', component(Counter));
</script>
```

`Counter`関数は、[lit-htmlのTemplateResult](https://lit-html.polymer-project.org/guide)を作る関数です。

`component`関数は、[lit-htmlのrender](https://lit-html.polymer-project.org/guide/rendering-templates)`TemplateResult`を渡し、`HTMLElement`のクラスを返します。

つまり、

> `component`関数 ＝ Web Componentにおける「コンポーネント ＝ HTMLElementを作る関数」

と捉えておいてください。

そのままカスタムエレメントとして登録できるということです。

## 別のDOM生成ライブラリも使える

「haunted」の目玉機能として、**adoptable**なところがあります。

`lit-html`以外の`hyperhtml`などのDOM生成ライブラリも使える仕組みです。

[Importing](https://github.com/matthewp/haunted#importing)

しかしせっかくなので、「hauntedの**adoptable**なところ」をお見せする為に、素のDOM APIを使ってみます。

先ほどのサンプルを少し書き換えました。

```html
<!DOCTYPE html>
<html lang="en">
  <my-counter></my-counter>

  <script type="module">
    import { html } from "https://unpkg.com/lit-html/lit-html.js";
    import haunted, { useState } from "https://unpkg.com/haunted/haunted.js";

    const { component } = haunted({
      // DOM総入れ替えの為、とてつもなく効率が悪い
      render(what, where) {
        // 親要素の中身をすべてクリア
        while (where.hasChildNodes()) {
          where.removeChild(where.firstChild);
        }

        // Counter関数で作ったDOM要素をぶち込む
        what.forEach(element => {
          where.appendChild(element);
        });
      }
    });

    function Counter() {
      const [count, setCount] = useState(0);

      // カウントする数字を見せる枠
      const countContainer = document.createElement("div");
      const countText = document.createTextNode(count);
      countContainer.appendChild(countText);

      // カウントアップのボタン
      const countupButton = document.createElement("button");
      countupButton.innerText = "Increment";
      countupButton.setAttribute("type", "button");
      countupButton.addEventListener("click", e => {
        setCount(count + 1);
      });

      return [countContainer, countupButton];
    }

    customElements.define("my-counter", component(Counter));
  </script>
</html>

```

今回作った`Counter`関数は、「DOM要素の配列」を返します。

`haunted`関数に渡す`render`関数には、「`what`と`where`」が引数として渡されますが、

`what`には、`Counter`関数の戻り値 ＝ 「DOM要素の配列」が入っています。

`where`は、親要素が渡されます。

今回は、親要素を空にして、DOM要素の配列を順番に入れていくだけの非効率ロジックですが、説明の為なのでご勘弁を。

## 属性を渡すには

作ったコンポーネントに属性を渡すには、`observedAttributes`を使います。

下は、`value`属性を渡して、初期値にする場合の例です。

```html
<!DOCTYPE html>
<html lang="en">
  <my-counter value="100"></my-counter>

  <script type="module">
    import { html } from "https://unpkg.com/lit-html/lit-html.js";
    import { component, useState } from "https://unpkg.com/haunted/haunted.js";

    function Counter({ value }) {
      const [count, setCount] = useState(Number(value) || 0);

      return html`
        <div id="count">${count}</div>
        <button type="button" @click=${() => setCount(count + 1)}>
          Increment
        </button>
      `;
    }

    Counter.observedAttributes = ["value"];

    customElements.define("my-counter", component(Counter));
  </script>
</html>
```

属性には文字列か`undefined`が来ると思うので、その前提でハンドリングするといいと思います。

今回の例は、`Number()`に渡して、`NaN`だったら強制的に`0`にしています。

## 属性で文字列しか渡せないのは嫌だ

「属性として」じゃなくて、「JavaScript関数の引数」として値を渡したいですよね。

コンポーネントを関数として、配列とかオブジェクトを引数として渡せるようにしたいです。

そんなときは、`virtual`関数を使いましょう。

```html
<!DOCTYPE html>
<html lang="en">
  <my-app></my-app>

  <script type="module">
    import { html } from "https://unpkg.com/lit-html/lit-html.js";
    import {
      component,
      useState,
      virtual
    } from "https://unpkg.com/haunted/haunted.js";

    function Counter({ value }) {
      const [count, setCount] = useState(value);

      return html`
        <div id="count">${count}</div>
        <button type="button" @click=${() => setCount(count + 1)}>
          Increment
        </button>
      `;
    }

    const VirtualCounter = virtual(Counter);

    function App() {
      return html`
        ${VirtualCounter({ value: 100 })}
      `;
    }

    customElements.define("my-app", component(App));
  </script>
</html>
```

さっきと変わったのは、`Counter`関数を`virtual`関数でラップした`VirtualCounter`を作っている点です。

`VirtualCounter({ value: 100 })`というように、普通に関数として呼び出せてます。

あとは、`<my-app>`コンポーネントを定義して、描写しているだけです。簡単。

しかしこちらは、[lit-htmlのdirective](https://lit-html.polymer-project.org/guide/creating-directives)を使っているようなので、**別のDOM生成ライブラリの利用はできない**ようです。

## 「hook」を自作する

もちろん「hook」を自作できます。

[Write Your Own Hook](https://github.com/matthewp/haunted#write-your-own-hook)

`useMyUseState`という「hook」を作ってみました。

~~本家の`useState`の1000倍くらい遅くて、危なっかしい俺の`useState`を使えというオラオラhookです。~~

```js
    import {
      hook,
      Hook
    } from "https://unpkg.com/haunted/haunted.js";

    const useMyUseState = hook(
      class extends Hook {
        constructor(id, el, value) {
          super(id, el);

          this.value = value;
        }

        update(value) {
          return [
            this.value,
            value => {
              this.value = value;
              this.el.update();
            }
          ];
        }

        teardown() {}
      }
    );
```

`update`メソッドは、`setState`が呼び出される毎に呼ばれます。

`useMyUseState`では、そのたびに`this.value`を更新し、`this.el.update()`でコンポーネントを再度描写しています。

`teardown`は、

- カスタムエレメントだと`disconnectedCallback`

- `virtual component`だと、「DOMに変更があったとき、親ノードが`ShadowRoot`かどうかで判定している」ので**たぶん**コンポーネントが消えた時でいいと思います。

# まとめ

正直、`lit-html`を昔使ったとき不満が多くてやめたんですが、`virtual component`とか、`lit-html`使ってみたくなる機能が入ってて、[universal-router](https://github.com/kriasoft/universal-router)とか組み合わせて、アプリ作ってみたら楽しそうだなあと思いました。

あと、ドキュメント読んで分からないところとかはソースコードを読んだので、もしかしたら間違ってるかもしれません。

その時はご指摘いただけるとありがたいです。

正直、わかりやすい記事書けた感が無いので、もっと文才のある方の記事をお待ちしております。（投げやり）

そんなこんなで、[hounted](https://github.com/matthewp/haunted)の紹介でした。