超簡易版のCustom Elements実装

2016-06-01

W3Cで標準化が進められているCustom Elementsは、ChromeではregisterElementで作ることができますが、SafariやIEなどのブラウザではPolyfillが必要なので使い勝手がよくありません。今回はCustom Elementsの必要な機能に絞ってモダンブラウザで使える実装をやりたいと思います。

Custom Elementsを知らない人向けに説明すると、HTMLではpタグとかdivタグのような標準のタグがありますが、独自のタグを定義して使用できるようにする機能です。
例えば、aタグは標準ではクリックすると設定されているhref属性の値に飛ぶことができますし、imgはsrc属性の画像を読み込んで表示します。

やりたいのは、<comment-list></comment-list>みたいなタグを配置すると、自動的にコメントの一覧データをサーバから取ってきて表示するようなことです。
Custom Elementsを使わないでもやろうと思えばできるのですが、コードが煩雑になって訳がわからなくなったり、変更を加えるのが困難になることが多くあります。

例えば、

<div id="comments"></div>
<script>document.onload = function(){
    var coms = dodument.getElementById('comments');
    // 一般的なajax機能が別途定義されているとして
    ajax({
        url:'comments.json'
    }).then(function(data){
        // 取ってきたデータを元にcoms内にDOMを挿入
    })
}</script>

この方法は、document.onloadをトリガーとしてコメント一覧を取得しますが、例えば以下のようにボタンが押された後にコメント一覧のDIVが非同期で生成された場合には、そのDIVが生成した後に一覧を取得して表示する、ということを行う必要があります。

<button>コメントを読む</button>
<script>
    document.querySelector('button').addEventListener('click', function(){
        var coms = document.createElement('div');
        document.body.appendChild(coms);
        ajax({
            url:'comments.json'
        }).then(function(data){
            // 取ってきたデータを元にcoms内にDOMを挿入
        })
    });
</script>

これくらいなら普通に管理できそうですが、コメント一覧のそれぞれのコメントに返信ボタンをつけるとすると、その返信ボタンをクリックした時のイベント付与は当然、そのボタンがDOM内に配置された後に行なう必要があるために関数のネストがどんどん深くなっていきます。

こんなときに欲しいのが今回の趣旨であるCustom Elementsのライフサイクルコールバックで、DOMに配置されたときに特定の処理を行う機能です。こんな感じでカスタムのタグが登録できたらどうでしょうか?

<comment-list></comment-list>
<script>
    customcomp('comment-list', function(coms){
        ajax({
            url:'comments.json'
        }).then(function(data){
            // 取ってきたデータを元にcoms内にDOMを挿入
        })
    })
</script>

このcustomcomp関数は、引数にタグ名とDOMに配置されたときに実行したい関数をとる関数です。
これを使うと

<button>コメントを読む</button>
<script>
    customcomp('comment-list', function(coms){
        ajax({
            url:'comments.json'
        }).then(function(data){
            // 取ってきたデータを元にcoms内にDOMを挿入
        })
    });

    document.querySelector('button').addEventListener('click', function(){
        var coms = document.createElement('comment-list');
        document.body.appendChild(coms);
    });
</script>

という具合にボタンが押されたときにはcomment-listタグのHTMLエレメントを配置するだけの処理を書くだけで済みます。さっきみたいに、コメント一覧のそれぞれのコメントに返信ボタンがあったとしても、そのコメント返信ボタンのタグを配置するだけです。

MutationObserverを使った実現方法

こんなことを実行しようとすると、DOMNodeInsertedというイベントがあって

document.addEventListener('DOMNodeInserted', function(){
    // DOMに何か挿入されたら実行する処理を書く
});

ということが可能ですが、同期的に動作して関係のない要素が挿入された場合でも処理が走るのでページが重くなってしまう欠点がありました。そのため現在は非推奨になった模様です。

同様の機能で、MutationObserverというDOM の変更を監視するオブザーバが定義されているのでそれを使えば良さそうです。
しかし、以下のような使い方では機能しません。

<comment-list></comment-list>
<script>
    var coms = document.querySelector('comment-list');

    // オブザーバインスタンスを作成
    var observer = new MutationObserver(function(mutations) {
        mutations.forEach(function(mutation) {
            // mutationのtypeなどを見たりして処理を実行
        });
    });
    // オブザーバの設定
    var config = { attributes: true, childList: true, characterData: true }
    observer.observe(target, config);
</script>

observer.observeが実行されて監視が始まる以前に、すでにcomment-listタグがDOM内に配置されているので、これでは要素の挿入は検知できません。そのため、監視を始めた時点ですでに配置されていないかどうかの処理を実行する必要があります。
おまけにMutationObserverに渡されるmutationsが面倒で、目的のタグを絞り込むのが大変だったりします。

cssアニメーションイベントを検知する方法

JavaScriptにはanimationstart、animationendイベントを検知する機能があり、これを使うと、cssアニメーションの開始と終了を監視できます。

<style>
@keyframes ins{
    0%{opacity:.9}
    1%{opacity:1}
}
comment-list{
    animation:.1s ins;
}
</style>
<comment-list></comment-list>
<script>
    document.addEventListener('animationend', function(e){
        if (e.animationName == 'ins') {
            // アニメーションが終了したら (== 要素がDOMに配置されたら)処理を実行する
            console.log(e.target);
        }
    })
</script>

ダミーのアニメーションを登録しておくと、実際にDOMに要素が配置されるのはscriptタグより前ですが、addEventListenerできちんと検知できます。CSSのアニメーションなのでsafariなど向けにwebkitのプリフィックスが必要ですが、上記では省いています。

前置きが長くなりましたが、以下のコードで実現したいことが達成できそうです。


<comment-list></comment-list>
<script>
    // 動的に<style>タグを生成してhead内に配置
    var styletag = document.createElement('style');
    var css = document.createTextNode('@keyframes ins{0%{opacity:.9}1%{opacity:1}}@-webkit-keyframes ins{0%{opacity:.9}1%{opacity:1}}');
    styletag.appendChild(css);
    document.head.appendChild(styletag);

    // タグ名と関数を登録するオブジェクト
    var comps = {};

    dodument.addEventListener(document.body.style.animation !== undefined ? 'animationend' : 'webkitAnimationEnd', function(e){
        if (e.animationName == 'ins') {
            comps[e.target.tagName](e.target);
        }
    });

    var customcomp = function(tag, func){
        comps[tag.toUpperCase()] = func;
        var css = document.createTextNode(tag+'{animation:.1s ins;-webkit-animation:.1s ins}');
        styletag.appendChild(css);
    }

    // 新しいタグの要素を登録する
    customcomp('comment-list', function(coms){
        ajax({
            url:'comments.json'
        }).then(function(data){
            // 取ってきたデータを元にcoms内にDOMを挿入
        })
    });
</script>

簡単なコードですが、これでIE10〜のモダンブラウザで動くことを確認しました。

JavaScriptHTML5