Yearly Archives: 2020

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文字くらいになりました。昨日の詰碁で使用しています。

2020-05-06 ,

オシツブシ

実戦でオシツブシが決まると気持ちいいね! 囲碁クエスト9路盤本日の対局より。

それで実際の所これ活きてたの?

(注意:↑↑に画面内に入ったらigo.js等を読み込んで、読み込み終わったら盤面を表示するスクリプトが仕込んであります。表示まで時間がかかるのはサーバが重いんじゃないかな……?)

2020-05-02 ,

JavaScript碁盤

JavaScript碁盤を書きました。

実行
ソースコード
https://github.com/misohena/js_igo

最近囲碁クエストにはまっているのですが、ブラウザ版には終局後の検討機能がありません。検討機能は終局後に巻き戻して途中から自分で好きに石を並べられるモードです。終局後に「あそこは何かあったんじゃないかな」「防ぐ手はなかったかな」などと思うことは良くありますが、ブラウザでプレイするとそれができません。Android版にはちゃんと検討機能がありますし過去の対局を振り返って検討することもできるのですが、デスクで作業しているときにいちいちスマホに手を伸ばしたくありません。

幸い棋譜(SGF)のエクスポート機能(ブラウザのテキスト領域に表示してコピーできる)はあるので、何か適当なソフトで読み込めば検討はできます。CGobanを入れてみたのですが、コピーしたSGFテキストを一度ファイルに保存してから読み込まなければならず面倒です。同じブラウザでSGFをペーストして読み込める碁盤があれば便利だなと思ったので作ることにしました。同じようなものは探せばいくらでもあるみたいですが、探すのも面倒ですし作るのも勉強になりますので。

基本的なロジックは10年以上前に作ったものがあったので流用しつつ現代風にアレンジ&機能追加しました。

盤面のデータ構造はUint32Arrayに1交点2ビットずつ詰め込む形を採用。9路盤を2*9*9=162ビット、162/32=5.0625で6dwords(24バイト)で表現出来ます。最小を目指すなら3(空点、黒、白)の倍数で記録していくべきなのですがさすがにそれはやりすぎかなと。盤外を表す点を用意すべきか等色々トレードオフがあってよく分からないのですが、盤面を沢山複製して保持するときにできるだけ小さい方がいいだろう、ということで。ハッシュも計算しやすそうですし。ただ、今回の用途までならどうやっても問題なし。

画像で表示するのも馬鹿らしいのでSVGで表示。グラデーションと影をつけてまあまあの見た目になりました。

履歴は最初からツリー構造を採用。巻き戻してから他の場所に打ったときは別のノードを作って記録します。分岐はA, B, C,…と盤面に表示できるようにしました。他のツールのようにツリー構造全体を図で表示出来たらかっこよかったのですが、これでも十分実用にはなります。

SGFインポート/エクスポート機能は今回の目的では必須。SGFフォーマットのページを見たらEBNFが書いてあったのでそれを元に手書きで解析器を作成。プロパティは、囲碁クエストのSGFを読むだけならSZ, B, Wくらいに対応すれば十分。後から詰碁を表示したいと思ってPL, AB, AW, AE, C, その他マーク等にも対応しました。

詰碁に対応するためにコメント機能、フリー編集機能、先番設定機能を追加。

一応スマホでも使えるようにタッチイベントに対応。19x19はさすがに小さいですね。ボタン類も一緒に小さくなってしまうので盤面部分だけ拡大縮小出来るようになるとよいのですが……。(→追記:対応しました)

あ、結果の判定機能はありません。死活判定しなければならないので難しいですよね……。死に石を指定出来るようにするとか、純碁みたいに全部打ち切るなら判定出来るのですが……。今後の大きな課題です。

純碁と言えば王銘エン先生の「こんなに面白い 世界の囲碁ルール」は面白かったです。オススメ。

ちなみに囲碁クエストをはじめたのは、知り合いが将棋ウォーズをやっていたからです。将棋は子供の頃に父親にボコボコにされたトラウマがあるのでやらないことにしているのです。なので囲碁ウォーズを試してみたのですが、Android版アプリの出来がすこぶる悪い。何回やっても起動しなくなるのです。時々思い出したようにすんなり起動するのですが、アプリを終了するとまた起動しなくなります。何が原因なのかまったく分かりません。調べてみると囲碁クエストという別のアプリの方が安定しているようなのでそちらをはじめました。

どちらもメインは9路盤のようです。19路のオンライン対局は大変なのでとてもやる気が起きませんが、9路なら短時間でプレイできるのでオススメです。

一問一答! 囲碁・9路盤の手筋 ~基本定石からヨセまで~ (囲碁人ブックス)」という本も買ってみたのですが、これはちょっと難しいですね。難易度が低い問題が分からなくて気を落としていたら難易度が中くらいのものがあっさり分かってしまったり。巻末の引き分け定石は参考になります。手順番号の着いた棋譜を脳内再生するのは苦手なのですが、今回作った碁盤を使って勉強してみようと思います。

Web上も探すと9路の情報が結構ありますね。Youtube動画もあります。