月別アーカイブ: 2014年6月

【JavaScript】注釈・脚注を補完する「AutocompleteAnnotations」

注釈・脚注を補完するスクリプト「AutocompleteAnnotations」を書いた。

ブログを書いていると【1】のように注釈や脚注を付けたい時がある。しかし、一々ハッシュリンク貼ったりa要素のtitle属性に内容を書いたりするのは面倒くさいので自動化する。以下サンプル

//脚注同士のリンクや説明を自動補完;
(function autocompleteAnnotations() {

	var range = document.createRange();

	//注釈側の処理;
	(function() {
		//注釈はannotationsというclass属性を持っている;
		var annotations = document.getElementsByClassName('annotations');
		var modelA = document.createElement('a');
		modelA.id = 'annotation';
		modelA.href = '#footnote';
		for(var i = 0; i < annotations.length; i++) {
			var a = modelA.cloneNode(true);
			a.id += i;
			a.href += i;
			//注釈文字をa要素で囲む;
			range.selectNodeContents(annotations[i]);
			range.surroundContents(a);
		}
	})();

	//脚注側の処理;
	(function() {
		//脚注はfootnotesというclass属性を持っている;
		var footnotes = document.getElementsByClassName('footnotes');
		var modelA = document.createElement('a');
		modelA.id = 'footnote';
		modelA.href = '#annotation';
		for(var i = 0; i < footnotes.length; i++) {
			//脚注を指している注釈側のtitle属性に同じ説明を加える;
			var annotation = document.getElementById('annotation' + i);
			annotation.title = footnotes[i].textContent;
			var a = modelA.cloneNode(true);
			a.id += i;
			a.href += i;
			a.textContent = annotation.textContent;
			range.setStart(footnotes[i], 0);
			range.insertNode(document.createTextNode(': '));
			range.insertNode(a);
		}
	})();

	range.detach();
})();

See the Pen AutocompleteAnnotations by shigure (@webkatu) on CodePen.

使い方

「autocompleteAnnotations.js」をdefer属性をつけて呼び出し、注釈部分の要素に「class=”annotations”」という属性をつけて、説明部分の要素に「class=”footnotes”」という属性をつければOK。

<head>
	<!-- defer属性をつけてscriptを呼び出す -->
	<script defer src="autocompleteAnnotations.js"></script>
</head>
<body>
	<!-- 注釈部分を"annotations"というclass名の要素で囲む -->
	<p>hogehoge<span class="annotations">【1】</span></p>
	<p>fugafuga</p>
	<!-- 説明部分(脚注)を"footnotes"というクラス名の要素で囲む -->
	<p class="footnotes">piyopiyo</p>
</body>
<head>
	<script defer src="autocompleteAnnotations.js"></script>
</head>
<body>
	<p>hogehoge<span class="annotations"><a id="annotation0" href="#footnote0" title="piyopiyo">【1】</a></span></p>
	<p>fugafuga</p>
	<p class="footnotes"><a id="footnote0" href="#annotation0">【1】</a>: piyopiyo</p>
</body>

GitHub

SyntaxHighlighterの導入と設定・改造

サイトで表示するコードを見やすくするsyntaxhighlighterを導入したり設定したり改造したりしたのでその備忘録。バージョンは3.0.83。

導入

まず、syntaxhighlighterのダウンロードページからダウンロード。解凍した中のscriptsフォルダとstylesフォルダを全てサーバーにアップロード。フォルダの中の使いたいスクリプトとスタイルシートをサイトのヘッダーに追加。

<head>
	<script src="scripts/shCore.js"></script> <!-- 必須 -->
	<script src="scripts/shBrushXml.js"></script> <!-- htmlやxmlのコードを書く場合 -->
	<script src="scripts/shBrushJScript.js"></script> <!-- JavaScriptのコードを書く場合 -->
	<script src="scripts/shBrushPython.js"></script> <!-- Pythonのコードを書く場合 -->
	<link rel="stylesheet" type="text/css" href="styles/shCoreDefault.css"> <!-- デフォルトのcss。他のテーマ使いたいなら他のcssを選択 -->
	<script>
		 SyntaxHighlighter.all(); //必須;
	</script>
</head>

実際に使う時はpre要素のクラス属性にクラス名を指定。

<pre class="brush: html"> <!-- htmlを書く時のクラス名 -->
	//ここにhtmlを書く;
</pre>

<pre class="brush: js"> <!-- JavaScriptを書く時のクラス名 -->
	//ここに処理を書く;
</pre>

