导言
最近在学AngularJS的实例教程PhoneCat Tutorial App,发现网上的中文教程都比较久远,与英文版对应不上,而且缺少组件和文件重构两节。所以决定自己整理一个中文简明教程。
此篇为13-14节。
0-5节:AngularJS Phonecat (步骤0-步骤5)
6-7节:AngularJS Phonecat (步骤6-步骤7)
8-9节:AngularJS Phonecat (步骤8-步骤9)
10-12节:AngularJS Phonecat (步骤10-步骤12)
13 REST与定制服务
在这一步,我们会改变程序获取数据的方法:
我们会自定义一个代表RESTful客户端的服务。通过这个客户端,我们可以更便捷地请求服务器数据,不需要处理底层的$httpAPI,HTTP方法以及URL。
REST在英语原文中未多做介绍,笔者在网上搜索了相关资料,推荐以下内容:
深入浅出REST
RESTful API 设计指南
依赖
RESTful功能由Angular的ngResource模块提供,该模块独立于Angular框架的核心模块,需要单独安装和引入。
前面我们使用Bower安装客户端依赖,这一步就更新bower.json配置以安装新的依赖:
bower.json:
{
"name": "angular-phonecat",
"description": "A starter project for AngularJS",
"version": "0.0.0",
"homepage": "https://github.com/angular/angular-phonecat",
"license": "MIT",
"private": true,
"dependencies": {
"angular": "1.5.x",
"angular-mocks": "1.5.x",
"angular-resource": "1.5.x", //增加ngResource模块
"angular-route": "1.5.x",
"bootstrap": "3.3.x"
}
}
更新了bower.json,我们就可以用命令行安装新模块:
npm install
注意:如果你是在全局环境中安装bower,你可以使用bower install进行安装。而这个项目我们已经预配使用npm install来运行bower install。
服务
我们创建了用于获取服务器上手机数据的服务。我们会把该服务放到ngResource模块中,并将该模块放入核心模块的依赖列表中。
app/core/phone/phone.module.js (核心模块):
angular.module('core.phone', ['ngResource']);
app/core/phone/phone.service.js (获取手机数据的服务):
angular.
module('core.phone').
factory('Phone', ['$resource',
function($resource) {
return $resource('phones/:phoneId.json', {}, {
query: {
method: 'GET',
params: {phoneId: 'phones'},
isArray: true
}
});
}
]);
我们使用模块API的factory()函数注册了一个自定义服务。并使用'Phone'来代表这个服务,调用factory()函数。factory()函数类似于一个控制器的构造函数,通过函数参数可以声明依赖注入。Phone服务声明了对$resource服务功能的依赖。
$resource服务只需要几行代码就能创建一个RESTful客户端。这个客户端可以替代低层级的$http服务。
app/core/core.module.js:
angular.module('core', ['core.phone']);
我们需要增添core.phone模块作为核心模块的依赖。
模板
我们在app/core/phone/phone.service.js中定制resource服务,所以需要在布局模板中引入这个文件和关联文件.module.js 。另外,我们也要加载angular-resource.js,它包含了ngRsource模块。
app/index.html:
...
...
...
组件控制器
通过factory()函数,我们可以用Phone服务替代低层级的$http服务,这样就简化了组件控制器(PhoneListController 和 PhoneDetailController)。Angular的$resource服务利用RESTful资源,提供了比$http简便的数据资源交互。现在,我们也更容易了解控制器的代码是如何工作的。
app/phone-list/phone-list.module.js 手机列表模块:
angular.module('phoneList', ['core.phone']);
app/phone-list/phone-list.component.js 手机列表组件:
angular.
module('phoneList').
component('phoneList', {
templateUrl: 'phone-list/phone-list.template.html',
controller: ['Phone',
function PhoneListController(Phone) {
this.phones = Phone.query(); //变化点
this.orderProp = 'age';
}
]
});
app/phone-detail/phone-detail.module.js 手机详情模块:
angular.module('phoneDetail', ['core.phone']);
app/phone-detail/phone-detail.component.js 手机详情组件:
angular.
module('phoneDetail').
component('phoneDetail', {
templateUrl: 'phone-detail/phone-detail.template.html',
controller: ['$routeParams', 'Phone',
function PhoneDetailController($routeParams, Phone) {
var self = this;
self.phone = Phone.get({phoneId: $routeParams.phoneId}, function(phone) {
self.setImage(phone.images[0]);
});
self.setImage = function setImage(imageUrl) {
self.mainImageUrl = imageUrl;
};
}
]
});
注意在手机列表控制器中,我们将
$http.get('phones/phones.json').then(function(response) {
self.phones = response.data;
});
替换成:
this.phones = Phone.query();
这是一个简单的声明:我们需要查询所有手机。
注意:在上面代码中,调用Phone服务方法时,没有传递回调函数。虽然这看起来就像同步获得了返回值,但实际并非如此。同步返回的是一个对象"future",在接收到XHR响应时,数据才会填充到"future"对象中。由于Angular的数据绑定,我们可以将该"future"对象绑定到模板上。这样,当数据返回时,视图就会自动更新。
有时,依靠future对象和数据绑定不能很好地满足我们的需求。所以,我们添加了回调函数来处理服务器响应。比如,手机详情组件的控制器就在回调函数中设置mainImageUrl。
测试
我们使用了ngResource模块,所以需要更新Karma配置文件。
karma.conf.js:
files: [
'bower_components/angular/angular.js',
'bower_components/angular-resource/angular-resource.js',
...
],
我们增加一个单元测试验证新服务是否能正确发出HTTP请求并返回预期的"future"对象/数组。
$resource服务扩充了响应对象:使用额外方法(如更新和删除资源)、利用属性(其中一些只能由Angular访问)。如果我们使用Jasmine的.toEqual()进行匹配,测试将会失败, 这是因为测试值不会与响应指完全匹配。
为了解决这个问题,我们使用自定义的等价测试用比较对象。自定义等价测试即angular.equals,它会忽略方法和带$-前缀的属性,比如由$resource服务注入的属性(记住,Angular的专有API会使用$前缀)。
app/core/phone/phone.service.spec.js:
describe('Phone', function() {
...
var phonesData = [...];
// 每次测试前增加自定义等价测试
beforeEach(function() {
jasmine.addCustomEqualityTester(angular.equals);
});
// 每次测试前加载包含`Phone`服务的功能模块
...
// 每次测试前实例化服务和`$httpBackend`
...
// 每次测试后确认没有其他期望或请求。
afterEach(function () {
$httpBackend.verifyNoOutstandingExpectation();
$httpBackend.verifyNoOutstandingRequest();
});
it('should fetch the phones data from `/phones/phones.json`', function() {
var phones = Phone.query();
expect(phones).toEqual([]);
$httpBackend.flush();
expect(phones).toEqual(phonesData);
});
});
这里,我们使用$httpBackend的verifyNoOutstandingExpectation() 和verifyNoOutstandingRequest()方法验证所有预期的请求成功发送且后续没有其他的请求
注意:我们还修改了组件测试,在适当的时候使用自定义匹配。
现在,你会看到命令窗口输出下面信息:
Chrome 49.0: Executed 5 of 5 SUCCESS (0.123 secs / 0.104 secs)
14 动画
在最后一节,我们要在模板代码中增加CSS和JS实现动画效果,增强我们的web程序。
我们使用ngAnimate模块实现动画。Angular内置指令通过钩子(hooks)来触发动画,对应的DOM元素会执行操作,例如利用ngRepeat插入/删除节点,利用ngClass添加/移除类。
依赖
动画功能由Angular的ngAnimate模块提供,它独立于Angular框架核心。另外,我们会用jQuery来实现JavaScript动画。
这一步我们会更新bower.json配置文件来包含新的依赖关系:
bower.json:
{
"name": "angular-phonecat",
"description": "A starter project for AngularJS",
"version": "0.0.0",
"homepage": "https://github.com/angular/angular-phonecat",
"license": "MIT",
"private": true,
"dependencies": {
"angular": "1.5.x",
"angular-animate": "1.5.x",//新的依赖,动画模块
"angular-mocks": "1.5.x",
"angular-resource": "1.5.x",
"angular-route": "1.5.x",
"bootstrap": "3.3.x",//bootstrap
"jquery": "2.2.x" //jquery
}
}
我们配置"angular-animate"为 "1.5.x"版本,"jquery"为 "2.2.x"版本。这里引入的jQuery并不是Angular函数库,而是标准的jQuery库。我们可以使用bower安装各种第三方函数库。
现在我们就让bower下载和安装依赖:
npm install
如何利用ngAnimate实现动画
请参阅 Animations
模板
为了实现动画,我们需要更新index.html,加载必要的依赖(angular-animate.js 和 jquery.js)、CSS和JS文件。ngAnimate包含了程序使用动画的必要代码。
app/index.html:
...
...
...
...
重要提醒:在Angular 1.5中必须要使用jQuery 2.1以上版本,jQuery1.x版本不被正式支持的。一定要在所有AngularJS脚本之前加载jQuery,否则AngularJS可能无法检测到jQuery并利用jQuery的方法。
动画通过CSS代码(app.animations.css)和JS代码(app.animations.js)创建。在此之前我们需要创建一个ngAnimate模块。
依赖
我们需要在主模块中增加一个ngAnimate依赖:
app/app.module.js:
angular.
module('phonecatApp', [
'ngAnimate',
...
]);
现在我们的程序就可以应用动画了,让我们来写些有趣的动画。
CSS过渡动画:Animating ngRepeat(用于手机列表页面)
对于phoneList组件模板,我们会把CSS过渡动画添加到ngRepeat指令中。我们需要给重复元素增加一个CSS类,这样就可以将它与CSS动画代码挂钩。
app/phone-list/phone-list.template.html:
...
//新增class
-
{{phone.name}}
{{phone.snippet}}
...
注意我们是怎么添加phone-list-item类的,这是我们要实现动画所需的HTML代码。
CSS过渡动画代码:
app/app.animations.css:
.phone-list-item.ng-enter,
.phone-list-item.ng-leave,
.phone-list-item.ng-move {
transition: 0.5s linear all;
}
.phone-list-item.ng-enter,
.phone-list-item.ng-move {
height: 0;
opacity: 0;
overflow: hidden;
}
.phone-list-item.ng-enter.ng-enter-active,
.phone-list-item.ng-move.ng-move-active {
height: 120px;
opacity: 1;
}
.phone-list-item.ng-leave {
opacity: 1;
overflow: hidden;
}
.phone-list-item.ng-leave.ng-leave-active {
height: 0;
opacity: 0;
padding-bottom: 0;
padding-top: 0;
}
正如你看到的,phone-list-item类通过下面几个类来触发动画钩子,实现显示/隐藏元素的动画:
- ng-enter,用于显示一个新加入页面的手机元素。
- ng-move,用于改变手机元素位置。
- ng-leave,用于从页面移除一个手机元素。
手机列表根据ng-repeat指令添加或者删除元素。比如,转换器数据改变,则列表中项目会有添加和删除手机项目的动画。
需要特别注意的是,当动画发生时,两套CSS类会被加入元素:
- "starting"类,代表动画的开始样式
- "active"类,代表动画的结束样式
starting类会触发一些带ng-前缀的事件(例如enter、move、leve),enter事件就会让元素增加ng-enter类。
active类会触发一些带-active后缀的事件。
这两套CSS类允许开发者指定动画的实现,是开始还是结束。
上面的例子中,在列表添加手机项目时,元素的高度会从0px变为120px;当删除手机项目时,元素高度则从120px变为0px,同时有淡入淡出的效果。这些都是由CSS过渡动画实现的。
尽管许多现代浏览器都能很好的支持CSS过渡和CSS动画,但IE9及以前版本是不支持的。如果你想对兼容老浏览器,可以使用JavaScript动画,我们会在后面讲到。
CSS关键帧动画:Animating ngView
这一步,我们要在ngView中增加切换动画。
在HTML模板中添加新的CSS类到ng-view元素中。为了让动画更具表现力,我们还需将ng-view元素放入contianer元素中。
app/index.html:
将CSS样式用.view-container包裹起来,这样我们会更容易在动画过程中改变.view-frame元素的位置。
一切准备就绪,我们可以增加过渡动画的CSS样式了。
app/app.animations.css:
...
.view-container {
position: relative;
}
.view-frame.ng-enter,
.view-frame.ng-leave {
background: white;
left: 0;
position: absolute;
right: 0;
top: 0;
}
.view-frame.ng-enter {
animation: 1s fade-in;
z-index: 100;
}
.view-frame.ng-leave {
animation: 1s fade-out;
z-index: 99;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
/* 旧版本浏览器需要在帧和动画前面加前缀*/
代码并不复杂,只是实现简单的淡入淡出效果。比较特别的是,我们使用绝对定位将新页面(有ng-enter类的标识)放到旧页面(有ng-leave类的标识)的上方。当旧页面淡出时,新页面也会淡入(而下一个页面也放到了新页面的上方)。
当淡出动画结束时,该元素就重DOM树中移除了。而淡入动画完成时,ng-enter和ng-enter-active类都会从该元素中删除,让该元素以默认CSS样式重绘、回流(即不再有绝对定位样式)。这个过程很流畅,让页面在路由改变时自然地切换,不会有跳跃感。
在ngRepeat中使用这些CSS类也是一样的。每次页面加载,ngView都会创建一个副本,下载模板并添加内容。这就保证所有视图都包含在一个HTML元素中,也更容易实现动画控制。
JavaScript实现ngClass动画(用于手机详情页面)
在phone-detail.template.html视图,我们有一个不错的缩略图切换效果:点击缩略图,手机大图进行切换。现在我们需要给它加一个动画效果。
先想一下整体过程:当用户点击缩略图,大图就切换最新点击的图片。而在HTML中改变图片状态的最好方式是使用CSS类。就像前面一样,我们可以用一个CSS类来驱动动画,这一次会在CSS类改变时进行动画。每当选中一个手机缩略图,.selected类就会添加到匹配的图片上,并播放动画。
首先,修改phone-detail.template.html中的HTML代码。注意,我们改变了显示大图的方式:
app/phone-detail/phone-detail.template.html:
...
和缩略图一样,我们用一个迭代器显示所有的概要文件列表。但是我们没有重复关联动画。相反的,我们会着眼与每个元素的类,特别是selected类,因为该类决定了元素处于可见/不可见状态。selected类由ngClass指令管理,根据特定的条件(img === $ctrl.mainImageUrl)。在这个例子中,总会有一个元素是selected的,并显示在视图中。
当一个元素添加selected类,在selected-add和selected-add-active类被添加之前,AngularJS会触发一个动画。当selected类移除时,selected-remove 和 selected-remove-active类也会添加到该元素中,这样就触发了另一个动画。
最后,为了确保页面第一次加载时手机图片可以正确显示,我们也修改了详情页的CSS样式:
app/app.css:
...
.phone {
background-color: white;
display: none;
float: left;
height: 400px;
margin-bottom: 2em;
margin-right: 3em;
padding: 2em;
width: 400px;
}
.phone:first-child {
display: block;
}
.phone-images {
background-color: white;
float: left;
height: 450px;
overflow: hidden;
position: relative;
width: 450px;
}
...
你可能在想,我们是不是要创建另一个CSS动画?好吧,虽然可以这么做,但我们还是看一下怎么使用.animation()方法创建基于JS的动画吧。
app/app.animations.js:
angular.
module('phonecatApp').
animation('.phone', function phoneAnimationFactory() {
return {
addClass: animateIn,
removeClass: animateOut
};
function animateIn(element, className, done) { //注意:done参数
if (className !== 'selected') return;
element.
css({
display: 'block',
position: 'absolute',
top: 500,
left: 0
}).
animate({
top: 0
}, done);
return function animateInEnd(wasCanceled) {
if (wasCanceled) element.stop();
};
}
function animateOut(element, className, done) {
if (className !== 'selected') return;
element.
css({
position: 'absolute',
top: 0,
left: 0
}).
animate({
top: -500
}, done);
return function animateOutEnd(wasCanceled) {
if (wasCanceled) element.stop();
};
}
});
我们将通过CSS类选择器(.phone)和一个动画工厂函数(phoneAnimationFactory())为目标元素创建自定义动画。工厂函数会返回一个对象,该对象关联着特定事件(object keys)和动画回调函数(object values)。事件的DOM操作由ngAnimate识别并钩住(执行),如addClass/removeClass/setClass、 enter/move/leave 和动画。相关的回调函数也由ngAnimate适时调用。
更多动画工厂函数的信息,请查看API Reference.
例子中,当一个元素通过ngClass指令添加了selected类时,会执行animateIn()这个回调函数,其中元素作为参数传递进来。animateIn()函数的最后一个参数是done函数。调用done(),会通知Angular自定义的JS动画已经结束。移除seleted类时,则执行animateOut()函数,原理一样。
注意,这里我们使用jQuery实现动画。在AngularJ中实现动画,jQuery并不是必须的,只是用原生JS实现动画其实已经超出了本教程的范围。如需了解jQuery.animat请查看jQuery animate。
通过事件回调函数,我们操作DOM并创建了动画。上面的代码中,使用的是.css()和.element.animate()操作DOM。结果是,新图片移动了500px,且前后两张图片同步移动500px,这样就实现了传送带动画。在animate()函数完成后,调用done函数通知Angular动画结束。
你可能已经注意到,每个动画回调函数都返回了一个函数,这是一个可选项。如果有设置这一项,当动画结束(完成/取消)时,它将被调用。该函数有一个布尔值参数,用于让开发者了解动画是否被取消了。该函数常用于在动画结束后执行必要的清理工作。
结语
我们的程序已经完成了。你可以使用 git checkout命令跳到某个步骤,随意试验你的代码。
更多的Angular概念,请参阅Developer Guide。
如果你准备用AngularJS开发一个项目,建议你先从angular-seed项目开始。