Backbone.jsでSingle Page Application

# 内容
今更感は満載ではありますが、以前にBackbone.jsを練習する為にSPAでCRUDなアプリケーションを作成したので、書き残しておきたいと思います。
サーバー側はnode.js、クライアント側はBackbone.js、タスクランナーはgulpでバンドルはwebpackを使用しています。
Githubのソースコードは[こちら](https://github.com/koseisakashita/crud_backbone/tree/design)。
練習用で制作したため、所々に拙い部分はあるかと思いますが、ご容赦ください。

#開発環境
- html
- css
  - sass
  - materialize.css
- backbone.js
  - underscore.js
  - jquery
- node.js (APIサーバー)
- gulp
- webpack
  - coffeescript
  - babel
  - html-loader

#ファイル構成
- app/
    - api-server/
    - assets/
        - coffee/
            - collections/
            - models/
            - views/
            - config.coffee
            - router.coffee
        - sass/
    - dist/
    - template/
        - create.html
        - detail.html
        - index.html
    - index.html
    - .babelrc
    - gulpfile.js
    - package.json
    - webpack.config.js

#環境設定
```webpack.config.js
var webpack = require('gulp-webpack').webpack
var path = require('path')
var current = process.cwd()           

module.exports = {
  entry:{
    main: './app/assets/coffee/router.coffee'
  },

  output:{
    filename: "[name].bundle.js"
  },
    //出力するファイル名
  resolve: {                                                  
    extensions: ['', '.js', '.coffee'],                 
    root: path.join(current, '/app/'),       
  },
  module:{
    loaders: [                                           
      {test: /\.coffee$/, loader: 'babel-loader!coffee-loader'},
      {test: /\.html$/, loader: 'html?minimize'}
    ],
    options: {
      presets: ['es2015']
    }
  },
}
```
htmlローダー、BabelやCoffeescriptのローダーを入れています。

#サーバー側
```users.js
const express = require('express');
const router = express.Router();
const test = require('../public/javascripts/data');

// 一覧
router.get('/', (req, res, next) => {
  res.json(test.datas);
});

// データ詳細
router.get('/:id', (req, res, next) => {
  // GETリクエストされたパラメータと合致するデータを抽出する。
  const resData = test.datas.filter((data) => {
    return req.params.id == data.id
  })
  res.json(resData[0]);
});

// データ作成
router.post('/', (req, res, next) => {
  // postされたデータをマージする。
  let data = Object.assign(
    {id: null, title: null,body: null},req.body)

  if(data.id === null){
    // POSTされたデータの中にIDがない場合にデータのIDを準備しておく。
    data = test.createId(data);
  } else {
    const isExist = test.chkExistId(data);
    if(isExist != null){
      res.send('id exist!')
      return
    }
  }
  // データを追加する。
  test.datas.push(data)
  res.json(test.datas)
})

router.put('/:id',(req, res, next) => {
  // PUTリクエストされたパラメータと合致するデータのインデックスを抽出する。
  const index = test.datas.map((data, i) => {
      return data.id + ''
  }).indexOf(req.params.id + '')
  if(index == -1){
    res.send('data no exist!');
    return
  }
  // データを更新する。
  test.datas[index] = {
    id: req.params.id,
    title:req.body.title,
    body:req.body.body
  }
  res.json(test.datas)
})

router.delete('/:id',(req, res, next) => {
  // DELETEリクエストされたパラメータと合致するデータのインデックスを抽出する。
  const index = test.datas.map((data, i) => {
      return data.id + ''
  }).indexOf(req.params.id + '')
  if(index == -1){
    res.send('data no exist!');
    return
  }
  // データを削除する。
  test.datas.splice(index, 1)
  res.json(test.datas)
})

module.exports = router;
```
APIサーバー側はnode.jsで作成しています。
初期表示するデータは仮に用意して、Backbone.jsで使い易いようにRESTfulに作っています。

#クライアント側

```router.coffee
config = require './config'
postListsView = require './views/postLists'
postDetailView = require './views/postDetail'
postCreateView = require './views/postCreate'

Router = Backbone.Router.extend({
    initialize: ->
        @rootElm = $('#app')
        
    routes :
        'create' : 'create',
        'detail(/:id)' : 'detail',
        '' : 'index',

    _tmplRender: (view) ->
        @rootElm.empty()
        @rootElm.html $(config.tmpl[view]).html()

    index : ->
        @_tmplRender 'index'
        postListsView.init()

    create : ->
        @_tmplRender 'create'
        postCreateView.init()

    detail : (id) ->
        @_tmplRender 'detail'
        postDetailView.init(id)
})

router = new Router()

$( () ->
    Backbone.history.start();
)
```
Backbone.Routerで各ページのルーティングをおこなっています。

```config.coffee
tmplLists = {}
tmplLists.index = require 'html!template/index.html'
tmplLists.detail = require 'html!template/detail.html'
tmplLists.create = require 'html!template/create.html'

テンプレートの不要な文字を削除する。
_.each tmplLists, (tmpl, key) ->
    tmpl = tmpl.slice 18, tmpl.length-2
    tmpl = tmpl.replace /\n/g, ''
        .replace /\\r/g, ''
        .replace /\\t/g, ''
        .replace /\\n/g, ''
        .replace /\\/g, ''
        .replace /> </g, '><'
    tmplLists[key] = tmpl

module.exports = 
    tmpl:
        index: tmplLists.index,
        detail: tmplLists.detail,
        create: tmplLists.create
```
SPAで切り替えるコンテンツの中身をJSで扱えるようHTMLローダーを使っていますが、その過程での不要な文字を削除しています。

```views/postLists.coffee
listView = require './postList'
postCollection = require '../collections/postCollection'

UserListsView = Backbone.View.extend({
	collection: postCollection

	init: ->
		postCollection.fetch(
			success: (collection, res, opt) =>
				@$el = $('#userList')
				@render()
		)

	render: ->
		if _.isEmpty postCollection.models
			@$el.addClass 'data-none'
			.text 'post nothing...'
			return

		_.each postCollection.models, (user) =>
			list = new listView(model:user.attributes)
			@.$el.append list.render().el
		return @
})
module.exports = new UserListsView()
```
一覧ページのリスト全体

```views/postList.coffee
module.exports = Backbone.View.extend({
	tagName: 'tr'
	template: _.template '<td><a href="#detail/<%= id %>"><%= title %></a></td><td><%= body %></td>'
	
	render: ->
		@.$el.append(@template(@model))
		return @;
});
```
一覧ページのリストアイテム

```views/postDetail.coffee
postModel = require '../models/post'

PostDetailView = Backbone.View.extend({

	init: (id) ->
		@id = id || null
		@url = 'http://localhost:3010/users/' + @id
		@$el = $('#detail')
		@$elm = 
			update: @$el.find('#update').children()
			inputTitle: @$el.find('#title')
			inputBody: @$el.find('#body')

		@setModel()

		# イベントを設定する。
		@delegateEvents(
			'click #delete':'del' 
			'click #update':'update' 
		)
		@post.on 'locationChange', @locationChange, @
		@post.on 'invalid', @valiMessage, @

	# リクエストのIDに応じたモデルを取得して初期値を設定する。
	setModel: ->
		@post = new postModel()
		@post = _.extend @post,
			url: @url
		@post.fetch(
			success: =>
				@$elm.inputTitle.val @post.attributes.title
				@$elm.inputBody.val @post.attributes.body
		)

	# 表示されている投稿を削除する。
	del: ->
		res = window.confirm('削除してよろしいですか？')
		if res
			@post.destroy(
				success: =>
					@post.trigger 'locationChange'
				error: =>
					location.reload()
			)

	# 表示されている投稿を更新する
	update:->
		data = 
			id: @id
			title: @$elm.inputTitle.val()
			body: @$elm.inputBody.val()

		@post.save(data,
			success: =>
				@post.trigger 'locationChange'
		)

	# バリデートエラーメッセージを表示する。
	valiMessage: (errors) ->
		$('.vali-error-txt').empty()
		$([@$elm.inputTitle[0], @$elm.inputBody[0]]).removeClass 'vali-error'

		_.each errors.validationError, (error) =>
			$('.error' + error.name).html error.message
			$('#' + error.name).addClass 'vali-error'

	locationChange: ->
		location.replace '#'
})
module.exports = new PostDetailView()
```
詳細ページ

```postCreate.coffee
postModel = require '../models/post'

PostCreateView = Backbone.View.extend({

	init:  ->
		@$el = $('#create')
		@$elm = 
			inputTitle: @$el.find('#title')
			inputBody: @$el.find('#body')

		@post = new postModel()
		@post = _.extend @post,
			url: 'http://localhost:3010/users/'

		# イベントを設定する。
		@delegateEvents(
			"click #create-btn":'saveModel' 
		)

		@post.on 'locationChange', @locationChange, @
		@post.on 'invalid', @valiMessage, @

	# 投稿を作成する
	saveModel:->
		data = 
			title: @$elm.inputTitle.val()
			body: @$elm.inputBody.val()

		@post.save(data,
			success: =>
				@post.trigger 'locationChange'
		)


	# バリデートエラーメッセージを表示する。
	valiMessage: (errors) ->
		$('.vali-error-txt').empty()
		$([@$elm.inputTitle[0], @$elm.inputBody[0]]).removeClass 'vali-error'

		_.each errors.validationError, (error) =>
			$('.error' + error.name).html error.message
			$('#' + error.name).addClass 'vali-error'

	locationChange: ->
		location.replace '#'
})
module.exports = new PostCreateView()
```
作成ページ
#まとめ
作成したのは結構前ですが、初めてSinglePageApplicationを作成したので、仕組みなどが理解できて非常に勉強になりました。
次はSPA+SSRなアプリケーションをnuxtあたりで作ってみたいと目論んでいます。