TODO要素を作って覚えるカスタム要素
この記事は最終更新日から1年以上が経過しています。
芽萌丸プログラミング部@programming
投稿日 2019/6/11
更新日 2022/4/27 ✏

作って覚えるカスタム要素

Custom Elements (カスタム要素) を理解する一番の近道は、実際に何かテキトーなカスタム要素を一つ作ってみることではないかと思います。そこで今回は、TODOを管理できる簡単なカスタム要素を実際に作ってみたいと思います。

  • 更新 2022/04/27 古いブラウザにも対応させるpolyfillを追加。

目次

TODOカスタム要素クラス

下記のtodo-element.jsが今回作成したTODOカスタム要素の本体です。中身はHTMLElementを継承したTodoElementというクラスです:

todo-element.js

/**
 * TODO management custom element
 * @version 0.1.0
 * @author 芽萌丸プログラミング部  {@link https://memo.appri.me/programming/}
 */
class TodoElement extends HTMLElement {
  
  /** 変更監視対象属性一覧 */
  static get observedAttributes() {
    return ['list', 'debug'];
  }

  /** コンストラクタ */
  constructor() {
    super();
    const self = this;
    this.debug = self.getAttribute("debug") === "true" ? true : false;
    if (self.debug) console.log("initializing...");
    
    //
    // カスタム要素内要素を定義
    //

    this.shadow = this.attachShadow({ mode: 'open' });

    // スタイル要素
    this.styleEl = document.createElement('style');
    this.styleEl.textContent = `
    .remove {
      color: red;
      padding: 4px 12px;
      background: transparent;
      border: 0px solid transparent;
      cursor: pointer;
    }
    `;
    this.shadow.appendChild(this.styleEl);

    // アイテム入力ボックス要素
    this.iptEl = document.createElement("input");
    this.iptEl.onkeyup = function(e) {
      const item = (self.iptEl.value) ? self.iptEl.value.trim() : null;
      if (!item) {
        self.btnAddEl.setAttribute("disabled", "true");
        return;
      } else {
        self.btnAddEl.removeAttribute("disabled");
      }
      if (e.keyCode === 13) {
        // ** Enter:
        self.btnAddEl.click(e);
      }
    };
    this.shadow.appendChild(this.iptEl);

    // アイテム追加ボタン要素
    this.btnAddEl = document.createElement("button");
    this.btnAddEl.textContent = "+";
    this.btnAddEl.setAttribute("disabled", "true");
    this.btnAddEl.onclick = function() {
      const item = (self.iptEl.value) ? self.iptEl.value.trim() : null;
      if (!item) return;
      const midx = self.getItems().findIndex(x => x === item);
      if (midx >= 0) {
        // 既に登録されているものは一旦削除してから追加しなおす
        self.removeItem(self.ulEl.querySelectorAll("li")[midx]);
      }
      self.addItem(item);
    };
    this.shadow.appendChild(this.btnAddEl);

    // アイテムリスト要素
    this.ulEl = document.createElement("ul");
    this.shadow.appendChild(this.ulEl);
  }

  connectedCallback() {
    const self = this;
    if (self.debug) console.log(`${self.constructor.name} element added to page.`);
  }

  disconnectedCallback() {
    const self = this;
    if (self.debug) console.log(`${self.constructor.name} element removed from page.`);
  }

  adoptedCallback(oldDoc, newDoc) {
    const self = this;
    if (self.debug) console.log(`${self.constructor.name} element moved to new page.`);
  }

  attributeChangedCallback(name, oldValue, newValue) {
    const self = this;
    if (self.debug) console.log(`${self.constructor.name} element attributes changed: "${name}" ${newValue}`);
    if (name === "list") {
      try {
        self.addItems(JSON.parse(newValue));
      } catch (ex) {
        if (self.debug) console.error("'list' attribute is an invalid JSON format.", ex);
      }
    }
  }

  //
  // my functions
  //

  getItems() {
    const self = this;
    const arr = [];
    const els = self.ulEl.querySelectorAll("li");
    for (let len = els.length, i = 0; i < len; i++) {
      arr.push(els[i].childNodes[0].textContent); // text node only
    }
    return arr;
  }