言語ごとのスクリプトファイルやクラス名については行番号や各行背景色で見やすい ソースコード ビュー ライブラリ SyntaxHighlighterを参照

設定・改造

  • 右上のはてなマークを消す

    サイトのヘッダーのscript要素に以下のコードを追加;

    <script>
    SyntaxHighlighter.defaults['toolbar'] = false; //←追加; 
    SyntaxHighlighter.all(); //必須;
    </script>
    
  • 縦スクロールバーを消す

    コードは全部表示されているのに縦スクロールバーが出るのは見栄えが悪いので、cssを編集する。shCoreDefault.cssの55行目辺りにある.syntaxhighlighterセレクタの中に以下の宣言を追加。

    .syntaxhighlighter {
    	overflow-y: hidden !important; /* 追加 */
    }
    
  • 1行ごとに色を変える

    いわゆるしましまにする。shCoreDefault.cssの231~234行目辺りにある.syntaxhighlighter .line.alt1.syntaxhighlighter .line.alt2のbackground-colorプロパティの値を変える。

    .syntaxhighlighter .line.alt1 {
    	background-color: white !important;
    }
    .syntaxhighlighter .line.alt2 {
    	background-color: #f8f8f8 !important; /* 変更 */
    }
    
  • 行を折り返す

    長いと横スクロールバーが出るので横スクロールバーを消すために行を折り返す。shCoreDefault.cssの71行目辺りにある.syntaxhighlighter .lineセレクタを以下のように変更。

    .syntaxhighlighter .line {
    	white-space:pre-wrap !important; /* 変更 */
    	word-wrap: break-word !important; /* 追加 */
    }
    
  • 行番号を消す

    行を折り返すと、行番号が適切でなかったり、ブラウザによっては行番号がずれたりするので行番号を消す。サイトのヘッダーのscript要素に以下のコードを追加。

    <script>
    SyntaxHighlighter.defaults['gutter'] = false; //←追加;
    SyntaxHighlighter.defaults['toolbar'] = false;
    SyntaxHighlighter.all(); //必須;
    </script>
    

    参照: Syntaxhighlighter3.0.83で長い行を折り返して表示する

  • borderをつける

    コードを書いている部分と文章をしっかり分けたほうが見やすいのでborderをつける。shCoreDefault.cssの55行目辺りにある.syntaxhighlighterセレクタの中に以下の宣言を追加。

    .syntaxhighlighter {
    	border: 1px solid #ddd !important;
    }
    
  • タイトル部分の色を変える

    pre要素のtitle属性にタイトルをつけると1行目にタイトルが表示される。その部分の色を変える。shCoreDefault.cssの243行目辺りにある.syntaxhighlighter table captionセレクタの中を変更。

    .syntaxhighlighter table caption {
    	color: #76daff !important; /* 変更 */
    	background-color: #3d3d3e !important; /* 追加 */
    }
    
  • その他色々

    • タイトル部分のpadding調整

      .syntaxhighlighter table caption {
      	padding: 0.5em !important; /* 変更 */
      }
      
    • コード部分のpadding調整

      .syntaxhighlighter.nogutter td.code .container textarea, .syntaxhighlighter.nogutter td.code .line {
      	padding: 0 0.5em !important; /* padding-leftから変更 */
      }
      
      .syntaxhighlighter table td.code {
      	padding: 0.5em 0 !important; /* 追加 */
      }
      

【JavaScript】pushStateとAjaxを使って非同期通信

HTML5のHistoryAPIとAjaxを使って非同期通信をしながらURLを変えるいわゆるpjaxっぽいことができたので、大まかな流れと気をつけたい点について書いていく。

Ajaxを使ってページを遷移する時、URLは変わらないし履歴も追加されない。しかし、HTML5のHistoryAPIと組み合わせると、URLを変え履歴も追加でき、通常どおりのページ移動と同じような動作を非同期通信で実現できる。

HistoryAPIについて

JavaScriptにはhistoryという履歴を管理するオブジェクトがありHTML5から履歴を追加したり変更したり検知したりできるようになった。pjaxの頭文字pであるpushStateはhistoryオブジェクトのメソッドで

history.pushState(state, title, url);

で呼び出せる。pushStateは履歴を追加するメソッドである。

例えば、するとURLが変わる。そして、履歴も追加されたのでブラウザの戻るボタンを押すことができ、押せばURLは元に戻る。ちなみに今実行されたコードはこれ。

history.pushState('testButton', null, '/test');

