ベーシック アドベントカレンダー 1日目です。

Web Components が好きなのですが、なかなかプロダクション環境に導入するキッカケがないので習作を通じて Web Components の手ざわり感を残してみることにします。今回のアドベントカレンダーでいくつかのパートに分かれて Web Components に関する小粒な記事を投稿する予定です。

まずは完成のデモ画像とソースコードです。

adv01

見ての通り、ボタンを押すと指定されたテキストがクリップボードにコピーされるコンポーネントです。

実際のデモとコードはこちら

index.html

<!doctype html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Copy Text</title>
  <script type="module" src="./app.js"></script>
  <style>
    copy-text {
      display: block;
      margin: 10px 0;
    }
    copy-text.custom::part(text),
    copy-text.custom::part(button) {
      color: deeppink;
    }
  </style>
</head>
<body>
  <copy-text text="copy text"></copy-text>
  <copy-text text="hogehoge" class="custom"></copy-text>
</body>
</html>

app.js

import './button.js'

class CopyText extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({mode: 'open'})
    console.log(this.buttonLabel)
  }

  connectedCallback() {
    this.render()
  }

  get text() {
    return this.getAttribute('text')
  }

  render() {
    this.shadowRoot.innerHTML = `
<style>
  div {
    font-size: 62.5%;
    display: flex;
    justify-content: flex-start;
  }
  input, copy-text-button::part(button) {
    border: 1px solid #cccccc;
    padding: 10px 10px;
    font-size: 1.0rem;
  }
  input {
    border-right: none;
    border-radius: 5px 0 0 5px;
  }
  copy-text-button::part(button) {
    border-radius: 0 5px 5px 0;
    background: #f0f0f0;
  }
</style>
<div part="component">
  <input part="text" type="text" value="${this.text}" readonly>
  <copy-text-button exportparts="button: button" text="${this.text}">
</div>
`
  }
}

customElements.define('copy-text', CopyText)

button.js

class CopyTextButton extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({mode: 'open'})
    this.addEventListener('click', () => { this.copyText() })
  }

  connectedCallback() {
    this.render()
  }

  get text() {
    return this.getAttribute('text')
  }

  copyText() {
    navigator.clipboard.writeText(this.text)
    this.shadowRoot.querySelector('button').innerText = 'copied!'
  }

  render() {
    this.shadowRoot.innerHTML = `<button part="button">copy</button>`
  }
}

customElements.define('copy-text-button', CopyTextButton)

それでは一つずつ簡単に解説を書いていきます。

Custom Elements で独自タグを作る

Web Components を構成する重要な Custom Elements を使って独自のタグを作ります。

<my-content>my content div</my-content>
<script>
  class MyContent extends HTMLElement {
    constructor() {
      super()
      const shadowRoot = this.attachShadow({ mode: 'open' })
      shadowRoot.innerHTML = `<div>${this.innerHTML}</div>`
    }
  }

  customElements.define('my-content', MyContent)
</script>

HTMLElement を継承した独自 Class を作ります。これを customElements.define() でタグの名前とともに登録するだけです。HTMLElement は一番ベーシックな HTML 要素を表すインターフェイスです。他にも HTMLButtonElement などありますが、基本は HTMLElement を使うと思います。

ちなみに HTMLElement にはコールバックがあります。

  • connectedCallback() : コンポーネントが append された時
  • disconnectedCallback() : コンポーネントが DOM から外された時
  • attributeChangedCallback() : 属性が変更されたときに発火

これらを使ってコンポーネントのライフサイクルを操作します。

Shadow DOM でスタイルを閉じ込める

Web Components でコンポーネントを作っていく上で CSS によるスタイルは外部からの影響を受けたくありません。逆にコンポーネント内部のスタイルも外部に漏れ出してほしくないです。そんなときは Shadow DOM を利用します。

