SourceChord

C#とXAML好きなプログラマの備忘録。最近はWPF系の話題が中心です。

そのReactほんとに必要ですか?~もうすぐElectronで使えるようになるWeb Componentsの世界~

この記事はelectronアドベントカレンダー 2016 21日目の記事です。
遅くなってしまい申し訳ありません。。。

アドベントカレンダーのリンクが間違っていたので修正しました・・・汗

前置き

↓去年はこんな記事を書いていました。

このCSS Grid Layout Module Level1ですが、少しずつ仕様の策定が進み、とうとう勧告候補の段階まできました。
CSS Grid Layout Module Level 1 CSS Grid Layout Module Level 1 (日本語訳)

CanIUseを見ると、もうすぐFirefoxChromeでの対応が行われるようです。
http://caniuse.com/#search=grid
https://developer.mozilla.org/ja/Firefox/Releases/52
f:id:minami_SC:20161225123610p:plain

未来は意外と早く来るもんですね。

本題

前置きが長くなりましたが、ここからが本題です。

近年のフロントエンド界隈では、ReactやらAngularやらのコンポーネント指向なライブラリ/フレームワークがもてはやされていると思います。

ですが、「ちょっとUIを部品化してみたい」という程度のケースでは、Reactなどのライブラリは少々OverKillな代物では、と感じます。
この手のライブラリを導入すると、なんだかんだで大量のツールチェインが必要になって大変ですよね。

一方で、UIをコンポーネント単位で部品化するための方法としてWebComponentsという仕様の策定が進められています。
主要ブラウザ全てで対応されるのはまだまだ先の話になりますが、このWebComponentsの機能はChrome54以降ではすでに実装されています。

Electron環境に限定すれば、わざわざReactやAngularを使わずとも、Web標準な方法でコンポーネント指向開発ができる未来が、もうすぐそこに来ています。

この記事では、そんなWebComponentsについてご紹介したいと思います。

補足

ReactにはUIのコンポーネント化だけでなく、ステートレスなコンポーネントとか一方向のデータフローなど、単純にUIを部品化する以上の目的があると思います。
なので、Web Componentsが使えるようになれば、ReactやAngularなどが不要になるということはないと思います。
開発対象の規模や用途に応じてケースバイケースで技術選定する上での、もう一つの選択肢ができるというイメージでしょうか?

ElectronのChrome54対応

現時点(2016/12/25現在)でElectronの内部で使用されているChromiumは53.0.2785.143となっています。

そのため、今のElectron最新版(v1.4系)では、WebComponentsの機能はまだ一部しか使えません。
ということで、この記事のサンプルコードは現在のElectronでは使用できません。
(Electronアドベントカレンダーの記事なのに・・・)

次のElectronメジャーバージョンアップでは、Chromium54以降のバージョンのものになると思われます。
以下のプルリクで対応が進められているようなので、動向が気になる人はこれをウォッチしているとよいかと。
Chrome 54 update by groundwater · Pull Request #7909 · electron/electron · GitHub

ElectronとChromiumの関係について

余談ですが、Electronは通常であればChromiumのバージョンアップに1~2週間遅れくらいで追従していました。
http://electron.atom.io/docs/faq/#when-will-electron-upgrade-to-latest-chrome

しかし、Chrome54から、Chromeのビルドツールがgypからgnというツールに変わりました。
これに伴い、Electron内部で使用するChromiumのバージョンアップに、いつもより多くの時間がかかっているようです。
Chrome54が9月にリリースされたので、12月にはElectronでもこの機能が使えるようになる、、、と踏んでこんな記事を用意してましたが、完全に誤算でした。。。

Web Componentsとは

WebComponents自体の詳細な解説は、すでにWeb上に優良な記事が多数あるので、ここでは詳細な説明は割愛し概要だけ取り上げることとします。

一言で説明すると↓こんな風に、独自タグを定義して別途作りこんだUI部品を組み込んでいけるような仕組みです。

<body>
    <sample-element></sample-element>
</body>

使ってみる

Web Componentsを構成する要素

Web Componentsは、以下の4つの技術を組み合わせて実現されています。

  • Custom Elements v1
  • HTML templates
  • HTML Imports
  • Shadow DOM v1

これら4つの要素を順番に使い、Web Componentsの基本的な動作を見ていきます。

サンプルコード

現時点のElectronでは、まだWebComponentsの機能は未実装な部分があるので、今回のサンプルコード類は動かせません。
サンプルコードは以下の場所に上げていますが、これは普通に静的なWebサーバーを立ち上げて、ブラウザで動作確認をするものです。

Chrome54以降のブラウザであれば、このサンプルコードの動作確認ができるかと思います。
一応、Electronの次バージョンがリリースされたら、Electron用のサンプルコードを作って追記しようと思います。

