前言
不知何时突然冒出“MEAN技术栈”这个新词,听起来很牛逼的样子,其实就是我们已经熟悉了的近两年在前端比较流行的技术,Mongodb、Express、AngularJs、Nodejs,由于这几项技术涵盖了从前端到后端再到数据库,可以用他们完整的开发一个Web应用了,所以成了一个非常牛逼的组合,颇有当年LAMP的气势。前端要从切图仔迈向全栈的路上,这几门技术必须得有所涉猎。本系列文章利用自己虚构的一个小项目为例,对“使用MEAN技术栈开发Web应用”做一个入门级的介绍。
AngularJs的争议
Angular,简称ng,是Google出品的优秀框架,在2013~2014年大红大紫,但是国内好像慢一拍,我从2015年才看到使用ng的项目大量出现。ng自出现伊始就有人诟病太难上手了,完全不同的开发方式,团队开发更是不知道如何组织代码。不过随着jQuery这位老大哥逐渐被抛弃,大家开始慢慢接受mvvm这样的编程思维。然而一个不好的消息是,ng团队打算重构的Angular2.0版本要发生重大变革,与1.0不能同日而语,虽然官方有1.0向2.0迁移的方案,但额外的工作总是不太好的,而且使用2.0还要付出更多的学习成本。
再加上今年又有React这个实力派雄起,ng的风头顿时被抢过去了,人们又开始研究React下的编程方式。不过我估计react的真正实用也得等到一两年后。眼下Angular1.x也仍然是一个不错的选择。尽管有2.0的变革,但是1.4还是一个稳定版本,我们使用稳定版本肯定是不会有问题的。
所以我的结论是,但用无妨,不会存在白学了这种事情,就算将来angular1.x废弃了,你学到的编程思维还是在的。
本文讨论如何使用AngularJs进行前端的架构,对于ng的基础知识不做讲解,需要了解的同学可以看我之前写过的一个系列(点击查看)。
练手项目简介
为了系统的学习“MEAN”技术栈,我虚构了一个小项目,先做一个介绍。
QuestionMaker,是一个用于生成调查问卷的系统,用户可以编辑试题(选择题、填空题),并可以实时预览编辑结果。然后还可以编辑一份试卷,为试卷添加试题,然后保存为一分完整的调查问卷。有点类似于调查派。先上一张截图吧:
项目的功能主要是CRUD操作,所以非常适合angular的应用场景,双向绑定对于实现实时预览这样的功能简直是信手拈来。
项目的前后端是完全分离的,后端不渲染页面,只提供数据接口,前端使用ng的动态模板来渲染页面,通过ajax请求来获取所需数据。
项目我已经开源到github,有兴趣的同学可以查看:https://github.com/Double-Lv/QuestionMaker
前端目录结构
用ng来构建一个项目应该如何安排目录结构呢?为了不人工增加复杂度,我这里没有用bower来管理依赖库,也没有其他文章中介绍的那样用yeoman来生成项目,只是单纯的手动来创建目录,这样可以把我们的注意力集中到项目的核心上,目录结构是这样的:
前端的代码都在src目录下,包括入口文件index.html,这样方便我们后续做合并压缩等编译工作,编译后的文件可以一并放入dist目录下。
首页index.html
这是项目的入口页面,其实就是一个大容器,在这里加载所有的js和css文件,然后提供一个视图容器就够了,因为从这个页面以后,我们页面就不再会有跳转,全部是通过前端路由来做局部刷新,首页的代码非常精简:
入口文件app.js
有了入口页面,还得有一个js的启动入口,就是这个app.js了,在这里它只做了两件事情:
1. 启动angular,代码只有一行:
1
|
var app = angular.module( 'QMaker' , [ 'ui.router' ]); |
我们拥有了一个名为app的全局模块。这里把ui.router给注入了,因为我们整个应用都用ui-router来做路由,后面会做详细介绍。
2. 把ui-router的$state和$stateParams服务挂到$rootScope上,这样我们在后面所有的模块中,都能够访问到路由参数,不必在每个地方都注入一次了。代码也是相当简单:
1
2
3
4
|
app.run( function ($rootScope, $state, $stateParams) { $rootScope.$state = $state; $rootScope.$stateParams = $stateParams; }); |
控制器合集controllers.js
controller.js里面是所有的controller定义,由于这个项目比较小,而且反正最后都要合并,所以就都放在一个文件里了,这样可以使用链式写法app.controller(‘a’, …).controller(‘b’, …), 一口气将所有的controller都定义好。如果项目比较大,controller多,可以把controllers建为一个文件夹,然后在里面放各个controller。
controller里面就是跟业务相关的一些代码了,如试题数据的初始化,添加答案、删除选项等操作。
但是当我们需要发起ajax请求的时候,如保存试题,就不宜在controller里面直接写了,这样会造成逻辑混杂代码混乱。所有需要请求服务端的操作,我们可以抽象为一个个服务,进行“分层”,通过ng提供的service机制来做调用。
服务合集services.js
接上面,所有和试题相关的服务端请求,我们可以封装成一个QuestionService,这个服务提供:提交试题、删除试题、更新试题等服务,这样层次就很清晰了。
所以,在services.js中,我们定义所有和服务相关的东西,在本项目中,我们的服务全都是ajax请求,可以用ng提供的$http服务来很方便的完成。事实上service中并不是必须写ajax请求,凡是可以抽象理解为“公共服务”的东西,都可以定义在这里,可以被其他模块随意调用。
指令合集directives.js
了解过ng的同学应该对指令不会陌生,通过指令我们可以用扩展html标签的方式来很容易的实现一些UI效果,使用方便、可被多个地方公共使用,就像过去我们写jquery插件一样。所有的指令都定义在这个文件中,同样可以使用链式写法,很爽。
在我们的项目中,有一些功能是通用的,例如列表的分页,那么就可以把分页功能做成一个指令。我定义了一个名为pagenav的指令,然后在所有需要用分页的地方就可以调用了,代码如下:
1
|
[pagenav pageobj="pageObject" pagefunc="pageFunction"][/pagenav] |
只需一个标签,然后通过属性指定分页数据和翻页函数即可。
过滤器合集filters.js
我们的项目使用ng提供的动态模板,服务端不渲染页面,只提供数据接口。有些数据我们需要进行格式化后进行输出,这就用到filter了,所有的filter都放在这里。filter的定义和使用的非常简单,此不不多述了。
前端路由定义routes.js
本项目使用ui-router来做前端路由,这个目测也是现在最流行的做法。ui-router是一个第三方插件,由于ng内置的ngRouter功能较弱,无法实现嵌套路由和多视图路由,而ui-router引入了“状态”这个概念来控制视图,从而实现这些功能,所以ui-router成了最好的选择。它是angular-ui项目(http://angular-ui.github.io/)中的一个模块,该项目还提供了很多基于ng的ui,像日期选择器什么的。ui-router貌似是最受欢迎的一个。
用ui-router可以实现嵌套路由和同一页面多视图,具体使用方法可以参考我博客中转载的几篇文章(点击查看)。
本项目中,由于整站无刷新,所以路径的层级会比较深,嵌套路由就派上了用场。在入口页面index.html中,用一个div来做父容器,加上ui-view属性,就可以在里面加载别的模板了。从试题列表到试题编辑页面的切换,就都在这个父容器中加载。
而在试题编辑页面,又有对应的题型编辑和试题预览视图,通过给ui-view赋予名字,就可以加载各自对应的模板,这里就是多视图的应用。代码片段如下:
1
2
3
4
5
|
<!--试题编辑视图--> [div ui-view= "editArea" ][/div] <!--试题预览视图--> [div ui-view= "previewArea" ][/div] |
在试卷预览页面,我们也需要对试题进行展示,只需在页面上在定义一个ui-view,然后在路由中进行配置,就可以加载试题预览模版,很容易的实现了模板的复用。
页面中没有任何逻辑,只需在route.js中配置好路由规则,整站无刷新跳转就这么轻而易举的实现了。
tpl目录
利用ui-router做了前端路由后,除了入口页面index.html外,其他所有页面就都变成模板了(被ui-router动态加载)。所有的模板都放在tpl目录下。如果业务的模块较多,可以在此目录下再新建文件夹,本项目比较简单,所以就只有一层。不论有多少层目录,在routers.js中配置好就OK啦。利用ui-router可以注入模板对应的控制器,所以代码中我们也不必在加ng-controller,模板文件中就是很干净的ng模板。
lib目录
这里放置的是项目所需的外部库。有angular、ui-router、jquery、bootstrap。你可以看到我只是把代码文件给直接放里面了,没有用当下流行的bower进行管理。是因为我不想再人为的增加复杂度,万一有人的机器上bower安装失败或者git环境有问题,或者github无法访问,都会令人十分沮丧。
反正就这几个稳定版本,不如直接下载过来。如果需要压缩我后期用gulp来搞一下就行了。
运行起基于express的Web服务器
express是一个Web应用开发框架,它基于nodejs,扩展了很多Web开发所需的功能,使得我们能够很方便的访问和操作request和response。请注意它和nginx或者tomcat并不是一个概念,它是一个开发框架,而不是服务器。
运行起基于express的web服务器是非常简单的,因为express都绑你封装好了。首先需要用npm安装好express,然后在项目根目录下新建一个server.js文件,内容如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
var express = require( 'express' ); var app = express(); app.listen(3000); var _rootDir = __dirname; var protectDir = _rootDir + '/protect/' ; app.use(express.static(_rootDir)); //注册路由 app.get( '/' , function (req, res){ res.sendFile(_rootDir+ '/src/index.html' ); }); app.use( function (req, res, next) { res.status(404).sendFile(_rootDir+ '/src/404.html' ); }); app.use( function (err, req, res, next) { console.error(err.stack); res.status(500).send( '500 Error' ); }); |
上述代码实现了这几个功能,首先创建了http服务器,监听在3000端口。
然后app.use(express.static(_rootDir));这一行是使用了静态文件服务的中间件,这样我们项目下的js、css以及图片等静态文件就都可以访问到了。
接下来是注册路由,此处只匹配一个路由规则,那就是”/”(网站的根目录),当匹配到此路由后把首页文件index.html直接用res.sendFile方法给发送到浏览器端。这样浏览器用http://127.0.0.1:3001就可以访问到index.html了。网站的其他页面也可以通过配置类似的路由进行返回。express还支持配置模板引擎,默认支持ejs,你也可以自己配置其他的比如handlebars。
但是在本项目中,我们用的是angular的前端模板,所以后端就不需要模板了,没有进行配置。我们的路由机制也是完全使用的ng的前端路由,所以在express中只配置一条就够了。
在最后还有两块代码,分别是404和500错误的捕获。你可能会疑惑为什么是这样写呢?从上到下排下来就能分别捕获404和500了吗?其实这就是express的中间件机制,在此机制下,对客户端请求的处理像是一个流水线,把所有中间件串联起来,只要某个中间件把请求返回了,就结束执行,否则就从上到下一直处理此请求。
上面代码的流程就是,先按路由规则来匹配路径,如果路由匹配不到,则认为是发生404。500的错误请注意一个细节,在回调函数的参数中,第一个会传入err,就是错误对象,以此来标记是一个500错误。
理解中间件
express的核心是中间件机制,通过使用各种中间件,能够实现灵活的组装我们所需的功能。中间件是在管道中执行的,所谓管道就是像流水线一样,每到达一个加工区,相应的中间件就可以处理request和response对象,处理完后再送往下一个加工区。如果某个加工区把请求终结了,比如调用send方法返回给了客户端,那么处理就终止了。大部分情况下,都有现成的中间件供我们使用,比如用body-parser解析请求实体,用路由(路由也是一种中间件)来正确的派发请求。
比如我们在server.js中添加如下的代码:
1
2
3
4
5
6
7
8
|
app.use( function (req, res, next){ console.log( '中间件1' ); next(); }); app.use( function (req, res, next){ console.log( '中间件2' ); }); |
我们添加了两个中间件,请求过来之后会先被第一个捕获,然后进行处理,输出“中间件1”。后面接着执行了next()方法,就会进入下一个中间件。一个中间件执行后只有两种选择,要么用next指向下一个中间件,要么将请求返回。如果什么都不做,请求将会被挂起,也就是说浏览器端将得不到返回,一直处于pendding状态。例如上面的中间件2,将会造成请求挂起,这是应该杜绝的。
路由设计
运行起了服务器,了解了中间件编程方式,接下来我们就该为前端提供api了。比如前端post一个请求到/api/submitQuestion来提交一份数据,我们该如何接收请求并做出处理呢,这就是路由的设计了。
给app.use的第一个参数传入路径可以匹配到对应的请求,例如:
1
|
app.use( '/api/submitQuestion' , function (){}) |
这样就可以捕获到刚刚的提交试题的请求,在第二个参数中可以进行相应的处理,比如把数据插入到数据库。
但是,要注意了,express路由的正确使用姿势并不是这样的。app.use是用来匹配中间件的路径的,而不是请求的路径。因为路由也是一种中间件,所以这样的用法也是能够完成功能的,但是我们还是应该按照官方标准的写法来写。
标准的写法是什么样子呢?代码如下:
1
2
3
|
var apiRouter = express.Router(); apiRouter.post( '/submitQuestion' , questionController.save); app.use( '/api' , apiRouter); |
我们利用的是express.Router这个对象,它同样有use、post、get等方法,用来匹配请求路径。然后我们再使用app.use把apiRouter作为第二个参数传进去。
要注意的是apiRouter.post和app.use的第一个参数。app.use匹配的是请求的“根路径”,这样可以把请求分为不同的类别,比如所有的异步接口我们都叫api,那么这类请求我们就都应该挂在“/api”下。按照这样的规则,我们整个项目的路由规则如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
//注册路由 app.get( '/' , function (req, res){ res.sendFile(_rootDir+ '/src/index.html' ); }); var apiRouter = express.Router(); apiRouter.post( '/getQuestion' , questionController.getQuestion); apiRouter.post( '/getQuestions' , questionController.getQuestions); apiRouter.post( '/submitQuestion' , questionController.save); apiRouter.post( '/updateQuestion' , questionController.update); apiRouter.post( '/removeQuestion' , questionController.remove); apiRouter.post( '/getPapers' , paperController.getPapers); apiRouter.post( '/getPaper' , paperController.getPaper); apiRouter.post( '/getPaperQuestions' , paperController.getPaperQuestions); apiRouter.post( '/submitPaper' , paperController.save); apiRouter.post( '/updatePaper' , paperController.update); apiRouter.post( '/removePaper' , paperController.remove); app.use( '/api' , apiRouter); |
在router的第二个参数中,我们传入了questionController.save这样的方法,这是什么东西呢?怎么有点MVC的味道呢?没错,我们已经能够匹配到路由了,那服务端的业务逻辑以及数据库访问等该如何组织代码呢?
用“MVC”组织代码
用MVC的结构组织代码当然是黄金法则了。express可以用模板引擎来渲染view层,路由机制来组织controller层,但是express并没有明确规定MVC结构应该怎样写,而是把自由选择交给你,自己来组织MVC结构。当然你也可以组织别的形式,比如像Java中的“n层架构”。
在本项目中,我们就以文件夹的形式来简单组织一下。因为我们使用了前端模板,所以后端的view层就不存在了,只有controller和model。看一下项目的目录:
在protect下有两个文件夹controllers和models分别放C和M。我们路由中使用的questionController对象就定义在questionController.js中,来看一下用于保存试题的save方法是如何定义的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
var Question = require( '../models/question' ); module.exports = { //添加试题 save: function (req, res){ var data = req.body.question; Question.save(data, function (err, data){ if (err){ res.send({success: false , error: err}); } else { res.send({success: true , data: data}); } }); } } |
questionController作为一个模块,使用标准的commonjs语法,我们定义了save方法,通过req.body.question,可以拿到前台传过来的数据。在这个模块中,我们require了位于model层的Question模型,没错,它就是用来操作数据库的,调用Question.save方法,这份数据就存入了数据库,然后在回调函数中,我们用res.send将json数据返回给前端。
定义好questionController后,我们就可以在server.js中把它给require进去了,然后就有了之前我们在路由中使用的
1
|
apiRouter.post( '/submitQuestion' , questionController.save); |
整个流程就串通起来了。
models文件夹中放的就是模型了,用来管理与数据库的映射和交互,这里使用了mongoose作为数据库的操作工具,model层如何来编写,本篇就不做介绍了,在下一篇中我们再详细讲解。
最后再声明一下,本篇文章的代码是基于一个练习项目QuestionMaker,为了更好理解文章中的叙述,请查看项目的源码:https://github.com/Double-Lv/QuestionMaker。