BackboneでSPA構築

いわゆる「普通の静的WEBサイト」(コーポレートサイトやサービスサイトなど.htmlのみのサイト)はSEOとかgoogleアナリティクスのコードの事とか考えると本来はSPAで構築すると結構めんどくさい。それでも敢えてBackboneでSPAで構築してみる。

目標

・aタグによるリンクをクリックして別のページを表示させる。
・ページが切り替わるごとにGoogleアナリティクスのコードを発行させる。
・ページ内にJSコンテンツを後から付け加えやすいように構築する。

Backboneの考え方

(1) テンプレート template

HTMLで書かれた文字列そのものと思っておけばOK。
例えば「お知らせボタンテンプレート」は'<li><a href=”/news/”>お知らせ</a></li>’のような感じ。

(2) ビュー view

オブジェクト指向がわかるなら「ビュー=HTMLページ内のオブジェクト」と思っておけばOK。
ビューは自身の表示用のテンプレートを1つもつ。
例えば「お知らせボタンビュー」は「お知らせボタンテンプレート」を1つもつ。
「お知らせボタンビュー」は何かの処理系から「表示しなさい」という指示を受けると「お知らせボタンテンプレート」の内容を出力する。

ビューの中にまたビューを存在させ,ビューの階層をつくることができる。
例えば「グロナビビュー」は「お知らせボタンビュー」と「会社概要ボタンビュー」と「お問合せビュー」をもつ。
つまりビューは複数のビューをもつ。

ビューはコレクション(※後述)の内容を自身に表示させることができる。
例えば「新着情報リストビュー」は「新着情報コレクション」の内容を「新着情報リストテンプレート」に代入して表示できる。
ビューは複数のコレクションをもてることにする。

(3) モデル model

1件のデータ。
例えば「新着情報モデル」は

{
 news_id: 3,
 title: '新商品のお知らせ',
 category: '商品'
}

という感じ。

(4) コレクション collection

複数件のデータ。
コレクションは複数のモデルをもつ。
例えば「新着情報コレクション」は「新着情報モデル」を複数もつ。

[
 {
  news_id: 3,
  title: '新商品のお知らせ',
  category: '商品'
 },
 {
  news_id: 4,
  title: '発売延期のお知らせ',
  category: '商品'
 }
]

という感じ。

(5) コンテナビュー container view

ここからは別にBackboneが定めている考え方ではなくオリジナルの考え。
ページ内に「コンテナビュー」という一番大元になるビューを作っておき,「コンテナビュー」内の表示をJSで切り替えることによりページ遷移をしているかのようにみせる。実際はJSで中身を切り替えているだけなので本来の意味でいうページ遷移は起きていない。
「コンテナビュー」の実体は<body>でもいいし,その直後の<div>などでもOK。

(6) ページビュー&ページテンプレート page view & page template

「コンテナビュー」が自身の表示を切り替える単位となるビューをページビューと呼ぶことにする。
例えば「TOPページビュー」,「お知らせページビュー」,「お問合せページビュー」など。
またこれらのページビューはそれぞれ「TOPページテンプレート」,「お知らせページテンプレート」,「お問合せページテンプレート」をもつ。

(6) エレメントビュー&エレメントテンプレート element view & element template

ページビューの内部でもつビューで再利用可能なものをエレメントビューと呼ぶことにする。
例えば「お知らせページビュー」は「グロナビビュー」や「新着情報リストビュー」などをもつが,これらのビューは「TOPページビュー」でも再利用されている。

(7) ルータ rooter

aタグのクリックやURLを直接変更するなどした場合にそれを感知してコンテナに適切なページビューを表示切替を行う機関。

ディレクトリ構成

/
├ common/
│ ├ img/
│ ├ css/
│ │ └ style.css
│ └ js/
│   └ lib/
│     ├ backbone-min.js
│     ├ jquery.min.js
│     ├ jquery.validateccfb.js
│     ├ jquery-ui.min.js
│     ├ require.js
│     ├ text.js
│     └ underscore-min.js
├ app/
│ ├ Config/
│ │ ├ require_config.js
│ │ └ Router.js
│ ├ Collection/
│ │ └ AppColletion.js
│ ├ Model/
│ │ └ AppModel.js
│ ├ Template/
│ │ ├ elements/
│ │ └ pages/
│ ├ View/
│ │ ├ elements/
│ │ ├ pages/
│ │ ├ AppView.js
│ │ ├ Container.js
│ │ └ PageView.js
│ └ main.js
└ index.html