pushStateの第3引数にURLを指定することによってURLが変わり、履歴が追加される。URLは相対パスでもいい。ちなみに、第1引数は履歴に情報を付加するオブジェクトで、history.stateに値がセットされる。これはどういう時に使うかというと、履歴が移動した時に発生するpopStateイベントからこのstateを元に処理したりする時に使える。後で具体的に使いながら見ていく。第2引数はその履歴のタイトルでページのタイトルを変えるためにあるようだが、実装されているブラウザが殆どないためnullでいい。他にもhistoryオブジェクトにはreplaceStateというメソッドがあり、使い方はpushStateと同じだが、replaceStateメソッドは現在の履歴を書き換えるだけで履歴は追加しない。

ここまで、Historyオブジェクトについて簡単に見てきたが、pushStateで履歴を追加しURLを変えても実際にそのURLにアクセスされるわけではない。そこでAjaxを使い、ページを遷移さるというわけである。

pjaxの流れと気をつける点

今回作ったサンプルを元に流れを見ていく。

大まかな流れとしては以下のような感じである。

  1. ユーザーがリンクからページを移動しようとする。
  2. pushStateを使って移動先のURLに変え、AjaxでそのURLのサーバーへリクエストする。
  3. サーバーから返ってきたデータを表示する。

流れは殆どAjaxで遷移する時と変わらない。しかし、pjaxにはいくらかの気をつけるべき点がある。気をつける点は以下。

  1. ユーザーが戻る進むボタンなどで履歴を移動した時もサーバーにリクエストする。
  2. pjaxを使わない通常のアクセスでも問題なくページを表示できるようにする。
  3. URLが変わるため、遷移しない部分のa要素のhref属性は絶対パスで指定する。
  4. ページ毎のscript要素を動的に実行する。

1については、popStateイベントを使う。2についてはそのままの意味で、リンク先に直接アクセスしたらテキストしか表示されないというのではユーザービリティに欠けるので、全部HTMLを用意する。3もそのままの意味で、もし非同期遷移しない部分のa要素を相対パスで指定しているとpjaxでURLが変わるので想定していないリンクを指すことになる。2、3については設計の問題で、pjaxを想定したHTMLを書かなければいけないためプログラム自体の汎用性が低くなる。今回は自分の力量不足もあってHTMLに頼っている部分も多い。サーバーサイドと組み合わせたりしてプログラムで制御するような設計にすればある程度汎用性は高くなると思う。4については、非同期遷移する際の問題点で、非同期遷移で得たscript要素は自動的に発火しないので、自分で実行する必要がある。これら流れと気をつける点を以下コードを見ながら具体的に書いていく。

実際のコード

ひとまず、サンプルのpjax.jsのコードを以下に表示する。

