{ "name": "myapp", "description": "Boilerplate web app using node, express, mongodb, backbone, marionette. Tooling includes Grunt, Bower, Browserify, etc.", "version": "0.0.1", "author": "Jason Krol", "repository": { "type": "git", "url": "https://github.com/jkat98/benm.git" },
"engines": { "node": "0.10.x" },2.engines项列出了运行工程所必须的运行时,很明显这里是node.js和它的版本
"scripts": { "start": "node server.js" },3.scripts说明了用“run“来启动工程、服务器、应用或者不管是什么,真正运行的是什么命令
"dependencies": { "express": "latest", "mongoose": "~3.8.3", "handlebars-runtime": "~1.0.12", "express3-handlebars": "~0.5.0", "MD5": "~1.2.0" },4.dependencies列举了工程要正确运行所需的最小依赖。没有这些,服务器不会运行。
"devDependencies": { "bower": "~1.2.8", "grunt": "~0.4.1", "grunt-contrib-concat": "~0.3.0", "grunt-contrib-jshint": "~0.7.2", "grunt-contrib-uglify": "~0.2.7", "grunt-bower-task": "~0.3.4", "grunt-nodemon": "~0.1.2", "karma-script-launcher": "~0.1.0", "karma-chrome-launcher": "~0.1.1", "karma-html2js-preprocessor": "~0.1.0", "karma-firefox-launcher": "~0.1.2", "karma-jasmine": "~0.1.4", "karma-requirejs": "~0.2.0", "karma-coffee-preprocessor": "~0.1.1", "karma-phantomjs-launcher": "~0.1.1", "karma": "~0.10.8", "grunt-contrib-copy": "~0.4.1", "grunt-contrib-clean": "~0.5.0", "browserify": "2.36.1", "grunt-browserify": "~1.3.0", "load-grunt-tasks": "~0.2.0", "time-grunt": "~0.2.3", "grunt-contrib-watch": "~0.5.3", "grunt-concurrent": "~0.4.2", "grunt-karma": "~0.6.2", "grunt-contrib-less": "~0.8.3", "grunt-contrib-handlebars": "~0.6.0", "grunt-contrib-cssmin": "~0.7.0", "hbsfy": "~1.0.0", "grunt-shell-spawn": "~0.3.0", "chai": "~1.9.0", "sinon": "~1.8.1", "sinon-chai": "~2.5.0", "grunt-simple-mocha": "~0.4.0", "proxyquire": "~0.5.2" } }
---- app---- controllers---- views-------- layouts---- public-------- js-------- css---- client-------- requires-------- spec-------- src-------- styles-------- templates---- spec-------- app-------- controllers
var express = require('express'), http = require('http'), path = require('path'), routes = require('./app/routes'), exphbs = require('express3-handlebars'), mongoose = require('mongoose'), seeder = require('./app/seeder'), app = express();在这里我们通过node的require方法引入了一堆模块。传递给require()的模块,如果没有./或者../,表示 是通过已安装的或者node.js内置的模块被加载的(例如:http是node.js内核的一部分,express是通过 npm安装的)。requires()中包含./或者../的是应用程序自己的模块,我们会稍微说明一下。
app.set('port', process.env.PORT || 3300); // ... app.use(express.logger('dev')); app.use(express.json()); app.use(express.urlencoded()); app.use(express.methodOverride()); app.use(express.cookieParser('some-secret-value-here')); app.use(app.router); app.use('/', express.static(path.join(__dirname, 'public'))); // development only if ('development' == app.get('env')) { app.use(express.errorHandler()); }上面的代码是很标准的ExressJS配置。每一个app.use都是加载一种ExpressJS中间件,它们都是基本插件 ,用于处理一些不需要我们操心的事情!最后一个app.use()(在//development only注释之前)是设置 public目录为静态目录,意味着Express将原封不动地提供这些文件(不会对这些文件做任何修改就返回给 客户端)。
app.set('views', __dirname + '/views'); app.engine('handlebars', exphbs({ defaultLayout: 'main', layoutsDir: app.get('views') + '/layouts' })); app.set('view engine', 'handlebars');我们在用Handlebars初始化视图引擎并将它指向views目录。很有可能你会碰到在这里用Jade的情况,因为 它是一个很流行的模板引擎,我很喜欢Jade(特别是精炼的HTML语法让它看起来像Zen代码一样)。但是, 这里我想用Handlebars,因为这样可以保持前后端的模板语言的统一。服务器端的Handlebars模板保存在 views目录中,对应的布局保存在layouts目录中。在后端使用Handlebars的语法跟前端的情况非常类似。
MongoDB入门如果你不熟悉MongoDB,下面就是一个速成教程。假设你已经在电脑上安装了MongoDB,你可以在任何终端 或者命令行简单敲击mongod命令来启动服务:$mongod在另一个终端或者命令行中,使用mongo命令进入MongoDB shell:$mongo进入了MongoDB shell后,命令行提示符会改变。几个常用命令:show dbs - 显示系统中可用的数据库列表use databasename - 切换到已经存在的数据库(或者如果不存在就新建它)db.things.find() - 列出当前活动数据库(用上面‘use’设置的)中“things”collection中的所有记 录db.things.insert({a: 1, b: 2 c: 3}) - 将参数对象插入到“things”collection中作为一条新记录db.things.find({a: 1}) - 返回一个含有属性a且其值为‘1’的记录列表
//connect to the db server: mongoose.connect('mongodb://localhost/MyApp'); mongoose.connection.on('open', function() { console.log("Connected to Mongoose..."); // check if the db is empty, if so seed it with some contacts: seeder.check(); });我们用于连接MongoDB的node.js驱动是Mongoose。有许多类似的用于MongoDB的node.js驱动,但我喜欢 Mongoose,因为它有可以用来与model一起工作的schema。(从一个有“代码优先”的.Net程序员的背景来 看,这是我学习node时最直接的一个比较)在server.js文件中我们只需要包含一小段代码来使用Mongoose 驱动连接MongoDB。注意,用于连接MongoDB的url是“mongodb://localhost/”,后面跟着要连接的数据库 名。MongoDB的一个好处是如果要连接的数据库不存在,它会自动创建一个!
var mongoose = require('mongoose'), models = require('./models'), md5 = require('MD5'); module.exports = { check: function() { // ...这里我们又一次包含了几个require(),但这次少了一些,只是由于这是一个特别的模块,只需做它分内的 事情,有较少的依赖;换句话说,只有Mongoose、数据模型和MD5(MD5只用来在Gravatar处理图像时生成 一个hash值)最重要的是module.exports这一行 - 导出了一个JavaScript对象,其中含有一个名叫 ‘check’的属性方法。Check()会检查Contacts collection中是否含有任何记录,如果没有,它会首先插 入一些模拟数据。
//routes list: routes.initialize(app); //finally boot up the server: http.createServer(app).listen(app.get('port'), function() { console.log('Server up: http://localhost:' + app.get('port')); });我们的server code现在终于配置完成了,并且连接到了数据库服务器,要做的最后一件事是配好路由然后 启动服务!
var home = require('../controllers/home'), contacts = require('../controllers/contacts'); module.exports.initialize = function(app) { app.get('/', home.index); app.get('/api/contacts', contacts.index); app.get('/api/contacts/:id', contacts.getById); app.post('/api/contacts', contacts.add); app.put('/api/contacts', contacts.update); };
module.exports = { index: function(req, res) { res.render('index'); } };这个工程中我们在路由和控制器之间用了1:1的关系,所以如果你看一下app目录中的route.js文件,你会 看到里面非常简单。字面上仅仅定义了API层的访问点,用于响应从前端过来的请求。每一条路由的真正实 现定义在控制器中,可以在controller/*.js中找到。此外,注意route.js文件最上面的require(),通过 引用../controllers/file(没有.js)引入了我们自己的模块。要注意的一点是我们的home控制器只返回 一个含有index属性的对象,这个属性会对Handlebars模板进行渲染。contacts控制器返回纯JSON结果 - 根据URL请求的不同,结果也会不一样。
var mongoose = require('mongoose'), Schema = mongoose.Schema, ObjectId = Schema.ObjectId; var Contact = new Schema({ email: { type: String }, name: { first: { type: String }, last: { type: String } }, phone: { type: String }, gravatar: { type: String } }); module.exports = { Contact: mongoose.model('Contact', Contact) };我们的整个服务端代码只包含6个小js文件,大多数里面只有几行代码。他们一起支撑起一个全功能的,具 有数据库连接和CRUD逻辑的web服务器。
{ "name": "myapp", "version": "0.0.1", "private": true, "dependencies": { "backbone.marionette": "~1.4.1", "jquery": "~1.10.2", "underscore": "~1.5.2", "backbone": "~1.1.0" }, "devDependencies": { "jasmine": "~1.3.1" }, "exportsOverride": { "jquery": { "js": "jquery.js" }, "underscore": { "js": "underscore.js" }, "backbone": { "js": "backbone.js" }, "backbone.marionette": { "js": "lib/backbone.marionette.js" }, "jasmine": {} } }dependencies和devDepandencies两部分都有定义。有了这些定义,你可以通过简单执行‘bower install ’,就像‘npm install’那样,它将会把在bower.json文件中预定义的文件下载下来(不需要挨个手工安 装)。
如果你要从头开始一个项目并且想在工程中引入jQuery,通过Bower能够很方便地安装:$ bower install jquery同时,假设你也需要Backbone.js和underscore.js:$ bower install backbone$ bower install upderscore然后,你需要做的就是在你的主布局文件中增加一个到bower_components/jquery/jquery.js的引用!
你用自己喜欢的方式将前端文件都组织好了。可能由于各种原因你有非常多的文件 - Backbone模型、视图、集合、路由、控制器,等等。此外,你决定使用LESS来写CSS - 因为你很潮而且大家都在用它。作为一名优秀的程序员,你最终希望工程中所有.js文件都合并到一个单独的文件,最好压缩过(并进行丑化,这样别人就无法搞懂你的源码也就不能偷走你的工作了)。很明显,LESS文件不能原样地返回给浏览器 - 它们也需要转换为普通的.css文件。此外,你践行TDD,希望在每次应用的代码或者测试代码本身发生变化时能定期测试前端代码。不仅如此,你需要真正启动node.js服务器这样你才能在本地浏览器上看到你的app - 每次你修改node代码时,你需要重启服务器。最后一点 - 上面这些只是为了执行app的一个独立测试,你将需要在每次修改代码时都重复上面大多数步骤。
module.exports = function(grunt) { require('time-grunt')(grunt); require('load-grunt-tasks')(grunt); grunt.initConfig({ pkg: grunt.file.readJSON('package.json'),这是Gruntfile最基础的部分。它基本就是用了node的module.exports来返回一个Grunt函数,然后你可以传入一个配置对象给initConfig来启动它。前面两行require了两个很方便的Grunt插件,最重要的一个是load-grunt-tasks。通常对于Gruntfile,你需要手工指定使用一个插件要完成什么任务。这个方便的小插件通过读取package.json文件并查找和加载在dependencies及devDependencies列表中以“grunt-”开头的插件来帮你自动实现这个目标!这让配置文件的很大一部分是不必要的了。
bower: { install: { options: { targetDir: 'client/requires', layout: 'byComponent' } } },
clean: { build: ['build'], dev: { src: ['build/app.js', 'build/<%= pkg.name %>.css', 'build/<%= pkg.name %>.js'] }, prod: ['dist'] },
browserify: { vendor: { src: ['client/requires/**/*.js'], dest: 'build/vendor.js', options: { shim: { jquery: { path: 'client/requires/jquery/js/jquery.js', exports: '$' }, underscore: { path: 'client/requires/underscore/js/underscore.js', exports: '_' }, backbone: { path: 'client/requires/backbone/js/backbone.js', exports: 'Backbone', depends: { underscore: 'underscore' } }, 'backbone.marionette': { path: 'client/requires/backbone.marionette/js/backbone.marionette.js', exports: 'Marionette', depends: { jquery: '$', backbone: 'Backbone', underscore: '_' } } } } }, app: { files: { 'build/app.js': ['client/src/main.js'] }, options: { transform: ['hbsfy'], external: ['jquery', 'underscore', 'backbone', 'backbone.marionette'] } }, test: { files: { 'build/tests.js': [ 'client/spec/**/*.test.js' ] }, options: { transform: ['hbsfy'], external: ['jquery', 'underscore', 'backbone', 'backbone.marionette'] } } },
less: { transpile: { files: { 'build/<%= pkg.name %>.css': [ 'client/styles/reset.css', 'client/requires/*/css/*', 'client/styles/less/main.less' ] } } },
concat: { 'build/<%= pkg.name %>.js': ['build/vendor.js', 'build/app.js'] },
copy: { dev: { files: [{ src: 'build/<%= pkg.name %>.js', dest: 'server/public/js/<%= pkg.name %>.js' }, { src: 'build/<%= pkg.name %>.css', dest: 'server/public/css/<%= pkg.name %>.css' }, { src: 'client/img/*', dest: 'server/public/img/' }] }, prod: { files: [{ src: ['client/img/*'], dest: 'dist/img/' }] } },
cssmin: { minify: { src: ['build/<%= pkg.name %>.css'], dest: 'dist/css/<%= pkg.name %>.css' } },
uglify: { compile: { options: { compress: true, verbose: true }, files: [{ src: 'build/<%= pkg.name %>.js', dest: 'dist/js/<%= pkg.name %>.js' }] } },
watch: { scripts: { files: ['client/templates/*.hbs', 'client/src/**/*.js'], tasks: ['clean:dev', 'browserify:app', 'concat', 'copy:dev'] }, less: { files: ['client/styles/**/*.less'], tasks: ['less:transpile', 'copy:dev'] }, test: { files: ['build/app.js', 'client/spec/**/*.test.js'], tasks: ['browserify:test'] }, karma: { files: ['build/tests.js'], tasks: ['jshint:test', 'karma:watcher:run'] } },
nodemon: { dev: { options: { file: 'server/server.js', nodeArgs: ['--debug'], watchedFolders: ['server/controllers', 'server/app'], env: { PORT: '3300' } } } }
shell: { mongo: { command: 'mongod', options: { async: true } } },
concurrent: { dev: { tasks: ['nodemon:dev', 'shell:mongo', 'watch:scripts', 'watch:less', 'watch:test'], options: { logConcurrentOutput: true } }, test: { tasks: ['watch:karma'], options: { logConcurrentOutput: true } } },
karma: { options: { configFile: 'karma.conf.js' }, watcher: { background: true, singleRun: false }, test: { singleRun: true } },
jshint: { all: ['Gruntfile.js', 'client/src/**/*.js', 'client/spec/**/*.js'], dev: ['client/src/**/*.js'], test: ['client/spec/**/*.js'] }这部分是Gruntfile.js文件中initConfig的最后一部分!前面讲了很多,但是通常如果你想让所有的事情都自动化并让自己过得舒服些,它可以变得更长。在最开始你配置Gruntfile花费的时间能够为你节省在项目开发过程中10倍于它自己的时间。更别提它让你的的大脑得到解放,不必担心其它乱七八糟的事情。
grunt.registerTask('init:dev', ['clean', 'bower', 'browserify:vendor']); grunt.registerTask('build:dev', ['clean:dev', 'browserify:app', 'browserify:test','jshint:dev', 'less:transpile', 'concat', 'copy:dev']); grunt.registerTask('build:prod', ['clean:prod', 'browserify:vendor', 'browserify:app','jshint:all', 'less:transpile', 'concat', 'cssmin', 'uglify', 'copy:prod']); grunt.registerTask('server', ['build:dev', 'concurrent:dev']); grunt.registerTask('test:client', ['karma:test']); grunt.registerTask('tdd', ['karma:watcher:start', 'concurrent:test']);每个‘注册’的任务都很不言自明,基本上你需要先给它一个名字,然后这样执行它
有关Browserify的注释像上面在Gruntfile.js配置部分讲述的那样,我们在应用的前端部分严重依赖Browserify。这只是因为我真的非常喜欢Browserify的工作方式,我喜爱它将前端编程变得像后端开发一样。以前,我写过一篇文章讨论在Backbone.js中使用require.js- 但是为了现在这篇文章,我决定做些改变,尝试一些不同的东西。其实相比于以前用require.js的经验,我个人更喜欢使用Browserify。可以说,如果你以前从来没有用过node.js或者任何前端模块化开发框架,它们看起来都会很让人困惑。我建议你读一下我以前的文章,require.js和Browserify的主题和概念说起来很类似。
var App = require('./app'); var myapp = new App(); myapp.start();Main.js就像它听起来那样 - 是主JavaScript文件。这个文件很小,但是它将启动所有东西!它做的第一件事情是,使用Browserify,require我们的主app对象(位于app.js)。有了这个对象我们可以创建一个新实例并正式启动这个app。
var Marionette = require('backbone.marionette'), Controller = require('./controller'), Router = require('./router'), ContactModel = require('./models/contact'), ContactsCollection = require('./collections/contacts');看起来很熟悉,对吗?这里我们声明app.js的依赖,声明了与Marionette、控制器、路由、联系人模型和联系人集合相对应的变量。每一个模块(除了Marionette)都是我们自己的文件,后面会有更多解释。注意Marionette依赖于jQuery、underscore和Backbone,但是我们不需要require它们,因为这已经在Gruntfile.js的Browserify的配置中通过依赖shim被实现了。
module.exports = App = function App() {}; App.prototype.start = function(){ App.core = new Marionette.Application();在App.start()中发生了4件重要的事情:
App.core.on("initialize:before", function (options) { App.core.vent.trigger('app:log', 'App: Initializing'); App.views = {}; App.data = {}; // load up some initial data: var contacts = new ContactsCollection(); contacts.fetch({ success: function() { App.data.contacts = contacts; App.core.vent.trigger('app:start'); } }); });这个事件在app真正启动之前先一步发生。你可以参考Marionette文档来弄清有哪些可用的事件以及它们发生的顺序。这里initialize:before发生在app:start之前 - 我们这样定义在app启动之前想要处理的事情。具体说,我们新建了App的一些缓存对象(视图和数据),然后从服务器得到联系人数据。一旦数据获取完成,我们真正触发app:start。
App.core.vent.bind('app:start', function(options){ App.core.vent.trigger('app:log', 'App: Starting'); if (Backbone.history) { App.controller = new Controller(); App.router = new Router({ controller: App.controller }); App.core.vent.trigger('app:log', 'App: Backbone.history starting'); Backbone.history.start(); } //new up and views and render for base app here... App.core.vent.trigger('app:log', 'App: Done starting and running!'); }); // ... App.core.start();在app:start里面,我们新建了一个controller的实例和一个router的实例,router将controller作为它构造函数一部分。二者都是Marionette对象(会再解释)。在本项目中,我们的前端路由和控制器的工作方式几乎跟后端node.js的路由和控制器一样,唯一的不同是node.js路由管理了服务器端可以访问的URL - Marionette路由管理着前端app运行时可以被访问的URL。将Backbone或者Marionette路由看作是与DOM事件类似的东西,但是DOM元素是window.location地址栏。如果浏览器地址栏中的URL改变了,触发应用中一个路由事件。(不像普通URL那样加载一个新的页面。)
var Marionette = require('backbone.marionette'); module.exports = Router = Marionette.AppRouter.extend({ appRoutes: { '#' : 'home', 'details/:id' : 'details', 'add' : 'add' } });router.js文件非常简单 - 新建一个Marionette AppRouter对象并将其赋值为一个appRoutes的集合。这些appRoutes将与Controller对象中的同名函数1:1对应。这里我们将app的根URL'#'指向控制器中的'home'函数。然后'details/:id'是得到联系人详细信息视图的URL,指向控制器的'detail'函数。最后我们将'add'URL指向控制器中的'add'函数。
home: function() { var view = window.App.views.contactsView; this.renderView(view); window.App.router.navigate('#'); },控制器是路由背后真正逻辑的拥有者。像我们前面提到的那样,路由和控制器有1:1的关系,意味着每一个定义在路由表中的路由都对应于一个定义在控制器中的函数。控制器中的每一个函数负责相应路由的屏幕渲染。用'home'函数举例来说,我们建立了一个视图,如果我们正在看详细信息视图的话还包括一个模型,然后通过调用控制器的renderView函数来渲染它。renderView函数首先会摧毁已经存在的视图,如果这个视图已经被渲染的话。这里要小心事件处理并保证不要有僵尸视图和/或者事件处理器留存。
var Backbone = require('backbone'); module.exports = ContactModel = Backbone.Model.extend({ idAttribute: '_id' }); var Backbone = require('backbone'), ContactModel = require('../models/contact'); module.exports = ContactsCollection = Backbone.Collection.extend({ model: ContactModel, url: '/api/contacts' });这个简单的app有一个最基本的模型 - 联系人,另外还有一个联系人集合。二者都定义在‘src’目录中的各自文件夹里。你能看到集合的url已经被设置为我们的API。另外,因为我们用mongoDB,所以我们手工将模型的id属性指向_id字段,MongoDB默认用这个字段作为唯一标识。
var Marionette = require('backbone.marionette'); var itemView = Marionette.ItemView.extend({ template: require('../../templates/contact_small.hbs'), initialize: function() { this.listenTo(this.model, 'change', this.render); }, events: { 'click': 'showDetails' }, showDetails: function() { window.App.core.vent.trigger('app:log', 'Contacts View: showDetails hit.'); window.App.controller.details(this.model.id); } }); module.exports = CollectionView = Marionette.CollectionView.extend({ initialize: function() { this.listenTo(this.collection, 'change', this.render); }, itemView: itemView });ItemView本身就含有一个会触发控制器中‘detail’函数的事件。ItemView还有一个对它的模型的监听器,这样如果联系人详细信息发生变化,视图也会被重新渲染。注意到ItemView有一个Handlebars模板,通过require()被引用进来。Browserify会在构建的时候用一个预编译好的JavaScript函数来替换这一行,这会让视图渲染相比于在浏览器中视图每次渲染时才编译要快得多。
events: { 'click a.save-button': 'save' }, save: function(e) { e.preventDefault(); var newContact = { name: { first: this.$el.find('#name_first').val(), last: this.$el.find('#name_last').val() }, email: this.$el.find('#email').val(), phone: this.$el.find('#phone').val() }; window.App.data.contacts.create(newContact); window.App.core.vent.trigger('app:log', 'Add View: Saved new contact!'); window.App.controller.home(); }这里我们定义了一个新的联系人对象,这只是一个普通的JavaScript对象,对应于在MongoDB侧我们的数据对象的样子。然后我们简单将它传递给Backbone的collection.create()函数 - 它将用Backbone的默认实现,将定义好的JSON对象变量传递给一个对API URL(在前面的集合中定义的)的POST请求。回到服务器侧,node.js路由监听对‘api/contacts’的POST请求,它会调用(node.js)控制器的‘add’函数。服务器端联系人控制器中的‘add’函数会用Mongoose创建一个新的联系人模型然后保存到MongoDB服务器。
add: function(req, res) { var newContact = new models.Contact(req.body); newContact.gravatar = md5(newContact.email); newContact.save(function(err, contact) { if (err) res.json({}); console.log('successfully inserted contact: ' + contact._id); res.json(contact); }); },
有关验证的重要提示:无论前端还是后端都没有进行任何验证,认识到这一点很重要。很明显这不好,但是我没有这样做只是希望让代码更简洁和易于消化。这是在真正的app中你需要实现的。(为安全起见,在前端和后端同时进行验证是一个好主意!)
web: node server.js在你的Heroku账号下建立一个app:
提示:在你推送代码到Heroku之前,因为需要Git来完成这件事,你要确保到目前为止做的任何修改都已经staged并committed(例如你刚刚为MongoHQ连接URI而修改了server.js)。做这个最简单的方式是用下面的命令:$ git add .$ git commit -m "Updates for Heroku"$ git push heroku master