导言
最近在学AngularJS的实例教程PhoneCat Tutorial App,发现网上的中文教程都比较久远,与英文版对应不上,而且缺少组件和文件重构两节。所以决定自己整理一个中文简明教程。
此篇为6-7节。
上一篇:AngularJS Phonecat(步骤0-步骤5)
6 下拉选择框--双向绑定
我们要增加一个下拉选择框,用于选择排序要求,让手机列表按要求排序。
为了实现自动排序,我们要在数据模型中添加排序的依据,并与迭代器联系起来。
组件模板
app/phone-list/phone-list.template.html:
<div class="container-fluid">
<div class="row">
<div class="col-md-2">
<!--Sidebar content-->
<p>
Search:
<input ng-model="$ctrl.query">
</p>
<!--新增选择框,绑定$ctrl.orderProp-->
<p>
Sort by:
<select ng-model="$ctrl.orderProp">
<option value="name">Alphabetical</option>
<option value="age">Newest</option>
</select>
</p>
</div>
<div class="col-md-10">
<!--Body content-->
<!--新增orderBy,以$ctrl.orderProp为依据-->
<ul class="phones">
<li ng-repeat="phone in $ctrl.phones | filter:$ctrl.query | orderBy:$ctrl.orderProp">
<span>{{phone.name}}</span>
<p>{{phone.snippet}}</p>
</li>
</ul>
</div>
</div>
</div>
- 增加<select>元素,并绑定到 $ctrl.orderProp(数据模型),用户可以从两个选项中进行选择。而orderBy又以$ctrl.orderProp为依据,这样选定的选项就会成为排序依据。
这种双向数据绑定,可以让数据变化及时反映到视图中,且不需要指定DOM操作。 - orderBy连接了filter,过滤后的数据才会输入orderBy进行排序。orderBy的工作原理是,复制输入的数组、进行排序、输出。
组件控制器
app/phone-list/phone-list.component.js:
angular.
module('phoneList').
component('phoneList', {
templateUrl: 'phone-list/phone-list.template.html',
controller: function PhoneListController() {
this.phones = [
{
name: 'Nexus S',
snippet: 'Fast just got faster with Nexus S.',
age: 1 //新增了age属性
}, {
name: 'Motorola XOOM™ with Wi-Fi',
snippet: 'The Next, Next Generation tablet.',
age: 2
}, {
name: 'MOTOROLA XOOM™',
snippet: 'The Next, Next Generation tablet.',
age: 3
}
];
this.orderProp = 'age'; //默认以“age”为排序依据
}
});
- 修改数据模型:每个phone对象都新增了age属性,用于按手机推出时间排序。
- 设置默认以“age”为排序依据。如果不设置默认值,即手机列表不会进行排序,直到用户从下拉菜单中选择选项。
- 双向绑定:页面加载后,我们会看到“Newest”被选中,这是因为控制器设置了默认排序依据“age”,实现了数据模型-->UI的绑定。如果我们在下拉菜单中选择“Alphabetically”,数据模型也会更新,进而手机列表重新进行排序,这就是UI-->数据模型的绑定。
总结起来就是,改变数据模型会改变视图,改变视图也会改变数据模型。
测试
组件的单元测试,用于验证默认排序
app/phone-list/phone-list.component.spec.js:
describe('phoneList', function() {
// 测试前需加载`phoneList` 模块
beforeEach(module('phoneList'));
// 测试控制器
describe('PhoneListController', function() {
var ctrl;
beforeEach(inject(function($componentController) {
ctrl = $componentController('phoneList');
}));
it('should create a `phones` model with 3 phones', function() {
expect(ctrl.phones.length).toBe(3);
});
it('should set a default value for the `orderProp` model', function() {
expect(ctrl.orderProp).toBe('age');
});
});
});
测试成功,则命令行输出:
Chrome 22.0: Executed 2 of 2 SUCCESS (0.021 secs / 0.001 secs)
端到端测试:用于验证下拉菜单改变时的排序
e2e-tests/scenarios.js:
describe('PhoneCat Application', function() {
describe('phoneList', function() {
...
it('should be possible to control phone order via the drop-down menu', function() {
var queryField = element(by.model('$ctrl.query'));
var orderSelect = element(by.model('$ctrl.orderProp'));
var nameOption = orderSelect.element(by.css('option[value="name"]'));
var phoneNameColumn = element.all(by.repeater('phone in $ctrl.phones').column('phone.name'));
function getNames() {
return phoneNameColumn.map(function(elem) {
return elem.getText();
});
}
queryField.sendKeys('tablet'); // Let's narrow the dataset to make the assertions shorter
//测试默认状态下的排序
expect(getNames()).toEqual([
'Motorola XOOM\u2122 with Wi-Fi',
'MOTOROLA XOOM\u2122'
]);
//选择按“name”排序
nameOption.click();
//测试排序
expect(getNames()).toEqual([
'MOTOROLA XOOM\u2122',
'Motorola XOOM\u2122 with Wi-Fi'
]);
});
...
运行测试,在命令行中输入:
npm run protractor
尝试
- phoneList组件控制器中,删除orderProp的默认值,下拉菜单会多一个空白栏("unknown")。
- 在phone-list.template.html模板添加一个{{$ctrl.orderProp}}的绑定来显示当前的排序依据。
- 反转排序只需要在排序值前面添加一个-号:
<option value="-age">Oldest</option>
7 XHR和依赖注入
一个真实的应用程序不会只有三条数据,也不会直接写到前端代码中。所以这一节,我们会使用AngularJS的内置服务$http从服务器中获取更多的数据,并使用依赖注入(dependency injection)为phonelist组件控制器提供服务。
数据
app/phones/phones.json用于保存手机数据,保存格式为json,文件中的一组手机数据如下所示:
[
{
"age": 13,
"id": "motorola-defy-with-motoblur",
"name": "Motorola DEFY\u2122 with MOTOBLUR\u2122",
"snippet": "Are you ready for everything life throws your way?"
...
},
...
]
组件控制器
使用$http向web服务器发送HTTP请求,并从文件app/phones/phones.json 中获取手机数据。$http是Angular的内置服务(built-in Services)之一,内置服务用于处理常见操作,Angular会在程序需要时自动注入这些服务。
服务由Angular的依赖注入子系统管理。依赖注入能让你的web app实现更好的结构(如独立的展示结构、数据结构和控制器)和组件松耦合(组件不用自己实现依赖关系,而是通过依赖注入子系统来实现),这样app也会更容易测试。
app/phone-list/phone-list.component.js:
angular.
module('phoneList').
component('phoneList', {
templateUrl: 'phone-list/phone-list.template.html',
//向控制器函数注入$http
controller: function PhoneListController($http) {
var self = this;
self.orderProp = 'age';
//$http获取json文件中的数据
$http.get('phones/phones.json').then(function(response) {
self.phones = response.data;
});
}
});
- $http发起GET请求,服务器响应请求,返回json文件中的手机数据
- $http服务返回一个promise对象,该对象有then()函数,我们在then()函数中将服务器返回的数据放入控制器。
注意:Angular会自动检测和解析json数据。 - 由于回调函数中的this不会指向控制器,需要引用变量self来指向控制器实例。
使用Angular的某个服务,只需要将该服务名称放入控制器构造函数的参数中,比如:
function PhoneListController($http) {...}
Angular的依赖注入会在控制器初始化时提供服务,并自动处理相应的层次依赖关系(通常一个服务还依赖于其他服务)。
注意:参数的名字很重要(不能随意变动),依赖注入会用这些名字查找服务功能并进行注入。
$前缀命名规则
Angula的内置服务service、作用域scope和一些其它的API都有一个$前缀。该前缀用于Angular提供的服务,所以我们自定义服务和数据模型的时候最好别用$前缀,以免发生命名冲突。另外,$$前缀用于私有属性,无法访问或修改。
压缩代码时的注意事项
因为依赖注入是通过参数名来实现的,所以压缩代码的时候,要注意参数名不能被压缩,否则会导致依赖注入出错。
对于该问题,我们需要提供一个禁止压缩的依赖名称列表,具体有两个方法:
- 在控制器构造函数中创建一个$inject字符串数组,每个字符串表示一个需要注入的服务名。例如:
function PhoneListController($http) {...}
//$inject字符串数组
PhoneListController.$inject = ['$http'];
...
.component('phoneList', {..., controller: PhoneListController});
- 在组件注册控制器的代码中使用内联注解数组,例如:
function PhoneListController($http) {...}
...
.component('phoneList', {..., controller: ['$http', PhoneListController]});
这两种方法可任选其一。如果使用第二种方法,通常会用内联构造函数来注册控制器:
.component('phoneList', {..., controller: ['$http', function PhoneListController($http) {...}]});
现在开始,我们会在教程中使用内联注解的方式进行处理。
app/phone-list/phone-list.component.js(为PhoneListController添加内联注解):
angular.
module('phoneList').
component('phoneList', {
templateUrl: 'phone-list/phone-list.template.html',
controller: ['$http', //内联注解
function PhoneListController($http) {
var self = this;
self.orderProp = 'age';
$http.get('phones/phones.json').then(function(response) {
self.phones = response.data;
});
}
]
});
测试
应用依赖注入使得控制器有了依赖关系,所以构建控制器的单元测试变复杂了。可以使用new运算符在构造函数中提供$http的模拟实现,不过现在不用那么麻烦。
Angular提供了用于单元测试的模拟$http服务,我们可以直接调用服务中的$httpBackend方法实现服务器的模拟响应。
app/phone-list/phone-list.component.spec.js:
describe('phoneList', function() {
beforeEach(module('phoneList'));
describe('controller', function() {
var $httpBackend, ctrl;
// 这里的依赖注入会忽略头部和尾部的下划线(如 _$httpBackend_).
// 这样变量就可以用和服务同样的名字,而不会有命名冲突
beforeEach(inject(function($componentController, _$httpBackend_) {
$httpBackend = _$httpBackend_;
//模拟发送http请求
$httpBackend.expectGET('phones/phones.json')
//模拟响应
.respond([{name: 'Nexus S'}, {name: 'Motorola DROID'}]);
ctrl = $componentController('phoneList');
}));
...
});
});
注意:因为需要加载Jasmine和angular-mocks.js到测试环境,所以我们有两个辅助方法module和inject用于访问和配置注入器。
- 使用inject()向Jasmin的beforeEach()函数注入$componentController和 $httpBackend服务的实例,用于创建每个测试。这样保证每个测试有共同的起点,又各自独立。
- 调用$componentController(),并传入参数'phoneList'(组件的名字)。
代码使用$http获取手机列表到控制器中,所以在创建控制器(PhoneListController)前,我们要告诉测试程序预期的输入请求:
- 向beforEach()函数注入$httpBackend的请求服务,它会模拟生产环境中的XHR和JSONP请求。这样我们在编写测试时,就不用处理原生API和与它们相关的全局状态(服务器响应结果)。这样也避免了异步请求(XHR和JSONP是异步请求),可以加快单元测试。
- $httpBackend.expectGET()方法可以模拟预期的HTTP请求和响应。注意:在调用$httpBackend.flush()之后,才会返回响应。
接着,我们来验证在收到响应之前,控制器中是否有phones属性:
it('should create a `phones` property with 2 phones fetched with `$http`', function() {
//收到响应前,phones未定义
expect(ctrl.phones).toBeUndefined();
//收到响应后,phones变成一个对象数组
$httpBackend.flush();
expect(ctrl.phones).toEqual([{name: 'Nexus S'}, {name: 'Motorola DROID'}]);
});
- $httpBackend.flush()激活浏览器请求序列,这会让$http服务返回预期的响应。
最后,们验证默认的orderProp值是否设置正确:
it('should set a default value for the `orderProp` property', function() {
expect(ctrl.orderProp).toBe('age');
});
命令行应该输出:
Chrome 49.0: Executed 2 of 2 SUCCESS (0.133 secs / 0.097 secs)
尝试
- 在phone-list.template.html底部,加入代码:
<pre>{{$ctrl.phones | filter:$ctrl.query | orderBy:$ctrl.orderProp | json}}</pre>
可以看到json格式的手机列表。
- 在控制器PhoneListController的$http回调函数中输入:
self.phones = response.data.slice(0, 5);
可以预处理HTTP响应,将返回的手机序号限制为0-5。
下一篇:AngularJS Phonecat (步骤8-步骤9)