(function() {

    var id = 'main'; //非同期遷移する要素のid;

    var transitionElement = document.getElementById(id);
    var display = new Display(transitionElement);
    var xhr = new XMLHttpRequest();

    //履歴を上書き;
    (function() {
        var url = location.href.replace(/index\..+/, '');
        history.replaceState({url: url}, null, url);    
    })();

    //対象のアンカー要素に非同期通信するよう設定;
    function setRequest() {
        var anchors = document.querySelectorAll('a[class *= "async"]');
        for(var i = 0; i < anchors.length; i++) {
            anchors[i].onclick = (function(i){
                return function(e) {
                    e.preventDefault();
                    var url = anchors[i].href;
                    url = url.replace(/index\..+/, '');
                    display.hide(function() {
                        //リンク先と現在のURLが一緒じゃないなら履歴を追加する
                        if(url !== location.href) {
                            history.pushState({url: url}, null, url);
                        }
                        request(url);
                    });
                }
            })(i);
        }
    }
    setRequest();

    //履歴を移動した時の処理;
    window.addEventListener('popstate', function(e) {
        if(e.state) {
            display.hide(function() {
                request(e.state.url);
            });
        }
    });

    function request(url) {
        xhr.abort();
        xhr.open('GET', url);
        xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
        xhr.responseType = 'document';
        xhr.overrideMimeType('text/html; charset=utf-8'); //文字化け対策;
        xhr.timeout = '10000';
        xhr.send(null);
    }

    xhr.addEventListener('loadend', function() {
        if(xhr.status === 200) {
            var response = xhr.response;
            var responseElement = response.getElementById(id);

            //必要な要素を書き換える;
            document.title = response.title;
            document.body.id = response.body.id;
            transitionElement.innerHTML = responseElement.innerHTML;

            //実行したいスクリプト要素を取得して実行;
            var scripts = response.querySelectorAll('script[class *= "async"]');
            for(var i = 0; i < scripts.length; i++) {
                var script = cloneScript(scripts[i]);
                transitionElement.appendChild(script);
            }

            setRequest();
            display.show();
            return;
        }
        //読み込みできなかった時の処理;
        transitionElement.innerHTML = '読み込みに失敗しました。';
        display.show();
    });

    //script要素をコピー;
    function cloneScript(element) {
        var script = document.createElement('script');
        var attrs = element.attributes;
        for(var i = 0, len = attrs.length; i < len; i++) {
            var attr = attrs[i];
            script.setAttribute(attr.name, attr.value);
        }
        script.innerHTML = element.innerHTML;
        return script;
    }

    //アニメーション;
    function Display(element) {
        this.element = element;
        this.opacity = Number(document.defaultView.getComputedStyle(element, null).opacity);
        this.hideTimer = null;
        this.showTimer = null;
        this.hide = function(func) {
            clearTimeout(this.showTimer);
            var style = this.element.style;
            var css = document.defaultView.getComputedStyle(this.element, null);
            style.opacity = css.opacity;
            var that = this;
            (function recursion() {
                if(style.opacity <= 0) {
                    style.display = 'none';
                    if(typeof func === 'function') {
                        func();
                    }
                    return;
                }
                style.opacity = (Number(style.opacity) - 0.1).toFixed(1);
                that.hideTimer = setTimeout(recursion, 15);
            })();
        };
        this.show = function(func) {
            clearTimeout(this.hideTimer);
            var style = this.element.style;
            style.opacity = 0;
            style.display = 'block';
            var that = this;
            (function recursion() {
                if(style.opacity >= Number(that.opacity.toFixed(1)) || style.opacity >= 1) {
                    style.opacity = that.opacity;
                    if(typeof func === 'function') {
                        func();
                    }
                    return;
                }
                style.opacity = (Number(style.opacity) + 0.1).toFixed(1);
                that.showTimer = setTimeout(recursion, 15);
            })();
        };
    }
})();

始めにHTMLの設計段階で非同期遷移する要素を決める。ここでは、「var id = ‘main’」とあるように「id=”main”」の要素を非同期遷移する。つまり、「id=”main”」の要素とレスポンスで返ってきた「id=”main”」の要素のinnerHTMLを交換するというわけである。基本的に現在のHTMLとレスポンスで返ってくるHTMLの構造は同じだと想定している。では、処理を見ていく。

var url = location.href.replace(/index\..+/, '');
history.replaceState({url: url}, null, url);

まず、初回アクセス時の履歴を書き換える必要がある。これはpopStateイベントの処理の時に詳しく説明するが、後々、ユーザーが戻るボタンを押して現在のページに戻ってきた時にサーバーに適切なリクエストを送るためである。ここでは、index.htmlなど「index.~」の文字は表示しないよう文字列処理を施している。

次に非同期通信したいa要素にクリックイベントをセットする。

//対象のアンカー要素に非同期通信するよう設定;
function setRequest() {
    var anchors = document.querySelectorAll('a[class *= "async"]');
    for(var i = 0; i < anchors.length; i++) {
        anchors[i].onclick = (function(i){
            return function(e) {
                e.preventDefault();
                var url = anchors[i].href;
                url = url.replace(/index\..+/, '');
                display.hide(function() {
                    //リンク先と現在のURLが一緒じゃないなら履歴を追加する
                    if(url !== location.href) {
                        history.pushState({url: url}, null, url);
                    }
                    request(url);
                });
            }
        })(i);
    }
}
setRequest();

function request(url) {
    xhr.abort();
    xhr.open('GET', url);
    xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
    xhr.responseType = 'document';
    xhr.overrideMimeType('text/html; charset=utf-8'); //文字化け対策;
    xhr.timeout = '10000';
    xhr.send(null);
}

ここではclass属性に「async」というclass名が含まれているa要素にイベントをセットしている。あらかじめHTML側で非同期通信したいa要素にasyncというclass属性を付加している。a要素がクリックされるとpushStateでそのa要素のリンク先のURLに変えて履歴を追加し、リンク先にXMLHttpRequestでリクエストを送る。もし、リンク先が現在のURLと同じであれば履歴は追加しない。ここでは、簡単なアニメーションを使っているのでdisplay.hideというメソッドの中に処理を書いているが、pjaxの流れとはあまり関係がないので無視していい。XMLHttpRequestでリクエストする時は文字化け対策としてxhr.overrideMimeType('text/html; charset=utf-8')でレスポンスで返ってくるHTMLのMIMEタイプを上書きする処理を書く。このsetRequest関数は非同期遷移後、毎回実行する。そのためonclickでイベントを定義しているが、addEventListenerでイベントを定義したい場合「addEventListenerの無名関数をremoveEventListenerで消す方法」を参照。