/common/img/や/common/css/の中身は適宜追加変更。もしかしたらどこかで再利用するかもしれないjsは/common/js/libに入れておく。
backboneアプリとしての本体は/app/の中に入れることにする。この中には5つのディレクトリとmain.jsという1つのファイルが入る。
main.jsはアプリとして最も最初に読まれるファイルで,すべてはここから開始される。
/app/Config/ は設定ファイル系。require_config.jsはrequire.jsの設定ファイル。Router.jsはルータ。このファイルでページビューのルーティングを設定する。
/app/Collection/, /app/Model/, /app/Template/, /app/View/はそれぞれコレクション,モデル,テンプレート,ビューを入れる。
/app/Template/, /app/View/にはさらにelementsとpagesというディレクトリを用意し,それぞれページ,エレメントのテンプレートやビューを入れる。

/app/View/AppView.js

define(function(require){
	// インクルード
	var Backbone = require('backbone');

	// クラス生成
	return Backbone.View.extend({
		/**
		 * 初期実行
		 */
		initialize: function(){
		},
		/**
		 * ビュー名
		 */
		name: '',
		/**
		 * テンプレート
		 */
		template: '',
		/**
		 * ビュー
		 */
		views: {},
		/**
		 * コレクション
		 */
		collections: {},
		/**
		 * 所持ビューを削除する
		 *
		 * @param string 	viewKey	ビューkey
		 * @return void
		 */
		removeChildViews: function(viewKey) {
			_(this.views).each(function(view, key){
				if (typeof(view.cid) !== 'undefined' && ((typeof(viewKey) === 'undefined') || (viewKey === key))) {
					view.removeChildViews();
					view.removeChildCollections();
					view.off();
					view.stopListening();
					view.beforeRemove();
					view.remove();
					delete this.views[key];
				}
			}, this);
			return;
		},
		/**
		 * 所持コレクションを削除する
		 *
		 * @param string 	collectionKey	コレクションkey
		 * @return void
		 */
		removeChildCollections: function(collectionKey) {
			_(this.collections).each(function(collection, key){
				if (typeof(collection.cid) !== 'undefined' && ((typeof(collectionKey) === 'undefined') || (collectionKey === key))) {
					collection.off();
					collection.stopListening();
					collection.beforeRemove();
					collection.remove();
					delete this.collections[key];
				}
			}, this);
			return;
		},
		/**
		 * 出力
		 *
		 * @param vars 	テンプレート変数
		 * @return this
		 */
		render: function(vars) {
			// HTMLを取得
			try {
				var html = '';
				if (this.template) {
					html = _.template(this.template)(vars);
				}
			} catch (err) {
				console.log(this.name + 'ビューでエラー');
				console.log(err);
				return this;
			}
			// HTMLを出力
			this.$el.html(html);
			// render後処理
			this.afterRender();
			// 終了
			return this;
		},
		/**
		 * render後処理
		 *
		 * @return void
		 */
		afterRender: function() {
		},
		/**
		 * 削除前処理
		 *
		 * @return void
		 */
		beforeRemove: function() {
		}
	});
});

共通ビューとして定義しておく。すべてのビューはこのAppViewを継承して作られる。

/app/View/Container.js

define(function(require){
	// インクルード
	var AppView = require('View/AppView');

	// クラス生成
	return AppView.extend({
		/**
		 * 初期実行
		 */
		initialize: function(){
		},
		/**
		 * ビュー名
		 */
		name: 'ContainerView',
		/**
		 * エレメント
		 */
		el: '#Container',
		/**
		 * イベント
		 */
		events: {
			'click a[href]': 'linkClick'
		},
		/**
		 * ビュー
		 */
		views: {},
		/**
		 * コレクション
		 */
		collections: {},
		/**
		 * 現在のlocation.href
		 */
		href: '',
		/**
		 * 新ページビュー表示,旧ページビューの非表示
		 *
		 * @return void
		 */
		updatePageView: function() {
			// 旧ページビュー削除
			this.removeChildViews('oldPageView');
			// 新ページビューの表示
			this.$el.append(this.views['pageView'].render().$el);
			// Googleアナリティクス(利用するにはGAのコードをhead内に記述)
			///var page = this.href.replace(location.protocol + '//' + location.host, '');
			///ga('send', 'pageview', page);
		},
		/**
		 * リンクのクリック
		 *
		 * @param object 	eve	イベント
		 * @return bool
		 */
		linkClick: function(eve) {
			// クリック対象の情報取得
			var $a = $(eve.currentTarget);
			var href = $a.attr('href');
			var thisClass = $a.prop('class');

			if ($a.prop('target') || href.slice(0, 4) === 'http') {
				// 内部リンク
				if ($a.prop('target') === '_self') {
					var _this = this;
					var t = setTimeout(function(){
						history.replaceState({}, "", _this.href);
					}, 100);
				}
				return;
			} else if (eve.ctrlKey || eve.metaKey) {
				// 別ウィンドウ表示
				window.open(href);
			} else if (href.slice(0, 1) !== '#') {
				// 普通のリンク
				Backbone.history.navigate(href, {trigger: true});
				$('body,html').css({scrollTop: 0});
			} else if (href === '#header') {
				// このページの先頭へ
				$('body,html').animate({scrollTop: 0}, 400, 'swing');
			} else {
				// #
			}
			// クリックイベントのキャンセル
			eve.preventDefault();
			return false;
		}
	});
});

