Web Components の最近の仕様と開発手法

# はじめに

Web Components だけで開発を行う場合、どのような技術を使えばいいのかを調べてみました。結論は最後に書いています。

# Web Components

## Specifications

Web Components specifications: https://github.com/w3c/webcomponents

> Web Components are a new browser feature that provides a standard component model for the Web, consisting of several pieces:
>
> - Shadow DOM
> - Custom Elements
> - HTML Imports
> - HTML Templates

## Can I use Web Components?

Can I use... Support tables for HTML5, CSS3, etc: https://caniuse.com/

- Shadow DOM (v1): https://caniuse.com/#feat=shadowdomv1
    - :no_entry_sign: Edge
    - :white_check_mark: Firefox 63+
    - :white_check_mark: Chrome 53+
    - :warning: Safari 10+
- Custom Elements (v1): https://caniuse.com/#feat=custom-elementsv1
    - :no_entry_sign: Edge
    - :white_check_mark: Firefox 63+
    - :white_check_mark: Chrome 67+
    - :warning: Safari 10.1+
- HTML Imports: https://caniuse.com/#feat=imports
    - :no_entry_sign: Edge
    - :no_entry_sign: Firefox
    - Chrome :white_check_mark: 36–72, :no_entry_sign: 73+
    - :no_entry_sign: Safari
- HTML Templates: https://caniuse.com/#feat=template
    - :white_check_mark: Edge 15+
    - :white_check_mark: Firefox 22+
    - :white_check_mark: Chrome 35+
    - :white_check_mark: Safari 9+

（Edge は Chromium ベースになることで、Shadow DOM (v1)、Custom Elements (v1) も :white_check_mark: になるかもしれません。）

# 開発環境

今回は macOS 上で試してみましたが、特に OS 依存な内容はないと思います。

## 開発に利用するツール

- Node.js (version 9.4.0)
- npm (version 5.4.0)
- 開発用 Web サーバ: https://github.com/indexzero/http-server

## 環境構築方法

### 開発用サーバインストール

```
$ npm install -g http-server
```

### ディレクトリ作成

```
$ cd /path/to/development
$ mkdir my-app
$ cd my-app
$ touch index.html
```

### 開発用サーバ起動

```
$ http-server
```

# Hello world

## HTML Imports を使った実装（非推奨）

Web Components のすべての要素 (Shadow DOM, Custom Elements, HTML Imports, HTML Templates) を使った実装です。

**ただし、HTML Imports は将来的に削除される可能性が高いため、この方法は推奨しません。**

<img width="1280" alt="スクリーンショット 2018-03-02 15.01.28.png" src="https://qiita-image-store.s3.amazonaws.com/0/120226/6e38a3d0-3360-ab14-b990-fa6df3cd94f6.png">

```text:Structure
/ (root)
+-- elements/
|   +-- my-app.html
+-- index.html
```

```html:index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>My App</title>
    <link rel="import" id="my-app" href="/elements/my-app.html">
  </head>
  <body>
    <p>This is not a custom element.</p>
    <my-app></my-app>
  </body>
</html>
```

```html:my-app.html
<template>
  <style>
    p {
      color: #f00;
    }
  </style>

  <p>This is a custom element!</p>
</template>

<script>
  class MyAppElement extends HTMLElement {
    constructor() {
      super();
      const shadowRoot = this.attachShadow({ mode: 'open' });
      const myApp = document.getElementById('my-app').import;
      const templateNode = myApp.getElementsByTagName('template')[0].content.cloneNode(true);
      shadowRoot.appendChild(templateNode);
    }

    connectedCallback() {
      console.log('my-app element is connected');
    }
  }

  customElements.define('my-app', MyAppElement);
</script>
```

## HTML Imports から ES6 Modules へ

「ES6 の Modules で実現できるよね」ということでお蔵入りになった HTML Imports。現状で Chrome でしか実装されておらず、Mozilla が HTML Imports を実装する予定がないと表明しているので、HTML Imports は今後削除される可能性が高いです。

また、Web Components をベースにした Polymer も、バージョン 3.0 では ES Modules を使った実装になるよう開発を進めているようです。

詳細は以下の記事を参照。