const shadowDiv = document.createElement('div')
const shadowRoot = shadowDiv.attachShadow({ mode: 'open' })
shadowRoot.innerHTML = `
  <style type="text/css">
    p {
      color: red;
    }
  </style>
  <p>shadow paragraph</p>
`
document.body.appendChild(shadowDiv)

Shadow DOM を単体で使うとこのような形になります。ざっくり言うと attachShadow({ mode: 'open' }) をDOM に指定すればいいという感じです。mode は false にもできますが、基本は open のままがいいと思います。参照: クローズド Shadow ルートの作成(非推奨)

Shadow DOM に外部からスタイルを上書きする

Web Components を外部配信した時、利用者側から見ると各パーツの微妙なスタイルはカスタマイズしたいというニーズも時にはあります。当然 Shadow DOM では外部の影響を受けないのが特徴なので、そのままではスタイルの上書きはできません。

そこで ::part() 疑似要素を使ってアクセスしてみます。これは予め Web Components 側で指定されている要素に対してアクセス可能な疑似要素です。逆に言うと、Web Components でカスタマイズ可能な指定がされていなければ不可能です。

const shadowDiv = document.createElement('div')
const shadowRoot = shadowDiv.attachShadow({ mode: 'open' })
shadowRoot.innerHTML = `
  <style type="text/css">
    p {
      color: red;
    }
  </style>
  <p>shadow paragraph</p>
  <p part="overridable">shadow overridable paragraph</p>
`
document.body.appendChild(shadowDiv)
div::part(overridable) {
  color: green;
}

Shadom DOM 内部で part 属性を使いアクセス可能な要素と名前を指定します。CSS で ::part() 疑似要素を使うとスタイルの上書きができます。

ネストされた Shadow DOM で ::part() を利用する

Web Components でコンポーネントを作っていくと、内部でコンポーネントをいくつかに分割して作ることが多いです。その場合、::part() 疑似要素では一番外側の要素にしかアクセスができません。

class NestedContent extends HTMLElement {
  constructor() {
    super()
    const shadowRoot = this.attachShadow({ mode: 'open' })
    shadowRoot.innerHTML = `
<p part="nested-paragraph">text</p>
`
  }
}
customElements.define('nested-content', NestedContent)

class MyContent extends HTMLElement {
  constructor() {
    super()
    const shadowRoot = this.attachShadow({ mode: 'open' })
    shadowRoot.innerHTML = `
<div>
  <nested-content></nested-content>
</div>
`
  }
}
customElements.define('my-content', MyContent)
my-content::part(nested-paragraph) {
  color: #f00; /* 効かない */
}

このような場合は、exportparts を利用することで外部に露出することができます。

<div>
  <nested-content exportparts="nested-paragraph: nested-paragraph"></nested-content>
</div>

exportparts="内部 part 名 : 露出する part 名" で利用できます。

ES Modules でコンポーネントを分割する

Web Components で単一の JS ファイルで構成できるのは、よほどシンプルな機能だけで実際にはいくつかのファイルに分割することになると思います。その際に ES Modules を利用すれば webpack などのビルドシステムを使わずとも簡単に分割できます。(過去 HTML Imports という仕様もありましたが現在は非推奨です)

<script type="module" src="app.js">

type="module" で読み込ませた JS ファイル内部で import export が使えるようになります。

export function something() {}
import { something } from './module.js'
something()

以上が今回の習作、テキストコピーコンポーネントを構成する技術要素を簡単に解説しました。対応しているブラウザはモダンブラウザ限定かつ polyfill も一部は完全に動作しない要素もありますが、標準の JavaScript API だけでシンプルにコンポーネント志向の開発ができるのはかなり良いですね。

実際には lit-html や lit-element、Stencil などの Web Components ライブラリを経由して使うことが多いかもしれません。また Vue や React Angular などのライブラリを Web Components として利用するケースもあると思います。そこらへんはまた別の記事で紹介できればと思います。