Web Componentsをフロントエンドフレームワークっぽく使ってみた

# 注意

**私はjavascriptを趣味で勉強しているものです。これが正しいやり方というわけではなく、私が趣味で勉強しながらこうやったら面白いんじゃないかと考えながら作ったものなので注意してください。**
**間違いなどありましたら、ご指摘お願いいたします** :bow:


タイトルの **フロントエンドフレームワークっぽく** という部分ですが、**コンポーネント単位で再利用可能**で**イベント設定ができる**、という感じのものを自分では思いながら作成しました。

この記事内のコードは全てchromeの最新で確認しました(version 73.0.3683.103)

# 参考

- [Web Componentsとは何か？ - Qiita](https://qiita.com/jtakiguchi/items/b1315f53b3726ff11b61)
- [Web Componentsに触れる - Qiita](https://qiita.com/Tksi/items/1b5d5094f064510593bb)
- [Web Components will replace your frontend framework](https://www.dannymoerkerke.com/blog/web-components-will-replace-your-frontend-framework)
- [Web Componentsを触ってみた ~後編~](https://liginc.co.jp/381393)
- [第1回 Shadow DOMが生まれた理由](https://app.codegrid.net/entry/shadow-dom-concepts)
- [shadow DOM の使い方 - Web Components | MDN](https://developer.mozilla.org/ja/docs/Web/Web_Components/Using_shadow_DOM)
- [Shadow DOM 201](https://www.html5rocks.com/ja/tutorials/webcomponents/shadowdom-201/)


# 簡単なカウンターを作成してみました

[コードはこちらにも用意しました](https://github.com/okumurakengo/00017_web_components)

![8uh9gDvkAh.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/a42c6b77-0d22-3347-06c1-e10c62a2be5f.gif)

```src/index.js
import AppElement from "./AppElement";

document.body.appendChild(AppElement);
```

```src/AppElement.js
import "./CountElement";
import "./BtnElement";

window.customElements.define("app-element", class extends HTMLElement {
    constructor() {
        super();
        const shadowRoot = this.attachShadow({ mode: "open" });

        shadowRoot.innerHTML = `      
            <div class="container">
                <count-element></count-element>
                <btn-element class="blue">up</btn-element>
                <btn-element class="green">down</btn-element>
            </div>

            <style>        
            .container {
                width: 200px;
                text-align: center;
                font-family: Verdana, sans-serif;
            }
            </style>
        `;
    }

    connectedCallback() {
        this.addEventListener("count", e => {
            this.shadowRoot.querySelector("count-element")
              .count(e.detail.countType);
        });
    }
});

export default document.createElement("app-element");
```

```src/BtnElement.js
window.customElements.define("btn-element", class extends HTMLElement {
    constructor() {
        super();
        const shadowRoot = this.attachShadow({mode: "open"});
        shadowRoot.innerHTML = `
            <button class="${this.className}" >
                <slot></slot>
            </button>

            <style>
            button {
                border-radius: 5px;
                cursor: pointer;
                font-size: 15px;
                height: 35px;
                width: 70px;
                color: #fff;
                transition: opacity .15s;
            }
            button:hover {
                opacity: .8;
            }

            button.blue {
                background-color: #007bff;
                border-color: #007bff;
            }
            button.green {
                background-color: #28a745;
                border-color: #28a745;
            }
            </style>
        `;
    }

    connectedCallback() {
        this.addEventListener("click", e => {
            this.count(e.target.textContent);
        });
    }

    count(countType) {
        this.dispatchEvent(new CustomEvent("count", { 
            bubbles: true,
            composed: true,
            detail: { countType },
        }));
    }
});
```

```src/CountElement.js
window.customElements.define("count-element", class extends HTMLElement {
    constructor() {
        super();
        const shadowRoot = this.attachShadow({mode: "open"});
        shadowRoot.innerHTML = `
            <p class="count">0</p>

            <style>
            .count {
                font-size: 25px;
                margin: 10px;
            }
            </style>
        `;
    }

    count(countTyep) {
        const p = this.shadowRoot.querySelector("p");
        p.textContent = Number(p.textContent) + (countTyep === "up" ? 1 : -1);
    }
});
```

```bash
npm i webpack webpack-cli
npx webpack --mode development #./dist/main.jsが出力
```

私のやり方が悪いかもしれないですが、vueやreactを使ったほうが綺麗になりそうだと思いました。

---

ここからは、自分が参考サイトなどから調べたりしたことを書いていきます

# Custom Elements

ユーザー定義のHTML要素

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
    <my-element></my-element>

<script>
class MyElement extends HTMLElement {
        constructor() {
            super();
            const shadowRoot = this.attachShadow({mode: "open"});
            shadowRoot.innerHTML = `      
                <div>hogefuga</div>    
                <style>        
                div {          
                    color: green;
                }      
                </style>      
            `;
        }
}
window.customElements.define("my-element", MyElement);
</script>
</body>
</html>
``` 

`window.customElements.define("my-element", MyElement);` と定義したので、htmlで`<my-element></my-element>`と書けば使うことができる　※タグ名の`-`は既存のタグ名と被らないようにつけたほうが良いようです

<img width="539" alt="Screen Shot 2019-04-14 at 18.11.53.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/415df029-de0c-7f8a-a028-87e3cba43752.png">

---

↑の例ではHTMLに直接`<my-element></my-element>`と書きましたが、javascriptからタグを生成することもできます

```html
<body>
    <script>
    window.customElements.define("my-element", class extends HTMLElement {
        constructor() {
            super();
            const shadowRoot = this.attachShadow({mode: "open"});
            shadowRoot.innerHTML = "<div>hogefuga</div>";
        }
    });

    // customElements.get()を使う
    const el = customElements.get("my-element");
    const myElement = new el();
    document.body.appendChild(myElement);

    // または、
    document.body.appendChild(document.createElement("my-element"));
    </script>
</body>
```


<img width="552" alt="Screen Shot 2019-04-14 at 18.26.52.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/d66fbcaa-3c2e-d7bd-983f-6607391f4131.png">






## ライフサイクルメソッド

- connectedCallback
- disconnectedCallback
- attributeChangedCallback
- adoptedCallback

### connectedCallback()

```html
<body>
    <my-element></my-element>
    <my-element></my-element>
    <my-element></my-element>

    <script>
    window.customElements.define("my-element", class extends HTMLElement {
        constructor() {
            super();
        }

        // 要素がDOMツリーに挿入されたときに呼び出される
        connectedCallback() {
            console.log(this) // ↑で3回my-elementタグを書いているので、3回実行される
        }
    });
    </script>
</body>
```

<img width="758" alt="Screen Shot 2019-04-14 at 18.22.37.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/8a6e8024-b21d-b1d3-152e-f7ea0cdfdd1e.png">

### attributeChangedCallback()

`observedAttributes()`の配列に存在する属性が変更された時に実行される

```html
<body>
    <my-element id="elm" foo="1" bar="a"></my-element>

    <script>
    window.customElements.define("my-element", class extends HTMLElement {
        constructor() {
            super();
            const shadowRoot = this.attachShadow({mode: "open"});
            shadowRoot.innerHTML = "<div>hogefuga</div>";
        }

        static get observedAttributes() {
            return ["foo", "bar"];
        }

        attributeChangedCallback(attr, oldVal, newVal) {
            switch(attr) {
                case "foo":
                    console.log("foo", oldVal, newVal);
                    return;
                case "bar":
                    console.log("bar", oldVal, newVal);
                    return;
            }
        }
    });
    </script>
</body>
```

![63mnVXJaQp.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/556b47cb-f4a0-2846-cf21-32cfec53791b.gif)

ちなみに実行順序は以下のようです

 ```
constructor -> attributeChangedCallback -> connectedCallback
```

## APIを設定

### 関数を定義

```html
<body>
    <my-element></my-element>

    <script>
    window.customElements.define("my-element", class extends HTMLElement {
        constructor() {
            super();
            const shadowRoot = this.attachShadow({mode: "open"});
            shadowRoot.innerHTML = "<div>hogefuga</div>";
        }

        doSomething() {
            console.log("do something");
        }
    });
    const element = document.querySelector('my-element');
    element.doSomething();
    </script>
</body>
```

<img width="638" alt="Screen Shot 2019-04-14 at 18.58.06.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/a3d94078-5f39-9593-4736-7b83e0ba7f6b.png">

### プロパティの定義

setterを使うことで簡単にできる、`disabled`プロパティを設定してみました

```html
<body>
    <my-element id="elm"></my-element>

    <script>
    window.customElements.define("my-element", class extends HTMLElement {
        constructor() {
            super();
            const shadowRoot = this.attachShadow({mode: "open"});
            shadowRoot.innerHTML = `
                <h1>hogefuga</h1>
                <input type="button" value="button">
            `;
        }

        set disabled(isDisabled) {
            if(isDisabled) {
                this.setAttribute("disabled", "");
                this.shadowRoot.querySelector("h1").style.opacity = 0.5;
                this.shadowRoot.querySelector("input").setAttribute("disabled", "");
            } else {
                this.removeAttribute("disabled");
                this.shadowRoot.querySelector("h1").style.opacity = 1;
                this.shadowRoot.querySelector("input").removeAttribute("disabled");
            }
        }

        get disabled() {
            return this.hasAttribute("disabled");
        }
    });
    </script>
</body>
```


![mti0zNrttZ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/6ba9dd5b-e068-9dc6-11ff-ceed343b406d.gif)

# Shadow DOM

Shadow DOMを使うとHTMLとCSSはコンポーネント内にカプセル化され、他のスタイルシートの影響を受けず、Shadow DOM内のスタイルが外部に影響することもなくなります。

video要素とかでもShadow DOMが使われています。

```html
<video controls></video>
```

chromeのデフォルトではデベロッパーツールでShadow DOMをみることはできません

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/16d86797-402e-5da7-cc08-918c20b6068a.png)

なので、settingから設定を変えましょう

![9ybbItHjXw.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/08357982-9d0e-4e62-9bd7-1c06ee583ce1.gif)

デベロッパーツールでもShadow DOMが確認できるようになりました

---

[shadow DOM の使い方](https://developer.mozilla.org/ja/docs/Web/Web_Components/Using_shadow_DOM)

引用

> 以下に shadow DOM における用語を定義します。
> 
> - Shadow host: shadow DOM が追加された、通常の DOM ノード
> - Shadow tree: shadow DOM の中にある DOM ツリー
> - Shadow boundary: shadow DOM と通常の DOM の境界
> - Shadow root: shadow ツリーの根ノード

`attachShadow()` でshadow root を任意の要素に追加することができます。
このメソッドではパラメータとして mode オプションを open または closed の値で取ります。 

`open` の場合は shadow DOM の内部にメインページに書かれた javascript からアクセスできます

```html
<div id="hoge"></div>
<div id="fuga"></div>
<video controls id="video"></video>

<script>
const hogeShadowRoot = hoge.attachShadow({mode: 'open'});
hogeShadowRoot.innerHTML = "<span>hogehoge</span>";
console.log(hoge.shadowRoot); // mode: 'open'と設定したので、shadow root を確認できる

const fugaShadowRoot = fuga.attachShadow({mode: 'closed'});
fugaShadowRoot.innerHTML = "<span>fugafuga</span>";
console.log(fuga.shadowRoot); // mode: 'closed'と設定したので、nullとなる

console.log(video.shadowRoot); // videoタグは mode:closed になっているので、null
</script>
```

![Screen Shot 2019-04-14 at 19.45.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/32b0a3c1-f956-39bc-7697-b00d2b3c270b.png)


### `:host`セレクタを使用してコンポーネント自体のスタイルを設定

shadow DOM が追加された、通常の DOMにスタイルを設定できる

```css
:host {
  display: block;
}
```

`disabled`属性が付いている時だけスタイルを当てることもできました

```html
<body>
<my-element></my-element>
<my-element disabled></my-element>

<script>
window.customElements.define("my-element", class extends HTMLElement {
    constructor() {
        super();
        const shadowRoot = this.attachShadow({mode: "open"});
        shadowRoot.innerHTML = `
            <h1>hogehoge</h1>
            <h2>fugafuga</h2>
            <h3>piyopiyo</h3>

            <style>
            /* disabled属性が付いている時だけstyleを適用 */
            :host([disabled]) {
                display: block;
                opacity: 0.5;
            }
            </style>
        `;
    }
});
</script>
</body>
```

<img width="765" alt="Screen Shot 2019-04-14 at 20.06.12.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/82d7ed2f-e682-db09-b10f-d17f31f636de.png">

### Shadow DOMの外側からstyleを適用する

css変数を使うことでうまくいくようです

```html
<my-element></my-element>
<style>
my-element {
    --background-color: tomato;
}
</style>
        
<script>
window.customElements.define("my-element", class extends HTMLElement {
    constructor() {
        super();
        const shadowRoot = this.attachShadow({mode: "open"});
        shadowRoot.innerHTML = `
            <h1>hogehoge</h1>
            <style>
            h1 {
                background-color: var(--background-color);
            }
            </style>
        `;
    }
});
</script>
```

<img width="765" alt="Screen Shot 2019-04-14 at 20.33.01.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/cf43da7f-afb1-9a07-788c-33eea4059fdf.png">

## slots

`<slot>`はShadow DOM内でプレースホルダーのように機能してくれます

ユーザー提供のマークアップとShadow DOMを合体させて新しいDOMツリーを作ってくれます

```html
<body>
<image-gallery>
  <img src="http://placehold.jp/100x100.png">
  <img src="http://placehold.jp/100x100.png">
</image-gallery>

<script>
window.customElements.define("image-gallery", class extends HTMLElement {
    constructor() {
        super();
        const shadowRoot = this.attachShadow({mode: "open"});
        shadowRoot.innerHTML = `
            <h1>hoge gallery</h1>
            <slot></slot>
        `;
    }
});
</script>
</body>
```

↑の例ですと、`<image-gallery>`の子要素が、Shadow DOMの`<slot></slot>`の部分に適用されます

<img width="796" alt="Screen Shot 2019-04-14 at 20.52.28.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/3862c7de-3dca-64da-c8a7-2abc7a0a6c42.png">

---

名前付きで`<slot>`を指定することもできます

```html
<body>
<image-gallery>
  <img src="http://placehold.jp/100x100.png" slot="fuga">
  <img src="http://placehold.jp/100x100.png" slot="fuga">
  <img src="http://placehold.jp/150x150.png" slot="piyo">
</image-gallery>

<script>
window.customElements.define("image-gallery", class extends HTMLElement {
    constructor() {
        super();
        const shadowRoot = this.attachShadow({mode: "open"});
        shadowRoot.innerHTML = `
            <h1>hoge gallery</h1>
            <div class="skyblue">
                <slot name="fuga"></slot>
            </div>
            <div class="limegreen">
                <slot name="piyo"></slot>
            </div>

            <style>
            .skyblue { background: skyblue; }
            .limegreen { background: limegreen; }
            </style>
        `;
    }
});
</script>
</body>
```

<img width="958" alt="Screen Shot 2019-04-14 at 20.56.50.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/218b0b13-ce71-5a72-2160-d1b2202feb0b.png">

---

`slotchange` イベントで`slot`が変更された時に、内容を取得することもできるようです

```html
<body>
<image-gallery>
    aaa
    <img src="http://placehold.jp/100x100.png">
    bbb
    <img src="http://placehold.jp/100x100.png">
    ccc
</image-gallery>

<script>
window.customElements.define("image-gallery", class extends HTMLElement {
    constructor() {
        super();
        const shadowRoot = this.attachShadow({mode: "open"});
        shadowRoot.innerHTML = `
            <h1>hoge gallery</h1>
            <slot></slot>
        `;
    }
});

const slot = document.querySelector("image-gallery").shadowRoot.querySelector("slot");
slot.addEventListener('slotchange', e => {
  const changedSlot = e.target;
  console.log(changedSlot.assignedNodes());
});
</script>
</body>
```

<img width="878" alt="Screen Shot 2019-04-14 at 21.03.37.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/fbcb2383-4b12-8c7d-d56f-ed33eaa7ab21.png">

# template element

templateタグのなかのマークアップは非表示になります。
javascriptでDOMをcloneして表示したいDOMに`appendChild()`などすることで表示することができます。

なので画面上の表示を大幅に変更したい場合などに、複数templateタグを用意して切り替えたりすると便利です。

```html
<body>
<my-element></my-element>

<script>
window.customElements.define("my-element", class extends HTMLElement {
    constructor() {
        super();
        const shadowRoot = this.attachShadow({mode: 'open'});

        this.shadowRoot.innerHTML = `
            <template id="view1">
                <p>This is view 1</p>
            </template>

            <template id="view1">
                <p>This is view 1</p>
            </template>

            <div id="container">
                <p>This is the container</p>
            </div>
        `;
    }

    connectedCallback() {
        const content = this.shadowRoot.querySelector('#view1').content.cloneNode(true);
        this.shadowRoot.querySelector('#container').appendChild(content);
    }
});
</script>
</body>
```

<img width="750" alt="Screen Shot 2019-04-14 at 21.28.51.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/f3c25374-94b4-dee9-aaa8-c90ae0e32f70.png">


# イベント設定

普通にイベント設定して動作してくれます

```html
<body>
<btn-element></btn-element>

<script>
window.customElements.define("btn-element", class extends HTMLElement {
    constructor() {
        super();
        const shadowRoot = this.attachShadow({mode: "open"});
        shadowRoot.innerHTML = "<button>click</button>";
    }

    connectedCallback() {
        this.addEventListener("click", e => {
            console.log("clickされました")
        });
    }
});
</script>
</body>
```

<img width="649" alt="Screen Shot 2019-04-14 at 21.42.24.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/8ef19fbe-82fc-46dd-476a-ef0c8902356a.png">

### Custom Elementからイベント発火して、別のCustom Elementのメソッドを実行する

```html
<body>
<my-app>
    <run-element></run-element>
    <alert-element></alert-element>
</my-app>

<script>
window.customElements.define("run-element", class extends HTMLElement {
    constructor() {
        super();
        const shadowRoot = this.attachShadow({mode: "open"});
        shadowRoot.innerHTML = "<button>click</button>";
    }

    connectedCallback() {
        this.addEventListener("click", e => {
            this.handleAlert();
        });
    }

    handleAlert() {
        // カスタムイベントでev-alertイベントを設定し、発火させる
        // bubbles: trueとすることでBubblingさせる
        // ※Bubblingとは、ある要素でイベントが発生すると、まずその要素でハンドラが実行され、次にその親で、次にその上の先祖ですべての処理が実行される動作
        this.dispatchEvent(new Event("ev-alert", { bubbles: true }));
    }
});

window.customElements.define("alert-element", class extends HTMLElement {
    constructor() {
        super();
        const shadowRoot = this.attachShadow({mode: "open"});
        shadowRoot.innerHTML = "<h1>hogehoge</h1>";
    }

    alert() {
        alert("文字背景を黄色にします");
        this.shadowRoot.querySelector("h1").style.background = "yellow";
    }
});

window.customElements.define("my-app", class extends HTMLElement {
    constructor() {
        super();
        const shadowRoot = this.attachShadow({mode: "open"});
        shadowRoot.innerHTML = "<slot></slot>";
    }

    connectedCallback() {
        // run-elementでev-alertイベントが発火すると、親のタグなので受け取れる
        this.addEventListener("ev-alert", e => {
            // 別のCustom Elementのメソッドを実行することができる
            this.querySelector("alert-element").alert();
        });
    }
});
</script>
</body>
```

![wQB5CXfS2W.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/9e6b4a71-f72c-ec54-e162-cd84d4687d61.gif)

---

イベントに値を持たせたい場合

`CustomEvent`を使い、`detail`を指定すると`e.detail`で受け取れる

```js
        // イベント発火
        this.dispatchEvent(new CustomEvent("hoge", {
            bubbles: true, 
            detail: { msg: "hello" },
        }));

        // イベントリスナー
        this.addEventListener("hoge", e => {
            console.log(e.detail); // { msg: hello }
        });
```

---

最後まで読んでいただいてありがとうございましたm(_ _)m