本章将使用AngularJS打造动态网页。同时我们还会测试下控制器代码。
应用开发中组织代码结构的方法有很多种。对于Angular应用来说,我们鼓励使用MVC的设计方法,它可以很好的解耦代码,让其有各自的侧重关注点。下面,我们将用一点Angular和JS代码来为我们的APP添加一些模型(model)、视图(Views)和控制器(Controllers)控件。
代码不用自己写~,只需要使用git命令切换出该步骤对应的代码即可。命令如下:
git checkout -f step-2
我们假设你如第一、二章介绍的那样已经运行了你的网站,所以你只需要刷新浏览器来查看效果。如果还没有运行,请参照第一章或第二章。
视图和模版
在Angular中,视图可以被理解为是模型(Model)数据在HTML页面的一种投射。这意味着无论何时,只要模型数据发生变化,视图也应该变化。这一切将由Angular自动完成,它将更新对应的数据绑定点的数据,从而更新视图。
下面是本章实例中视图对应的模版代码:
app/index.html
<html ng-app="phonecatApp">
<head>
...
<script src="bower_components/angular/angular.js"></script>
<script src="js/controllers.js"></script>
</head>
<body ng-controller="PhoneListCtrl">
<ul>
<li ng-repeat="phone in phones">
<span>{{phone.name}}</span>
<p>{{phone.snippet}}</p>
</li>
</ul>
</body>
</html>
我们把之前静态页面里的展示手机列表的硬编码代码换成了ngRepeat
指令和2个Angualr表达式。
-
<li>
标签里中属性ng-repeat="phone in phones"
对应了Angular中的repeater(循环)指令,这个循环指令告诉Angular为phones列表中的每一个phone对象都创建一个<li>
标签。 - 花括号中的2个表达式(
{{phone.name}}
和{{phone.snippet}}
)将会被Angular替换为表达式对应的值。
细心的读者可以发现,这个例子中我们新增了一种Angular命令——ng-controller
,它把一个控制器即PhoneListCtrl
附着在<body>标签中。
目前来看:
花括号中的两个变量代表了模型数据,而模型数据由PhoneListCtrl
控制器来完成设置或者叫做填充
注意:我们已经使用
ng-app="phonecatApp"
加载了一个Angular模块,其中这个模块的名称叫phonecatApp。这个模块将包含我们定义的PhoneListCtrl
控制器。
如图所示为本例Angular对应的域(Scopes)。
模型和控制器
本例中的模型数据十分简单(一个简单的手机信息数组),它由PhoneListCtrl控制器负责初始化工作。 定义一个Angular控制器也很简单,它对应了一个JS函数,这个函数只有一个参数 $scope
。如下所示:
app/js/controllers.js
var phonecatApp = angular.module('phonecatApp', []);
phonecatApp.controller('PhoneListCtrl', function ($scope) {
$scope.phones = [
{'name': 'Nexus S',
'snippet': 'Fast just got faster with Nexus S.'},
{'name': 'Motorola XOOM? with Wi-Fi',
'snippet': 'The Next, Next Generation tablet.'},
{'name': 'MOTOROLA XOOM?',
'snippet': 'The Next, Next Generation tablet.'}
];
});
这段代码首先定义了一个Angular应用phonecatApp
(还记得大明湖畔的夏雨荷吗?...台词错了,还记得上文HTML中的<html ng-app="phonecatApp">
吗?这里的名字可不是随便写的,而是要和JS中新建的Angular应用名称一致),然后为这个应用添加了一个控制器PhoneListCtrl
,在这个控制器中,我们在该域下定义了一个数组变量phones,并且为它赋值。
这个控制器代码看起来是十分简单的,不过它扮演的角色可是很关键的。它提供给我们一个存放模型数据的上下文(就是那个$scope
参数),从而允许我们轻松建立模型和视图之间的绑定。Anglar在整个表示层、数据层、逻辑层的关联中做了以下两件事:
-
<body>
标签中的ngController
命令设置的控制器名称,指导Angular使用JS文件controllers.js
中我们为应用创建的名为PhoneListCtrl
的控制器。 -
PhoneListCtrl
控制器中的代码把对应的手机数据绑定到$scope
变量里,这个$scope
变量代表了一个域
,这个域是根域(root scope)
的派生。Angular应用定义的时候也会同时创建这个域
。
域(Scope)
官方很希望我们能理解域
的概念,他们认为它非常关键。一个域
就像胶水一样,把模版、模型和控制器黏在一起。Angular使用域
和包含在模版、数据模型和控制器内的信息以同步的方式来分离模型和视图(注:这句话没太看懂,原文:Angular uses scopes, along with the information contained in the template, data model, and controller, to keep models and views separate, but in sync.)。任意模型的改变将被投射在视图上,同样的视图的变化也会反射到模型中。
可以从这里angular scope documentation了解更多关于 Angular域
的相关知识。
测试
Angular设计路线
使用了把视图和控制器分开的方式,这让代码测试也变得简单了。如果我们的把控制器函数定义在了全局范围(就是JS的全局域),我们可以mock一个简单域
对象来初始化这个控制器。
test/e2e/scenarios.js
describe('PhoneListCtrl', function(){
it('should create "phones" model with 3 phones', function() {
var scope = {},
ctrl = new PhoneListCtrl(scope);
expect(scope.phones.length).toBe(3);
});
});
这段测试代码初始话了控制器PhoneListCtrl
并且校验了域
中绑定的3条手机记录数组。这个例子告诉我们在Angular中创建单元测试是十分简单的。软件开发过程中测试是很重要的,所以我们鼓励大家写单元测试。
测试非全局范围内的控制器
实际情况中,我们可能不希望在全局命名控件内定义控制器函数。比如,我们本例中就使用了匿名函数为phoneCatApp
模块创建了一个控制器。(指这句phonecatApp.controller('PhoneListCtrl', function ($scope) {
)。
Angular为这种情况提供了一个服务即$controller
,它将根据名字收集你的控制器。下面是一个使用了$controller
服务的测试代码,功能和上一段代码一样:
test/unit/controllersSpec.js:
describe('PhoneListCtrl', function(){
beforeEach(module('phonecatApp'));
it('should create "phones" model with 3 phones', inject(function($controller) {
var scope = {},
ctrl = $controller('PhoneListCtrl', {$scope:scope});
expect(scope.phones.length).toBe(3);
}));
});
这个测试的过程包含4个关键步骤:
- 在每次测试开始之前,我们告诉Angular加载phoneApp应用模块。
- 我们要求Angular把
$controller
服务注入到测试函数中。 - 我们使用
$controller
服务创建PhoneListCtrl
控制器的一个实例。 - 有了这个实例之后,我们就可以验证三条手机信息记录了。
编写和运行测试
Angular开发者在编写测试时很喜欢使用Jasmine开创的行为驱动开发框架(Behavior-driven Development (BBD) framework)。虽然Angular不要求你一定要使用Jasmine的这个框架,该教程中所有的测试用例都是基于Jasmine V1.3。你可以从Jasmine的主页或者Jasmine框架文档获得更多的知识。
Angular种子
项目已经预先配置了使用Karma来运行单元测试,前提是你得确保你已经安装了Karma和响应的依赖控件。没有的话你需要先 npm install
。
然后我们输入npm test
来运行我们的测试代码。有几点需要说明一下:
- 首先Karma将自动创建Chrome和Fixfox浏览器的实例。这些都可以在后台自动完成,你无需care。Karma将自动使用创建的浏览器实例来进行测试。
- 如果你本地只安装了这两个浏览器中的一个,那么对应的你要修改下对应的Karma配置。比如如果你只安装了Chrome浏览器,你可以打开
test/karma.conf.js
文件,然后做以下修改:
...
browsers: ['Chrome' ],
...
- 你将看到类似下面一样的终端输出:
info: Karma server started at http://localhost:9876/
info (launcher): Starting browser "Chrome"
info (Chrome 22.0): Connected on socket id tPUm9DXcLHtZTKbAEO-n
Chrome 22.0: Executed 1 of 1 SUCCESS (0.093 secs / 0.004 secs)
这就对了!说明已经通过了。
- 要想重新运行测试,你只需要修改任意JS源代码。Karma将会监测到,然后重新运行测试。怎么样,Angular是不是很贴心?
有一点需要注意一下,如果自动测试过程中Karma打开了浏览器窗口,请不要最小化。因为有些操作系统对于最小化的浏览器,会限制其内存分配,这将会是的Karma的整个测试过程很慢。
实验小能手
在index.html中新增一个绑定,比如:
<p>Total number of phones: {{phones.length}}</p>
在控制器中创建一个新的模型变量,如:
$scope.name = "World";
然后绑定其到index.html
模版中:
<p>Hello, {{name}}!</p>
刷新你的浏览器,看看是不是显示"Hello, World!"。
在控制器测试代码中添加对本次改动的测试,打开./test/unit/controllersSpec.js
添加:
expect(scope.name).toBe('World');
在index.html
中定义一个循环器,用它来创建一个简单的表格:
<table>
<tr><th>row number</th></tr>
<tr ng-repeat="i in [0, 1, 2, 3, 4, 5, 6, 7]"><td>{{i}}</td></tr>
</table>
然后,让表格的序号从1开始。
<table>
<tr><th>row number</th></tr>
<tr ng-repeat="i in [0, 1, 2, 3, 4, 5, 6, 7]"><td>{{i+1}}</td></tr>
</table>
加分作业:尝试使用ng-repeat
创建一个8*8的表格。
为了故意让单元测试出错,从而观察输出,我们可以把代码expect(scope.phones.length).toBe(3)
改成 toBe(4)
。
总结
现在你已经初步了解了如何使用Angular以MVC设计方法构建一个动态网站,以及如何测试它。接下来的一章,我们将为这个网站添加全文搜索功能。