Web Components入門: フルに導入されたプロジェクトに入った結果とサンプル実装

# Web Componentsがフルに導入されたプロジェクトに入った結果とサンプル実装

## 導入


この記事は、CAM Advent Calendar 2日目の記事です:golf:
前回は `id:ishikawa_pro` の [社内で見かけるNode.jsデザインパターン](http://ishikawa-pro.hatenablog.com/entry/advent-calendar/2019/cam-inc/day1) を紹介しましたが
今回は業務で使用しているフロントエンド技術の一部を紹介したいと思います:rocket:
僕は新卒でサイバーエージェントに入社し、Androidを2年半、現在はフロントエンドをしています。(1年半程)
最近、Web Componentsがフルに導入されたプロジェクトに移動して来たので、この一ヶ月のWeb Componentsを通じて学んだ事を共有していきたいと思います:pencil:

実際に業務でWeb Componentsがフルに使用されているプロダクトはこちらの[Fensi](https://fensi.plus/)というサービスです
![images.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33553/9f6bd026-e557-061e-e6fc-86b3dac4a21e.jpeg)

Chromeの`dev tool`で見るとbody直下の `fensi-app`から`shadowDOM`になっているのがわかります。
結論このベースから使う事の課題点も判明しましたが。。
Web Componentsの始まりです。

![スクリーンショット 2019-12-01 4.45.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33553/e188dc08-be23-9a33-2856-6d4014bfe537.png)


ちなみに、今回僕が作成したサンプルのGithubは[こちら](https://github.com/MuuKojima/web-components-todo/blob/master/README.md)になります:earth_africa:

![スクリーンショット 2019-12-01 3.36.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33553/1b02e291-eb6d-6788-7c75-f2bd075631fa.png)

## Web Componentsとは？

### なにができる？

・ カスタムタグ作れるe.g. `<my-foo></my-foo>`

・ カスタムタグ内を完全にカプセル化できる。CSSが外部に影響を与えないし、外部が内部に影響もしない

### フレームワークでも似たような事ができますが、React, Vue, etc…

・カスタムタグ作ったり
・カプセル化ぽいことできたり
・CSS in JSでclass名を被らないようにしたり

### フレームワーク使わなくてもWeb標準で出来る時代に

モダンブラウザがPolyfillなしでもWeb Componentsに対応 [Can i use](https://caniuse.com/#search=web%20components)

### 仕様は4つの構成からなる

・ CustomElements
・ Shadow DOM
・ HTML Template
・ ES Module

(現行の仕様はv1)

#### Custom Elements

カスタムタグ作れるe.g. `<my-foo></my-foo>`
(新しいHTMLタグを定義し、使用できる)

#### Shadow DOM

カプセル化されスコープが形成されるので
CSSなども外部に影響しないし、外部が内部にも影響しない

#### HTML Templates

`<template></template>` タグを用いて雛形となる仮想のタグを作る。
テンプレートタグはアクティベートされるまでは、スクリプト, 画像, オーディオなどもロードされない。
`<template><div>描画されない</div></template>`
[テンプレートタグの例: Code Pen](https://codepen.io/muukojima-the-lessful/pen/VwwXojQ?editors=1010)

#### ES Module

JSファイルの読み込み方法の一つ
`type=”module"` を指定することで
`defer`のようにDOM解析後にscriptが実行される
`defer`との違いはざっくりいうと、外部のスクリプトを読み込むか、内部のスクリプトを読み込むかの違いらしい。

ES Moduleの例

```index.html
<script type="module" src="index.js"></script>
```

```index.js
import { sayMessage } from "./alert.js";
showMessage("Hello!");
```

```alert.js
export function showMessage(message) {
  alert(message);
}
```

## それではWeb Componentsをスクラッチで書きながらサンプルを作っていきましょう

※ 実際の業務ではWeb Componentsのラッパーライブラリが使用される事と思います
現状では、 `lit-element`, `stencil.js`, `skatejs` といったところでしょう

### ディレクトリ構成

[リポジトリはこちら](https://github.com/MuuKojima/web-components-todo/blob/master/README.md)

```
 web-components-todo
   ├── README.md
   ├── package-lock.json
   ├── package.json
   └── src
       ├── index.html
       ├── todoItem.js
       └── todoList.js
```

### 環境の設定

事前に[node.js](https://nodejs.org/ja/) が入っている事を前提にしています

web-components-todo/package.json

```package.json
{
  "name": "web-components-todo",
  "version": "0.0.1",
  "description": "",
  "main": "./src/todoList.js",
  "scripts": {
    "start": "serve ./src"
  },
  "author": "",
  "license": "MIT",
  "dependencies": {
    "serve": "^11.2.0"
  }
}
```

### index.html

1. ここではスクリプトの読み込みに `type="module"` を使用します
DOM解析後にスクリプトが実行されます
こうすることでbodyの一番下にスクリプトを書くなどの作業がいらないので、headタグに書く事ができるようになります
ここで`ESModule`を使用する事ができました

2. カスタムエレメントの命名には必ず、ハイフンを含めなくてはいけません(ハイフンがないとエラーになります)
`<todo-list></todo-list>`
ここで`Custom Elements`を使用する事ができました

web-components-todo/src/index.html

```index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
　　<!-- 1. type="module"を使用 -->
    <script type="module" src="todoList.js"></script>
  </head>
  <body>
    <!-- 2. ハイフンを含める -->
    <todo-list></todo-list>
  </body>
</html>
```

### todoList.js
全体図
web-components-todo/src/todoList.js

```todoList.js
// アイテムとなるjsをimport
import './todoItem.js';

// テンプレートタグの生成
const template = document.createElement('template');
// テンプレートの中に、innerHTMLでhtmlとcssを構築
template.innerHTML = `
  <style>
    /* host要素はこのエレメント全体に当たるCSS  */
    :host {
      /* カスタムエレメントはデフォルトでインライン要素なので、display: block;に変更 */
      display: block;
      font-family: Helvetica;
      font-weight: bold;
      color: black;
    }
    h1 {
      text-align: center;
      font-size: 50px;
    }
    form {
      text-align: center;
      margin-bottom: 20px;
    }
    .container {
      text-align: center;
    }
  </style>
  <h1>Todo List</h1>
  <form>
    <input type="text"></input>
    <button class="submit" type="button">submit</button>
  </form>
  <div class="container"></div>
`;

// HTMLElementを継承する。HTMLElementの上位クラスはElement
class TodoList extends HTMLElement {
  // コンストラクタで初期化処理を行い、必ず先頭で親コンストラクタをsuper();で呼び出す
  constructor() {
    super();
　　 // ShadowRootの形成
    this._shadowRoot = this.attachShadow({mode: 'open'});
　  // テンプレートタグのアクティベート
    this._shadowRoot.appendChild(template.content.cloneNode(true));
    // TODO リストの初期化
    this._todoList = [];
    this._containerElm = this._shadowRoot.querySelector('.container');
    this._submitElm = this._shadowRoot.querySelector('.submit');
    this._clickListener =  this._add.bind(this);
    this._inputElm = this._shadowRoot.querySelector('input');
  }
  
  // このエレメントがアタッチされた時に呼ばれる関数
  connectedCallback() {
    this._submitElm.addEventListener('click', this._clickListener);
　　// アタッチされた後に自前のレンダー関数を呼ぶ
    this._render();
  }

  // このエレメントがデアタッチされた時に呼ばれる関数
  // イベントリスナーの削除や、メモリの解放等をこのライフサイクルで行う
  disconnectedCallback() {
    this._submitElm.removeEventListener('click', this._clickListener)
    for (let item of this._containerElm.children) {
      item.clearListner();
    }
  }
  
  // 追加されたアイテム分をコンテナ内に追加し、描画していく
  _render() {
    this._containerElm.innerHTML = '';
    this._todoList.forEach((item, index) => {
      const todoElm = document.createElement('todo-item');
      todoElm.label = item.label;
      todoElm.checked = item.checked;
      todoElm.index = index;
      const onToggleListener = this._toggle.bind(this);
      const onRemoveListener = this._remove.bind(this);
      todoElm.addEventListener('onToggle', onToggleListener);
      todoElm.addEventListener('onRemove', onRemoveListener);
      todoElm.clearListner = () => {
        todoElm.removeEventListener('onToggle', onToggleListener);
        todoElm.removeEventListener('onRemove', onRemoveListener);
      };
      this._containerElm.appendChild(todoElm);
    });
  }
  
 // todoの追加
  _add() {
    if (!this._inputElm.value) {
      return;
    }
    const todo = { label: this._inputElm.value, checked: false};
    this._todoList.unshift(todo);
    this._inputElm.value = '';
    this._render();
  }
  
  // todoの完了・未完了
  _toggle(e) {
    const todo = this._todoList.find((item, index) => {
      return index === e.detail.index;
    });
    todo.checked = !todo.checked;
    this._render();
  }

  // todoの削除
  _remove(e) {
    const index = e.detail.index;
    this._todoList.splice(index, 1);
    this._render();
  }
}

// エレメントの定義
window.customElements.define('todo-list', TodoList);
```

テンプレートタグ生成を生成し、innerHTMLで構造を記述していく  
ここで`HTML Templates`を使う準備ができました

```todoList.js
// テンプレートタグの生成
const template = document.createElement('template');
// テンプレートの中に、innerHTMLでhtmlとcssを構築
template.innerHTML = `
   省力...
`;
```

Classの作成とコンポーネントを定義する
コンスタントラクタでShadowrootを定義する
ここで`Shadow DOM`を使う準備ができました

```todoItem.js
class TodoList extends HTMLElement {
　constructor() {
   super();
　　// ShadowRootの形成
   this._shadowRoot = this.attachShadow({mode: 'open'});
　 // テンプレートタグのアクティベート
   this._shadowRoot.appendChild(template.content.cloneNode(true));
  }
  省力...
}

window.customElements.define('todo-list', TodoList);
```

レンダー関数を定義し、todoListの配列を回して、todoを追加する

```todoList.js
省略...
  // 追加されたアイテム分をコンテナ内に追加し、描画していく
  _render() {
    this._containerElm.innerHTML = '';
    this._todoList.forEach((item, index) => {
      const todoElm = document.createElement('todo-item');
      todoElm.label = item.label;
      todoElm.checked = item.checked;
      todoElm.index = index;
      const onToggleListener = this._toggle.bind(this);
      const onRemoveListener = this._remove.bind(this);
      todoElm.addEventListener('onToggle', onToggleListener);
      todoElm.addEventListener('onRemove', onRemoveListener);
      todoElm.clearListner = () => {
        todoElm.removeEventListener('onToggle', onToggleListener);
        todoElm.removeEventListener('onRemove', onRemoveListener);
      };
      this._containerElm.appendChild(todoElm);
    });
  }
省略...
```

そしてtodoの追加・削除・チェックなどの関数を追加

```todoList.js
省略...
 // todoの追加
  _add() {
    if (!this._inputElm.value) {
      return;
    }
    const todo = { label: this._inputElm.value, checked: false};
    this._todoList.unshift(todo);
    this._inputElm.value = '';
    this._render();
  }

  // todoの完了・未完了
  _toggle(e) {
    const todo = this._todoList.find((item, index) => {
      return index === e.detail.index;
    });
    todo.checked = !todo.checked;
    this._render();
  }

  // todoの削除
  _remove(e) {
    const index = e.detail.index;
    this._todoList.splice(index, 1);
    this._render();
  }
省略...
```

最後にライフサイクルを追加し、レンダーを呼び出すように定義

```todoList.js
省略...
  // このエレメントがアタッチされた時に呼ばれる関数
  connectedCallback() {
    this._submitElm.addEventListener('click', this._clickListener);
　　// アタッチされた後に自前のレンダー関数を呼ぶ
    this._render();
  }

  // このエレメントがデアタッチされた時に呼ばれる関数
  // イベントリスナーの削除や、メモリの解放等をこのライフサイクルで行う
  disconnectedCallback() {
    this._submitElm.removeEventListener('click', this._clickListener)
    for (let item of this._containerElm.children) {
      item.clearListner();
    }
  }
省略...
```

### todoItem.js

web-components-todo/src/todoItem.js

```todoItem.js
const template = document.createElement('template');
template.innerHTML = `
  <style>
    :host {
      display: block;
      font-family: Helvetica;
      font-weight: bold;
      padding: 10px;
    }
  </style>
  <input class="checkbox" type="checkbox">
  <label class="label"></label>
  <button class="remove" type="button">remove</button>
`;

class Todo extends HTMLElement {
  // アトリビュートの変更を受け取るためのホワイトリストを配列で作成
  static get observedAttributes() {
    return ['label', 'checked', 'index'];
  }
  
  // ホワイトリストに登録したアトリビュートに変更があった場合に呼ばれる関数
  attributeChangedCallback(name, oldValue, newValue) {
    switch(name){
      case 'label':
        this._label = newValue;
        break;
      case 'checked':
        this._checked = this.hasAttribute('checked');
        break;
      case 'index':
        this._index = Number(newValue);
        break;
    }
    this._render();
  }

  constructor() {
    super();
    this._shadowRoot = this.attachShadow({ 'mode': 'open' });
    this._shadowRoot.appendChild(template.content.cloneNode(true));
    this._label =  '';
    this._index = 0;
    this._checked =  false;
    this._checkBoxElm = this._shadowRoot.querySelector('.checkbox');
    this._toggleListener = this._dispatchToggle.bind(this);
    this._removeElm = this._shadowRoot.querySelector('.remove');
    this._removeListener = this._dispatchRemove.bind(this);
    this._labelElm = this._shadowRoot.querySelector('.label');
  }

  connectedCallback() {
    this._checkBoxElm.addEventListener('click', this._toggleListener);
    this._removeElm.addEventListener('click', this._removeListener);
    this._render();
  }

  disconnectedCallback() {
    this._checkBoxElm.removeEventListener('click', this._toggleListener)
    this._removeElm.removeEventListener('click', this._removeListener);
  }

  _render() {
    this._labelElm.textContent = this._label;
    this._checkBoxElm.checked = this._checked;
    this._labelElm.style.textDecoration = this._checked ? 'line-through'  : 'none';
  }

  // 親コンポーネントにトグルボタンが押された事を通知する関数
  _dispatchToggle() {
    // カスタムイベントを作成し、親コンポーネントに通知する
    // ここではバブリングさせ、孫要素だった場合も考慮する
    this.dispatchEvent(new CustomEvent('onToggle',
      {
        detail: { index: this._index },
        bubbles: true,
        composed: true
      }
    ));
  }
  
  // 親コンポーネントに削除ボタンが押された事を通知する関数
  _dispatchRemove() {
    this.dispatchEvent(new CustomEvent('onRemove',
      {
        detail: { index: this._index },
        bubbles: true,
        composed: true
      }
    ));
  }
  
  // 親からプロパティを通して、自身のアトリビュートを更新するためのアクセッサー郡
　// この方法はgoogleのweb componentsベストプラクティスを参考に
  get label() {
    return this.getAttribute('label');
  }

  set label(val) {
    if (val) {
      this.setAttribute('label', val);
    } else {
      this.removeAttribute('label');
    }
  }

  get checked() {
    return this.getAttribute('checked');
  }

  set checked(val) {
    if (val) {
      this.setAttribute('checked', '');
    } else {
      this.removeAttribute('checked');
    }
  }

  get index() {
    return this.getAttribute('index');
  }

  set index(val) {
    if (val) {
      this.setAttribute('index', val);
    } else {
      this.removeAttribute('index');
    }
  }
}

window.customElements.define('todo-item', Todo);
```

親コンポーネントと同様に各種設定をしていき、
アイテムがクリックされた時のイベントを設定する

```todoItem.js
省略...
  // 親コンポーネントにトグルボタンが押された事を通知する関数
  _dispatchToggle() {
    // カスタムイベントを作成し、親コンポーネントに通知する
    // ここではバブリングさせ、孫要素だった場合も考慮する
    this.dispatchEvent(new CustomEvent('onToggle',
      {
        detail: { index: this._index },
        bubbles: true,
        composed: true
      }
    ));
  }

  // 親コンポーネントに削除ボタンが押された事を通知する関数
  _dispatchRemove() {
    this.dispatchEvent(new CustomEvent('onRemove',
      {
        detail: { index: this._index },
        bubbles: true,
        composed: true
      }
    ));
  }
省略...
```

最後にプロパティを更新するためのアクセッサー郡も定義する

```todoItem.js
省略...
  get label() {
    return this.getAttribute('label');
  }

  set label(val) {
    if (val) {
      this.setAttribute('label', val);
    } else {
      this.removeAttribute('label');
    }
  }
省略...
```

### ビルド

Install
`$ npm install .`

Run
`$ npm run start`

Server running at http://localhost:5000

![スクリーンショット 2019-12-01 3.36.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33553/1b02e291-eb6d-6788-7c75-f2bd075631fa.png)

## 長所と短所

### 長所:
スコープが形成されるので、衝突を気にしなくて良い。
ライフサイクルも最低限用意されているので、そこにフックする事で大抵の事はできる。

### 短所:
例えば、協業しているパートナーからスクリプトの埋め込み依頼があったとしよう、
そのスクリプトはDOMを操作するものだったとしたら、ShadowDOMの中には入れない。
例えば、ログイン画面のinputタグにブラウザの自動保管を入れたいとしよう、inputタグはネストされたShadowDOMの中に埋もれているので、当然ブロックされる
例えば、GoogleBotはShadowDOMを解釈できるようになったが、他のボットはどうだろうか。

## 結論

現状プロダクトレベルで使用できているが、新しい技術なので、課題はたくさんある。
しかし、これが新たなWeb標準である事はたしかであり、ベースの部分から入れなくても、
ボタンなどのリーフノードから、徐々に入れてみることはできるので、できる所からWebComponentsの恩恵を受けていこう

## 参考文献

https://vaadin.com/tutorials/using-web-components
https://developers.google.com/web/fundamentals/web-components/best-practices
https://developers.google.com/web/fundamentals/web-components/examples/howto-tabs
https://dev.to/thepassle/web-components-from-zero-to-hero-4n4m