コンテナビュー。ルータによってインスタンスを作られ,ページビューを保持したり削除したりする。
ページが切り替わるタイミングでupdatePageView()関数が実行されるのでGoogleアナリティクスのコード実行などをしたい場合はこの関数内に記述する。

/app/View/PageView.js

define(function(require){
	// インクルード
	var AppView = require('View/AppView');

	// クラス生成
	return AppView.extend({
		/**
		 * 初期実行
		 */
		initialize: function(){
		},
		/**
		 * ビュー名
		 */
		name: 'PageView',
		/**
		 * タグ名
		 */
		tagName: 'section',
		/**
		 * クラス名
		 */
		className: 'pageTemplate',
		/**
		 * ビュー
		 */
		views: {},
		/**
		 * コレクション
		 */
		collections: {},
		/**
		 * ページビュー変数
		 */
		arguments: {},
		/**
		 * href
		 */
		href: '',
		/**
		 * GET変数
		 */
		_GET: {}
	});
});

ページビュー。すべてのページビューはこのクラスを継承して作られる。

/app/Collection/AppCollection.js

define(function(require){
	// インクルード
	var Backbone = require('backbone');

	// クラス生成
	return Backbone.Collection.extend({
		/**
		 * 初期実行
		 */
		initialize: function(){
		},
		/**
		 * コレクション名
		 */
		name: '',
		/**
		 * 削除前処理
		 *
		 * @return void
		 */
		beforeRemove: function() {
		},
		/**
		 * ステータス
		 *	0: 初期状態
		 *	1: 件数取得中
		 *	2: 取得中
		 *	3: 終了処理中
		 */
		status: 0,
		/**
		 * 総件数
		 */
		total: 0,
		/**
		 * モデルを取得する
		 *
		 * @param options 	オプション
		 * @return void
		 */
		gets: function(options) {
			// パラメータ
			var defaultOptions = {
				conditions: {},
				limit: 10,
				offset: 0
			}
			options = $.extend(true, {}, defaultOptions, options);

			// ステータス分岐
			var _this = this;
			switch(this.status) {
				case 0: // 初期状態
					this.status = 1;
					setTimeout(function() {
						_this.gets(options);
					}, 0);
					break;
				case 1: // 件数取得中
					this.status = 2;
					setTimeout(function() {
						_this.gets(options);
					}, 0);
					break;
				case 2: // 取得中
					this.status = 3;
					setTimeout(function() {
						_this.gets(options);
					}, 0);
					break;
				case 3: // 終了処理中
					this.status = 0;
					setTimeout(function() {
						_this.gets(options);
					}, 0);
					break;
				default: // キャンセル中
			}
		}
	});
});

/app/Model/AppModel.js

define(function(require){
	// インクルード
	var Backbone = require('backbone');

	// クラス生成
	return Backbone.Model.extend({
		/**
		 * 初期実行
		 */
		initialize: function(){
		},
		/**
		 * モデル名
		 */
		name: '',
		/**
		 * 削除前処理
		 *
		 * @return void
		 */
		beforeRemove: function() {
		}
	});
});

/app/Config/Router.js

