GreaseMonkeyによるAjaxのすすめ(実装編)

 前回はGreaseMonkeyによるAjaxなユーザースクリプトを書き始める段階までやりました。
 それでは今回は、実際にAjaxな感じにCFlatのホームページを動かしてみましょう。

無名スコープで囲む

 JavaScriptでは、普通に関数を定義するとwindowオブジェクトのメンバーとして定義されてしまいます。
 windowオブジェクトに余計なメンバーを追加しないためによく使われるテクニックとして、処理を無名関数の中に記述する、というものがあります。例えばこんな感じ:

(function() {
	hoge();

	function hoge() {
		alert('hoge');
	}
})() ; // ← (function() { ... }) で無名関数オブジェクトを作り、()でそれを実行する

 この手法は、GreaseMonkeyであればwindowオブジェクトを汚さないので必要ないのですが、念のための理由で次のように囲んでおきます。

(function (global, $, undefined) {

})(window, window.jQuery) ;

 まず、globalについて。GreaseMonkeyではセキュリティ上の理由のため、GM_xxx()関数を使う場合はwindowの機能に制約がかかります。本来のwindowの機能はunsafeWindowという形で提供される[1]のですが、その辺の違いを吸収するためにglobalなんて変数を用意して、適切なオブジェクトを示すようにしておきます。
 次の$は、$をjQueryオブジェクトへのショートカットとするためのものです。$はjQuery以外のライブラリでも使うため、もしかしたらwindow.$はjQueryオブジェクトではないかもしれません(CFlatのホームページでは、window.$はundefinedとなっていました)。
 最後のundefined、これはJavaScriptではundefinedがキーワード「ではない」ために起こる各種の問題を回避するためにあります。つまり、ブラウザによってはundefinedへの代入を行なうとundefinedを上書きできてしまうので[2]、渡していない第三引数の名前をundefinedとすることによって万が一誰かがundefinedを上書き済みでもスクリプト内でundefinedを上書きしない限りは大丈夫、ということになります。

aタグの動作を書き換える

 Ajaxな動作をするためには、aタグが普通の動作をしてしまっては困ります。同じページ内でありながら、なおかつ履歴に残るようにするためには、http://www.cflat-inc.com/hoge へのリンクであれば http://www.cflat-inc.com/#!/hoge へのリンクに書き換えるようにする、というルールにすればよいでしょう。
 幸い、CFlatのホームページは相対URLによる指定がないので、http://www.cflat-inc.com/http://www.cflat-inc.com/!#/ に単純に書き換えるだけで十分そうです。

