AngularJS Phonecat (步骤6-步骤7)

导言


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

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

推荐阅读更多精彩内容