Angular 学习手册第二版(三)
原文:
zh.annas-archive.org/md5/6C06861E49CB1AD699C8CFF7BAC7E048译者:飞龙
第七章:使用 Angular 进行异步数据服务
连接到数据服务和 API,并处理异步信息是我们作为开发人员在日常生活中的常见任务。在这方面,Angular 为其热情的开发人员提供了无与伦比的工具集,帮助他们消费、消化和转换从数据服务中获取的各种数据。
有太多的可能性,需要一本整书来描述你可以通过连接到 API 或通过 HTTP 异步地从文件系统中消费信息所能做的一切。在本书中,我们只是浅尝辄止,但本章涵盖的关于 HTTP API 及其伴随的类和工具的见解将为您提供一切所需,让您的应用程序在短时间内连接到 HTTP 服务,而您可以根据自己的创造力来发挥它们的全部潜力。
在本章中,我们将:
-
看看处理异步数据的不同策略
-
介绍 Observables 和 Observers
-
讨论函数式响应式编程和 RxJS
-
审查 HTTP 类及其 API,并学习一些不错的服务模式
-
了解 Firebase 以及如何将其连接到您的 Angular 应用程序
-
通过实际的代码示例来看待前面提到的所有要点
处理异步信息的策略
从 API 中获取信息是我们日常实践中的常见操作。我们一直在通过 HTTP 获取信息——当通过向认证服务发送凭据来对用户进行身份验证时,或者在我们喜爱的 Twitter 小部件中获取最新的推文时。现代移动设备引入了一种无与伦比的消费远程服务的方式,即推迟请求和响应消费,直到移动连接可用。响应速度和可用性变得非常重要。尽管现代互联网连接速度超快,但在提供此类信息时总会涉及响应时间,这迫使我们建立机制以透明地处理应用程序中的状态,以便最终用户使用。
这并不局限于我们需要从外部资源消费信息的情景。
异步响应-从回调到承诺
有时,我们可能需要构建依赖于时间作为某个参数的功能,并且需要引入处理应用程序状态中这种延迟变化的代码模式。
针对所有这些情况,我们一直使用代码模式,比如回调模式,触发异步操作的函数期望在其签名中有另一个函数,该函数在异步操作完成后会发出一种通知,如下所示:
function notifyCompletion() {
console.log('Our asynchronous operation has been completed'); }
function asynchronousOperation(callback) {
setTimeout(() => { callback(); }, 5000); }
asynchronousOperation(notifyCompletion);
这种模式的问题在于,随着应用程序的增长和引入越来越多的嵌套回调,代码可能变得相当混乱和繁琐。为了避免这种情况,Promises引入了一种新的方式来构想异步数据管理,通过符合更整洁和更稳固的接口,不同的异步操作可以在同一级别链接甚至可以从其他函数中分割和返回。以下代码介绍了如何构造Promise:
function getData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(42);
}, 3000);
})
}
getData().then((data) => console.log('Data',data)) // 42
前面的代码示例可能有点冗长,但它确实为我们的函数提供了更具表现力和优雅的接口。至于链式数据,我们需要了解我们要解决的问题。我们正在解决一种称为回调地狱的东西,看起来像这样:
getData(function(data){
getMoreData(data, function(moreData){
getEvenMoreData(moreData, function(evenMoreData) {
// done here
});
});
});
如前面的代码所示,我们有一个情况,即在执行下一个异步调用之前,我们依赖于先前的异步调用和它带回的数据。这导致我们不得不在回调中执行一个方法,然后在回调中执行另一个方法,依此类推。你明白了吧——代码很快就会变得很糟糕,也就是所谓的回调地狱。继续讨论链式异步调用的主题,链式是解决回调地狱的答案,Promises允许我们像这样链接它们:
getData()
.then(getMoreData)
.then(getEvenMoreData);
function getData() {
return new Promise(resolve) => resolve('data');
}
function getMoreData(data) {
return new Promise((resolve, reject) => resolve('more data'));
}
function getEvenMoreData(data) {
return new Promise((resolve, reject) => resolve('even more data'));
}
在前面的代码中,.then()方法调用的链接显示了我们如何清晰地将一个异步调用排在另一个异步调用之后,并且先前的异步调用已经将其结果输入到即将到来的async方法中。
因此,Promises以其强大的编码能力风靡编程领域,似乎没有开发人员会质疑它们为游戏带来的巨大价值。那么,为什么我们需要另一种范式呢?嗯,因为有时我们可能需要产生一个响应输出,该输出遵循更复杂的处理过程,甚至取消整个过程。这不能通过Promises来实现,因为它们一旦被实例化就会被触发。换句话说,Promises不是懒惰的。另一方面,在异步操作被触发但尚未完成之前取消它的可能性在某些情况下可能非常方便。Promises只允许我们解决或拒绝异步操作,但有时我们可能希望在达到那一点之前中止一切。此外,Promises表现为一次性操作。一旦它们被解决,我们就不能期望收到任何进一步的信息或状态变化通知,除非我们从头开始重新运行一切。此外,我们有时需要更主动地实现异步数据处理。这就是 Observable 出现的地方。总结一下 Promises 的限制:
-
它们无法被取消
-
它们会立即执行
-
它们只是一次性操作;没有简单的重试方法
-
它们只会响应一个值
Observable 简而言之
Observable 基本上是一个异步事件发射器,通知另一个元素,称为观察者,状态已经改变。为了做到这一点,Observable 实现了所有需要产生和发射这样的异步事件的机制,并且可以在任何时候被触发和取消,无论它是否已经发出了预期的数据事件。
这种模式允许并发操作和更高级的逻辑,因为订阅 Observable 异步事件的观察者将会反应 Observable 的状态变化。
这些订阅者,也就是我们之前提到的观察者,会一直监听 Observable 中发生的任何事情,直到 Observable 被处理掉,如果最终发生的话。与此同时,信息将在整个应用程序中更新,而不会触发例行程序。
我们可能可以在一个实际的例子中更透明地看到所有这些。让我们重新设计我们在评估基于 Promise 的异步操作时涵盖的示例,并用setInterval命令替换setTimeout命令:
function notifyCompletion() {
console.log('Our asynchronous operation has been completed');
}
function asynchronousOperation() {
let promise = new Promise((resolve, reject) => {
setInterval(resolve, 2000); });
return promise;
}
asynchronousOperation().then(notifyCompletion);
复制并粘贴上述片段到浏览器的控制台窗口,看看会发生什么。文本“我们的异步操作已经完成”将在 2 秒后只显示一次,并且不会再次呈现。承诺自行解决,整个异步事件在那一刻终止。
现在,将浏览器指向在线 JavaScript 代码 playground,比如 JSBIN(jsbin.com/),并创建一个新的代码片段,只启用 JavaScript 和 Console 选项卡。然后,确保您从“添加库”选项下拉菜单中添加 RxJS 库(我们将需要这个库来创建 Observables,但不要惊慌;我们将在本章后面介绍这个库),并插入以下代码片段:
let observable$ = Rx.Observable.create(observer => {
setInterval(() => {
observer.next('My async operation');
}, 2000);
});
observable$.subscribe(response => console.log(response));
运行它,并期望在右窗格上出现一条消息。2 秒后,我们将看到相同的消息出现,然后再次出现。在这个简单的例子中,我们创建了一个observable,然后订阅了它的变化,将其发出的内容(在这个例子中是一个简单的消息)作为一种推送通知输出到控制台。
Observable 返回一系列事件,我们的订阅者会及时收到这些事件的通知,以便他们可以相应地采取行动。这就是 Observable 的魔力所在——Observable 不执行异步操作并终止(尽管我们可以配置它们这样做),而是开始一系列连续的事件,我们可以订阅我们的订阅者。
如果我们注释掉最后一行,什么也不会发生。控制台窗格将保持沉默,所有的魔法将只在我们订阅我们的源对象时开始。
然而,这还不是全部。在这些事件到达订阅者之前,这个流可以成为许多操作的主题。就像我们可以获取一个集合对象,比如数组,并对其应用map()或filter()等函数方法来转换和操作数组项一样,我们也可以对我们的 Observable 发出的事件流进行相同的操作。这就是所谓的响应式函数编程,Angular 充分利用这种范式来处理异步信息。
在 Angular 中的响应式函数编程
Observable 模式是我们所知的响应式函数编程的核心。基本上,响应式函数脚本的最基本实现涵盖了我们需要熟悉的几个概念:
-
可观察对象
-
观察者
-
时间线
-
一系列具有与对象集合相同行为的事件
-
一组可组合的操作符,也称为响应式扩展
听起来令人生畏?其实不是。相信我们告诉你,到目前为止你所经历的所有代码比这复杂得多。这里的重大挑战是改变你的思维方式,学会以一种反应式的方式思考,这是本节的主要目标。
简而言之,我们可以说,响应式编程涉及将异步订阅和转换应用于事件的 Observable 流。我们可以想象你现在的无表情,所以让我们组合一个更具描述性的例子。
想想交互设备,比如键盘。键盘上有用户按下的按键。用户按下每一个按键都会触发一个按键事件。该按键事件包含大量元数据,包括但不限于用户在特定时刻按下的特定按键的数字代码。当用户继续按键时,会触发更多的keyUp事件,并通过一个虚拟时间线传输。keyUp 事件的时间线应该如下图所示:
从前面的 keyUps 时间线中可以看出,这是一系列连续的数据,其中 keyUp 事件可以在任何时候发生;毕竟,用户决定何时按下这些按键。还记得我们写的 Observable 代码,包含setTimeout吗?那段代码能够告诉一个概念观察者,每隔 2 秒就应该发出另一个值。那段代码和我们的 keyUps 有什么区别?没有。嗯,我们知道定时器间隔触发的频率,而对于 keyUps,我们并不知道,因为这不在我们的控制之中。但这真的是唯一的区别,这意味着 keyUps 也可以被视为一个 Observable:
let key = document.getElementId('.search');
/*
we assume there exist a button in the DOM like this
<input class="search" placeholder="searchfor"></input>
*/
let stream = Rx.Observable.fromEvent(key, 'keyUp');
stream.subscribe((data) => console.log('key up happened', data))
所以,我真正告诉你的是,超时以及 keyUps 可以被视为同一个概念,即 Observable。这样更容易理解所有异步事物。然而,我们还需要另一个观察,即无论发生什么异步概念,它都是以列表的方式发生的。
尽管时间可能不同,但它仍然是一系列事件,就像一个列表。列表通常有一堆方法来投影、过滤或以其他方式操作它的元素,猜猜,Observable 也可以。列表可以执行这样的技巧:
let mappedAndFiltered = list
.map(item => item + 1)
.filter(item > 2);
因此,Observables 可以如下:
let stream = Rx.Observable
.create(observer => {
observer.next(1);
observer.next(2);
})
.map(item => item + 1)
.filter(item > 2);
在这一点上,区别只是命名不同。对于列表,.map()和.filter()被称为方法。对于 Observable,相同的方法被称为 Reactive Extensions 或操作符。想象一下,在这一点上,keyUps和超时可以被描述为 Observables,并且我们有操作符来操作数据。现在,更大的飞跃是意识到任何异步的东西,甚至是 HTTP 调用,都可以被视为 Observables。这意味着我们突然可以混合和匹配任何异步的东西。这使得一种称为丰富组合的东西成为可能。无论异步概念是什么,它和它的数据都可以被视为一个流,你是一个可以按照自己的意愿来弯曲它的巫师。感到有力量——你现在可以将你的应用程序转变为一个反应式架构。
RxJS 库
如前所述,Angular 依赖于 RxJS,这是 ReactiveX 库的 JavaScript 版本,它允许我们从各种情景中创建 Observables 和 Observable 序列,比如:
-
交互事件
-
承诺
-
回调函数
-
事件
在这个意义上,响应式编程并不旨在取代承诺或回调等异步模式。相反,它也可以利用它们来创建 Observable 序列。
RxJS 提供了内置支持,用于转换、过滤和组合生成的事件流的广泛的可组合操作符。其 API 提供了方便的方法来订阅观察者,以便我们的脚本和组件可以相应地对状态变化或交互输入做出响应。虽然其 API 如此庞大,以至于详细介绍超出了本书的范围,但我们将重点介绍其最基本的实现,以便您更好地理解 Angular 如何处理 HTTP 连接。
在深入研究 Angular 提供的 HTTP API 之前,让我们创建一个简单的 Observable 事件流的示例,我们可以用 Reactive Extensions 来转换,并订阅观察者。为此,让我们使用前一节中描述的情景。
我们设想用户通过键盘与我们的应用程序进行交互,可以将其转化为按键的时间线,因此成为一个事件流。回到 JSBIN,删除 JavaScript 窗格的内容,然后写下以下片段:
let keyboardStream$ = Rx.Observable
.fromEvent(document, 'keyup')
.map(x => x.which);
前面的代码相当自描述。我们利用Rx.Observable类及其fromEvent方法来创建一个事件发射器,该发射器流式传输在文档对象范围内发生的keyup事件。每个发射的事件对象都是一个复杂对象。因此,我们通过将事件流映射到一个新流上,该新流仅包含与每次按键对应的键码,来简化流式传输的对象。map方法是一种响应式扩展,具有与 JavaScript 中的map函数方法相同的行为。这就是为什么我们通常将这种代码风格称为响应式函数式编程。
好了,现在我们有了一个数字按键的事件流,但我们只对观察那些通知我们光标键击中的事件感兴趣。我们可以通过应用更多的响应式扩展来从现有流构建一个新流。因此,让我们用keyboardStream过滤这样一个流,并仅返回与光标键相关的事件。为了清晰起见,我们还将这些事件映射到它们的文本对应项。在前面的片段后面添加以下代码块:
let cursorMovesStream$ = keyboardStream
.filter(x => {
return x > 36 && x < 41;
})
.map(x => {
let direction;
switch(x) {
case 37:
direction = 'left';
break;
case 38:
direction = 'up';
break;
case 39:
direction = 'right';
break;
default:
direction = 'down';
}
return direction;
});
我们本可以通过将filter和map方法链接到keyboardStream Observable 来一次性完成所有操作,然后订阅其输出,但通常最好分开处理。通过以这种方式塑造我们的代码,我们有一个通用的键盘事件流,以后可以完全不同的用途重复使用。因此,我们的应用程序可以扩展,同时保持代码占用空间最小化。
既然我们提到了订阅者,让我们订阅我们的光标移动流,并将move命令打印到控制台。我们在脚本的末尾输入以下语句,然后清除控制台窗格,并单击输出选项卡,以便我们可以在上面输入代码,以便我们可以尝试不同的代码语句:
cursorMovesStream$.subscribe(e => console.log(e));
单击输出窗格的任意位置将焦点放在上面,然后开始输入随机键盘键和光标键。
你可能想知道我们如何将这种模式应用到从 HTTP 服务中获取信息的异步场景中。基本上,你到目前为止已经习惯了向 AJAX 服务提交异步请求,然后通过回调函数处理响应或者通过 promise 进行处理。现在,我们将通过返回一个 Observable 来处理调用。这个 Observable 将在流的上下文中作为事件发出服务器响应,然后通过 Reactive Extensions 进行更好地处理响应。
介绍 HTTP API
现在,在我们深入描述 Angular 框架在HttpClient服务实现方面给我们的东西之前,让我们谈谈如何将XmlHttpRequest包装成一个 Observable。为了做到这一点,我们首先需要意识到有一个合同需要履行,以便将其视为成功的包装。这个合同由以下内容组成:
-
使用
observer.next(data)来发出任何到达的数据 -
当我们不再期望有更多的数据时,我们应该调用
observer.complete() -
使用
observer.error(error)来发出任何错误
就是这样;实际上非常简单。让我们看看XmlHttpRequest调用是什么样子的:
const request = new XMLHttpRequest();
request.onreadystatechange = () => {
if(this.readyState === 4 and this.state === 200) {
// request.responseText
} else {
// error occurred here
}
}
request.open("GET", url);
request.send();
好的,所以我们有一个典型的回调模式,其中onreadystatechange属性指向一个方法,一旦数据到达就会被调用。这就是我们需要知道的所有内容来包装以下代码,所以让我们来做吧:
let stream$ = Rx.Observable.create(observer => {
let request = new XMLHttpRequest();
request.onreadystatechange = () => {
if(this.readyState === 4 && this.state === 200) {
observer.next( request.responseText )
observer.complete();
} else {
observer.error( request.responseText )
}
}
})
就是这样,包装完成了;你现在已经构建了自己的 HTTP 服务。当然,这还不够,我们还有很多情况没有处理,比如 POST、PUT、DELETE、缓存等等。然而,重要的是让你意识到 Angular 中的 HTTP 服务为你做了所有繁重的工作。另一个重要的教训是,将任何类型的异步 API 转换为与我们其他异步概念很好契合的 Observable 是多么容易。所以,让我们继续使用 Angular 的 HTTP 服务实现。从这一点开始,我们将使用HttpClient服务。
HttpClient类提供了一个强大的 API,它抽象了处理通过各种 HTTP 方法进行异步连接所需的所有操作,并以一种简单舒适的方式处理响应。它的实现经过了很多精心的考虑,以确保程序员在开发利用这个类连接到 API 或数据资源的解决方案时感到轻松自在。
简而言之,HttpClient类的实例(已经作为Injectable资源实现,并且可以在我们的类构造函数中作为依赖提供者注入)公开了一个名为request()的连接方法,用于执行任何类型的 HTTP 连接。Angular 团队为最常见的请求操作(如 GET、POST、PUT 以及每个现有的 HTTP 动词)创建了一些语法快捷方式。因此,创建一个异步的 HTTP 请求就像这样简单:
let request = new HttpRequest('GET', 'jedis.json');
let myRequestStream:Observable<any> = http.request(request);
而且,所有这些都可以简化为一行代码:
let myRequestStream: Observable<any> = http.get('jedis.json');
正如我们所看到的,HttpClient类的连接方法通过返回一个 Observable 流来操作。这使我们能够订阅观察者到流中,一旦返回,观察者将相应地处理信息,可以多次进行:
let myRequestStream = http
.get<Jedi[]>('jedis.json')
.subscribe(data => console.log(data));
在前面的例子中,我们给get()方法一个模板化类型,它为我们进行了类型转换。让我们更加强调一下这一点:
.get<Jedi[]>('jedis.json')
这个事实使我们不必直接处理响应对象并执行映射操作将我们的 JSON 转换为 Jedi 对象列表。我们只需要记住我们资源的 URL,并指定一个类型,你订阅的内容就可以立即用于我们服务的订阅。
通过这样做,我们可以根据需要重新发起 HTTP 请求,我们的其余机制将相应地做出反应。我们甚至可以将 HTTP 调用表示的事件流与其他相关调用合并,并组合更复杂的 Observable 流和数据线程。可能性是无限的。
处理头部
在介绍HttpClient类时,我们提到了HttpRequest类。通常情况下,您不需要使用低级别的类,主要是因为HttpClient类提供了快捷方法,并且需要声明正在使用的 HTTP 动词(GET、POST 等)和要消耗的 URL。话虽如此,有时您可能希望在请求中引入特殊的 HTTP 头,或者自动附加查询字符串参数到每个请求中,举例来说。这就是为什么这些类在某些情况下会变得非常方便。想象一个使用情况,您希望在每个请求中添加身份验证令牌,以防止未经授权的用户从您的 API 端点中读取数据。
在以下示例中,我们读取身份验证令牌并将其附加为标头到我们对数据服务的请求。与我们的示例相反,我们将options哈希对象直接注入到HttpRequest构造函数中,跳过创建对象实例的步骤。Angular 还提供了一个包装类来定义自定义标头,我们将在这种情况下利用它。假设我们有一个 API,希望所有请求都包括名为Authorization的自定义标头,附加在登录系统时收到的authToken,然后将其持久化在浏览器的本地存储层中,例如:
const authToken = window.localStorage.getItem('auth_token');
let headers = new HttpHeaders();
headers.append('Authorization', `Token ${authToken}`);
let request = new HttpRequest('products.json', { headers: headers });
let authRequest = http.request(request);
再次强调,除了这种情况,您很少需要创建自定义请求配置,除非您希望在工厂类或方法中委托请求配置的创建并始终重用相同的Http包装器。Angular 为您提供了所有的灵活性,可以在抽象化应用程序时走得更远。
处理执行 HTTP 请求时的错误
处理我们请求中引发的错误,通过检查Response对象返回的信息实际上非常简单。我们只需要检查其Boolean属性的值,如果响应的 HTTP 状态在 2xx 范围之外,它将返回false,清楚地表明我们的请求无法成功完成。我们可以通过检查status属性来双重检查,以了解错误代码或type属性,它可以假定以下值:basic,cors,default,error或opaque。检查响应标头和HttpResponse对象的statusText属性将提供有关错误来源的深入信息。
总的来说,我们并不打算在每次收到响应消息时检查这些属性。Angular 提供了一个 Observable 操作符来捕获错误,在其签名中注入我们需要检查的HttpResponse对象的先前属性:
http.get('/api/bio')
.subscribe(bio => this.bio = bio)
.catch(error: Response => Observable.of(error));
值得注意的是,我们通过使用catch()操作符捕获错误,并通过调用Observable.of(error)返回一个新的操作符,让我们的错误作为我们创建的新 Observable 的输入。这对我们来说是一个不会使流崩溃的方法,而是让它继续存在。当然,在更真实的情况下,我们可能不只是创建一个新的 Observable,而是可能记录错误并返回完全不同的东西,或者添加一些重试逻辑。关键是,通过catch()操作符,我们有一种捕获错误的方法;如何处理它取决于您的情况。
在正常情况下,您可能希望检查除了错误属性之外的更多数据,除了在更可靠的异常跟踪系统中记录这些信息之外。
注入 HttpClient 服务
HttpClient服务可以通过利用 Angular 独特的依赖注入系统注入到我们自己的组件和自定义类中。因此,如果我们需要实现 HTTP 调用,我们需要导入HttpClientModule并导入HttpClient服务:
// app/biography/biography.module.ts
import { HttpClientModule } from '@angular/common/http';
@NgModule({
imports: [ HttpClientModule ]
})
export class BiographyModule {}
// app/biography/biography.component.ts
import { Component } from '@angular/core';
import { HttpClient } from '@angular/http';
@Component({
selector: 'bio',
template: '<div>{{bio}}</div>'
})
export class BiographyComponent {
bio: string;
constructor(private http: HttpClient) {
const options = {}; this.http.get('/api/bio', { ...options, responseType: 'text' }) .catch(err => Observable.of(err)) .subscribe(x => this.bio= bio)
}
}
在提供的代码中,我们只是按照我们在上一节中指出的bio示例进行。请注意我们如何导入HttpClient类型,并将其作为依赖项注入到Biography构造函数中。
通常,我们需要在应用程序的不同部分执行多个 HTTP 调用,因此通常建议创建一个DataService和一个DataModule,它包装了HttpClientModule和HttpClient服务。
以下是创建这样一个DataService的示例:
import {Http} from '@angular/http';
import {Injectable} from '@angular/core';
@Injectable()
export class DataService {
constructor(private http:HttpClient) {}
get(url, options?) {}
post(url, payload, options?) {}
put(url, payload, options?) {}
delete(url) {}
}
相应的DataModule将如下所示:
import {DataService} from './data.service';
import {HttpModule} from '@angular/http';
@NgModule({
imports: [HttpClientModule],
providers: [DataService]
})
如果您想为调用后端添加自己的缓存或授权逻辑,这就是要做的地方。另一种方法是使用HttpInterceptors,在本章的即将到来的部分中将提供使用HttpInterceptors的示例。
当然,任何想要使用这个DataModule的模块都需要导入它,就像这样:
@NgModule({
imports: [DataModule],
declarations: [FeatureComponent]
})
export class FeatureModule {}
我们的FeatureModule中的任何构造现在都可以注入DataService,就像这样:
import { Component } from '@angular/core';
@Component({})
export class FeatureComponent {
constructor(private service: DataService) { }
}
一个真实的案例研究 - 通过 HTTP 提供 Observable 数据
在上一章中,我们将整个应用程序重构为模型、服务、管道、指令和组件文件。其中一个服务是TaskService类,它是我们应用程序的核心,因为它提供了我们构建任务列表和其他相关组件所需的数据。
在我们的示例中,TaskService 类包含在我们想要传递的信息中。在实际情况下,您需要从服务器 API 或后端服务中获取该信息。让我们更新我们的示例以模拟这种情况。首先,我们将从 TaskService 类中删除任务信息,并将其包装成一个实际的 JSON 文件。让我们在共享文件夹中创建一个新的 JSON 文件,并用我们在原始 TaskService.ts 文件中硬编码的任务信息填充它,现在以 JSON 格式:
[{
"name": "Code an HTML Table",
"deadline": "Jun 23 2015",
"pomodorosRequired": 1
}, {
"name": "Sketch a wireframe for the new homepage",
"deadline": "Jun 24 2016",
"pomodorosRequired": 2
}, {
"name": "Style table with Bootstrap styles",
"deadline": "Jun 25 2016",
"pomodorosRequired": 1
}, {
"name": "Reinforce SEO with custom sitemap.xml",
"deadline": "Jun 26 2016",
"pomodorosRequired": 3
}]
将数据正确包装在自己的文件中后,我们可以像使用实际后端服务一样从我们的 TaskService 客户端类中使用它。但是,为此我们需要在 main.ts 文件中进行相关更改。原因是,尽管在安装所有 Angular 对等依赖项时安装了 RxJS 包,但反应式功能操作符(例如map())并不会立即可用。我们可以通过在应用程序初始化流的某个步骤中插入以下代码行来一次性导入所有这些内容,例如在main.ts的引导阶段:
import 'rxjs/Rx';
然而,这将导入所有反应式功能操作符,这些操作符根本不会被使用,并且会消耗大量带宽和资源。相反,惯例是只导入所需的内容,因此在 main.ts 文件的顶部追加以下导入行:
import 'rxjs/add/operator/map';
import { bootstrap } from '@angular/platform-browser-dynamic';
import AppModule from './app.module';
bootstrapModule(AppModule);
当以这种方式导入反应式操作符时,它会自动添加到 Observable 原型中,然后可以在整个应用程序中使用。应该说,可讳操作符的概念刚刚在 RxJS 5.5 中引入。在撰写本书时,我们刚刚在修补操作员原型的转变中,如上所述,并进入可讳操作符空间。对于感兴趣的读者,请查看这篇文章,其中详细描述了这对您的代码意味着什么。更改并不是很大,但仍然有变化:blog.angularindepth.com/rxjs-understanding-lettable-operators-fe74dda186d3
利用 HTTP - 重构我们的 TaskService 以使用 HTTP 服务
所有依赖项都已经就位,现在是重构的时候了
我们的 TaskService.ts 文件。打开服务文件,让我们更新导入语句块:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
import { Task } from './task.model';
首先,我们导入HttpClient和Response符号,以便稍后可以注释我们的对象。Observable 符号是从 RxJS 库导入的,以便我们可以正确注释我们的异步 HTTP 请求的返回类型。我们还从文件task.model.ts导入Task作为模型(它是一个接口),如下所示:
export interface Task {
name: string;
deadline: string;
pomodorosRequired: number;
queued: boolean;
}
我们将通过两个步骤重构此服务:
-
重写服务以使用 HTTP 服务。
-
实现存储/反馈模式并给服务一个状态。
使用 Angular HTTP 服务
现在,我们将使用 HTTP 服务替换现有的静态数据实现。为此,我们调用 HTTP 服务的http.get()方法来获取数据,但我们还需要使用 map 操作符来获得我们可以向外显示的结果:
import { HttpClient } from '@angular/common/http';
import { Task } from './task.model';
export default class TaskService {
constructor(private http:HttpClient) {}
getTasks(): Observable<Task[]> {
return this.http.get<Task[]>(`tasks.json`)
}
}
要使用先前定义的服务,我们只需要告诉模块关于它。我们通过将其添加到providers关键字来实现这一点:
// app/tasks/task.module.ts
@NgModule({
imports: [ /* add dependant modules here */ ],
declarations: [ ./* add components and directives here */ ]
providers: [TaskService],
})
export class TaskModule {}
此后,我们需要在使用者组件中注入TaskService并以适当的方式显示它:
// app/tasks/task.component.ts
@Component({
template: `
<div *ngFor="let task of tasks">
{{ task.name }}
</div>
`
})
export class TasksComponent {
tasks:Task[];
constructor(private taskService:TaskService){
this.taskService.getTasks().subscribe( tasks => this.tasks = tasks)
}
}
大多数情况下使用有状态的 TaskService
到目前为止,我们已经介绍了如何将 HTTP 服务注入到服务构造函数中,并且已经能够从组件订阅这样的服务。在某些情况下,组件可能希望直接处理数据而不是使用 Observables。实际上,我们大多数情况下都是这样。因此,我们不必经常使用 Observables;HTTP 服务正在利用 Observables,对吧?我们正在谈论组件层。目前,我们在组件内部正在发生这种情况:
// app/tasks/task.service.ts
@Component({
template: `
<div *ngFor="let task of tasks$ | async">
{{ task.name }}
</div>
`
})
export class TaskListComponent {
tasks$:Observable<Task[]>;
constructor(private taskService: TaskService ) {}
ngOnInit() {
this.tasks$ = this.taskService.getTasks();
}
}
在这里,我们看到我们将taskService.getTasks()分配给一个名为tasks$的流。tasks$变量末尾的$是什么?这是我们用于流的命名约定;让我们尝试遵循任何未来流/可观察字段的命名约定。我们在 Angular 的上下文中将 Observable 和 stream 互换使用,它们的含义是相同的。我们还让| async异步管道与*ngFor一起处理它并显示我们的任务。
我们可以以更简单的方式做到这一点,就像这样:
// app/tasks/tas.alt.component.ts
@Component({
template: `
<div *ngFor="let task of tasks">
{{ task.name }}
</div>
`
})
export class TaskComponent {
constructor(private taskService: TaskService ) {}
get tasks() {
return this.taskService.tasks;
}
}
因此,发生了以下更改:
-
ngOnInit()和分配给tasks$流的部分被移除了 -
异步管道被移除
-
我们用
tasks数组替换了tasks$流
这还能工作吗?答案在于我们如何定义我们的服务。我们的服务需要暴露一个项目数组,并且我们需要确保当我们从 HTTP 获取到一些数据时,或者当我们从其他地方接收到数据时,比如来自 Web 套接字或类似 Firebase 的产品时,数组会发生变化。
我们刚刚提到了两种有趣的方法,套接字和 Firebase。让我们解释一下它们是什么,以及它们如何与我们的服务相关。Web 套接字是一种利用 TCP 协议建立双向通信的技术,所谓的全双工连接。那么,在 HTTP 的背景下提到它为什么有趣呢?大多数情况下,您会有简单的场景,您可以通过 HTTP 获取数据,并且可以利用 Angular 的 HTTP 服务。有时,数据可能来自全双工连接,除了来自 HTTP。
那么 Firebase 呢?Firebase 是谷歌的产品,允许我们在云中创建数据库。正如可以预料的那样,我们可以对数据库执行 CRUD 操作,但其强大之处在于我们可以设置订阅并监听其发生的更改。这意味着我们可以轻松创建协作应用程序,其中许多客户端正在操作相同的数据源。这是一个非常有趣的话题。这意味着您可以快速为您的 Angular 应用程序提供后端,因此,出于这个原因,它值得有自己的章节。它也恰好是本书的下一章。
回到我们试图表达的观点。从理论上讲,添加套接字或 Firebase 似乎会使我们的服务变得更加复杂。实际上,它们并不会。您需要记住的唯一一件事是,当这样的数据到达时,它需要被添加到我们的tasks数组中。我们在这里做出的假设是,处理来自 HTTP 服务以及来自 Firebase 或 Web 套接字等全双工连接的任务是有趣的。
让我们看看在我们的代码中涉及 HTTP 服务和套接字会是什么样子。您可以通过使用包装其 API 的库轻松利用套接字。
大多数浏览器原生支持 WebSockets,但仍被认为是实验性的。话虽如此,依然有意义依赖于一个帮助我们处理套接字的库,但值得注意的是,当 WebSockets 变得不再是实验性的时候,我们将不再考虑使用库。对于感兴趣的读者,请查看官方文档developer.mozilla.org/en-US/docs/Web/API/WebSockets_API
有一个这样的库是socket.io库;可以通过以下方式安装它:
npm install socket.io
要开始在 Angular 中使用这个,您需要:
-
导入
socket.io-client。 -
通过调用
io(url)建立连接;这将返回一个套接字,您可以向其添加订阅。 -
等待包含我们想要在应用程序中显示的有效负载的传入事件。
-
生成事件并在想要与后端通信时发送可能的有效负载
以下代码将只向您展示如何执行这些步骤。然而,套接字的实现还有更多,比如创建后端。要了解使用 Angular 和socket.io的完整示例是什么样子,鼓励感兴趣的读者查看 Torgeir Helgwold 的以下文章:
www.syntaxsuccess.com/viewarticle/socket.io-with-rxjs-in-angular-2.0
这实际上不是一个 HTTP 主题,这就是为什么我们只显示代码中感兴趣的部分,这是我们将接收数据并将其添加到任务数组中的地方。我们还强调了套接字的设置和拆除。强调是用粗体来做的,如下所示:
import * as io from 'socket.io-client'**;** export class TaskService {
subscription;
tasks:Task[] = [];
constructor(private http:HttpClient) {
this.fetchData();
this.socket = io(this.url**); // establishing a socket connection** this.socket.on('task', (data) => {
// receive data from socket based on the 'task' event happening
this.tasks = [ ..this.tasks, data ];
});
}
private fetchData() {
this.subscription =
this.http.get<Task[]>('/tasks')
.subscribe( data => this.tasks = data );
}
// call this from the component when component is being destroyed
destroy() {
this.socket.removeAllListeners('task'); // clean up the socket
connection
}
}
这是一个非常简单的示例,非常适合在模板中显示数据,并在tasks数组更改时更新模板。正如您所看到的,如果我们涉及socket,那也没关系;我们的模板仍然会被更新。
这种做法还包括另一种情况——两个或更多兄弟组件如何通信?答案很简单:它们使用TaskService。如果您希望其他组件的模板得到更新,那么只需更改任务数组的内容,它将反映在 UI 中。以下是此代码:
@Component({
template: `
<div *ngFor="let task of tasks">
{{ task.name }}
</div>
<input [(ngModel)]="newTask" />
<button (click)="addTask()" ></button>
`
})
export class FirstSiblingComponent {
newTask: string;
constructor(private service: TaskService) {}
get tasks() {
return this.taskService.tasks;
}
addTask() {
this.service.addTask({ name : this.newTask });
this.newTask = '';
}
}
这意味着我们还需要向我们的服务添加一个addTask()方法,如下所示:
import * as io from 'socket.io-client'**;** export class TaskService {
subscription;
tasks: Task[] = [];
constructor(private http:Http) {
this.fetchData();
this.socket = io(this.url); // establishing a socket connection
this.socket.on('task', (data) => {
// receive data from socket based on the 'task' event happening
this.tasks = [ ..this.tasks, data ];
});
}
addTask(task: Task) {
this.tasks = [ ...this.tasks, task];
}
private fetchData() {
this.subscription =
this.http.get('/tasks')
.subscribe(data => this.tasks = data);
}
// call this from the component when component is being destroyed
destroy() {
this.socket.removeAllListeners('task'); // clean up the socket
connection
}
}
另一个组件在设置taskService、公开tasks属性和操作tasks列表方面看起来基本相同。无论哪个组件采取主动通过用户交互更改任务列表,另一个组件都会收到通知。我想强调这种通用方法的工作原理。为了使这种方法起作用,您需要通过组件中的 getter 公开任务数组,如下所示:
get tasks() {
return this.taskService.tasks;
}
否则,对它的更改将不会被接收。
然而,有一个缺点。如果我们想确切地知道何时添加了一个项目,并且,比如说,基于此显示一些 CSS,那该怎么办?在这种情况下,您有两个选择:
-
在组件中设置套接字连接并在那里监听数据更改。
-
在任务服务中使用行为主题而不是任务数组。来自 HTTP 或套接字的任何更改都将通过
subject.next()写入主题。如果这样做,那么当发生更改时,您可以简单地订阅该主题。
最后一个选择有点复杂,无法用几句话解释清楚,因此下一节将专门解释如何在数组上使用BehaviourSubject。
进一步改进-将 TaskService 转变为有状态、更健壮的服务
RxJS 和 Observables 并不仅仅是为了与 Promises 一一对应而到来。RxJS 和响应式编程到来是为了推广一种不同类型的架构。从这样的架构中出现了适用于服务的存储模式。存储模式是确保我们的服务是有状态的,并且可以处理来自 HTTP 以外更多地方的数据。数据可能来自的潜在地方可能包括,例如:
-
HTTP
-
localStorage
-
套接字
-
Firebase
在网络连接间歇性中断时处理服务调用
首先,您应该确保如果网络连接中断,应用程序仍然可以正常工作,至少在读取数据方面,您对应用程序用户有责任。对于这种情况,如果 HTTP 响应未能传递,我们可以使用localStorage进行回答。然而,这意味着我们需要在我们的服务中编写以下方式工作的逻辑:
if(networkIsDown) {
/* respond with localStorage instead */
} else {
/* respond with network call */
}
让我们拿出我们的服务,并稍微修改一下以适应离线状态:
export class TaskService {
getTasks() {
this.http .get<Task[]>('/data/tasks.json') .do( data => { localStorage.setItem('tasks', JSON.stringify(data)) })
.catch(err) => {
return this.fetchLocalStorage();
})
}
private fetchLocalStorage(){
let tasks = localStorage.getItem('tasks');
const tasks = localStorage.getItem('tasks') || [];
return Observable.of(tasks);
}
}
正如您所看到的,我们做了两件事:
-
我们添加
.do()运算符来执行副作用;在这种情况下,我们将响应写入localStorage -
我们添加了
catch()操作符,并响应一个包含先前存储的数据或空数组的新 Observable
用这种方式解决问题没有错,而且在很多情况下,这甚至可能足够好。然而,如果像之前建议的那样,数据从许多不同的方向到达,会发生什么?如果是这种情况,那么我们必须有能力将数据推送到流中。通常,只有观察者可以使用observer.next()推送数据。
还有另一个构造,Subject。Subject具有双重性质。它既能向流中推送数据,也可以被订阅。让我们重写我们的服务以解决外部数据的到达,然后添加Sock.io库支持,这样您就会看到它是如何扩展的。我们首先使服务具有状态。诱人的做法是直接编写如下代码:
export class TaskService {
tasks: Task[];
getTasks() {
this.http .get<Task[]>('/data/tasks.json') .do( data => { **this.tasks = mapTasks( data );** localStorage.setItem('tasks', JSON.stringify(data)) })
.catch(err) => {
return this.fetchLocalStorage();
})
}
}
我们建议的前述更改是加粗的,并且包括创建一个tasks数组字段,并对到达的数据进行任务字段的赋值。这样做是有效的,但可能超出了我们的需求。
引入 store/feed 模式
不过,我们可以做得更好。我们可以更好地做到这一点,因为我们实际上不需要创建那个最后的数组。在这一点上,你可能会想,让我弄清楚一下;你希望我的服务具有状态,但没有后备字段?嗯,有点,而且使用一种称为BehaviourSubject的东西是可能的。BehaviourSubject具有以下属性:
-
它能够充当
Observer和Observable,因此它可以推送数据并同时被订阅 -
它可以有一个初始值
-
它将记住它上次发出的值
因此,使用BehaviourSubject,我们实际上一举两得。它可以记住上次发出的数据,并且可以推送数据,使其在连接到其他数据源(如 Web 套接字)时非常理想。让我们首先将其添加到我们的服务中:
export class TaskService {
private internalStore:BehaviourSubject;
constructor() {
this.internalStore = new BehaviourSubject([]); // setting initial
value
}
get store() {
return this.internalStore.asObservable();
}
private fetchTasks(){
this.http .get<Task[]>('/data/tasks.json') .map(this.mapTasks) .do(data => { **this.internalStore.next( data )** localStorage.setItem('tasks', JSON.stringify(data)) })
.catch( err => {
return this.fetchLocalStorage();
});
}
}
在这里,我们实例化了BehaviourSubject,并且可以看到它的默认构造函数需要一个参数,即初始值。我们给它一个空数组。这个初始值是呈现给订阅者的第一件事。从应用程序的角度来看,在等待第一个 HTTP 调用完成时展示第一个值是有意义的。
我们还定义了一个store()属性,以确保当我们向外部公开BehaviourSubject时,我们将其作为Observable。这是防御性编码。因为主题上有一个next()方法,允许我们将值推送到其中;我们希望将这种能力从不在我们服务中的任何人身上夺走。我们这样做是因为我们希望确保任何添加到其中的内容都是通过TaskService类的公共 API 处理的:
get store() {
return this.internalStore.asObservable();
}
最后的更改是添加到.do()操作符的
// here we are emitting the data as it arrives
.do(data => { this.internalStore.next(data) })
这将确保我们服务的任何订阅者始终获得最后发出的数据。在组件中尝试以下代码:
@Component({})
export class TaskComponent {
constructor(taskService: TaskService ) {
taskService.store.subscribe( data => {
console.log('Subscriber 1', data);
})
setTimeout(() => {
taskService.store
.subscribe( data => console.log('Subscriber 2', data)); // will get the latest emitted value
}, 3000)
}
}
在这一点上,我们已经确保无论何时开始订阅taskService.store,无论是立即还是在 3 秒后,如前面的代码所示,我们仍然会获得最后发出的数据。
持久化数据
如果我们需要持久化来自组件表单的内容怎么办?那么,我们需要做以下操作:
-
在我们的服务上公开一个
add()方法 -
进行一个
http.post()调用 -
调用
getTasks()以确保它重新获取数据
让我们从更简单的情况开始,从组件中添加任务。我们假设用户已经输入了创建应用程序 UI 中的Task所需的所有必要数据。从组件中调用了一个addTask()方法,这反过来调用了服务上类似的addTask()方法。我们需要向我们的服务添加最后一个方法,并且在该方法中调用一个带有 POST 请求的端点,以便我们的任务得到持久化,就像这样:
export class TaskService {
addTask(task) {
return this.http.post('/tasks', task);
}
}
在这一点上,我们假设调用组件负责在组件上执行各种 CRUD 操作,包括显示任务列表。通过添加任务并持久化它,提到的列表现在将缺少一个成员,这就是为什么有必要对getTasks()进行新的调用。因此,如果我们有一个简单的服务,只有一个getTasks()方法,那么它将返回一个任务列表,包括我们新持久化的任务,如下所示:
@Component({})
export class TaskComponent implements OnInit {
ngOnInit() {
init();
}
private init(){
this.taskService.getTasks().subscribe( data => this.tasks = data )
}
addTask(task) {
this.taskService.addTask(task).subscribe( data => {
this.taskService.getTasks().subscribe(data => this.tasks = data)
});
}
}
好的,如果我们有一个简化的TaskService,缺少我们漂亮的存储/反馈模式,那么这将起作用。不过,有一个问题——我们在使用 RxJS 时出错了。我们所说的错误是什么?每次我们使用addTask()时,我们都建立了一个新的订阅。
你想要的是以下内容:
-
订阅任务流
-
清理阶段,订阅被取消订阅
让我们先解决第一个问题;一个流。我们假设我们需要使用我们的TaskService的有状态版本。我们将组件代码更改为这样:
@Component({})
export class TaskComponent implements OnInit{
private subscription;
ngOnInit() {
this.subscription = this.taskService.store.subscribe( data => this.tasks = data );
}
addTask(task) {
this.taskService.addTask( task ).subscribe( data => {
// tell the store to update itself?
});
}
}
正如你所看到的,我们现在订阅了 store 属性,但是我们已经将taskService.addTask()方法内的重新获取行为移除,改为这样:
this.taskService.addTask(task).subscribe( data => {
// tell the store to update itself?
})
我们将把这个刷新逻辑放在taskService中,像这样:
export class TaskService {
addTask(task) {
this.http
.post('/tasks', task)
.subscribe( data => { this.fetchTasks(); })
}
}
现在,一切都按预期运行。我们在组件中有一个订阅任务流,刷新逻辑被我们通过调用fetchTasks()方法推回到服务中。
我们还有一项业务要处理。我们如何处理订阅,更重要的是,我们如何处理取消订阅?记得我们如何向组件添加了一个subscription成员吗?那让我们完成了一半。让我们为我们的组件实现一个OnDestroy接口并实现这个约定:
@Component({
template : `
<div *ngFor="let task of tasks">
{{ task.name }}
</div>
`
})
export class TaskComponent implements OnInit, implements OnDestroy{
private subscription;
tasks: Task[];
ngOnInit() {
this.subscription = this.taskService.store.subscribe( data => this.tasks = data );
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
addTask(task) {
this.taskService.addTask( task );
}
}
通过实现OnDestroy接口,我们有一种方法在订阅上调用unsubscribe(),我们在OnDestroy接口让我们实现的ngOnDestroy()方法中这样做。因此,我们为自己清理了一下。
实现OnInit接口和OnDestroy接口的模式是在创建组件时应该做的事情。在ngOnInit()方法中设置订阅和组件需要的其他任何内容是一个良好的实践,相反,在ngOnDestroy()方法中取消订阅和其他类型的构造是一个良好的实践。
然而,还有一种更好的方法,那就是使用async管道。async管道将消除保存订阅引用并调用.unsubscribe()的需要,因为这在async管道内部处理。我们将在本章的后续部分更多地讨论async管道,但是这是组件利用它而不是OnDestroy接口的样子:
@Component({
template: `
<div *ngFor="let task of tasks | async">
{{ task.name }}
</div>
`
})
export class TaskComponent implements OnInit{
get tasks() {
return this.taskService.store;
}
addTask(task) {
this.taskService.addTask( task );
}
}
我们的代码刚刚删除了很多样板代码,最好的部分是它仍然在工作。只要你的所有数据都在一个组件中显示,那么async管道就是最好的选择;然而,如果你获取的数据是在其他服务之间共享或者作为获取其他数据的先决条件,那么使用async管道可能就不那么明显了。
最重要的是,最终你要求使用这些技术之一。
刷新我们的服务
我们几乎描述完了我们的TaskService,但还有一个方面我们需要涵盖。我们的服务没有考虑到第三方可能对终端数据库进行更改。如果我们远离组件或重新加载整个应用程序,我们将看到这些更改。如果我们想在更改发生时看到这些更改,我们需要有一些行为告诉我们数据何时发生了变化。诱人的是想到一个轮询解决方案,只是在一定的时间间隔内刷新数据。然而,这可能是一个痛苦的方法,因为我们获取的数据可能包含一个庞大的对象图。理想情况下,我们只想获取真正发生变化的数据,并将其修改到我们的应用程序中。在宽带连接时代,为什么我们如此关心这个问题?这是问题所在——一个应用程序应该能够在移动应用上使用,速度和移动数据合同可能是一个问题,所以我们需要考虑移动用户。以下是一些我们应该考虑的事情:
-
数据的大小
-
轮询间隔
如果数据的预期大小真的很大,那么向一个端点发出请求并询问它在一定时间后发生了什么变化可能是一个好主意;这将大大改变有效载荷的大小。我们也可以只要求返回一个部分对象图。轮询间隔是另一个需要考虑的事情。我们需要问自己:我们真的需要多久才能重新获取所有数据?答案可能是从不。
假设我们选择一种方法,我们要求获取增量(在一定时间后的变化);它可能看起来像下面这样:
constructor(){
lastFetchedDate;
INTERVAL_IN_SECONDS = 30;
setInterval(() => {
fetchTasksDelta( lastFetchedDate );
lastFetchedDate = DateTime.now;
}, this.INTERVAL_IN_SECONDS * 1000)
}
无论你采取什么方法和考虑,记住并不是所有用户都在宽带连接上。值得注意的是,越来越多的刷新场景现在 tend to be solved with Web Sockets,所以你可以在服务器和客户端之间创建一个开放的连接,服务器可以决定何时向客户端发送一些新数据。我们将把这个例子留给你,亲爱的读者,使用 Sockets 进行重构。
我们现在有一个可以:
-
无状态
-
能够处理离线连接
-
为其他数据服务提供服务,比如 sockets
-
能够在一定的时间间隔内刷新数据
所有这些都是通过BehaviourSubject和localStorage实现的。不要把 RxJS 只当作Promise的附加功能,而是使用它的构造和操作符来构建健壮的服务和架构模式。
HttpInterceptor
拦截器是一段可以在您的 HTTP 调用和应用程序的其余部分之间执行的代码。它可以在您即将发送请求时以及接收响应时挂钩。那么,我们用它来做什么呢?应用领域有很多,但有些可能是:
-
为所有出站请求添加自定义令牌
-
将所有传入的错误响应包装成业务异常;这也可以在后端完成
-
重定向请求到其他地方
HttpInterceptor是从@angular/common/http导入的一个接口。要创建一个拦截器,您需要按照以下步骤进行:
-
导入并实现
HttpInterceptor接口 -
在根模块提供程序中注册拦截器
-
编写请求的业务逻辑
创建一个模拟拦截器
让我们采取所有先前提到的步骤,并创建一个真正的拦截器服务。想象一下,对某个端点的所有调用都被定向到一个 JSON 文件或字典。这样做将创建一个模拟行为,您可以确保所有出站调用都被拦截,并在它们的位置上,您用适当的模拟数据回应。这将使您能够以自己的节奏开发 API,同时依赖于模拟数据。让我们深入探讨一下这种情况。
让我们首先创建我们的服务。让我们称之为MockInterceptor。它将需要像这样实现HttpInterceptor接口:
import { HttpInterceptor } from '@angular/common/http'; export class MockInterceptor implements **HttpInterceptor** { constructor() { } intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { }
}
为了履行接口的约定,我们需要有一个接受请求和next()处理程序作为参数的intercept()方法。此后,我们需要确保从intercept()方法返回HttpEvent类型的 Observable。我们还没有在那里写任何逻辑,所以这实际上不会编译。让我们在intercept()方法中添加一些基本代码,使其工作,像这样:
import { HttpInterceptor } from '@angular/common/http'; export class MockInterceptor implements HttpInterceptor { constructor() { } intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { return next.handle(request**);** }
}
我们添加了对next.handle(request)的调用,这意味着我们接受传入的请求并将其传递到管道中。这段代码并没有做任何有用的事情,但它可以编译,并且教会我们,无论我们在intercept()方法中做什么,我们都需要使用请求对象调用next.handle()。
让我们回到最初的目标——模拟出站请求。这意味着我们想要用我们的请求替换出站请求。为了实现我们的模拟行为,我们需要做以下事情:
-
调查我们的出站请求,并确定我们是要用模拟来回应还是让它通过
-
如果我们想要模拟它,构造一个模拟响应
-
使用
providers为一个模块注册我们的新拦截器
让我们在intercept()方法中添加一些代码,如下所示:
import { HttpInterceptor } from '@angular/common/http';
export class MockInterceptor implements HttpInterceptor {
constructor() { }
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (request.url.startsWith('/starwars') && request.method === 'GET') { const url = request.url; const newUrl = `data${url.substring('/starwars'.length)}.json`; const req = new HttpRequest('GET', newUrl); return next.handle(req); } else { return next.handle(request); }
}
}
我们在这里基本上是在说,我们正在尝试对某个东西执行 GET 请求。/starwars将会拦截它,而不是响应一个 JSON 文件。所以,/starwars/ships将会导致我们响应ships.json,/starwars/planets将会导致planets.json。你明白了吧;所有其他请求都会被放行。
我们还有一件事要做——告诉我们的模块这个拦截器存在。我们打开我们的模块文件并添加以下内容:
@NgModule({
imports: [BrowserModule, HttpClientModule]
providers: [{
provide: HTTP_INTERCEPTORS,
useClass: MockInterceptor,
multi: true **}**] })
一些最佳实践
在处理 Angular 中的数据服务时,特别是涉及到 Observables 时,有一些最佳实践需要遵循,其中包括:
-
处理你的错误。这是不言而喻的,希望这对你来说并不是什么新鲜事。
-
确保任何手动创建的 Observables 都有一个清理方法。
-
取消订阅你的流/可观察对象,否则可能会出现资源泄漏。
-
使用 async 管道来为你管理订阅/取消订阅过程。
到目前为止,我们还没有讨论如何在手动创建 Observables 时创建清理方法,这就是为什么我们将在一个小节中进行讨论。
在 Firebase 部分已经提到了 async 管道几次,但值得再次提及并通过解释它在订阅/取消订阅流程中的作用来建立对它的了解。
异步操作符
async 管道是一个 Angular 管道,因此它用在模板中。它与流/可观察对象一起使用。它发挥了两个作用:它帮助我们少打字,其次,它节省了整个设置和拆除订阅的仪式。
如果它不存在,当尝试从流中显示数据时,很容易会输入以下内容:
@Component({
template: `{{ data }}`
})
export class DataComponent implements OnInit, implements OnDestroy {
subscription;
constructor(private service){ }
ngOnInit() {
this.subscription = this.service.getData()
.subscribe( data => this.data = data )
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
}
正如你所看到的,我们需要订阅和取消订阅数据。我们还需要引入一个数据属性来分配它。async 管道为我们节省了一些按键,所以我们可以像这样输入我们的组件:
@Component({
template: `{{ data | async }}`
})
export class DataComponent implements OnInit {
data$;
constructor(private service){ }
ngOnInit() {
this.data$ = this.service.getData();
}
}
这是少了很多代码。我们删除了:
-
OnDestroy接口 -
subscription变量 -
任何订阅/取消订阅的调用
我们确实需要添加{{ data | async }},这是一个相当小的添加。
然而,如果我们得到的是一个更复杂的对象,并且我们想要显示它的属性,我们必须在模板中输入类似这样的内容:
{{ (data | ansync)?.title }}
{{ (data | ansync)?.description }}
{{ (data | ansync)?.author }}
我们这样做是因为数据还没有设置,此时访问属性会导致运行时错误,因此我们使用了?操作符。现在,这看起来有点冗长,我们可以使用-操作符来解决这个问题,就像这样:
<div *ngIf="data | async as d">
{{ d.title }}
{{ d.description }}
{{ d.author }}
</div>
现在看起来好多了。使用async pipe将减少大量样板代码。
做一个好公民 - 在自己之后清理
好的,所以我已经告诉过你调用.unsubscribe()的重要性,你现在应该相信我,如果不调用它,资源就不会被清理。当你处理有着永无止境的数据流的流时,比如滚动事件,或者在需要创建自己的 Observables 时,了解这一点非常重要。我现在将展示一些 Observable 的内部,以使事情更清晰:
let stream$ = Observable.create( observer => {
let i = 0;
let interval = setInterval(() => {
observer.next(i++);
}, 2000)
})
let subscription = stream$.subscribe( data => console.log( data ));
setTimeout((
subscription.unsubscribe();
) =>, 3000)
这是一个创建自己的 Observable 的例子。你以为只因为你按照指示调用了.unsubscribe()就安全了?错。间隔会继续计时,因为你没有告诉它停止。慌乱中,你关闭了浏览器标签,希望 Observable 消失 - 现在你是安全的。正确的方法是添加一个清理函数,就像这样:
let stream$ = Observable.create( observer => {
let i = 0;
let interval = setInterval(() => {
observer.next(i++);
}, 2000);
return function cleanUp() {
clearInterval( interval );
}
})
let subscription = stream$.subscribe( data => console.log( data ));
setTimeout(() => subscription.unsubscribe(), 3000);
调用subscription.unsubscribe()时,它将在内部调用cleanUp()函数。大多数,如果不是全部,用于创建 Observables 的工厂方法都会定义自己的cleanUp()函数。重要的是,你应该知道,如果你冒险创建自己的 Observable,请参考本节,做一个好公民,并实现cleanUp()函数。
总结
正如我们在本章开头指出的,要详细介绍 Angular HTTP 连接功能所能做的所有伟大事情,需要不止一个章节,但好消息是我们已经涵盖了几乎所有我们需要的工具和类。
其余的就留给你的想象力了,所以随时可以尽情发挥,通过创建全新的 Twitter 阅读客户端、新闻源小部件或博客引擎,以及组装各种你选择的组件来将所有这些知识付诸实践。可能性是无限的,你可以选择各种策略,从 Promises 到 Observables。你可以利用响应式功能扩展和强大的Http类的令人难以置信的功能。
正如我们已经强调的那样,天空是无限的。但是,我们还有一条漫长而令人兴奋的道路在前方。现在我们知道了如何在我们的组件中消费异步数据,让我们来探索如何通过将用户路由到不同的组件中,为我们的应用提供更广泛的用户体验。我们将在下一章中介绍这个内容。
第八章:Firebase
Firebase 是一个移动和 Web 应用平台,最初由 Firebase Inc.在 2011 年开发,2014 年被 Google 收购。从那时起,它已经从云中的响应式数据库发展成了一个完整的产品套件。然而,我们将专注于数据库方面的事情,因为这对于 Angular 开发人员来说是有趣的部分。所以,最好的方式是将 Firebase 视为后端作为服务。这意味着使用 Firebase,没有理由构建自己的 REST 服务;你只需要连接到它。
值得指出的是,它最终是一个有付费计划的产品,但绝对可以在不支付任何费用的情况下创建玩具项目。
好的,后端作为服务,知道了。然而,它真正的卖点在于它是响应式的。它是响应式的,意味着如果你订阅了数据库上的一个集合,而客户端在某处对该集合进行了更改,你将收到通知并可以相应地采取行动。这听起来熟悉吗?是的,你想对了:它听起来像 RxJS 和 Observables,这就是为什么 Firebase API 已经被封装在了称为 AngularFire2 的 RXJS 中,这是一个你可以轻松从npm安装并添加到你的项目中的 Angular 模块。
因此,使用 Firebase 的商业案例首先是当你想要创建协作应用程序时。我要大胆地说,这就像是 Web 套接字,但在云中并且有一个底层数据库,所以不仅有通信部分,还有数据部分。
在这一章中,你将学到:
-
Firebase 是什么
-
在你的 Angular 应用中利用 AngularFire2
-
如何监听并对变化做出反应
-
如何使用 CRUD 操作来操作你的 Firebase 数据
-
为什么处理身份验证和授权很重要以及如何设置它们
三向绑定与双向绑定
我们有不同类型的绑定。AngularJS 使双向绑定变得著名。这意味着能够从两个不同的方向改变数据:
-
视图中的变化会改变控制器上的数据
-
控制器上的变化会反映在视图中
至于三向绑定,我们是什么意思?让我们通过一个应用来说明这一点;最好通过一张图片来描述。
你需要在这里想象的是,我们开发了一个使用 Firebase 的应用程序。我们在两个不同的浏览器窗口中启动了该应用程序。在第一个窗口中,我们进行了一项更改,这一更改在第二个浏览器窗口中得到了反映,例如,向列表中添加一个项目。那么,会发生什么步骤呢?
我们在这里看到的最好是从右到左阅读:
-
实例一:用户更改视图
-
实例一:更改传播到模型
-
这会触发与 Firebase 数据库实例的同步
-
第二个实例正在监听同步
-
第二个实例的模型正在更新
-
第二个实例的视图正在更新
你看到了:在一个地方进行更改,在两个或更多实例中看到结果,取决于你生成了多少实例。
关于存储的一些话-列表的问题
在深入了解 Firebase 之前,让我们首先解释为什么我们首先谈论列表。在关系数据库中,我们将使用 SQL、表和标准形式来定义我们的数据库。这在 Firebase 数据库中并不适用,因为它由 JSON 结构组成,看起来像这样:
{
"person": { "age": 11, "name": "Charlie", "address": "First Street, Little Town" },
"orders": { "someKeyHash": { "orderDate": "2010-01-01", "total": 114 },
"someOtherKeyHash": { "orderDate": "2010-02-28" }
}
请注意,在关系数据库中,订单集合将是一个orders表,有很多行。在这里,它似乎是一个对象;为什么呢?
列表中的对象-解决删除问题
列表通常与列表中的每个项目关联一个索引,如下所示:
-
0:项目 1
-
1:项目 2
-
2:项目 3
这没有问题,直到你开始考虑当许多同时用户开始访问相同的数据时会发生什么。只要我们进行读取,就没有问题。但是如果我们尝试其他操作,比如删除,会发生什么呢?
通常情况下,当你删除东西时,索引会被重新分配。如果我们删除前面的item2,我们将得到一个新的情况,看起来像这样:
-
0:项目 1
-
1:项目 3
想象我们根据索引进行删除,你的数据看起来像这样:
-
0:项目 1
-
1:项目 2
-
2:项目 3
-
3:项目 4
现在,两个不同的用户可以访问这些数据,其中一个想要删除索引 1,另一个想要删除索引 3。我们可能会采用一个锁定算法,所以一个用户在几毫秒之前删除了索引 1,而另一个用户删除了索引 3。第一个用户的意图是删除item2,第二个用户的意图是删除item4。第一个用户成功地完成了他们的目标,但第二个用户删除了一个超出范围的索引。
这意味着在多用户数据库中删除索引上的东西是疯狂的,但在 Firebase 的情况下,这意味着当它们被存储时,列表不是列表;它们是对象,看起来像这样:
{
212sdsd: 'item 1',
565hghh: 'item 2'
// etc
}
这避免了删除问题,因此是列表以其方式表示的原因。
AngularFire2
AngularFire2 是将 Firebase API 封装在 Observables 中的库的名称。这意味着我们可以在想要监听更改等情况时,对它可能的外观有一些预期。我们将在本章的后面部分回到更改场景。
官方存储库可以在以下链接找到:
github.com/angular/angularfire2
如何进行 CRUD 和处理身份验证的出色文档可以在上述链接的页面底部找到。
核心类
在深入研究 AngularFire2 之前,了解一些基本信息是很有必要的;这些信息涉及核心对象及其职责:
-
AngularFireAuth -
FirebaseObjectObservable -
FirebaseListObservable
AngularFireAuth处理身份验证。FirebaseObjectObservable是您想要与之交谈的核心对象,当您知道您处理的数据库属性是对象类型时。最后,FirebaseListObservable是一个类似列表的对象。从前面我们知道 Firebase 列表并不真正是列表,但这并不妨碍该对象具有列表通常具有的方法。
管理工具
管理工具可以在firebase.google.com/找到。一旦进入,点击右上角的 GO TO CONSOLE 链接。您应该有一个 Gmail 帐户。如果有的话,那么您也有一个 Firebase 帐户,您只需要设置数据库。然后,您应该选择创建一个项目;给它一个您选择的标题和您的位置。
它应该是这样的:
完成这些步骤后,您将被带到管理页面,它看起来像这样:
上述屏幕截图显示了左侧菜单,然后右侧是内容窗格。右侧显示的内容根据您在左侧选择的内容而变化。正如您所看到的,您可以控制很多东西。在开始创建数据库时,您最重要的选项是:
-
身份验证:在这里,您设置想要的身份验证类型:无身份验证、用户名/密码、社交登录等。
-
数据库:在这里,您设计数据库应该是什么样子。这里还有一些选项卡,让我们控制数据库集合的授权。
其他选项也很有趣,但对于本节的目的来说,这超出了我们的范围。
定义您的数据库
我们转到左侧的数据库菜单选项。在下面的截图中,我已经向根节点添加了一个节点,即 book 节点:
在我们的根元素上悬停,我们会看到一个+字符,允许我们向根节点添加一个子节点。当然,我们也可以通过单击特定元素并向其添加子节点来创建更复杂的对象,看起来像这样:
正如您所看到的,我们可以很容易地构建出我们的数据库,它具有类似 JSON 的外观。
将 AngularFire2 添加到您的应用程序
到了将 Firebase 支持添加到我们的 Angular 应用程序的时候。为了做到这一点,我们需要做以下事情:
-
下载 AngularFire2 的
npm库。 -
将该库导入到我们的 Angular 应用程序中。
-
设置我们的 Firebase 配置,以便 Firebase 让我们检索数据。
-
注入适当的 Firebase 服务,以便我们可以访问数据。
-
在一个组件中呈现数据。
链接github.com/angular/angularfire2/blob/master/docs/install-and-setup.md是在您的 Angular 应用程序中设置 Firebase 的官方链接。这可能会随时间而改变,因此如果在更新 AngularFire2 库后,书中的说明似乎不再起作用,可以值得检查此页面。不过,让我们按照步骤进行。
下载 AngularFire2 库就像输入这样简单:
npm install angularfire2 firebase --save
下一步是从 Firebase 管理页面获取配置数据,并将其保存到配置对象中。返回管理页面。通过按下以下按钮,您可以转到配置的正确页面:
-
概述
-
将 Firebase 添加到您的 Web 应用程序
此时,您已经有了一个对象形式的配置,具有以下属性:
let config = { apiKey: "<your api key>", authDomain: "<your auth domain>", databaseURL: "<your database url>", projectId: "<your project id>", storageBucket: "<your storage bucket>", messagingSenderId: "<your messaging senderid>" };
先前的值取决于您的项目。我只能建议您从管理页面复制配置,以进行下一步。
下一步是使用@angular-cli搭建一个 Angular 应用程序,并查找app.module.ts文件。在其中,我们将把我们的配置分配给以下变量:
export const environment = {
firebase: { apiKey: "<your api key>", authDomain: "<your auth domain>", databaseURL: "<your database url>", projectId: "<your project id>", storageBucket: "<your storage bucket>", messagingSenderId: "<your messaging sender id>" } }
现在我们需要指示模块导入我们需要的模块。基本上,有三个模块可以导入:
-
AngularFireModule:这用于初始化应用程序 -
AngularFireDatabaseModule:这用于访问数据库;这是必要的导入 -
AngularFireAuthModule:这用于处理身份验证;一开始不是必要的,但随着应用程序的增长,它肯定会变得必要 - 安全性问题?
让我们导入前两个,这样我们就可以使用 Firebase 并从中提取一些数据:
import { AngularFireModule } from 'angularfire2'; import { AngularFireDatabaseModule } from 'angularfire2/database'; @NgModule({
imports: [
AngularFireModule.initializeApp(environment.firebase),
AngularFireDatabaseModule
]
})
在这一点上,我们已经完成了配置 Angular 模块,可以继续进行AppComponent,这是我们将注入 Firebase 服务的地方,这样我们最终可以从 Firebase 中提取一些数据:
@Component({
template : `to be defined`
})
export class AppComponent {
constructor(private angularFireDatabase: AngularFireDatabase) {
this.angularFireDatabase
.object('/book')
.valueChanges()
.subscribe(data => {
console.log('our book', data);
});
}
}
就是这样:一个完整的 Firebase 设置,从下载 AngularFire2 到显示您的第一个数据。
保护我们的应用程序
保护我们的应用程序至关重要。这不是一个如果,这是一个必须,除非您正在构建一个玩具应用程序。目前在 Firebase 中有三种方法可以做到这一点:
-
身份验证:这是我们验证用户输入正确凭据以登录应用程序的地方
-
授权:这是我们设置用户有权访问/修改应用程序中哪些资源的地方
-
验证:这是我们确保只有有效数据被持久化在数据库中的地方
身份验证 - 允许访问应用程序
身份验证意味着当您尝试登录时我们识别您。如果您的凭据与数据库中的用户匹配,那么应用程序应该让您进入;否则,您将被拒之门外。Firebase 有不同的身份验证方式。目前,以下身份验证方法是可能的:
-
电子邮件/密码
-
电话
-
谷歌
-
Facebook
-
Twitter
-
GitHub
-
匿名
这基本上是您在 2017 年所期望的:从简单的电子邮件/密码身份验证到 OAuth 的一切。
授权 - 决定谁有权访问哪些数据,以及如何访问
至于授权,可以设置规则:
-
在整个数据库上
-
每个集合
同样重要的是要知道规则是通过以下方式执行的:
-
原子地:适用于特定元素
-
级联:适用于特定元素及其所有子元素
权限级别要么是:
-
读取:这将使读取资源的内容成为可能
-
写入:这将使您能够修改资源
-
拒绝:这将阻止对目标资源的任何写入或读取操作
这需要一个例子。想象一下,您有以下数据库结构:
foo {
bar: {
child: 'value'
}
}
原子授权意味着我们需要明确;如果我们不明确,那么默认情况下是拒绝访问。让我们试着对前面的结构强制执行一些规则。我们转到数据库菜单选项下的规则部分。规则被定义为一个 JSON 对象,如下所示:
rules: {
"foo": {
"bar": {
".read": true,
".write": false,
"child": {}
}
}
}
这意味着我们已经为bar设置了一个明确的原子规则,并且该规则被其子元素继承,也就是说,它以级联方式起作用。另一方面,foo没有规则。如果尝试访问这些集合,这将产生以下后果:
// deny
this.angularFireDatabase.object('/foo');
// read allowed, write not allowed
this.angularFireDatabase.object('/foo/bar');
// read allowed, write not allowed
this.angularFireDatabase.object('/foo/bar/child');
这解释了现行规则的类型。我敦促您通过研究以下链接来深入了解这个主题:
-
一般情况下保护您的数据:
firebase.google.com/docs/database/security/securing-data -
为每个用户保护数据,即为不同类型的用户设置不同的权限级别:
firebase.google.com/docs/database/security/user-security
验证
这是一个非常有趣的话题。这里所指的是,我们可以通过设置关于数据形状的规则来控制允许进入我们集合的数据。您基本上指定了数据必须具备的一组要求,以便插入或更新被认为是可以执行的。就像读/写授权规则一样,我们指定一个规则对象。让我们描述两种不同版本的验证,这样你就能掌握它:
-
传入数据必须包括这些字段
-
传入数据必须在此范围内
我们可以这样描述第一种情况:
{
"rules": {
"order": {
"name": {},
"quantity"
}
}
}
这是一个代码片段,展示了前面的规则生效时的影响:
// will fail as 'quantity' is a must have field
angularFireDatabase.object('/order').set({ name : 'some name' });
在第二种情况下,我们可以这样设置规则:
{
"rules": {
"order": {
"quantity": {
".validate": "newData.isNumber() && newData.val() >=0
&& newData.val() <= 100"
}
}
}
}
前面指定的规则规定,任何传入的数据必须是数字类型,必须大于或等于0,并且小于或等于100。
这是一个代码片段,展示了这个规则的影响:
// fails validation
angularFireDatabase.object('order').set({ quantity : 101 })
正如你所看到的,这使得我们非常容易保护我们的数据免受不必要的输入,从而保持数据库的整洁和一致。
处理数据 - CRUD
现在,我们来到了令人兴奋的部分:如何处理数据,读取我们的数据,添加更多数据等等。简而言之,术语创建,读取,更新,删除(CRUD)。
因此,当我们使用 CRUD 时,我们需要了解我们正在操作的结构的一些信息。我们需要知道它是对象还是列表类型。在代码方面,这意味着以下内容:
this.angularFireDatabase.object(path).<operation>
this.angularFireDatabase.list(path).<operation>
前面说到,我们可以将我们从数据库中查看的数据视为对象或列表。根据我们的选择,这将影响我们可以使用的方法,也会影响返回的数据样式。如果数据库中有类似列表的结构,并选择将其视为对象,这一点尤其明显。假设我们有以下存储结构:
{
id1: { value : 1 },
id2: { value : 2 }
}
如果我们选择将其视为列表,我们将得到以下响应:
[{
$key: id1,
value : 1
},
{
$key: id2,
value : 2
}]
这意味着我们可以使用push()等方法向其中添加内容。
如果我们选择将数据视为一个对象,那么它将返回如下内容:
{
id1: { value : 1 },
id2: { value : 2 }
}
这可能是你想要的,也可能不是。因此请记住,如果它是一个列表,就把它当作一个列表。如果你选择.object()而不是.list(),Firebase 不会因此对你进行惩罚,但这可能会使数据更难处理。
读取数据
让我们看看读取的情况。以下代码将从数据库中的属性读取数据:
let stream$ = this.angulatFireDatabase.object('/book').valueChanges();
由于它是一个流,这意味着我们可以通过两种方式之一获取数据:
-
使用 async 管道,在模板中显示可观察对象本身
-
从
subscribe()方法中获取数据并将其分配给类的属性。
如果我们进行第一种情况,代码将如下所示:
@Component({
template: ` <div *ngIf="$book | async; let book;">
{{ ( book | async )?.title }}
</div>
`
})
export class Component { book$: Observable<Book>; constructor(private angularFireDatabase: AngularFireDatabase) { this.book$ = this.angularFireDatabase
.object('/book')
.valueChanges()
.map(this.mapBook); }
private mapBook(obj): Book { return new Book(obj); }
}
class Book { constructor(title: string) { } }
值得强调的是我们如何请求数据库中的路径,并使用.map()操作符转换结果:
this.book = this.angularFireDatabase
.object('/book')
.map(this.mapBook);
在模板中,我们使用 async 管道和一个表达式来显示我们的Book实体的标题,当它已经解析时:
<div *ngIf="book$ | async; let book">
{{ book.title }}
</div>
如果我们进行第二种情况,代码将如下所示:
@Component({
template: `
<div>
{{ book.title }}
</div>
`
})
export class BookComponent
{
book:Book;
constructor(private angularFireDatabase: AngularFireDatabase) {
this.angularFireDatabase.object('/book')
.map(mapBook).subscribe( data => this.book = data );
}
mapBook(obj): Book {
return new Book(obj);
}
}
class Book {
constructor(title:string) {}
}
这将减少一些输入,但现在你必须记住取消订阅你的流;这在之前的例子中没有添加。在可能的情况下,请使用 async 管道。
更改数据
有两种类型的数据更改可能发生:
-
破坏性更新:我们覆盖了原有的内容
-
非破坏性更新:我们将传入的数据与已有的数据合并
用于破坏性更新的方法称为set(),使用方式如下:
this.angularFireDatabase.object('/book').set({ title : 'Moby Dick' })
考虑到我们之前的数据如下:
{
title: 'The grapes of wrath',
description: 'bla bla'
}
现在,它已经变成了:
{
title: 'Moby Dick'
}
这正是我们所说的破坏性更新:我们覆盖了title属性,但我们也失去了description属性,因为整个对象被替换了。
如果数据的破坏不是您想要的,那么您可以使用更轻的update()方法。使用它就像写下面这样简单:
this.angularFireDatabase.object('/book').update({ publishingYear : 1931 })
假设在update()操作之前,数据看起来像下面这样:
{
title: 'Grapes of wrath',
description: 'Tom Joad and his family are forced from the farm'
}
现在看起来像这样:
{
title : 'Grapes of wrath',
description : 'Tom Joad and his family are forced from the farm...',
publishingYear : 1931
}
记住根据您的意图选择适当的更新操作,因为这会有所不同。
删除数据
删除数据很简单。我们需要将其分成两个不同的部分,因为它们有些不同,一个是删除对象,一个是删除列表中的项目。
在 Firebase 中,有不同的订阅数据的方式。您可以使用称为valueChanges()的方法订阅更改。这将为您提供要显示的数据。只要您想要显示数据,那么使用此方法就可以了。但是,当您开始想要更改特定数据,比如从列表中删除项目,或者简而言之,当您需要知道您要操作的资源的确切键值时,那么您需要一个新的函数。这个函数被称为snapshotChanges()。使用该函数会给您一个更原始的资源版本。在这种情况下,您需要挖掘出要显示的值。
让我们从第一种情况开始,即删除一个对象。
删除对象
让我们看看两种不同的remove()场景。在第一种情况下,我们想要删除我们的路径指向的内容。想象一下我们正在查看路径/书。那么,我们的删除代码非常简单:
this.angularFireDatabase.list('/books').remove();
删除列表中的项目
在 Firebase 中,从 Firebase 控制台查看数据库时,列表看起来像这样:
books {
0 : 'tomato',
1: 'cucumber'
}
当然,每个项目都有一个内部表示,指向具有哈希值的键。我们有以下情景;我们想要删除列表中的第一项。我们编写的代码看起来像这样:
this.angularFireDatabase.list('/books').remove(<key>)
现在我们发现我们不知道要删除的项目的键是什么。这就是我们开始使用snapshotChanges()方法并尝试找出这一点的地方:
this.angularFireDatabase
.list('/books')
.snapshotChanges()
.subscribe( list => {
console.log('list',list);
})
列表参数是一个列表,但列表项是一个包含我们需要的键以及我们打算在 UI 中显示的值的复杂对象。我们意识到这是正确的方法,并决定在我们的流上使用map()函数将其转换为书籍列表。
首先,我们修改我们的book.model.ts文件,包含一个 key 属性,就像这样:
export class Book {
title: string;
key: string;
constructor(json) {
this.title = json.payload.val();
this.key = json.key;
}
}
我们可以看到,我们需要改变如何访问数据;我们的数据可以在payload.val()下找到,而我们的key很容易检索到。有了这个知识,我们现在可以构建一个列表:
@Component({})
export class BookComponent {
books$:Observable<Book[]>
constructor(private angularFireDatabase:AngularFireDatabase){
this.books$ = this.angularFireDatabase
.list('/books')
.snapshotChanges()
.map(this.mapBooks);
}
private mapBooks(data): Book[] {
return data.map(json => new Book(json));
}
remove(key) {
this,books$.remove(key);
}
在以下代码片段中,我们循环遍历列表中的所有书籍,并为列表中的每本书创建一个删除按钮。我们还将每个删除按钮连接到book.key,也就是我们的key,这是我们在向 Firebase 通信删除操作时需要的。
<div *ngFor="let book of books | async">
{{ book.title }}
<button (click)="remove(book.key)">Remove</button>
</div>
响应变化
Firebase 的云数据库不仅仅是一个看起来像 JSON 的数据库,它还在数据发生变化时推送数据。您可以监听这种变化。这不仅为您提供了云存储,还为您提供了以更协作和实时的方式构建应用程序的机会。许多系统已经像这样工作,例如大多数售票系统、聊天应用程序等。
想象一下,使用 Firebase 构建的系统,例如,预订电影票。您可以看到一个人何时预订了一张票,或者在聊天系统中收到了一条消息,而无需轮询逻辑或刷新应用程序;构建起来几乎是小菜一碟。
AngularFire2,即 Firebase 上的 Angular 框架,使用 Observables。Observables 在发生变化时传达这些变化。从之前的知识中,我们知道可以通过给 subscribe 方法一个回调来监听这些变化,就像这样:
this.angularFireDatabase
.list('/tickets')
.valueChanges()
.subscribe(tickets => {
// this is our new ticket list
});
作为开发人员,您可以拦截这种变化发生时,通过注册subscribe()方法,例如,显示 CSS 动画以吸引用户对变化做出响应,以便他们可以相应地做出响应。
添加身份验证
除非我们至少有一些适当的身份验证,否则我们无法真正构建一个应用程序并称其为发布准备就绪。基本上,我们不能信任任何人使用我们的数据,只有经过身份验证的用户。在 Firebase 中,您可以为数据库设置最高级别的身份验证。在管理工具中点击数据库菜单选项卡,然后选择规则选项卡。应该显示如下内容:
{
"rules": {
".read": "auth != null",
".write": "auth != null"
}
}
让我们来强调以下行:
".read": "auth != null"
在这种情况下,这设置了你整个数据库的读取权限,并且我们给了它值auth != null。这意味着你需要认证才能有任何读取数据库的权限。你可以看到在下一行我们有相同的值,但这次是针对一个叫做.write的规则,它控制着写入权限。
这是一个很好的默认权限。当然,在测试数据库时,你可能想要将值auth == null来关闭认证,但记得将值设置回auth != null,否则你会让你的数据库完全开放。
设置任何类型的认证意味着我们需要执行一些步骤,即:
-
确保规则是开启的,也就是
auth != null -
启用安全方法
-
添加用户或令牌(如果是 OAuth)
-
在应用中使用
AuthService来以编程方式登录用户
使用邮箱/密码进行简单认证
让我们设置一个简单的用户/密码认证。点击认证菜单选项,然后选择登录方法选项卡。然后,启用邮箱/密码选项。它应该看起来像这样:
在这一点上,我们需要添加一个用户,一个被允许访问我们数据的用户。所以,让我们设置这个用户。我们去到用户选项卡,然后点击“添加用户”按钮。它应该看起来像这样:
好的,现在我们有一个邮箱为a@b.com,密码为abc123的用户。我们仍然需要登录这样一个用户,数据库才会显示数据给我们。如果我们不登录,我们的应用看起来会非常空,没有任何数据。我们还会在控制台日志中得到很多错误,说我们没有权限查看数据。
在之前设置 Firebase 时,我们只设置了数据库本身,而没有设置认证部分。由于 Firebase 是一个 Angular 模块,我们需要遵循一些规则:
-
导入模块并将其添加到
@NgModule的import关键字中 -
将
AngularFireAuth服务放入@NgModule中的providers关键字中,这样组件就能够将其注入到其构造函数中 -
执行一个编程登录
模块方面的事情看起来像下面这样:
import { AngularFireAuthModule,
AngularFireAuth } from 'angularfire2/auth'; @NgModule({
imports: [
AngularFireAuthModule
],
providers: [AngularFireAuth]
})
现在,我们准备将服务注入到组件中并执行登录:
import { AngularFireDatabase } from 'angularfire2/database';
import { AngularFireAuth } from 'anguarfire2/auth';
@Component({
template : `
<div *ngFor="let b of books$ | async">
{{ b.title }} {{ b.author }}
</div>
<div *ngIf="book$ | async; let book">
{{ book.title }} {{ book.author }}
</div>
`
})
export class BookComponent {
user;
book$: Observable<Book>;
books$: Observable<Book[]>;
constructor(
private authService: AngularFireAuth,
private angularFireDatabase: AngularFireDatabase
) {
this.user = this.authService.authState;
this.authService.auth
.signInWithEmailAndPassword('a@b.com','abc123'**)**
.then(success => { this.book = this.angularFireDatabase .object('/book')
.valueChanges().map(this.mapBook); this.books = this.angularFireDatabase
.list('/books')
.valueChanges()
.map(this.mapBooks); },
err => console.log(err)
);
}
}
在这里,我们做了两件有趣的事情。
首先,我们将authService的authState分配给一个用户。这是一个 Observable,一旦登录,将包含你的用户。我们现在已经学会了可以使用 async 管道显示 Observables。然而,我们有兴趣从这个用户中获取两件事,uid和email,这样我们就可以看到我们以正确的用户身份登录了。编写模板代码看起来像这样是很诱人的:
<div *ngIf="user | async; let user">
User : {{ user.uid }} {{ user.email }}
</div>
这为我们创建了一个名为 user 的变量,我们可以在登录后引用它。正如预期的那样,一旦登录,这将为我们打印出用户。
现在,我们来看看我们之前的代码的第二部分,登录调用:
authService
.auth .signInWithEmailAndPassword('a@b.com','abc123')
.then(success => { this.book = this.angularFireDatabase.object('/book')
.map(this.mapBook); this.books$ = this.angularFireDatabase.list('/books')
.map(this.mapBooks);
},
err => console.log(err)
)
在这里,我们与authService的auth属性交谈,并调用signInWithEmailAndPassword(email, password)方法。我们传递凭据。该方法返回一个 promise,解决了这个 promise 后,我们设置了我们的属性book和books。如果我们不这样做,首先进行身份验证,我们将会得到很多“访问不允许”的错误。
这里有更多的signInWith...方法,如下所示:
我们敦促你亲自尝试一下。
至于认证方式,我们只是触及了表面。以下是完整的登录方法范围:
尝试一下,看看哪些对你和你的应用程序有用。
总结
Firebase 是一种强大的技术,本质上是云端的后端;它具有响应式 API。AngularFire2 是包装 Firebase 的库的名称。该库专门用于与 Angular 一起使用。
可以监听来自 Firebase 的更改。AngularFire2 通过 RxJS 和 Observables 传达这些更改,这使得我们很容易将 Firebase 纳入我们的应用程序中,一旦我们掌握了使用 HTTP 的 Observables 的基础知识。
希望这是一个有教育意义的章节,进一步激励你选择在 Angular 中使用 RxJS 作为异步操作的首选。
本章是关于独立产品 Firebase 的。重点是要展示在你的指尖上有一种非常强大的技术,它扩展了你对 RxJS 的新知识。
在下一章中,我们将涵盖构建 Angular 应用程序的一个非常重要的方面,即路由。路由是一个核心概念,它允许我们将应用程序分成几个逻辑页面。我们谈论逻辑页面而不是实际页面,因为我们正在构建单页面应用程序(SPA)。你会问什么是区别?路由组件,你将在下一章中了解更多信息,将帮助你定义可以路由到的组件,以及帮助你定义应用程序中可以切换的视口。把你的应用程序想象成一个通行证或者一个框架。在应用程序的框架内,你可以定义诸如顶部菜单或左侧菜单之类的东西,但中间的绘画是你的应用程序中可以切换的部分。我们称之为可替换部分的页面。
第九章:路由
在之前的章节中,我们在应用程序中分离关注点并添加不同的抽象层,以增加应用程序的可维护性做得很好。然而,我们忽视了视觉方面,以及用户体验部分。
此刻,我们的用户界面中充斥着组件和各种东西,散布在单个屏幕上,我们需要提供更好的导航体验和一种直观地改变应用程序状态的逻辑方式。
这是路由变得特别重要的时刻,它给了我们建立应用程序导航叙事的机会,允许我们将不同的兴趣领域分割成不同的页面,这些页面通过一系列链接和 URL 相互连接。
然而,我们的应用程序只是一组组件,那么我们如何在它们之间部署导航方案呢?Angular 路由器是以组件化为目标而构建的。我们将看到如何创建自定义链接,并在接下来的页面中让组件对其做出反应。
在本章中,我们将:
-
了解如何定义路由以在组件之间切换,并将它们重定向到其他路由
-
根据请求的路由触发路由并在我们的视图中加载组件
-
处理和传递不同类型的参数
-
深入了解更高级的路由
-
查看不同的保护路由的方式
-
揭示如何通过查看不同的异步策略来改善响应时间
为 Angular 路由器添加支持
在应用程序中使用路由意味着您希望在导航中在不同主题之间进行切换。通常会使用顶部菜单或左侧菜单,并点击链接以到达目的地。这会导致浏览器中的 URL 发生变化。在单页应用程序(SPA)中,这不会导致页面重新加载。要设置 Angular 路由器非常容易,但我们需要一些准备工作才能被认为已经设置好:
-
在
index.html中指定基本元素 -
导入
RouterModule并告知根模块 -
设置路由字典
-
确定应用程序视口的放置位置,即确定内容应放置在页面的哪个位置
-
如果您想要调查诸如路由或查询参数之类的事情,或者如果您需要以编程方式将用户路由到应用程序中的另一页,则与路由服务进行交互。
指定基本元素
我们需要告诉 Angular 我们想要使用的基本路径,这样它才能在用户浏览网站时正确构建和识别 URL,正如我们将在下一节中看到的那样。我们的第一个任务将是在<HEAD>元素内插入一个基本href语句。在<head>标签内的代码语句的末尾添加以下代码行:
//index.html
<base href="/">
基础标签告诉浏览器在尝试加载外部资源(如媒体或 CSS 文件)时应该遵循的路径,一旦它深入到 URL 层次结构中。
导入和设置路由模块
现在,我们可以开始玩转路由库中存在的所有好东西。首先,我们需要导入RouterModule,我们在应用程序的根模块中执行此操作。因此,我们打开一个名为app.module.ts的文件,并在文件顶部插入以下行:
import { RouterModule } from '@angular/router';
一旦我们这样做了,就该将RouterModule添加为AppModule类的依赖项了。
RouterModule是一个有点不同的模块;它需要在添加为依赖模块的同时进行初始化。它看起来像这样:
@NgModule({
imports: [RouterModule.forRoot(routes, <optional config>)]
})
我们可以看到这里指向了我们尚未定义的变量路由。
定义路由
routes是一个路由条目列表,指定了应用程序中存在哪些路由以及哪些组件应该响应特定路由。它可以看起来像这样:
let routes = [{
path: 'products',
component: ProductsComponent
}, {
path: '**',
component: PageNotFound
}]
路由列表中的每个项目都是一个带有多个属性的对象。最重要的两个属性是path和component。path 属性是路由路径,注意,您应该指定不带前导/的路径值。因此,将其设置为products,与前面的代码一样,意味着我们定义了用户导航到/products时会发生什么。component属性指向应该响应此路由的组件。指出的组件、模板和数据是用户在导航到该路由时将看到的内容。
第一个指定的路由定义了路径/products,最后一个路由项指定了**,这意味着它匹配任何路径。顺序很重要。如果我们首先定义了路由项**,那么products将永远不会被命中。最后定义**的原因是,我们希望有一个路由来处理用户输入未知路由的情况。现在,我们可以向用户展示一个由PageNotFound组件模板定义的漂亮页面,而不是向用户显示空白页面。
您可以在路由项上定义更多属性,也可以设置更复杂的路由。现在这就够了,这样我们就可以对路由设置有一个基本的理解。
定义一个视口
一旦我们走到这一步,就是定义一个视口,路由内容应该在其中呈现。通常,我们会构建一个应用程序,其中一部分内容是静态的,另一部分可以被切换,就像这样:
//app.component.html
<body>
<!- header content ->
<!- router content ->
<!- footer content ->
</body>
在这一点上,我们涉及router-outlet元素。这是一个告诉路由器这是你应该呈现内容的元素。更新您的app.component.html看起来像这样:
<body>
<!- header content ->
<router-outlet> </router-outlet>
<!- footer content ->
</body>
现在我们已经导入并初始化了router模块。我们还为两个路由定义了一个路由列表,并且已经定义了路由内容应该呈现的位置。这就是我们建立路由器的最小设置所需的一切。在下一节中,我们将看一个更现实的例子,并进一步扩展我们对路由模块的了解以及它可以帮助我们的知识。
构建一个实际的例子-设置路由服务
让我们描述一下问题领域。在本书的过程中,我们一直在处理番茄钟会话的上下文中的任务。到目前为止,我们一直在一个大的可视堆中创建所有需要的组件和其他构造。从用户的角度来看,更自然的方法是想象我们有专门的视图可以在之间导航。以下是用户的选择:
-
用户到达我们的应用程序并检查待办任务的当前列表。用户可以安排任务按顺序完成,以获得下一个番茄钟会话所需的时间估计。
-
如果需要,用户可以跳转到另一个页面并查看创建任务表单(我们将创建表单,但直到下一章才实现其编辑功能)。
-
用户可以随时选择任何任务并开始完成它所需的番茄钟会话。
-
用户可以在已经访问过的页面之间来回移动。
让我们看看前面的用户交互,并翻译一下这意味着我们应该支持哪些不同的视图:
-
需要有一个列出所有任务的页面
-
应该有一个包含创建任务表单的页面
-
最后,应该有一种方法在页面之间来回导航
为演示目的构建一个新组件
到目前为止,我们已经构建了两个明确定义的组件,我们可以利用它们来提供多页面导航。但为了提供更好的用户体验,我们可能需要第三个。我们现在将介绍表单组件,我们将在第十章中更详细地探讨,作为我们示例中更多导航选项的一种方式。
我们将在任务特性文件夹中创建一个组件,预期在下一章中使用该表单来发布新任务。在每个位置指出的位置创建以下文件:
// app/tasks/task-editor.component.ts file
import { Component } from '@angular/core';
@Component({
selector: 'tasks-editor',
templateUrl: 'app/tasks/task-editor.component.html'
})
export default class TaskEditorComponent {
constructor() {}
}
// app/tasks/task-editor.component.html file
<form class="container">
<h3>Task Editor:</h3>
<div class="form-group">
<input type="text"
class="form-control"
placeholder="Task name"
required>
</div>
<div class="form-group">
<input type="Date"
class="form-control"
required>
</div>
<div class="form-group">
<input type="number"
class="form-control"
placeholder="Points required"
min="1"
max="4"
required>
</div>
<div class="form-group">
<input type="checkbox" name="queued">
<label for="queued"> this task by default?</label>
</div>
<p>
<input type="submit" class="btn btn-success" value="Save">
<a href="/" class="btn btn-danger">Cancel</a>
</p>
</form>
这是组件的最基本定义。我们需要从我们的特性模块中公开这个新组件。最后,我们需要在路由列表中为这个组件输入路由项并配置路由。在app/tasks/task.module.ts文件中添加以下代码片段:
import { TasksComponent } from './tasks.component';
import { TaskEditorComponent } from './task.editor.component';
import { TaskTooltipDirective } from './task.tooltip.directive';
@NgModule({
declarations: [
TasksComponent,
TaskEditorComponent,
TaskTooltipDirective
],
exports: [
TasksComponent,
TaskEditorComponent,
TaskTooltipDirective
]
})
export class TaskModule{}
现在是时候配置路由了。我们需要分两步完成:
-
创建包含我们路由的模块
routes.ts -
在根模块中设置路由
首要任务是定义路由:
// app/routes.ts file
[{
path: '',
component : HomeComponent
},{
path: 'tasks',
name: 'TasksComponent',
component: TasksComponent
}, {
path: 'tasks/editor',
name: 'TaskEditorComponent',
component: TaskEditorComponent
}, {
path: 'timer',
name: 'TimerComponent',
component: TimerComponent
}
]
第二个任务是初始化路由。我们在根模块中完成这个任务。要初始化路由,我们需要调用RouteModule及其静态方法forRoot,并将路由列表作为参数提供给它:
// app/app.module.ts file
import { RouterModule } from '@angular/router';
import routes from './routes';
@NgModule({
...
imports: [RouterModule.forRoot(routes)]
...
})
清理路由
到目前为止,我们已经设置了路由,使它们按照应该的方式工作。然而,这种方法并不那么容易扩展。随着应用程序的增长,将会有越来越多的路由添加到routes.ts文件中。就像我们将组件和其他结构移动到它们各自的特性目录中一样,我们也应该将路由移动到它们应该属于的地方。到目前为止,我们的路由列表包括一个属于计时器特性的路由项,两个属于任务特性的路由项,以及一个指向默认路由/的路由项。
我们的清理工作将包括:
-
为每个特性目录创建一个专用的
routes.ts文件 -
在每个具有路由的特性模块中调用
RouteModule.forChild -
从任何不严格适用于整个应用程序的根模块中删除路由,例如
** = route not found
这意味着应用程序结构现在看起来像以下内容:
/timer
timer.module.ts
timer.component.ts
routes.ts
/app
app.module.ts
app.component.ts
routes.ts
/task
task.module.ts
task.component.ts
routes.ts
...
创建了一些文件后,我们准备初始化我们的功能路由。基本上,对于/timer/routes.ts和/task/routes.ts,初始化是相同的。因此,让我们看一下routes.ts文件和预期的更改:
import routes from './routes';
@NgModule({
imports: [
RouteModule.forChild(routes)
]
})
export class FeatureModule {}
这里的重点是,将路由从app/routes.ts移动到<feature>/routes.ts意味着我们在各自的模块文件中设置路由,即<feature>/<feature>.module.ts。此外,当设置功能路由时,我们调用RouteModule.forChild,而不是RouteModule.forRoot。
路由指令 - RouterOutlet、RouterLink 和 RouterLinkActive
我们已经在为 Angular 路由添加支持部分提到,为了设置路由,有一些基本的必要步骤使路由工作。让我们回顾一下它们是什么:
-
定义路由列表
-
初始化
Route模块 -
添加视口
对于这个实际示例的目的和目的,我们已经完成了前两项,剩下的是添加视口。一个指令处理 Angular 的视口;它被称为RouterOutlet,只需要放置在设置路由的组件模板中。因此,通过打开app.component.html并添加<router-outlet></router-outlet>,我们解决了列表上的最后一个项目。
当然,路由还有很多内容。一个有趣的事情,这是每个路由器都期望的,就是能够在定义的路由给定的情况下生成可点击的链接。routerLink指令为我们处理这个,并且以以下方式使用:
<a routerLink="/" routerLinkActive="active">Home</a>
routerLink指向路由路径,注意前导斜杠。这将查找我们的路由列表中定义的与路由路径/对应的路由项。经过对我们的代码的一些调查,我们找到了一个看起来像下面这样的路由项:
[{
path : '',
component : HomeComponent
}]
特别注意在定义路由时,我们不应该有前导斜杠,但是在使用该路由项创建链接并使用routerLink指令时,我们应该有一个尾随斜杠。
这产生了以下元素:
<a _ngcontent-c0="" routerlink="/" routerlinkactive="active" ng-reflect-router-link="/" ng-reflect-router-link-active="active" href="/" class="active">Home</a>
看起来很有趣,关键是href设置为/,类已设置为 active。
最后一部分很有趣,为什么类会被设置为活动状态?这就是routerLinkActive="active"为我们做的。它调查当前路由是否与我们当前所在的routerLink元素相对应。如果是,它将被授予活动 CSS 类。考虑以下标记:
<a routerLink="/" routerLinkActive="active" >Home</a> <a routerLink="/tasks"routerLinkActive="active" >Tasks</a>
<a routerLink="/timer"routerLinkActive="active" >Timer</a>
只有一个元素会被设置为活动类。如果浏览器的 URL 指向/tasks,那么它将是第二项,而不是第一项。添加活动类的事实给了你作为开发者的机会,可以为活动菜单元素设置样式,因为我们正在创建一个链接列表,就像前面的代码所定义的那样。
命令式地触发路由
导航的方式不仅仅是点击具有routerLink指令的元素。我们也可以通过代码或命令式地处理导航。为此,我们需要注入一个具有导航能力的导航服务。
让我们将导航服务,也称为Router,注入到一个组件中:
@Component({
template : `
<Button (click)="goToTimer()">Go to timer</Button>
`
})
export class Component {
constructor(private router:Router) {}
goToTimer() {
this.router.navigate(['/timer']);
}
}
如你所见,我们设置了一个goToTimer方法,并将其与按钮的点击事件关联起来。在这个方法中,我们调用了router.navigate(),它接受一个数组。数组中的第一项是我们的路由;请注意末尾斜杠的使用。这就是命令式导航的简单方式。
处理参数
到目前为止,我们在路由中配置了相当基本的路径,但是如果我们想要构建支持在运行时创建参数或值的动态路径呢?创建(和导航到)从我们的数据存储中加载特定项目的 URL 是我们每天需要处理的常见操作。例如,我们可能需要提供主细节浏览功能,因此主页面中的每个生成的 URL 都包含在用户到达细节页面时加载每个项目所需的标识符。
我们基本上在这里解决了一个双重问题:在运行时创建具有动态参数的 URL,并解析这些参数的值。没问题;Angular 路由已经帮我们解决了这个问题,我们将通过一个真实的例子来看看。
构建详细页面 - 使用路由参数
首先,让我们回到任务列表组件模板。我们有一个路由,可以带我们到任务列表,但是如果我们想要查看特定的任务,或者想要将任务显示在特定的页面上呢?我们可以通过以下方式轻松解决:
-
更新任务组件,为每个项目添加导航功能,让我们能够导航到任务详细视图。
-
为一个任务设置路由,其 URL 路径将是
tasks/:id。 -
创建一个
TaskDetail组件,只显示一个任务。
让我们从第一个要点开始:更新tasks.component.ts。
应该说的是,我们可以用两种方式解决这个问题:
-
进行命令式导航
-
使用
routerLink构建一个带有参数的路由
让我们先尝试展示如何进行命令式导航:
// app/tasks/tasks.component.html file
@Component({ selector: 'tasks', template: ` <div*ngFor="let task of store | async">
{{ task.name }}
<button (click)="navigate(task)">Go to detail</button>
</div> `
})
export class TasksComponent {
constructor(private router: Router) {}
navigate(task:Task) {
this.router.navigate(['/tasks',task.id]);
}
}
让我们强调以下代码片段:
this.router.navigate(['/tasks',task.id]);
这将产生一个看起来像/tasks/13或/tasks/99的链接。在这种情况下,13和99只是编造的数字,用来展示路由路径可能是什么样子的。
导航的第二种方式是使用routerLink指令。为了实现这一点,我们的前面的模板将略有不同:
<div*ngFor="let task of store | async">
{{ task.name }}
<a [routerLink]="['/tasks/',task.id]">Go to detail</a>
</div>
这两种方式都可以,只需选择最适合你的方式。
现在对于列表中的第二项,即设置路由,这将匹配先前描述的路由路径。我们打开task/routes.ts并向列表中添加以下条目:
[
...
{
path : '/tasks/:id',
component : TaskDetailComponent
}
...
]
有了这个路由,我们列表中的最后一项需要修复,即定义TaskDetailComponent。让我们从一个简单版本开始:
import { Component } from '@angular/core'; @Component({
selector: 'task-detail', template: 'task detail' })
export class TaskDetailComponent { }
有了这一切,我们能够点击列表中的任务并导航到TaskDetailComponent。然而,我们在这里并不满意。这样做的真正原因是为了更详细地查找任务。因此,我们在TaskDetail组件中缺少一个数据调用到我们的TaskService,在那里我们要求只获取一个任务。记得我们到TaskDetail的路由是/tasks/:id吗?为了正确调用我们的TaskService,我们需要从路由中提取出 ID 参数,并在调用我们的TaskService时使用它作为参数。如果我们路由到/tasks/13,我们需要使用getTask(13)调用TaskService,并期望得到一个Task。
因此,我们有两件事要做:
-
从路由中提取出路由参数 ID。
-
在
TaskService中添加一个getTask(taskId)方法。
为了成功完成第一个任务,我们可以注入一个叫做ActivatedRoute的东西,并与它的params属性交谈,这是一个 Observable。来自该 Observable 的数据是一个对象,其中一个属性是我们的路由参数:
this.route .params .subscribe( params => {
let id = params['id']; });
好吧,这只解决了问题的一半。我们能够以这种方式提取出 ID 参数的值,但我们并没有对它做任何处理。我们也应该进行数据获取。
如果我们添加switchMap语句,那么我们可以获取数据,进行数据调用,并返回数据的结果,如下所示:
@Component({
template: `
<div *ngIf="(task$ | async) as task">
{{ task.name }}
</div>
`
})
export class TaskDetailComponent implements OnInit {
task$:Observable<Task>;
constructor(private route:ActivatedRoute) {}
ngOnInit() {
this.task$ = this.route .params
.switchMap( params =>
this.taskService.getTask(+params['id'])
)
}
}
最后一步是向TaskService添加getTask方法:
export class TaskService{
...
getTask(id): Observable<Task> {
return this.http.get(`/tasks/${id}`).map(mapTask);
}
}
过滤您的数据-使用查询参数
到目前为止,我们一直在处理tasks/:id格式的路由参数。像这样形成的链接告诉我们上下文是任务,并且要到达特定任务,我们需要指定其编号。这是关于缩小到我们感兴趣的特定数据的。查询参数有不同的作用,它们旨在对数据进行排序或缩小数据集的大小:
// for sorting
/tasks/114?sortOrder=ascending
// for narrowing down the data set
/tasks/114?page=3&pageSize=10
查询参数被识别为?字符之后发生的一切,并且由&符号分隔。要获取这些值,我们可以使用ActivatedRoute,就像我们处理路由参数一样,但是我们要查看ActivatedRouter实例上的不同集合:
constructor(private route: ActivatedRoute) {}
getData(){
this.route.queryParamMap
.switchMap( data => { let pageSize = data.get('pageSize'); let page = data.get('page'); return this._service.getTaskLimited(pageSize,page); })
高级功能
到目前为止,我们已经涵盖了基本的路由,包括路由参数和查询参数。不过,Angular 路由器非常强大,能够做更多的事情,比如:
-
定义子路由,每个组件都可以有自己的视口
-
相对导航
-
命名出口,同一个模板中可以有不同的视口
-
调试,您可以轻松启用调试,展示基于您的路由列表的路由工作方式
子路由
什么是子路由?子路由是一个概念,我们说一个路由有子路由。我们可以像这样为一个功能编写路由:
{
path : 'products',
component : ProductListComponent
},
{
path : 'products/:id',
component : ProductsDetail
},
{
path : 'products/:id/orders',
component : ProductsDetailOrders
}
然而,如果我们想要有一个产品容器组件,并且在该组件中,我们想要显示产品列表或产品详细信息会发生什么?对于这种情况,我们希望以不同的方式分组我们的路由。我们已经明确表示Product容器是您应该路由到的父组件。因此,当转到路由/products时,它将是第一个响应者。让我们从设置products路由开始。它应该监听/products URL,并且有ProductsContainerComponent做出响应,如下所示:
{
path: 'products',
component : ProductsContainerComponent
}
我们的其他路由可以作为其子路由添加,如下所示:
{
path: 'products',
component : ProductsContainerComponent,
children : [{
path : '',
component : ProductListComponent
}, {
path: ':id',
component : ProductDetailComponent
}, {
path : ':id/orders',
component : ProductsDetailOrders
}]
}
现在,从组织的角度来看,这可能更有意义,但在技术上有一些区别;ProductsContainer将需要有自己的router-outlet才能工作。因此,到目前为止,我们应用的快速概述如下:
/app . // contains router-outlet
/products
ProductsContainerComponent // contains router outlet
ProductListComponent
ProductDetailComponent
ProductsDetailOrders
这样做的主要原因是我们可以创建一个容器,为其提供一些页眉或页脚信息,并呈现可替换的内容,就像我们可以为应用程序组件的模板做的那样:
// ProductsContainerComponent template
<!-- header -->
<router-outlet></router-outlet>
<!-- footer -->
总之,容器方法的好处如下:
-
创建子路由意味着我们可以将功能着陆页视为页面视图或视口,因此我们可以定义诸如页眉、页脚和页面的一部分作为可以替换的内容
-
在定义路由路径时,我们需要写得更少,因为父路由已经被假定
绝对导航与相对导航
有两种导航方式:绝对路由和相对路由。绝对路由是从路由根目录指定其路由,例如/products/2/orders,而相对路由则知道其上下文。因此,相对路由可能看起来像/orders,因为它已经知道自己在/products/2,所以完整的路由将读作/products/2/orders。
您可能只使用绝对路径就可以了;但是使用相对路径也有好处:重构变得更容易。想象一下移动一堆组件,突然所有硬编码的路径都是错误的。您可能会认为您应该创建路由的类型化版本,例如routes.ProductList,这样您只需要在一个地方进行更改。这可能是这样,那么您就处于一个良好的状态。然而,如果您不采用这些工作方式,那么相对路由就适合您。因此,让我们看一个示例用法:
this.router.navigate(['../'], { relativeTo: this.route });
在这里,我们向上走了一级。想象一下,我们在/products。这会把我们带回到/。这里的重要部分是包括第二个参数并指定relativeTo: this.route部分。
命名出口
如果您只是不断地添加它们,那么我们可以在组件模板中有多个出口指令。
<router-outlet></router-outlet>
<router-outlet></router-outlet>
<router-outlet></router-outlet>
<router-outlet></router-outlet>
我们将内容呈现出四次。这并不是我们添加多个出口的真正原因。我们添加多个router-outlet是为了能够给它们取不同的名称。然而,这样做的商业案例是什么呢?想象一下,我们想要显示一个页眉部分和一个正文部分;根据我们所在的路由部分不同,它们会有所不同。它可能看起来像这样:
<router-outlet name="header"></router-outlet>
<router-outlet name="body"></router-outlet>
现在,我们能够在路由时针对特定的router-outlet进行定位。那么我们该如何:
-
定义应该定位到特定命名出口的路由?
-
导航到命名出口?
-
清除命名的出口?
以下代码显示了我们如何设置路由:
{ path: 'tasks', component: JedisShellComponent,
children : [{
path: '',
component : JediHeaderComponent,
outlet : 'header'
},
{
path: '',
component : JediComponent,
outlet : 'body'
}] }
前面的代码显示了我们如何设置一个外壳页面,它被称为外壳,因为它充当了命名出口的外壳。这意味着我们的外壳组件看起来像这样:
static data
<router-outlet name="header"></router-outlet>
<router-outlet name="body"></router-outlet>
some static data after the outlet
我们还设置了两个子路由,分别指向一个命名的出口。想法是当我们路由到/tasks时,TaskHeaderComponent将被渲染到头部出口,TaskComponent将被渲染到主体出口。
还有一种完全不同的使用路由的方式,即作为弹出出口。这意味着我们可以将内容渲染到一个出口,然后再将其移走。为了实现这一点,我们需要设置路由如下:
{
path : 'info',
component : PopupComponent,
outlet : 'popup'
}
这需要与一个命名的出口一起定义,就像这样:
<router-outlet name="popup"></router-outlet>
首先浏览到一个页面,这个PopupComponent将不可见,但我们可以通过设置一个方法来使其可见,比如这样:
@Component({
template : `
<button (click)="openPopup()"></button>
`
})
export class SomeComponent {
constructor(private router: Router) {}
openPopup(){ this.router.navigate([{ outlets: { popup : 'info' }}]) }
}
这里有趣的部分是router.navigate的参数是{ outlets : { <name-of-named-outlet> : <name-of-route> } }。
通过这种语法,我们可以看到只要路由正确设置,就可以在其中渲染任何内容。所以,假设路由看起来像这样:
{
path : 'info',
component : PopupComponent,
outlet : 'popup'
},
{
path : 'error',
component : ErrorComponent,
outlet : 'popup'
}
现在,我们有两个可能被渲染到popup出口的候选者。要渲染错误组件,只需写入以下内容:
this.router.navigate([{ outlets: { popup : 'error' }])
还有一件事情我们需要解决,那就是如何移除命名出口的内容。为此,我们需要修改我们的组件如下:
@Component({
template : `
<button (click)="openPopup()"></button>
`
})
export class SomeComponent {
constructor(private router: Router) {}
openPopup(){ this.router.navigate([{ outlets: { popup : 'info'} }]) }
closePopup() { this.router.navigate([{ outlets: { popup: null }}]) }
}
我们添加了closePopup()方法,里面我们要做的是针对我们命名的popup出口并提供一个空参数,就像这样:
this.router.navigate([ outlets: { popup: null } ])
调试
为什么我们要调试路由?嗯,有时路由不会按我们的想法工作;在这种情况下,了解更多关于路由的行为和原因是很有帮助的。要启用调试,您需要提供一个启用调试的配置对象,就像这样:
RouterModule.forRoot(routes,{ enableTracing: true })
尝试从我们的起始页面路由到,比如,/products将会是这样:
我们可以看到这里触发了几个事件:
-
NavigationStart:导航开始时
-
RoutesRecognized:解析 URL 并识别 URL
-
路由配置加载开始:在读取延迟加载配置时触发
-
RouteConfigLoadEnd:路由已经延迟加载完成
-
GuardsCheckStart:评估路由守卫,也就是说,我们能否前往这个路由
-
GuardsCheckEnd:路由守卫检查完成
-
ResolveStart: 尝试在路由到路径之前获取我们需要的数据 -
ResolveEnd: 完成解析它所依赖的数据 -
NavigationCancel: 有人或某物取消了路由 -
NavigationEnd: 完成路由
有很多可能发生的事件。正如您从前面的图像中所看到的,我们的项目列表涵盖的事件比图像显示的更多。这是因为我们没有任何懒加载的模块,因此这些事件不会被触发,而且我们也没有设置任何解析守卫,例如。此外,NavigationCancel只有在某种原因导致路由失败时才会发生。了解触发了哪些事件以及何时触发是很重要的,这样您就会知道代码的哪一部分可能出错。我们将在下一节中仔细研究事件GuardsCheckStart和GuardsCheckEnd,以确定您是否有权限访问特定路由。
通过位置策略微调我们生成的 URL
正如您所见,每当浏览器通过routerLink的命令或通过Router对象的 navigate 方法的执行导航到一个路径时,显示在浏览器位置栏中的 URL 符合我们习惯看到的标准化 URL,但实际上是一个本地 URL。从不会向服务器发出调用。URL 显示自然结构的事实是由于 HTML5 历史 API 的pushState方法在幕后执行,并允许导航以透明的方式添加和修改浏览器历史记录。
有两个主要的提供者,都是从LocationStrategy类型继承而来,用于表示和解析浏览器 URL 中的状态:
-
PathLocationStrategy: 这是位置服务默认使用的策略,遵循 HTML5pushState模式,产生没有哈希碎片的清晰 URL(example.com/foo/bar/baz)。 -
HashLocationStrategy: 此策略利用哈希片段来表示浏览器 URL 中的状态(example.com/#foo/bar/baz)。
无论Location服务默认选择的策略是什么,您都可以通过选择HashLocationStrategy作为首选的LocationStrategy类型,回退到基于旧的哈希导航。
为此,请转到app.module.ts并告诉路由器,从现在开始,每当注入器需要绑定到LocationStrategy类型以表示或解析状态(内部选择PathLocationStrategy),它应该不使用默认类型,而是使用HashLocationStrategy。
您只需要在RouterModule.forRoot()方法中提供第二个参数,并确保useHash设置为true:
....
@NgModule({
imports : [
RouterModule.forRoot(routes, { useHash : true })
]
})
使用 AuthGuard 和 CanActivate hook 来保护路由
我们可以使用CanActivate有两种方式:
-
限制需要登录的数据访问
-
限制需要具有正确角色的数据访问
因此,这实质上涉及潜在的身份验证和授权。我们需要做的是:
-
创建一个需要评估您是否有权限的服务
-
将该服务添加到路由定义中
这只是您创建的任何服务,但它需要实现CanActivate接口。所以,让我们创建它:
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private authService: AuthService){ }
canActivate() {
return this.authService.isAuthenticated();
}
}
我们所做的是通过声明canActivate()方法来实现CanActivate接口。我们还注入了一个我们假装存在的AuthService实例。关键是canActivate()方法应该在导航应该继续时返回true,在应该停止时返回false。
现在,下一步是将此服务添加到路由配置;我们通过添加到canActivate属性保存的列表来实现:
{
path : 'products',
component: ProductsShell,
canActivate: [ AuthGuard ]
}
让我们尝试一下,看看如果我们从canActivate()方法返回true或false,我们的路由调试会发生什么变化:
在GuardsCheckEnd中,我们看到shouldActivate: true属性被发出。这是因为我们的canActivate方法当前返回true,也就是说,我们允许路由发生。
让我们看看如果我们将canActivate更改为返回false会发生什么:
在这里,我们可以看到在GuardsCheckEnd事件中,shouldActivate现在的值为false。我们还可以看到发出了NavigationCancel事件。最终结果是,基于canActivate()方法返回false,我们不被允许改变路由。现在轮到您实现一个真正的身份验证/授权方法并使其真正起作用。
Resolve - 在路由之前获取和解析数据
使用此钩子的原因是,我们可以延迟路由发生,直到我们获取了所有必要的数据。但是,您不应该有任何长时间运行的操作。更真实的情况是,您已经导航到了一个产品路由,比如/products/114,并且想要在数据库中查找该产品并将其提供给路由。
您需要以下内容来实现这一点:
-
实现
Resolve<T>接口 -
从
resolve()方法返回一个Promise -
将服务设置为模块的提供者
-
在提供数据的路由的 resolve 属性中设置服务
让我们实现这个服务:
@Injectable()
export class ProductResolver implement Resolve<Product> {
constructor(
private http:Http,
private service: DataService,
private router:Router
) {}
resolve(route: ActivatedRouteSnapshot) {
let id = route.paramMap.get('id');
return this.service.getProduct( id ).then( data => {
if(data) {
return data;
}
else {
this.router.navigate(['/products']);
}
}, error => { this.router.navigate(['/errorPage']) });
}
}
// product.service.ts
export class DataService {
getProduct(id) {
return http.get(`/products/${id}`)
.map( r => r.json )
.map(mapProduct)
.toPromise()
}
}
在这一点上,我们已经实现了Resolve<T>接口,并确保从resolve()方法返回一个Promise。我们还有一些逻辑,如果我们得到的数据不是我们期望的,或者发生错误,我们将重定向用户。
作为下一步,我们需要将服务添加到我们模块的providers关键字中:
@NgModule({
...
providers: [ProductResolver]
...
})
对于最后一步,我们需要将服务添加到路由中:
{
path: 'products/:id',
resolve: [ProductResolver],
component: ProductDetail
}
CanDeactivate - 处理取消和保存
好的,我们有以下情况:用户在一个页面上,他们填写了很多数据,然后决定按下一个导航链接离开页面。在这一点上,作为开发者,你想建立以下内容:
-
如果用户填写了所有数据,他们应该继续导航
-
如果用户没有填写所有数据,他们应该有离开页面的选项,或者留下来继续填写数据
为了支持这些情景,我们需要做以下事情:
-
创建一个实现
CanDeactivate接口的服务。 -
将目标组件注入到服务中。
-
将该服务设置为模块的提供者。
-
在路由中将服务设置为
canDeactivate响应器。 -
使目标组件可注入,并将其设置为模块的提供者。
-
编写逻辑来处理所有字段都填写的情况 - 如果字段缺失,则保持路由,如果字段缺失,则显示一个确认消息,让用户决定是否继续路由或不继续。
从服务开始,它应该是这样的:
@Injectable()
export class CanDeactivateService implements CanDeactivate {
constructor(private component: ProductDetailComponent) {}
canDeactivate(): boolean | Promise<boolean> {
if( component.allFieldsAreFilledIn() ) {
return true;
}
return this.showConfirm('Are you sure you want to navigate away,
you will loose data');
}
showConfirm() {
return new Promise(resolve => resolve( confirm(message) ))
}
}
值得强调的是,我们如何在canDeactivate方法中定义逻辑,使其返回类型要么是Boolean,要么是Promise<boolean>。这使我们有自由在所有有效字段都填写的情况下提前终止方法。如果没有,我们向用户显示一个确认消息,直到用户决定该做什么。
第二步是告诉模块关于这个服务:
@NgModule({
providers: [CanDeactivateService]
})
现在,要改变路由:
{
path : 'products/:id',
component : ProductDetailComponent,
canDeactivate : [ CanDeactivateService ]
}
接下来,我们要做一些我们通常不做的事情,即将组件设置为可注入的;这是必要的,这样它才能被注入到服务中:
@Component({})
@Injectable()
export class ProductDetailComponent {}
这意味着我们需要将组件作为模块中的提供者添加:
@NgModule({
providers: [
CanDeactivateService, ProductDetailComponent
]
})
异步路由 - 提高响应时间
最终,您的应用程序将会变得越来越庞大,您放入其中的数据量也会增长。这样做的结果是,应用程序在初始启动时需要很长时间,或者应用程序的某些部分需要很长时间才能启动。有一些方法可以解决这个问题,比如懒加载和预加载。
懒加载
懒加载意味着我们不会一开始就加载整个应用程序。我们的应用程序的部分可以被隔离成只有在需要时才加载的块。今天,这主要集中在路由上,这意味着如果您请求一个以前没有访问过的特定路由,那么该模块及其所有构造将被加载。这不是默认情况下存在的东西,但是您可以很容易地设置它。
让我们看看一个现有模块及其路由,看看我们如何将其变成一个懒加载模块。我们将不得不在以下地方进行更改:
-
我们特性模块的路由列表
-
在我们应用程序的路由中添加一个路由条目,使用特定的懒加载语法
-
删除其他模块中对特性模块的所有引用
首先,让我们快速查看一下我们特性模块在更改之前的路由列表:
// app/lazy/routes.ts
let routes = [{
path : 'lazy',
component : LazyComponent
}]
// app/lazy/lazy.module.ts
@NgModule({
imports: [RouterModule.forChild(routes)]
})
export class LazyModule {}
我们的第一项任务是将第一个路由条目的路径从 lazy 更改为'',一个空字符串。听起来有点违反直觉,但有一个解释。
我们要做的第二件事是纠正第一件事;我们需要在我们的应用程序模块路由中添加一个懒加载路由条目,就像这样:
// app/routes.ts
let routes = [{
path: 'lazy', loadChildren: 'app/lazy/lazy.module#LazyModule' }];
正如您所看到的,我们添加了loadChildren属性,该属性期望一个字符串作为值。这个字符串值应该指向模块的位置,因此它看起来像<从根目录到模块的路径>#<模块类名>。
最后一步是删除其他模块中对该模块的所有引用,原因很自然:如果您还没有导航到/lazy,那么服务或组件等实际上还不存在,因为它的捆绑包还没有加载到应用程序中。
最后,让我们看看这在调试模式下是什么样子。第一张图片将展示在我们导航到懒加载模块之前的样子:
在这里,我们有我们项目设置生成的正常捆绑包。现在让我们导航到我们的懒加载路由:
我们可以看到一个名为5.chunk.js的捆绑包已经被添加,它包含了我们新加载的模块及其所有构造。
不过,需要小心的是,不要在想要在其他地方使用的延迟加载模块中放置构造。相反,你可以让你的lazy模块依赖于其他模块中的服务和构造,只要它们不是延迟加载的。因此,一个很好的做法是尽可能多地将模块延迟加载,但共享功能不能延迟加载,出于上述原因。
CanLoad - 除非用户有权限,否则不要延迟加载
延迟加载是一个很棒的功能,可以通过确保应用程序只启动绝对需要的捆绑包来大大减少加载时间。然而,即使你确保大多数模块都是延迟加载的,你需要更进一步,特别是如果你的应用程序有任何身份验证或授权机制。
考虑以下情况,假设你的多个模块需要用户进行身份验证或具有管理员角色。如果用户在这些区域不被允许,那么当用户路由到它们的路径时加载这些模块是没有意义的。为了解决这种情况,我们可以使用一个叫做CanLoad的守卫。CanLoad确保我们首先验证是否根据条件延迟加载某个模块是有意义的。你需要做以下事情来使用它:
-
在服务中实现
CanLoad接口和canLoad()方法。 -
将前述服务添加到路由的
CanLoad属性中。
以下创建了一个实现CanLoad接口的服务:
@Injectable()
export class CanLoadService implements CanLoad {
canLoad(route: Route) {
// replace this to check if user is authenticated/authorized
return false;
}
}
从代码中可以看出,canLoad()方法返回一个布尔值。在这种情况下,我们让它返回false,这意味着模块不会被加载。
我们需要做的第二件事是更新路由以使用这个服务作为canLoad守卫:
{
path: 'lazy', loadChildren : 'app/lazy/lazy.module#LazyModule', canLoad: [CanLoadService**]** }
如果我们尝试浏览到localhost:4200/lazy,我们无法前往,因为我们的canLoad通过返回false告诉我们不能。查看控制台,我们还看到以下内容:
在这里,它说由于守卫,无法加载子级,所以守卫起作用。
注意当你更新CanLoadService和canLoad()方法来返回true时,一切都像应该的那样正常加载。
不要忘记将CanLoadService添加到根模块的 providers 数组中。
预加载
到目前为止,我们一直在讨论急加载和懒加载。在这种情况下,急加载意味着我们一次性加载整个应用程序。懒加载是指我们将某些模块标识为只在需要时加载的模块,也就是说,它们是懒加载的。然而,在这两者之间还有一些东西:预加载模块。但是,为什么我们需要介于两者之间的东西呢?嗯,想象一下,我们可以非常肯定地知道,普通用户在登录后 30 秒内会想要访问产品模块。将产品模块标记为应该懒加载的模块是有道理的。如果它可以在登录后立即在后台加载,那么当用户导航到它时,它就已经准备好了。这正是预加载为我们做的事情。
我们通过发出以下命令来启用预加载:
@NgModule({
imports: [
RouterModule.forRoot(routes, {
preloadingStrategy: PreloadAllModules
})
]
})
PreloadAllModules值预加载每个懒加载的路由,除了那些由canLoad守卫保护的路由。这是有道理的:canLoad只有在我们经过身份验证/授权或者基于我们设置的其他条件时才加载。
因此,如果我们有一堆模块都被设置为懒加载,比如产品、管理员、类别等等,所有这些模块都会根据PreloadAllModules在初始启动后立即加载。这在桌面上可能已经足够了。然而,如果你使用的是 3G 等移动连接,这可能会太重了。在这一点上,我们需要更好、更精细的控制。我们可以实现自己的自定义策略来做到这一点。我们需要做以下几件事来实现这一点:
-
创建一个实现
PreloadingStrategy和preload方法的服务。 -
如果应该预加载,
preload()方法必须调用load()方法,否则应该返回一个空的 Observable。 -
通过在路由上使用数据属性或使用服务来定义路由是否应该预加载。
-
将创建策略服务设置为
preloadingStrategy的值。
首先,定义我们的服务,我们可以这样创建它:
@Injectable() export class PreloadingStrategyService implements PreloadingStrategy { preload(route: Route, load: () => Observable<any>): Observable<any> { if(route.data.preload) {
**return** load**();** } else { return Observable.of(null**);** }
}
}
我们可以看到,如果我们的route.data包含预加载布尔值,我们会调用 load 方法。
现在,为了正确设置路由:
{
path: 'anotherlazy', loadChildren: 'app/anotherlazy/anotherlazy.module#AnotherLazyModule', data: { preload: true } }
数据属性已设置为包含我们的preload属性的对象。
现在是最后一步。让我们让RouterModule.forRoot()意识到这个服务的存在:
@NgModule({
imports: [
RouterModule.forRoot(routes, {
preloadingStrategy: PreloadingStrategyService
})
]
})
简而言之,这是一种非常有效的方式,可以确保用户在不陷入急切加载或等待懒加载的情况下获得最佳体验。
总结
我们现在已经揭示了 Angular 路由器的强大功能,希望您喜欢探索这个库的复杂性。在路由器模块中,绝对闪亮的一点是我们可以通过简单而强大的实现涵盖大量选项和场景。
我们已经学习了设置路由和处理不同类型参数的基础知识。我们还学习了更高级的功能,比如子路由。此外,我们还学习了如何保护我们的路由免受未经授权的访问。最后,我们展示了异步路由的全部功能,以及如何通过延迟加载和预加载来真正提高响应时间。
在下一章中,我们将加强我们的任务编辑组件,展示 Angular 中 Web 表单的基本原理以及使用表单控件获取用户输入的最佳策略。