define(function(require){
    // インクルード
    var Container = require('View/Container');
 
    // クラス生成
    return Backbone.Router.extend({
        /**
         * 初期実行
         */
        initialize: function(){
            // コンテナインスタンス生成
            this.container = new Container();
        },
        /**
         * コンテナインスタンス
         */
        container: null,
        /**
         * ルーティング
         */
        routes: function(){
            // ページビュー(callbackName: PageView)
            var PageViews = {
                'index': require('View/pages/index'),
                'news': require('View/pages/news'),
                'newsDetail': require('View/pages/news/detail'),
                'contact': require('View/pages/contact')
                // (以下こんな感じで追加)
            };
            // ルーティング(urlPattern: callbackName)
            var viewRoutes = {
                // TOP
                '(/)': 'index',
                'index(/)': 'index',
                // お知らせ一覧
                'news(/)': 'news',
                // お知らせ詳細
                'news/:id(/)': 'newsDetail'
                // (以下こんな感じで追加)
            };
            // コールバック関数作成
            var madeCallbackNames = {};
            _.each(viewRoutes, function(callbackName, urlPattern) {
                // 未作成の場合は作成する
                if (typeof(madeCallbackNames[callbackName]) === 'undefined') {
                    madeCallbackNames[callbackName] = 1;
                    // URLがurlPatternに一致した時に実行
                    this.on('route:' + callbackName, function(){
                        // 2重実行の防止
                        if (this.container.href === location.href) {
                            return false;
                        }
                        this.container.href = location.href;
                        // 旧ページビューを保持
                        this.container.removeChildViews('oldPageView');
                        this.container.views['oldPageView'] = $.extend(true, {}, this.container.views['pageView']);
                        // 新ページビュー作成
                        var PageView = PageViews[callbackName];
                        this.container.views['pageView'] = new PageView();
                        this.container.views['pageView'].arguments = arguments;
                        this.container.views['pageView'].href = location.href;
                        this.container.views['pageView']._GET = {};
                        if (location.search !== '') {
                            _.each(location.search.slice(1).split('&'), function(str){
                                var keys = str.split('=');
                                this.container.views['pageView']._GET[keys[0]] = keys[1];
                            });
                        }
                        // 新ページビューの表示・旧ページビューの非表示
                        this.container.updatePageView();
                    });
                }
            }, this);
            return viewRoutes;
        }
    });
});

ページを増やしていく際はPageViewsやviewRoutesに適宜追加していく。

/app/main.js

define(function(require){
	// インクルード
	var Backbone = require('backbone');

	// ルータ初期化
	var Router = require('Config/Router');
	var router = new Router();
	Backbone.history.start({pushState: true});
});

全ての処理はこのファイルがスタートになる。
まずやるべきはルータの初期化と実行。
8行目; pushStateは対応していないブラウザもあるが,とりあえずtrueにしておく。

/app/Config/require_config.js

var require = {
	baseUrl: '/app/',
	paths: {
		'jquery': '/common/js/lib/jquery.min',
		'jquery-ui': '/common/js/lib/jquery-ui.min',
		'underscore': '/common/js/lib/underscore-min',
		'backbone': '/common/js/lib/backbone-min',
		'text': '/common/js/lib/text',
		'jquery.validateccfb': '/common/js/lib/jquery.validateccfb'
	},
	shim: {
		'jquery-ui': {
			deps: ['jquery']
		},
		'backbone': {
			deps: ['jquery', 'underscore'],
			exports: 'Backbone'
		},
		'text': {
			deps: ['backbone'],
			exports: 'Text'
		},
		'jquery.validateccfb': ['jquery']
	}
};

JSのインクルード設定を行う。pathsは各ライブラリのパス設定。shimでは読み込む順番などを設定。詳しくはrequire.jsの公式などを参照。

/index.html

<!DOCTYPE html>
<html>
<head>






</head>
<body id="Container">
</body>
</html>

7行目: require_config.jsを呼び出す。まずここでrequire.jsの設定を行う。require.jsを利用することでJSファイルのインクルードを簡潔にする。
8行目: ここの書き方が重要!srcにはrequire.js本体へのパスを書くが,data-main属性にmain.jsのパスを入れる。このパスはrequire_config.js内で設定するbaseUrlからの相対パスでOKなのでここではファイル名だけを記述する。
11行目: ここではbodyをコンテナビューとして利用することにする。

/app/View/pages/index.js

define(function(require){
	// インクルード
	var PageView = require('View/PageView');

	// クラス生成
	return PageView.extend({
		/**
		 * イニシャライズ
		 *
		 * @return void
		 */
		initialize: function(){
		},
		/**
		 * ビュー名
		 */
		name: 'pageIndex',
		/**
		 * ID名
		 */
		id: 'PageIndex',
		/**
		 * ビュー
		 */
		views: {},
		/**
		 * コレクション
		 */
		collections: {},
		/**
		 * テンプレート
		 */
		template: require('text!Template/pages/index.html'),
		/**
		 * render後処理
		 *
		 * @return void
		 */
		afterRender: function() {
		}
	});
});

ここからは適宜真似して追加。

/app/Template/pages/index.html

これはテストです。

一式
クローン