Custom Elements v1

まずは、Custom Elementsから。
Custom Elementsでは、独自タグを定義してhtmlやJavaScriptから利用できるようにすることができます。
Web Componentsの肝になる部分です。

HTMLElementなどのベースとなる型を継承して、独自タグ用のクラスを作ります。
そして、customElements.define()という関数に、タグ名と先ほど作成したクラスを指定して実行します。

index.js

class SampleElement extends HTMLElement {
    constructor() {
        super();
    }
    connectedCallback() {
        this.innerHTML = `
<div>
    <h1>Sample Component</h1>
    <p>
        CustomElements v1のサンプル
    </p>
    <button>sample</button>
</div>`;
    }
}

customElements.define('sample-element', SampleElement);

これでsample-elementというタグが定義されて、html上から使用できるようになります。
以下のように書いてページを表示してみると、先ほどinnerHTMLに文字列で渡したDOM要素が描画されることが確認できます。

index.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Document</title>
    <script src="index.js"></script>
</head>
<body>
    <sample-element></sample-element>
</body>
</html>

f:id:minami_SC:20161225123759p:plain

HTML templatesと組み合わせる

先ほどの例では、コンポーネントの中身となるDOM要素を、JavaScript中に文字列として定義していました。

文字列として書いてしまっているので、エディタでのシンタックスハイライトも効かないですし、コード短縮化など、htmlに関わる各種ツール類との連携もできません。

これはイケてませんね。

ということで、templateタグを使い、DOM要素の定義をhtmlファイル側に移動します。

index.js

class SampleElement extends HTMLElement {
    constructor() {
        super();
    }
    connectedCallback() {
        const template = document.querySelector('#sample-element-template');
        const instance = template.content.cloneNode(true);
        this.appendChild(instance);
    }
}

customElements.define('sample-element', SampleElement);

index.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Document</title>
    <script src="index.js"></script>
    <template id="sample-element-template">
        <div>
            <h1>Sample Component</h1>
            <p>
                CustomElements v1のサンプル
            </p>
            <button>sample</button>
        </div>
    </template>
</head>
<body>
    <sample-element></sample-element>
</body>
</html>

f:id:minami_SC:20161225123807p:plain
実行結果は特に変わりません。

HTML Imports

html側にコンポーネントのDOM要素の定義を持ってくることができました。 しかし、このままではコンポーネント利用者側のhtmlに、templateタグによるコンポーネント定義が残ってしまいます。

これらを、HTML Importsの機能を使って外部にもっていきましょう。

ついでにフォルダ構成なども変更し、以下のような構成にしてみます。
f:id:minami_SC:20161225123820p:plain

sample-elementというフォルダ内に、コンポーネント定義をすべて閉じ込めることができました。

sample-element/sample-element.js

class SampleElement extends HTMLElement {
    constructor() {
        super();
    }
    connectedCallback() {
        const ownerDocument = document.currentScript.ownerDocument;
        const template = ownerDocument.querySelector('#sample-element-template');
        const instance = template.content.cloneNode(true);
        this.appendChild(instance);
    }
}

customElements.define('sample-element', SampleElement);

sample-element/sample-element.html

<template id="sample-element-template">
    <div>
        <h1>Sample Component</h1>
        <p>
            CustomElements v1のサンプル
        </p>
        <button>sample</button>
    </div>
</template>
<script src="sample-element.js"></script>

index.html
コンポーネント利用者側は、HTML Importsを使って以下のようにコンポーネント定義ファイルを読み込むだけで使えるようになります。

<link rel="import" href="sample-element/sample-element.html">
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Document</title>
    <link rel="import" href="sample-element/sample-element.html">
</head>
<body>
    <sample-element></sample-element>
</body>
</html>

実行結果は変わらないので省略します。

Shadow DOM

CSSには名前空間のようなスコープはありません。
意図しない場所への影響を与えないように、クラス名の命名規則で頑張ったり、セレクタの書き方を工夫して衝突回避していました。

ですが、Shadow DOMを使えば、Shadow Rootという他のDOM要素からの影響を受けない領域を作ることができます。

Shadow Rootの内部で書いたスタイルなどは、コンポーネントの外部には影響を与えないので、クラスの命名規則でムリヤリ頑張ったりしなくても、スタイルの衝突を防ぐことができます。

sample-element/sample-element.js

class SampleElement extends HTMLElement {
    constructor() {
        super();
    }
    connectedCallback() {
        const ownerDocument = document.currentScript.ownerDocument;
        const template = ownerDocument.querySelector('#sample-element-template');
        const instance = template.content.cloneNode(true);

        // ShadowDOMの構築
        let shadowRoot = this.attachShadow({mode: 'open'});
        shadowRoot.appendChild(instance);
    }
}