function convertPaths($elms)
{
	$elms.find('a[href]').each(function() {
		var $this = $(this) ;
		var href = $this.attr('href') ;
		var match = href.match(/^(http:\/\/www\.cflat-inc\.com\/)(.*)$/) ;
		if (!match || match[2].match(/^#!\//)) return ;
		$this.attr('href', match[1] + '#!/' + match[2]) ;
	}) ;
}

ハッシュが変化した際の処理を行なう

 これは簡単、hashchangeイベントを取得して、適切に処理してやれば問題ありません。
 また、ページの読み込み時にも同じ処理はしなければならないでしょうから、$(document).ready() 時に上記のリンク先変換と共に実行してやればいいでしょう。

$(document).ready(function(){
	convertPaths($('body')) ;
	$(window).on('hashchange', onHashChange).trigger('hashchange') ;
});

 重要なのはその「適切に処理」の内容ですが、こんな感じになっています。

function onHashChange()
{
	var hash = global.location.hash ;
	if ('' === hash) hash = '#!/' ;
	var match = hash.match(/^(#!\/.*?)?(#.*?)?$/) ;

	movePage(match[1], function() {
		jumpAnchor(match[2]) ;
	}) ;	
}

 ここで、match[1]は本来のURL部分で、match[2]は本来のハッシュ部分、ということになります。movePage()でAjaxな読み込みをして、ハッシュが指定されている場合はそこへ移動する処理を行ないます。
 これに、既に読み込み済みのページの場合はキャッシュしておく処理などを加えると、次のようになるでしょう。

var currentPage = '#!' + global.location.pathname + global.location.search ;
var stateCache = {} ;

function movePage(newPage, afterMovePage)
{
	if (undefined === newPage || newPage === currentPage) {
		if (afterMovePage instanceof Function) afterMovePage() ;
	}
	else {
		var currentState = new State() ;
		if (currentState.isErrorPage()) currentState = null ;
		if (stateCache[newPage]) {
			// ページ更新
			stateCache[newPage].modify() ;
			if (currentState) stateCache[currentPage] = currentState ;
			currentPage = newPage ;
			if (afterMovePage instanceof Function) afterMovePage() ;
		}
		else {
			// ページ取得
			var $container = $('#container').activity() ;
			$.get(newPage.substr(2), function(html) {
				new State(html).modify() ;
			}).always(function() {
				// エラーがあっても同じことはする
				$container.activity(false) ;
				if (currentState) stateCache[currentPage] = currentState ;
				currentPage = newPage ;
				if (afterMovePage instanceof Function) afterMovePage() ;
			}) ;
		}
	}
}

function jumpAnchor(hash)
{
	if ('' === hash || undefined === hash) return ;

	hash = hash.substr(1).replace(/\\"'/g, '\\$1') ;
	var offset = $('a[name="' + hash + '"], *[id="' + hash + '"]').offset() ;
	if (offset) $('html, body').scrollTop(offset.top) ;
}

 ここで、Stateクラスは、引数を渡さなければ現在のページ構造から、引数を渡せば指定したHTMLから、ページごとに変化する部分を抽出して保存するクラスです。ページの表示中にユーザーが動的に操作するかもしれませんので、一度modify()した後のオブジェクトは削除してしまい、次にページ遷移する時に改めてStateオブジェクトを構築する仕組みになっています。
 ちなみに、HTMLのパースは結構適当。気になるようでしたら真面目にパースして下さい(ただし、$(html)でパースすると本来読み込みを省略したかった不要部分の読み込みが開始されてしまうため、文字列処理でパースすること)。

function State(html)
{
	var $dummy = $('<div>') ;

	var title = null ;
	var menuClasses = [] ;
	var $graphics = null ;
	var $breadcrumbs = null ;
	var $contents = null ;

	if (undefined === html) {
		// 現在のページの状態を保存する
		title = document.title ;
		var $lis = $('#menu-nav > li') ;
		for (var i = 0 ; i < $lis.length ; ++i) {
			menuClasses.push($($lis[i]).attr('class')) ;
		}
		$graphics = $('#header-gra').contents().appendTo($dummy) ;
		$breadcrumbs = $('body > .breadcrumbs').appendTo($dummy) ;
		$contents = $('#contents').contents().appendTo($dummy) ;
	}
	else {
		// 取得したhtmlから要素を作る($(html)でパースすると不要なものも読み込むのでパースはしない)
		var match = html.match(/<title>(.*?)<\/title>/) ;
		if (match) title = $(match[0]).text() ;
		match = html.match(/<ul\s+id="menu-nav"[\s\S]*?<\/ul>/m) ;
		if (match) {
			match[0].replace(/<li\s+[\s\S]+?class="(.*?)"/mg, function(x, classes) {
				menuClasses.push(classes) ;
			}) ;
		}
		match = html.match(/<div\s+id="header-gra"[\s\S]*?>([\s\S]*?)<\/div>/m) ;
		if (match) $graphics = $(match[1]) ;
		match = html.match(/<div\s+class="(?:[\s\S].*?\s)?breadcrumbs(?:\s[\s\S].*?)?"[\s\S]*?>([\s\S]*?)<\/div>/m) ;
		if (match) $breadcrumbs = $(match[0]) ; else $breadcrumbs = $('') ;
		match = html.match(/(<div\s+id="contents"[\s\S]*?>[\s\S]*?)<div\s+id="sidebar"[\s\S]*?>/m) ;
		if (match) $contents = $(match[1]).contents() ;

		convertPaths($graphics) ;
		convertPaths($breadcrumbs) ;
		convertPaths($contents) ;
	}

	this.isErrorPage = function() {
		// $graphicsが空ならエラーとして扱う
		return (0 === $graphics.length) ;
	} ;

	this.modify = function() {
		if (null !== title) {
			document.title = title ;
			$('title').text(title) ;
		}
		var $lis = $('#menu-nav > li') ;
		for (var i = 0 ; i < $lis.length ; ++i) {
			if (undefined !== menuClasses[i]) $($lis[i]).attr('class', menuClasses[i]) ;
		}
		$('#header-gra').contents().remove().end().append($graphics) ;
		$('body > .breadcrumbs').remove() ;
		$('#header').after($breadcrumbs) ;
		$('#contents').contents().remove().end().append($contents) ;
	} ;
}

 jQuery.fn.remove()を使わずに削除した要素のイベント等を保持したり、タイトルを変更したりと、地味に面倒な処理があったりします。

JavaScriptの処理とか

 お問い合わせページではJavaScriptを使ってAjax処理しているっぽいので、URLが #!/contact/ の時は実行してやりましょう。
 このスクリプトは、http://www.cflat-inc.com/.../scripts.js?... というモノっぽいのですが、実はこれ、どんなページでも読み込んでますね。

とりあえずここまで

 こんな感じのスクリプトになりました。本当はお問い合わせページ用に
 正直、CFlatのホームページがAjax化されたところで何も嬉しいことはないと思いますので、サイト運営者に迷惑がかからない程度にお好きなところでお遊び下さい。

// ==UserScript==
// @name        CFlat AJAXer
// @namespace   http://www.cflat-inc.com/?ajaxer
// @description CFlatのホームページをAJAX化する
// @include     http://www.cflat-inc.com/
// @include     http://www.cflat-inc.com/#*
// @require     https://raw.github.com/neteye/jquery-plugins/master/activity-indicator/activity-indicator.js
// @version     1
// ==/UserScript==

(function (global, $, undefined) {

var currentPage = '#!' + global.location.pathname + global.location.search ;
var stateCache = {} ;

var onShowContact = null ;

$(document).ready(function(){
	var $script = $('script[src^="http://www.cflat-inc.com/"][src*="/scripts.js"]') ;
	$script.remove() ;
	$.get($script.attr('src'), function(src) {
		onShowContact = src ;
		convertPaths($('body')) ;
		$(window).on('hashchange', onHashChange).trigger('hashchange') ;
	}) ;
});

function convertPaths($elms)
{
	$elms.find('a[href]').each(function() {
		var $this = $(this) ;
		var href = $this.attr('href') ;
		var match = href.match(/^(http:\/\/www\.cflat-inc\.com\/)(.*)$/) ;
		if (!match || match[2].match(/^#!\//)) return ;
		$this.attr('href', match[1] + '#!/' + match[2]) ;
	}) ;
}

function onHashChange()
{
	var hash = global.location.hash ;
	if ('' === hash) hash = '#!/' ;
	var match = hash.match(/^(#!\/.*?)?(#.*?)?$/) ;

	movePage(match[1], function() {
		jumpAnchor(match[2]) ;
	}) ;	
}

function movePage(newPage, afterMovePage)
{
	if (undefined === newPage || newPage === currentPage) {
		if (afterMovePage instanceof Function) afterMovePage() ;
	}
	else {
		var currentState = new State() ;
		if (currentState.isErrorPage()) currentState = null ;
		if (stateCache[newPage]) {
			// ページ更新
			stateCache[newPage].modify() ;
			if (currentState) stateCache[currentPage] = currentState ;
			currentPage = newPage ;
			if (afterMovePage instanceof Function) afterMovePage() ;
		}
		else {
			// ページ取得
			var $container = $('#container').activity() ;
			$.get(newPage.substr(2), function(html) {
				new State(html).modify() ;

				if (newPage.match(/^#!\/contact\//)) eval(onShowContact) ;
			}).always(function() {
				// エラーがあっても同じことはする
				$container.activity(false) ;
				if (currentState) stateCache[currentPage] = currentState ;
				currentPage = newPage ;
				if (afterMovePage instanceof Function) afterMovePage() ;
			}) ;
		}
	}
}

function jumpAnchor(hash)
{
	if ('' === hash || undefined === hash) return ;

	hash = hash.substr(1).replace(/\\"'/g, '\\$1') ;
	var offset = $('a[name="' + hash + '"], *[id="' + hash + '"]').offset() ;
	if (offset) $('html, body').scrollTop(offset.top) ;
}

function State(html)
{
	var $dummy = $('<div>') ;

	var title = null ;
	var menuClasses = [] ;
	var $graphics = null ;
	var $breadcrumbs = null ;
	var $contents = null ;

	if (undefined === html) {
		// 現在のページの状態を保存する
		title = document.title ;
		var $lis = $('#menu-nav > li') ;
		for (var i = 0 ; i < $lis.length ; ++i) {
			menuClasses.push($($lis[i]).attr('class')) ;
		}
		$graphics = $('#header-gra').contents().appendTo($dummy) ;
		$breadcrumbs = $('body > .breadcrumbs').appendTo($dummy) ;
		$contents = $('#contents').contents().appendTo($dummy) ;
	}
	else {
		// 取得したhtmlから要素を作る($(html)でパースすると不要なものも読み込むのでパースはしない)
		var match = html.match(/<title>(.*?)<\/title>/) ;
		if (match) title = $(match[0]).text() ;
		match = html.match(/<ul\s+id="menu-nav"[\s\S]*?<\/ul>/m) ;
		if (match) {
			match[0].replace(/<li\s+[\s\S]+?class="(.*?)"/mg, function(x, classes) {
				menuClasses.push(classes) ;
			}) ;
		}
		match = html.match(/<div\s+id="header-gra"[\s\S]*?>([\s\S]*?)<\/div>/m) ;
		if (match) $graphics = $(match[1]) ;
		match = html.match(/<div\s+class="(?:[\s\S].*?\s)?breadcrumbs(?:\s[\s\S].*?)?"[\s\S]*?>([\s\S]*?)<\/div>/m) ;
		if (match) $breadcrumbs = $(match[0]) ; else $breadcrumbs = $('') ;
		match = html.match(/(<div\s+id="contents"[\s\S]*?>[\s\S]*?)<div\s+id="sidebar"[\s\S]*?>/m) ;
		if (match) $contents = $(match[1]).contents() ;

		convertPaths($graphics) ;
		convertPaths($breadcrumbs) ;
		convertPaths($contents) ;
	}

	this.isErrorPage = function() {
		// $graphicsが空ならエラーとして扱う
		return (0 === $graphics.length) ;
	} ;

	this.modify = function() {
		if (null !== title) {
			document.title = title ;
			$('title').text(title) ;
		}
		var $lis = $('#menu-nav > li') ;
		for (var i = 0 ; i < $lis.length ; ++i) {
			if (undefined !== menuClasses[i]) $($lis[i]).attr('class', menuClasses[i]) ;
		}
		$('#header-gra').contents().remove().end().append($graphics) ;
		$('body > .breadcrumbs').remove() ;
		$('#header').after($breadcrumbs) ;
		$('#contents').contents().remove().end().append($contents) ;
	} ;
}

})(window, window.jQuery) ;


※1 どうやら、unsafeWindowを使うとユーザーの気付かないうちにXSSやりたい放題なんだとか。
※2 現状、主要ブラウザでは、undefinedへの代入は一見成功したように見えてundefined値のままになる、という動作をするようなので、杞憂かもしれませんが……。