次にレスポンス時の処理を見る前に履歴移動を検知するpopStateイベントの処理を見る。

window.addEventListener('popstate', function(e) {
    if(e.state) {
        display.hide(function() {
            request(e.state.url);
        });
    }
});

このpopStateイベントのイベントオブジェクトにはstateというプロパティがある。これはhistory.stateと同じものを指している。このstateというのはpushStateやreplaceStateの第一引数にセットしたもので、履歴に付加している情報である。先ほど見たpushStateやreplaceStateの第一引数には「{url: url}」というオブジェクトをセットした。つまり、stateはurlというプロパティを持っていて値はその履歴のURLであるので、このURLにリクエストを送る。そうすれば、そのURLに相応しいドキュメントを非同期で得ることが出来る。pushStateやreplaceStateの第一引数にurl情報を付加すれば、popStateイベントでその情報を元にサーバーにリクエストできるというわけである。

最後にレスポンスが返ってきた時の処理を見る。

xhr.addEventListener('loadend', function() {
    if(xhr.status === 200) {
        var response = xhr.response;
        var responseElement = response.getElementById(id);

        //必要な要素を書き換える;
        document.title = response.title;
        document.body.id = response.body.id;
        transitionElement.innerHTML = responseElement.innerHTML;

        //実行したいスクリプト要素を取得して実行;
        var scripts = response.querySelectorAll('script[class *= "async"]');
        for(var i = 0; i < scripts.length; i++) {
            var script = cloneScript(scripts[i]);
            transitionElement.appendChild(script);
        }

        setRequest();
        display.show();
        return;
    }
    //読み込みできなかった時の処理;
    transitionElement.innerHTML = '読み込みに失敗しました。';
    display.show();
});

//script要素をコピー;
function cloneScript(element) {
    var script = document.createElement('script');
    var attrs = element.attributes;
    for(var i = 0, len = attrs.length; i < len; i++) {
        var attr = attrs[i];
        script.setAttribute(attr.name, attr.value);
    }
    script.innerHTML = element.innerHTML;
    return script;
}

ここでは、レスポンスをdocumentで受け取っている。そして、そのdocumentの中から「id="main"」の要素を取得して、innerHTMLで書き換えている。innerHTMLではなく要素をそのまま代入することを試したところ、その要素内にscript要素があった時、chromeではscriptが実行され、IEやFireFoxではscriptは実行されないという結果になった(仕様的にはどちらが正しいんだろう・・・)。個人的には自動的にscript要素を実行してくれたほうが有り難いのだが、innerHTMLで書き換えて実行したいscriptは自分で実行する。ここでは、title要素やbodyのidも書き換えている。そして、実行したいscript要素をcloneScriptで複製して実行する。ここでは、実行したいscript要素はclass名で判別している。あらかじめHTMLで動的に実行したいscript要素のclass属性に「async」というclass名を書いておく。cloneScriptについては、「script要素<script>を動的に実行する」を参照。そして要素の書き換えが完了したら、再びsetRequest関数を実行して、対象のa要素にクリックイベントを定義する。これは、書き換えた要素内に非同期通信したいa要素が含まれているかもしれないからである。

最後に

今回はpjaxの流れを知るためにプログラムを書いてみたが、思った以上に気をつけないといけない点が多い。ここでは見てこなかったが、ソーシャルボタンの再読み込みやAdsenseやAnalyticsなどなど主にscript要素が絡んでいる処理を個別に扱わないといけなかったりする。【1】だからあまり実用的ではないかもしれないが、画面遷移しない、簡単に高速化できるなどメリットは大きい。正直自分はpjaxをバリバリ使っているサイトを見てJavaScriptを始めたほどpjaxの技術に感動した(というかAjaxが9割)。今後はAjaxよりかWebSocketを使って通信する手法が確立されそうだが、いずれにせよHistoryAPIを使った流れを知っておくのはいいかもしれない。pjaxの流れ自体は難しくないので、設計をしっかり考えてサーバーサイドと連携するとこの技術をもっと面白く使えると思う。

とりあえずまたまたGitHubに上げてみた。