customElements.define('sample-element', SampleElement);

sample-element/sample-element.html

<template id="sample-element-template">
    <style>
        .title{
            color: red;
        }

        button{
            background: lightblue;
        }
    </style>
    <div>
        <h1 class="title">Sample Component</h1>
        <p>
            CustomElements v1のサンプル
        </p>
        <button>sample</button>
    </div>
</template>
<script src="sample-element.js"></script>

index.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Document</title>
    <style>
        button {
            font-size: 20px;
        }
    </style>
    <link rel="import" href="sample-element/sample-element.html">
</head>
<body>
    <sample-element></sample-element>

    <hr />
    <h2 class="title">Web Componentsの呼び出し元</h2>
    <button>ほげほげ</button>
</body>
</html>

f:id:minami_SC:20161225123856p:plain
コンポーネントの内部/外部で、スタイルが互いに干渉していないことが確認できます。

具体的なサンプル

以上で、WebComponentsを構成する一通りの要素の使い方を見てきました。

具体的なコンポーネントの例として、シンプルなストップウォッチのコンポーネントを作ってみました。

stopwatch-element.html

<template id="stopwatch-element-template">
    <div>
        <span id="content"></span>
        <button id="btnStartStop">start</button>
        <button id="btnReset">reset</button>
    </div>
</template>
<script src="stopwatch-element.js"></script>

stopwatch-element.js

class StopwatchElement extends HTMLElement {
    constructor() {
        super();
        this.baseTime = null;
        this.offset = null;
        this.timerId = null;
    }

    /** DOMに要素が追加された際に発生するイベント */
    connectedCallback() {
        const ownerDocument = document.currentScript.ownerDocument;
        const template = ownerDocument.querySelector('#stopwatch-element-template');
        const instance = template.content.cloneNode(true);

        let shadowRoot = this.attachShadow({mode: 'open'});
        shadowRoot.appendChild(instance);

        this.content = shadowRoot.querySelector("#content");
        this.showTime(0);
        // 各種イベントハンドラの設定
        this.btnStartStop = shadowRoot.querySelector("#btnStartStop");
        this.btnStartStop.addEventListener('click', this.onStartStop.bind(this));
        let btnReset = shadowRoot.querySelector("#btnReset");
        btnReset.addEventListener('click', this.onReset.bind(this));
    }

    /** start/stopボタン押下時のイベント */
    onStartStop() {
        if (!this.timerId) {
            this.btnStartStop.textContent = "stop";
            this.baseTime = Date.now(); 
            this.timerId = setInterval(() => {
                let ellapse = this.offset + Date.now() - this.baseTime;
                this.showTime(ellapse);
            }, 10);
        } else {
            clearInterval(this.timerId);
            let ellapse = Date.now() - this.baseTime;
            this.offset += ellapse;
            this.timerId = null;
            this.btnStartStop.textContent = "start";
        }
    }

    /** resetボタン押下時のイベント */
    onReset() {
        clearInterval(this.timerId);
        this.showTime(0);
        this.timerId = null;
        this.baseTime = null;
        this.offset = null;
    }

    /** 経過時間を表示するための関数 */
    showTime(time) {
        let pad = (num, digit) => ('000' + Math.floor(num)).slice(-digit);
        let h = pad(time / (60*60*1000), 2);
        time = time % (60*60*1000);
        let m = pad(time / (60*1000), 2);
        time = time % (60*1000);
        let s = pad(time / 1000, 2);
        time = time % 1000
        let ms = pad(time, 3);
        this.content.textContent = `${h}:${m}:${s}.${ms}`;
    }

}

customElements.define('stopwatch-element', StopwatchElement);

index.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Document</title>
    <link rel="import" href="stopwatch-element/stopwatch-element.html">
</head>
<body>
    <stopwatch-element></stopwatch-element>
    <hr />
    <stopwatch-element></stopwatch-element>
    <hr />
</body>
</html>

f:id:minami_SC:20161225124247p:plain

ただテキストで経過時間が表示されるだけのものですが、コンポーネントに関わるUI定義やロジックを、再利用しやすいように独立して記述できるのがわかると思います。

まとめ

以上、駆け足でElectronでのWeb Componentsの使い方を見てきました。

この記事のサンプルでは、フロント側では特にライブラリを使用していません。
(ブラウザでの動作確認のために、テスト用Webサーバーとしてnode-staticを使っているだけ。)

ReactもAngularも使ってないですが、ちゃんとコンポーネント指向な開発ができる、という雰囲気はつかめていただけたのではないでしょうか?
未来感ハンパねぇですね!!

Electronの次のメジャーバージョンアップを楽しみに待ちましょう♪