LoginSignup
5

More than 3 years have passed since last update.

posted at

updated at

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

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

導入

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

実際に業務でWeb Componentsがフルに使用されているプロダクトはこちらのFensiというサービスです
images.jpg

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

スクリーンショット 2019-12-01 4.45.19.png

ちなみに、今回僕が作成したサンプルのGithubはこちらになります:earth_africa:

スクリーンショット 2019-12-01 3.36.00.png

Web Componentsとは?

なにができる?

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

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

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

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

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

モダンブラウザがPolyfillなしでもWeb Componentsに対応 Can i use

仕様は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

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 といったところでしょう

ディレクトリ構成

リポジトリはこちら

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

環境の設定

事前にnode.js が入っている事を前提にしています

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

長所と短所

長所:

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

短所:

例えば、協業しているパートナーからスクリプトの埋め込み依頼があったとしよう、
そのスクリプトは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

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
What you can do with signing up
5