  addItem(item) {
    const self = this;
    const liEl = document.createElement("li");
    liEl.textContent = item;
    const btnRemoveEl = document.createElement("button");
    btnRemoveEl.textContent = "Delete";
    btnRemoveEl.setAttribute("class", "remove");
    btnRemoveEl.onclick = function(e) {
      self.removeItem(e.currentTarget.parentElement);
    };
    liEl.appendChild(btnRemoveEl);
    self.ulEl.prepend(liEl);
  }

  addItems(items) {
    const self = this;
    for (let len = items.length, i = 0; i < len; i++) {
      self.addItem(items[i]);
    }
  }

  removeItem(item) {
    item.parentNode.removeChild(item);
  }

  dumpItems() {
    const self = this;
    return JSON.stringify(self.getItems());
  }

}

// NOTE: module.exports に参照を渡しておいて browserify にも対応させておきます:
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined')
  module.exports = TodoElement;
else {
  window.TodoElement = TodoElement;
}

// TODOカスタム要素を "todo-element" として登録します:
if (typeof customElements !== "undefined") {
  customElements.define('todo-element', TodoElement);
}

コードレビュー

作成手順

先ほどのカスタム要素の作成手順です。基本的には下記の手順でコーディングしていくだけで大抵のカスタム要素は作れるのではないでしょうか:

  1. class TodoElement extends HTMLElement {...}何らかのHTML要素クラスを継承してカスタム要素クラスを作成
  2. static get observedAttributes() { return ['list', 'debug']; }値の変更を監視したい属性があればそれらの属性名を指定
  3. コンストラクタ内でshadow = this.attachShadow({ mode: 'open' });で shadow DOM を取得し、そこにタグ内部で使いたい子要素を追加
  4. 必要なら下記のハンドラーメソッドを実装:
    • connectedCallback()要素がドキュメントに挿入されたときに呼び出されます
    • disconnectedCallback()要素がドキュメントから削除されたときに呼び出されます
    • attributeChangedCallback(attr, oldVal, newVal)要素の属性が変更、追加、削除、または置換されたときに呼び出されます (監視対象の属性に対してのみ)
    • adoptedCallback(oldDoc, newDoc)要素が新しい文書に採用されたときに呼び出されます
  5. 最後にcustomElements.define('todo-element', TodoElement);でカスタム要素を登録します
  6. おまけ:browserifyにも対応させるために下記のコードも追加しておきました。コレは個人的な好みです:
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined')
  module.exports = TodoElement;
else
  window.TodoElement = TodoElement;

属性

今回のTODOカスタム要素には下記の2つの属性を持たせました:

  1. list属性: デフォルトのTODO一覧を設定
  2. debug属性:trueをセットすることでコンソールにデバッグログを出力

どちらもstatic get observedAttributes() {...}で値変更の監視対象に指定してあります。外部から属性値がセットされるとattributeChangedCallback()が発火します。

公開メソッド

dumpItems()などのメソッドは外部から参照できます。そのため<button onclick="alert(document.querySelector('todo-element').dumpItems());">Dump Items</button>のように外部から呼び出すことができます。

使い方

...
<div>TODOカスタム要素のテスト</div>
<todo-element list='["aaa","bbb","ccc"]' debug="true"></todo-element>
<button onclick="alert(document.querySelector('todo-element').dumpItems());">Dump Items</button>
<script src="./todo-element.js"></script>
...

TIP: もし古いブラウザでも動かしたい場合はこちらのpolyfillをロードしてください:

<!-- TIP: 古いブラウザでも動かしたい時は[こちらのpolyfill]をロードします: -->
<!-- see: https://github.com/webcomponents/polyfills/tree/master/packages/webcomponentsjs#how-to-use -->
<script src="https://unpkg.com/@webcomponents/webcomponentsjs@2.6.0/custom-elements-es5-adapter.js"></script>
<script src="https://unpkg.com/@webcomponents/webcomponentsjs@2.6.0/webcomponents-loader.js"></script>

上記のコードはこんな感じで動きます:

TODOカスタム要素のテスト

以上となります。


芽萌丸プログラミング部
芽萌丸プログラミング部@programming
プログラミング関連アカウント。Web標準技術を中心に書いていきます。フロントエンドからサーバサイドまで JavaScript だけで済ませたい人たちの集いです。記事は主に @TanakaSoftwareLab が担当。