12
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2019-04-14

注意

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

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

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

参考

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

コードはこちらにも用意しました

8uh9gDvkAh.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);
    }
});
npm i webpack webpack-cli
npx webpack --mode development #./dist/main.jsが出力

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


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

Custom Elements

ユーザー定義の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>と書けば使うことができる ※タグ名の-は既存のタグ名と被らないようにつけたほうが良いようです

Screen Shot 2019-04-14 at 18.11.53.png

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

<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>
Screen Shot 2019-04-14 at 18.26.52.png

ライフサイクルメソッド

  • connectedCallback
  • disconnectedCallback
  • attributeChangedCallback
  • adoptedCallback

connectedCallback()

<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>
Screen Shot 2019-04-14 at 18.22.37.png

attributeChangedCallback()

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

<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

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

constructor -> attributeChangedCallback -> connectedCallback

APIを設定

関数を定義

<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>
Screen Shot 2019-04-14 at 18.58.06.png

プロパティの定義

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

<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

Shadow DOM

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

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

<video controls></video>

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

image.png

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

9ybbItHjXw.gif

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


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 からアクセスできます

<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

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

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

:host {
  display: block;
}

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

<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>
Screen Shot 2019-04-14 at 20.06.12.png

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

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

<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>
Screen Shot 2019-04-14 at 20.33.01.png

slots

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

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

<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>の部分に適用されます

Screen Shot 2019-04-14 at 20.52.28.png

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

<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>
Screen Shot 2019-04-14 at 20.56.50.png

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

<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>
Screen Shot 2019-04-14 at 21.03.37.png

template element

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

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

<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>
Screen Shot 2019-04-14 at 21.28.51.png

イベント設定

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

<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>
Screen Shot 2019-04-14 at 21.42.24.png

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

<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


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

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

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

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

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

12
10
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
12
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?