2020-05-06

JavaScriptとCSSの遅延読み込み

ブログにJavaScriptものを貼るときは色々気を使うんですよね。ブログ全体のheadにscriptタグを直接書くのは嫌ですし、エントリーにscriptタグを直接書いても良いのですが同じスクリプトを使うエントリーが複数同じページに表示されたときに二重に読み込んでしまうのは困ります。また、使用箇所がまだ表示されていないのに読み込んでしまうとサイトが重くなってしまいます。このサーバ、かなり遅いみたいですし。

というわけで遅延読み込みの仕組みを作ってみました。要素が画面内に入ったら指定されたスクリプトやcssを読み込みます。

//
// 一応IE11でも動くように作っています。IE8はaddEventListenerがないので動きません。
//
(function(w, d){
    // Array.prototype.forEach.call(a, f)の代わり
    var each=function(a,f){for(var i=0;i<a.length;++i){f(a[i]);}};
    // Promiseもどき。対応しているならPrms=Promiseでも良い。
    //var Prms = Promise;
    function Prms(f){
        var thenCb;//複数必要ならthens=[]で。thenではthens.push(cb)、succではeach(thens,function(thenCb){...})
        this.then=function(cb){
            thenCb=cb;
            return new Prms(function(succ){
                cb.nextCb=succ;
            });
        };
        var succ=function(result){
            setTimeout(function(){
                if(typeof thenCb=="function"){
                    var next=thenCb(result);
                    if(next&&thenCb.nextCb){next.then(thenCb.nextCb);}
                }
            },0);
        };
        f(succ);
    }
    Prms.resolve = function(){
        return new Prms(function(succ){succ();});
    };
    Prms.all = function(arr){
        return new Prms(function(succ){
            var count = arr.length;
            function onSucc(){
                if(--count == 0){
                    succ();
                }
            }
            each(arr,function(e){e.then(onSucc);});
        });
    };

    // 指定されたurlを読み込むタグ(cssならlink、それ以外ならscript)を
    // headへ追加して読み込みが終わったら解決するPromiseを返します。
    //
    // 配列を指定した場合は sequentially の指定によって処理が変わります。
    // sequentially が false なら同時に読み込みます。
    // sequentially が true なら先頭から順番に読み込みます。
    //
    // 配列内の配列も読み込みますが、sequentiallyが反転します。
    // 例えばload([a, [c, d, [e, f]], g], false)の場合、
    // - a, c, gは同時
    // - dはcの後
    // - e, fはdの後
    // に読み込みます。
    //
    function load(url, sequentially){
        //console.log("load(" + url + " " + (sequentially ? "sequentially" : "parallel") + ")");
        if(typeof url=="string"){
            return new Prms(function(succ){
                // 既に追加されている<link rel=stylesheet>、<script>要素を列挙する。
                //
                // この関数が追加した要素には.isUrlLoadingが設定されていて、
                // trueなら読み込み中。falseなら読み込み済み。
                // 他で追加した要素は読み込み済みか判定する方法が見当たらない
                // ので、読み込み済みと判断する。
                //
                // loadのたびに毎回探し直す必要がある。
                // sequentiallyの場合は前のloadが実行されるタイミングで
                // <script>や<link>が追加されるので。
                var es={};//elements
                each(d.getElementsByTagName("link"),function(link){if(link.getAttribute("rel")=="stylesheet"){es[link.getAttribute("href")]=link;}});
                each(d.getElementsByTagName("script"),function(script){es[script.getAttribute("src")]=script;});
                var head=d.head||d.getElementsByTagName("head")[0];

                var e=es[url];//既に追加済みのelementがあるなら取得
                if(e){
                    //console.log("already added " + url);
                    // すでに追加されている場合
                    if(e.isUrlLoading){
                        // 読み込み中の場合
                        var old = e.onload;
                        e.onload = old ? function(){old(); succ();} : succ; //フックする
                    }
                    else{
                        // 読み込み済みまたは不明な場合
                        //console.log("already loaded? " + url);
                        succ();
                    }
                }
                else{
                    if(/\.css/.test(url)){
                        // .cssの場合
                        e=d.createElement("link");
                        e.isUrlLoading=true;
                        e.rel="stylesheet";
                        e.type="text/css";
                        e.href=url;
                    }
                    else{
                        // その他は.jsと仮定
                        e=d.createElement("script");
                        e.isUrlLoading=true;
                        e.type="text/javascript";
                        e.src=url;
                    }
                    function onLoad(ev){
                        if(e.isUrlLoading){
                            console.log("loaded: " + url); //この関数で追加した要素が読み込み完了。
                            e.isUrlLoading=false;
                            succ();
                        }
                    }
                    e.onload = onLoad;
                    //e.onreadystatechange= はIE11のエミュレーションによればIE9以降不要。IE8はaddEventListenerに対応していないほどなのでいいや。
                    head.appendChild(e);
                }
            });
        }
        else if(url instanceof Array){
            // 配列の場合
            if(sequentially){
                // 先頭から一つずつ読み込み
                return new Prms(function(succ){
                    function next(){
                        if(url.length == 0){
                            succ();
                        }
                        else{
                            //console.log("start load " + url[0]);
                            load(url.shift(), false).then(next);
                        }
                    }
                    next();
                });
            }
            else{
                // 同時に読み込み
                // mapが使えるならreturn Prms.all(url.map(u=>load(u, true)));
                var prmss = [];
                each(url, function(u){
                    //console.log("start load " + u + " parallel");
                    prmss.push(load(u, true));});
                return Prms.all(prmss);
            }
        }
        /*
          else if(typeof url=="function"){
          // 関数の場合、実行したらPromiseを返すものと仮定
          return url();
          }
          else if(url instanceof Prms){
          // Promiseはそのまま
          return url;
          }
        */
        else{
            throw new Error("Unknown url type");
            return null;
        }
    }

    function onViewport(elem){
        return new Prms(function(succ){
            // scrollイベントを使う。本当はIntersectionObserverを使いたい。
            function onScroll(ev){
                var MARGIN=50;
                var rect=elem.getBoundingClientRect();
                if(rect.bottom+MARGIN>=0&&rect.top-MARGIN<=(w.innerHeight||d.documentElement.clientHeight)){
                    w.removeEventListener("load",onScroll,false);
                    w.removeEventListener("scroll",onScroll,false);
                    succ(elem);
                }
            }
            w.addEventListener("load",onScroll,false);
            w.addEventListener("scroll",onScroll,false);
        });
    }

    function loadScriptOnViewport(elem, urls){
        if(typeof elem == "string"){
            elem = d.getElementById(elem);
        }
        return new Prms(function(succ){
            onViewport(elem).then(function(elem){
                load(urls).then(function(){succ(elem);});
            });
        });
    }
    w.loadScriptOnViewport = loadScriptOnViewport;
})(window, document);

これを次のように使います。

var div = document.createElement("div");
document.currentScript.parentNode.appendChild(div);
//divが画面内に入ったらigo.css, igo.js, igo_view.jsを読み込む。igo.jsとigo_view.jsは順番に読み込む。
loadScriptOnViewport(div, ["igo.css", ["igo.js", "igo_view.js"]]).then(){
   div.appendChild((new GameView()).rootElement);
};

ファイルは配列で読み込む順番を指定出来ます。cssとjsは同時に読み込んでも問題ありませんがjsは順番を守る必要があるケースが多々あるので。

IEを切り捨てて良いなら色々と短くできる箇所があると思います。Promiseもアロー関数も使えますし。読み込むスクリプトがIE非対応ならここで対応する意味はありません。少し切り詰めてminifyかけたら1260文字くらいになりました。昨日の詰碁で使用しています。