导言
最近在学AngularJS的实例教程PhoneCat Tutorial App,发现网上的中文教程都比较久远,与英文版对应不上,而且缺少组件和文件重构两节。所以决定自己整理一个中文简明教程。
此篇为8-9节。
上一篇:AngularJS Phonecat (步骤6-步骤7)
8 模板链接和图片
在这一步,我们要为手机列表增加缩略图和链接。
数据
phones.json文件保存了手机id和手机图片的url,url指向app/img/phones/文件夹。
app/phones/phones.json (其中一组数据):
[
{
...
"id": "motorola-defy-with-motoblur",
"imageUrl": "img/phones/motorola-defy-with-motoblur.0.jpg",
"name": "Motorola DEFY\u2122 with MOTOBLUR\u2122",
...
},
...
]
模板
app/phone-list/phone-list.template.html:
...
<ul class="phones">
<li ng-repeat="phone in $ctrl.phones | filter:$ctrl.query | orderBy:$ctrl.orderProp" class="thumbnail">
<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.id}}绑定到<code>a</code>标签的<code>href</code>属性,{{phone.name}}绑定到<code>a</code>标签。
- 引入手机图片,需要设置<code>img</code>标签。如果直接使用<code>src</code>绑定{{phone.imageUrl}},Angular未初始化前就会展开绑定,也就会发出无效的url请求。为此,我们使用<code>ngSrc</code>命令,它会在Angular初始化后再进行绑定。
端到端测试
e2e-tests/scenarios.js:
...
it('should render phone specific links', function() {
var query = element(by.model('$ctrl.query'));
query.sendKeys('nexus');
//点击链接
element.all(by.css('.phones li a')).first().click();
//检查链接的url是否正确
expect(browser.getLocationAbsUrl()).toBe('/phones/nexus-s');
});
...
该测试验证程序是否能正确生成图片链接。在命令行输入<code>npm run protractor</code>运行测试。
9 路由与多视图
这一步,我们会学习如何创建布局模板,如何利用Angular路由模块(ngRoute)实现多视图。
当你在浏览器中输入/index.html。会重定向到/index.html#!/phones并显示手机列表。当点击手机链接时,会转到手机详情页面。
依赖
路由功能由<code>ngRoute</code>模块提供,这是一个独立于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-route": "1.5.x",
"bootstrap": "3.3.x"
}
}
指明"angular-route": "1.5.x",会让bower安装1.5.x版本的angular-route模块。
如果你是在全局环境中安装bower,你可以使用<code>bower install</code>进行安装。而这个项目我们已经预配使用npm来运行bower install,所以只需要输入命令行:
npm install
多视图、路由与布局模板
我们的程序逐渐增大,也变得更复杂。在上一节,我们只有一个的视图(用来显示手机列表),所有的模板代码都放置在phone-list.template.html中。这一节,我们会添加一个视图来展示手机详情。
为了添加详情页,我们会将index.html转换成布局模板(layout template),在所有页面中通用。其他局部模板(partial templates)会通过当前路由引入到布局模板中(当前路由决定引入哪个局部模板)。
Angular通过$routeProvider声明路由,它是$route服务的提供者。该服务会将控制器、视图模板、当前url连接起来。它还能实现深层链接:浏览器历史(后退/前进)、收藏标签。
ngRoute让控制器和特定url的模板关联起来。具体方法是:将组件关联到路由上,组件作为提供者负责提供视图模板和控制器。
关于依赖注入(DI):注入器(Injector)和提供者(Providers)
一般来说,一个对象只能通过三种方法来得到它的依赖项目:
- 在对象内部创建依赖项目
- 将依赖作为一个全局变量来进行查找或引用
- 将依赖传递到需要它的地方
第一种方法无法隔离对象和依赖项目,第二种方法则容易污染全局作用域。所以我们使用第三种方法,即依赖注入。依赖注入是一种设计模式,它移除了硬编码依赖,因此使得我们可以在运行中随时移除并改变依赖项目。这对于测试也很有好处,我们可以用测试环境中的一个模拟对象来替换生产环境中的一个真实对象。
依赖注入(DI)也是AngularJS的核心,所以我们要了解其基本原理。
1)Angular为什么需要依赖注入?
AngularJS的组件之间无法互相直接调用,一个组件必须通过注入器调用另一个组件。这样的好处是组件之间相互解耦,对象整个生命周期的管理都丢给了注入器。
2)Angular如何实现依赖注入?
程序启动时,Angular会创建一个注入器(injector),用于查找和注入程序所需的服务。注入器本身并不了解服务($http、$route)能做什么,甚至服务是否存在(除非服务配置在适当的模块定义中),它只是存放服务的容器。
注入器:
- 加载依赖:加载程序依赖的模块定义
- 注册依赖:注册模块定义的提供者
- 注入依赖:当有实际的请求时,注入器通过提供者(注:提供者作为参数注入函数)实例化具体的服务及对应的依赖。
提供者(一般指组件):
提供者是提供(创建)服务实例的对象,它的配置API可用来控制服务的创建和运行的行为。比如$route服务,$routeProvider暴露的API允许你为程序定义路由。
注意:提供者只能注入到config函数,因此你不能把$routeProvider注入PhoneListController
Angular模板解决了全局变量的问题。与AMD或者require.js模块不同,Angular模板不需处理脚本加载顺序和延迟获取等问题,这些目标是独立的模块系统,他们可以并列存在和实现自己的目标。
要深入理解Angular的依赖注入,请参阅Understanding Dependency Injection
模板
$route服务通常与ngView指令联合使用。ngView指令会将当前路由的视图模板加到布局模板中,这让index.html模板看起来更简洁。
app/index.html:
<head>
...
<script src="bower_components/angular/angular.js"></script>
<script src="bower_components/angular-route/angular-route.js"></script>
<script src="app.module.js"></script>
<script src="app.config.js"></script>
...
<script src="phone-detail/phone-detail.module.js"></script>
<script src="phone-detail/phone-detail.component.js"></script>
</head>
<body>
<div ng-view></div>
</body>
我们在index.html中增加了4个新的<code><script></code>标签,用于加载额外的JS脚本。
- angular-route.js: 定义ngRoute模块,用于提供路由
- app.config.js: 配置主模块所需的提供者。
- phone-detail.module.js: 定义一个包含手机详情组件的新模块。
- phone-detail.component.js: 定义一个手机详情的组件。
注意,我们移除了<code><phone-list></phone-list></code>,增加了含ng-view属性的<code>div</code>元素。
配置模板
模板的.config() 方法可以获取配置所需的提供者。
为了获取ngRoute中的的提供者、服务和指令,我们需要为phonecatApp增加ngRoute模块。
app/app.module.js:
angular.module('phonecatApp', [
'ngRoute', //增加ngRoute模块
...
]);
除了核心服务和指令,我们还需要配置$route服务(使用它的提供者)。
app/app.config.js:
angular.
module('phonecatApp').
config(['$locationProvider', '$routeProvider',
function config($locationProvider, $routeProvider) {
$locationProvider.hashPrefix('!');
//配置$route服务
$routeProvider.
when('/phones', {
template: '<phone-list></phone-list>'
}).
when('/phones/:phoneId', {
template: '<phone-detail></phone-detail>'
}).
otherwise('/phones');
}
]);
通过.config() 方法,我们请求提供者(如$routeProvider)注入到配置函数中,并使用提供者的方法指定服务行为。在这里,我们使用$routeProvider.when()和$routeProvider.otherwise() 制定程序路由的选择规则。
我们还使用$locationProvider.hashPrefix()将哈希前缀设置为!,该前缀会加到我们客户端路由链接中,位于文件路径与字符(#)之间(例如 index.html#!/some/path)。虽然设置前缀不是必须的,但这是种好的做法(具体原因超出本课程范围,不做讨论)。!是最常用的前缀。
我们的定义了如下路由:
- when('/phones'):当url以/phones结尾时,显示手机列表。为了构造这个视图,Angular会创建一个phoneList组件的实例来管理视图。注意,我们在index.html中也使用了相同的标记(/phones)。
- when('/phones/:phoneId'): 当url以/phones/:phoneId结尾时,显示手机详情。:phoneId是一个URL变量片段。该视图由phoneDetail组件管理。
- otherwise('/phones'): 当前url没有找到匹配的路由时,重定向到/phones页面。
我们再次使用了phoneList组件,又添加了一个新的组件phoneDetail。现在phoneDetail组件只是显示选择的手机ID(不太吸引人,我们下一节会增强该组件)。
注意第2个路由声明中的:phoneId参数。$route服务使用路由声明(/phones/:phoneId)作为一个模板来匹配当前的URL。所有:phoneId变量都会被提取到$routeParams对象中。
phoenDetail组件
phoenDetail组件用于处理手机详情视图。遵循和phoneList一样的规则,我们使用一个单独文件夹,创建一个phoneDetail模块,并将该模块作为依赖加到主模块phoneact中。
app/phone-detail/phone-detail.module.js 详情模块:
angular.module('phoneDetail', [
'ngRoute'
]);
app/phone-detail/phone-detail.component.js 详情组件:
angular.
module('phoneDetail').
component('phoneDetail', {
template: 'TBD: Detail view for <span>{{$ctrl.phoneId}}</span>',
controller: ['$routeParams',
function PhoneDetailController($routeParams) {
this.phoneId = $routeParams.phoneId;
}
]
});
app/app.module.js 主模块:
angular.module('phonecatApp', [
...
'phoneDetail',
...
]);
子模块依赖的注意事项
phoneDetail模块依赖于ngRoute模块:ngRoute提供$ routeParams对象,并将$ routeParams对象注入到phoneDetail组件的控制器。由于ngRoute也是phonecatApp主模块的依赖,所以服务和指令在程序中随处可用(包括phoneDetail组件)。
这意味着,即使我们没有将ngRoute放入phoneDetail组件的依赖列表中,我们的程序仍然可以正常运作。虽然省略子模块依赖(这些依赖已经导入主模块)听起来挺好的,但它打破程序的模块化,所以并不可取。
想象一下,我们现在要在新项目中使用phoneDetail功能,但新项目并没有声明ngRoute依赖。这样,注入器就无法提供$routeParams,新程序也就无法运行了。
所以,我们要始终明确一个子模块的依赖。不要依靠从父模块继承的依赖(因为父模块某天可能就不存在了)。
在多个模块声明相同的依赖不会产生额外的“成本”,因为每个依赖依然会加载一次。有关模块及其依赖的详细信息请参阅module。
测试
我们的部分模块依赖与ngRoute,所以我们需要在Karma配置文件添加angular-route。
karma.conf.js:
files: [
'bower_components/angular/angular.js',
'bower_components/angular-route/angular-route.js',
...
],
使用端到端测试导航到各个URL,验证程序是否能正确渲染视图:
e2e-tests/scenarios.js
...
it('should redirect `index.html` to `index.html#!/phones', function() {
browser.get('index.html');
expect(browser.getLocationAbsUrl()).toBe('/phones');
});
...
//导航到/phones
describe('View: Phone list', function() {
beforeEach(function() {
browser.get('index.html#!/phones');
});
...
});
...
//导航到/phones/nexus-s
describe('View: Phone details', function() {
beforeEach(function() {
browser.get('index.html#!/phones/nexus-s');
});
it('should display placeholder page with `phoneId`', function() {
expect(element(by.binding('$ctrl.phoneId')).getText()).toBe('nexus-s');
});
});
...
在命令行中输入<code>npm run protractor</code>就可以运行测试了。