導言
最近在學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項目開始。