AngularJS Phonecat (步骤13-步骤14)--完结篇

导言


最近在学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:

<head>
  ...
  <script src="bower_components/angular-resource/angular-resource.js"></script>
  ...
  <script src="core/phone/phone.module.js"></script>
  <script src="core/phone/phone.service.js"></script>
  ...
</head>

组件控制器

通过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:

...

<!-- 引入CSS-->
<link rel="stylesheet" href="app.animations.css" />

...

<!-- 用于JS动画,在angular.js之前引入-->
<script src="bower_components/jquery/dist/jquery.js"></script>

...

<!-- 增加AngularJS的动画支持-->
<script src="bower_components/angular-animate/angular-animate.js"></script>

<!-- 定义JS动画 -->
<script src="app.animations.js"></script>

...

重要提醒:在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:

...
<ul class="phones">
  //新增class
  <li ng-repeat="phone in $ctrl.phones | filter:$ctrl.query | orderBy:$ctrl.orderProp"
      class="thumbnail phone-list-item">
    <a href="#!/phones/{{phone.id}}" class="thumb">
      <img ng-src="{{phone.imageUrl}}" alt="{{phone.name}}" />
    </a>
    <a href="#!/phones/{{phone.id}}">{{phone.name}}</a>
    <p>{{phone.snippet}}</p>
  </li>
</ul>
...

注意我们是怎么添加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:

<div class="view-container">
  <div ng-view class="view-frame"></div>
</div>

将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:

<div class="phone-images">
  <img ng-src="{{img}}" class="phone"
      ng-class="{selected: img === $ctrl.mainImageUrl}"
      ng-repeat="img in $ctrl.phone.images" />
</div>
...

和缩略图一样,我们用一个迭代器显示所有的概要文件列表。但是我们没有重复关联动画。相反的,我们会着眼与每个元素的类,特别是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项目开始。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,047评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,807评论 3 386
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,501评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,839评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,951评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,117评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,188评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,929评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,372评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,679评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,837评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,536评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,168评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,886评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,129评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,665评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,739评论 2 351

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,885评论 25 707
  • 本文从 这里 翻译过来的。 2048这个游戏有一段时间特别火,Github上有其原始版本,游戏看起来很简单,但是...
    江枫阅读 1,459评论 2 7
  • 家禽生产中的理念更新与发展策略…… 养鸡业经过几十年的发展,在广大农村经历了从散养到养殖专业户,再到家庭化规模养殖...
    04534cdd7064阅读 356评论 0 0
  • 时间,仿佛是越走越快了。 在这个繁忙的城市中,每个人都走得那么快,像是决心要在一代人的时间里活千百次。夹竹桃的花开...
    云笙丨寒枫阅读 408评论 0 0
  • 林州市永和希望小学非常注重师生写字水平的提升。对学生,不但开设了每周两次的“写字提升班”,而且让学生坚持“每日午写...
    甲午之印阅读 65评论 0 0