- [The state of Web Components – Mozilla Hacks – the Web developer blog](https://hacks.mozilla.org/2015/06/the-state-of-web-components/)
- [Polymer 3.0 preview: npm and ES6 Modules - Polymer Project](https://www.polymer-project.org/blog/2017-08-22-npm-modules)
    - ↑ の参考和訳記事: [Polymer 3.0 Previewについて〜npmとES6モジュールへの移行〜 - Qiita](https://qiita.com/jtakiguchi/items/b287077c89cf0c1c32a9)
- [Polymer 3.0: New year, new preview - Polymer Project](https://www.polymer-project.org/blog/2018-01-18-polymer-3-new-preview)
- [HTML Imports and ES Modules - w3c/webcomponents - GitHub](https://github.com/w3c/webcomponents/blob/gh-pages/proposals/HTML-Imports-and-ES-Modules.md)
- [Web Components 周辺の仕様とか 2017年秋 - EagleLand](https://1000ch.net/posts/2017/webcomponents-specs.html)

## ES6 Modules を使った実装

HTML Imports の代わりに ES6 の Modules を使って実装した場合は次のような感じになります。Custom Elements がひとつだけだと Modules を使う必要がないので、ひとつ増やしました。

<img width="1280" alt="スクリーンショット 2018-03-02 15.07.24.png" src="https://qiita-image-store.s3.amazonaws.com/0/120226/92ffd65d-2d54-4a63-4899-6c09b9c02622.png">

```text:Structure
/ (root)
+-- elements/
|   +-- my-app.js
|   +-- my-date.js
+-- index.html
```

```html:index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>My App</title>
    <script type="module" src="/elements/my-app.js"></script>
  </head>
  <body>
    <p>This is not a custom element.</p>
    <my-app></my-app>
  </body>
</html>
```

```js:my-app.js
import '/elements/my-date.js';

class MyAppElement extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'open' });
    shadowRoot.innerHTML = this.template();
  }

  connectedCallback() {
    console.log('my-app element is connected');
  }

  template() {
    return `
      <style>
        p {
          color: #f00;
        }
      </style>

      <p>This is a custom element!</p>
      <my-date></my-date>
    `;
  }
}

customElements.define('my-app', MyAppElement);
```

```js:my-date.js
class MyDateElement extends HTMLElement {
  constructor() {
    super();
    this.now = new Date();

    const shadowRoot = this.attachShadow({ mode: 'open' });
    shadowRoot.innerHTML = this.template();
  }

  connectedCallback() {
    console.log('my-date element is connected');
  }

  template() {
    return `
      <p>現在日時は <time datetime="${this.now.toISOString()}">${this.now.toLocaleString()}</time> です。</p>
    `;
  }
}

customElements.define('my-date', MyDateElement);
```

## ES6 Modules を使う場合の欠点

上記のように ES Modules を使えば HTML Imports を使わない実装が可能になります。ただし、以下のような欠点があると考えます。

### キモい

JavaScript の中で HTML を書くのがキモい。上記の例では ES6 の Template Literals を使っているので、従来の方法に比べれば、JavaScript 内で HTML を書きやすくなっています。でもやっぱりマークアップとスクリプトはきっちり分けたいというのが個人的な感想です。

ちなみに、この場合は HTML Templates も使わないことになります。

### 遅延読み込みができない

ES Modules は最初のページ読み込み時に `import` される JS ファイルをすべて読み込むため、特定のファイルだけ別のタイミングで読み込ませるといったことができません。

コレを解決するには、以下の方法をとる必要があります。

#### 任意のタイミングで `script` 要素を追加する

```js
const scriptElement = document.createElement('script');
scriptElement.src = '/elements/my-date.js';
document.head.appendChild(scriptElement);
```

#### `import()` を使う

関数ライクな `import()` を使って動的インポートを行うということが TC39 で提案されています。返り値は `Promise` です。

現在では Chrome 63+ および Safari 11+ で利用可能です。

- [tc39/proposal-dynamic-import: import() proposal for JavaScript](https://github.com/tc39/proposal-dynamic-import)
- [Dynamic import() | Web | Google Developers](https://developers.google.com/web/updates/2017/11/dynamic-import)
- [Chrome 63以降で使えるJavaScriptのdynamic import（動的読み込み） - Qiita](https://qiita.com/tonkotsuboy_com/items/f672de5fdd402be6f065)

# 結論

ここまでの調査をまとめると、フレームワーク等を使わずに Web Components だけを使った実装をするには、次のような方法をとることが考えられます。

- Shadow DOM (v1) + Custom Elements (v1) + ES Modules を使う。
    - HTML Imports は非推奨。
- 部品化された要素のマークアップは ES6 の Template Literals を使う。
    - ただし、キモい。
    - マークアップとスクリプトは分けたい（せっかく HTML Templates もあるのに）。
- 遅延読み込みさせるには、任意のタイミングで `script` 要素を追加する処理を書くか、`import()` を使う。
    - ただし、`import()` は時期尚早か。

本番のプロダクトでは素直に React や Vue.js などのフレームワークを使ったほうがいいかもしれないですが、ベータ版として興味本位で作ってみるのは全然アリだろうと思います。