PrimeNG-Angular-开发加速指南-全-
PrimeNG Angular 开发加速指南(全)
原文:
zh.annas-archive.org/md5/091f737cacc7958a2c5308ed80ecb869译者:飞龙
第一章:第四章:探索 Angular 组件功能
在第三章,“介绍 CSS 自定义属性和新提供者作用域”,我们深入探讨了新的平台和框架功能,为第二部分,使用你学到的 Angular Ivy 功能构建真实世界应用程序做准备。让我们继续,但这次将重点放在 Angular 组件包中引入的新 API 上。
随着 Angular Ivy 的推出,推出了两个官方 Angular 包用于 Google 产品:YouTube Player 和嵌入式 Google Maps。在本章中,我们将探讨这两个包。
最后,我们将介绍 Angular CDK 引入的两个新 API:剪贴板 API 和组件测试工具。Angular CDK 的剪贴板指令、服务和域对象与操作系统的原生剪贴板交互。组件工具是一个测试 API,它使用测试即用户的方法包装一个或多个 Angular 组件。它可以在单元测试和端到端测试的上下文中使用。
本章涵盖了以下主题:
-
Angular YouTube Player
-
Angular Google Maps 组件
-
剪贴板 API
-
使用组件工具进行用户测试
了解这些主题将使您能够在第二部分,使用你学到的 Angular Ivy 功能构建真实世界应用程序中使用这些强大的功能。
技术要求
要继续本章,您需要以下内容:
-
Angular 9.0
-
TypeScript 3.6
您可以在本书的配套 GitHub 仓库中找到视频和映射示例的完整代码:github.com/PacktPublishing/Accelerating-Angular-Development-with-Ivy/tree/main/projects/chapter4。
Angular YouTube Player
作为 Angular Ivy 的一部分,Angular 团队发布了官方的 Angular 组件用于 Google 产品。其中之一是 Angular YouTube Player。正如其名称所示,它用于在您的 Angular 应用程序中嵌入 YouTube 视频播放器,同时获得 Angular 数据绑定的便利性以及通过组件引用以编程方式访问 YouTube Player API。
在本节中,我们将介绍开始使用 Angular YouTube Player 所需的设置。然后,我们将查看其整个 API,以便熟悉其功能和用法。
入门
首先,请确保使用以下命令安装@angular/youtube-player包:
ng add @angular/youtube-player
现在将YouTubePlayerModule添加到声明将使用 YouTube Player 组件的组件的模块中,如下面的代码所示:
import { NgModule } from '@angular/core';
import { YouTubePlayerModule } from '@angular/youtube-player';
import { VideoComponent } from './video.component';
@NgModule({
declarations: [VideoComponent],
exports: [VideoComponent],
imports: [YouTubePlayerModule],
})
export class VideoModule {}
在第一次使用 Angular YouTube Player 组件之前,我们必须加载 YouTube IFrame API 脚本。这是一个 100 KB 的脚本,因此根据您的优先级,有几种加载方式。您可以将它作为 scripts 选项的一部分加载到您的应用程序项目配置中的 angular.json 中,但这样用户就必须始终承担加载、解析和执行此脚本的前期费用。
相反,我们可以在使用它的组件激活时加载它,正如我们将在以下教程中看到的那样:
-
首先,我们导入我们需要的 Angular 核心和常用 API,并声明组件元数据和组件名称,在这种情况下为
VideoComponent:import { DOCUMENT } from '@angular/common'; import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; @Component({ selector: 'app-video', templateUrl: './video.component.html', }) export class VideoComponent implements OnDestroy, OnInit { -
接下来,我们添加一个私有属性以保持对将要动态加载的脚本的引用:
#youtubeIframeScript: HTMLScriptElement; -
现在,我们注入我们平台的
Document对象,即浏览器或服务器:constructor(@Inject(DOCUMENT) private document: Document) { -
我们创建一个指向 https://www.youtube.com/iframe_api 的
async脚本元素。这是一个初始化脚本的加载器脚本,它设置了嵌入 YouTube 视频所需的 YouTube API。Angular YouTube Player 组件是这个 API 的便捷包装器:this.#youtubeIframeScript = this.document.createElement('script'); this.#youtubeIframeScript.src = 'https://www.youtube.com/iframe_api'; this.#youtubeIframeScript.async = true; } -
当
VideoComponent初始化时,将 YouTube IFrame API 脚本元素添加到 HTML 文档的 body 元素中:ngOnInit(): void { this.document.body.appendChild( this.#youtubeIframeScript); } -
当
VideoComponent停用时,我们移除脚本元素:ngOnDestroy(): void { this.document.body.removeChild( this.#youtubeIframeScript); } }
不要担心脚本多次加载。浏览器将缓存它,YouTube IFrame API 脚本将检测到它已经被加载。为了更健壮的设置解决方案,我们可以监听脚本元素的 loaded 事件,在代码中设置一个已加载标志,并在下次此组件激活时添加一个条件以避免再次加载脚本。
这就是我们使用 Angular YouTube Player 组件所需的所有设置。完整的示例可以在本书的配套 GitHub 仓库中找到,如本章引言中所述。现在,让我们继续到使用说明。
使用方法
使用 Angular YouTube Player 的最简单示例是将以下代码片段放入组件模板中:
<youtube-player videoId="8NQCgmAQEdE"></youtube-player>
我们将 YouTube 视频 ID 传递给 videoId 输入属性,Angular YouTube Player 将处理其他所有事情。当然,它也允许进行更多定制。让我们首先看看 YouTubePlayer 组件的数据绑定 API。
API 参考
截至 Angular 版本 12,YouTube Player 没有 API 参考。本节为您提供了这些信息,这样您就不必查找源代码并将其与在线 YouTube JavaScript API 参考进行交叉引用,以便使用它。
数据绑定 API
使用数据绑定 API,我们可以声明式地配置 Angular YouTube Player 组件。输入属性用于配置设置,而输出属性则发出关于用户交互和视频环境的事件。
首先,我们将查看输入属性。
输入属性
输入属性用于配置嵌入播放器的播放和视觉效果:
-
@Input() endSeconds: number | undefined;要设置 YouTube 视频的播放结束点,请传递从视频开始处的秒数偏移量。
-
@Input() height: number | undefined;YouTube 播放器的高度通过
height输入属性指定,单位为 CSS 像素。默认值为390。 -
@Input() playerVars: YT.PlayerVars | undefined;可以通过
playerVars输入属性传递许多其他选项,例如,我们可以传递{ modestbranding: YT.ModestBranding.Modest }来隐藏 YouTube 标志。有关完整参考,请参阅developers.google.com/youtube/player_parameters.html?playerVersion=HTML5#Parameters。 -
@Input() showBeforeIframeApiLoads: boolean | undefined;showBeforeIframeApiLoads输入属性默认为false,但可以设置为true,以使 YouTube 播放器组件在 YouTube IFrame API 加载之前抛出错误。 -
@Input() startSeconds: number | undefined;要设置 YouTube 视频的播放起始点,请传递从视频开始处的秒数偏移量。
-
@Input() suggestedQuality: YT.SuggestedVideoQuality | undefined;suggestedQuality输入属性接受以下质量标识符之一:'default'、'small'、'medium'、'large'、'hd720'、'hd1080'、'highres'。 -
@Input() videoId: string | undefined;videoId输入属性接受要播放的 YouTube 视频的 ID。 -
@Input() width: number | undefined;YouTube 播放器的宽度通过
width输入属性指定,单位为 CSS 像素。默认值为640。
接下来,我们将查看输出属性,它们会发出有关用户交互和视频环境的事件。
输出属性
输出属性暴露了由 YouTube IFrame API 发出的事件。有关事件的完整参考,请参阅 https://developers.google.com/youtube/iframe_api_reference#Events:
-
@Output() apiChange: Observable<YT.PlayerEvent>;当字幕模块加载或卸载时,
apiChange输出属性会发出事件。 -
@Output() error: Observable<YT.OnErrorEvent>;当发生以下错误之一时,
error输出属性会发出事件,所有这些错误都可以通过YT.PlayerError枚举访问:EmbeddingNotAllowed、EmbeddingNotAllowed2、Html5Error、InvalidParam、VideoNotFound。 -
@Output() playbackQualityChange: Observable<YT.OnPlaybackQualityChangeEvent>;当播放质量改变时,以下质量标识符之一将通过
playbackQualityChange输出属性发出:'default'、'small'、'medium'、'large'、'hd720'、'hd1080'、'highres'。 -
@Output() playbackRateChange:Observable<YT.OnPlaybackRateChangeEvent>;当视频播放速率改变时,
playbackRateChange输出属性会触发一个事件,其中其data属性是一个数字,例如1.0,表示播放速度。 -
@Output() ready: Observable<YT.PlayerEvent>;当 YouTube 播放器完全加载并准备好被控制时,会触发此事件。
-
@Output() stateChange: Observable<YT.OnStateChangeEvent>;每当 YouTube 播放器状态改变为以下之一时,都会输出一个事件,这些都可以通过
YT.PlayerState枚举访问:BUFFERING、CUED、ENDED、PAUSED、PLAYING、UNSTARTED。
我们可以通过其组件方法向 YouTube 播放器发送命令或读取信息,我们将在下一节讨论这些方法。
组件方法
YouTubePlayer组件有几个公共方法,我们可以使用这些方法来控制嵌入的视频播放器:
-
getAvailablePlaybackRates(): number[];确定当前活动或队列视频支持的播放速率。对于仅支持正常播放速度的视频,返回
[1.0]。可能返回类似[0.25, 0.5, 1.0, 1.5, 2.0]的数组。 -
getCurrentTime(): number;确定视频开始以来的秒数。
-
getDuration(): number;确定当前活动视频的持续时间(以秒为单位)。如果视频的元数据尚未加载,则返回
0。对于直播流,它将返回自流开始、重置或中断以来的秒数。
-
getPlaybackRate(): number;确定当前活动或队列视频的播放速率,其中
1.0是正常播放速度。 -
getPlayerState(): YT.PlayerState | undefined;确定当前播放器状态,可以是以下之一,这些都可以通过
YT.PlayerState枚举访问:BUFFERING、CUED、ENDED、PAUSED、PLAYING、UNSTARTED。返回由stateChange输出属性触发的事件表示的最新值。 -
getVideoEmbedCode(): string;确定嵌入 HTML 页面中视频所需的 HTML 标记,例如:
<iframe id="ytplayer" type="text/html" width="720" height="405" src="img/8NQCgmAQEdE" frameborder="0" allowfullscreen></iframe> -
getVideoLoadedFraction(): number;确定播放器已缓冲的视频百分比,其中
0.0是 0%,1.0是 100%。 -
getVideoUrl(): string;确定视频在youtube.com上的完整 URL。
-
getVolume(): number;确定音量级别在
0到100之间。仅返回整数。当静音时,此方法将返回音频静音时的活动级别。 -
isMuted(): boolean;检查音频是否静音。
true表示静音,false表示未静音。 -
mute(): void;静音音频。
-
pauseVideo(): void;暂停视频。通过
stateChange触发一个事件。 -
playVideo(): void;开始播放视频。不计入 YouTube 视频的观看次数。通过
stateChange触发一个事件。 -
seekTo(seconds: number, allowSeekAhead: boolean): void;跳转到指定的标记时间戳。如果跳转前视频处于暂停状态,视频将继续暂停。将
allowSeekAhead设置为false可以防止播放器从服务器下载未缓冲的内容。这可以与进度条一起使用。 -
setPlaybackRate(playbackRate: number): void;调整播放速度。仅影响当前活动或排队的视频。将
1.0传递给playbackRate将播放速度设置为正常。我们首先应该调用
getAvailablePlaybackRates来检查视频支持哪些播放速率。监听由playbackRateChange输出属性发出的事件,以验证播放速度是否已成功调整。如果传递的
playbackRate与支持的播放速率不完全匹配,则将匹配最近的速率,四舍五入到1.0。 -
setVolume(volume: number): void;将音量调整到
0到100之间的水平。仅接受整数。 -
stopVideo(): void;停止加载或播放视频。如果我们知道用户不会在 YouTube 播放器中观看其他视频,我们可以使用这个方法。在播放不同视频之前不需要调用它。通过
stateChange发出事件,但状态可以是CUED、ENDED、PAUSED或UNSTARTED中的任何一个。 -
unMute(): void;取消静音。
在了解 YouTubePlayer 组件的完整组件 API 后,您可以在其之上构建自己的控件,配置应用程序中所有 YouTube Player 的默认设置,同时控制多个 YouTube Player,或使用 Angular 实现一个 YouTube 视频片段小部件。
我们已经讨论了如何安装和设置您的应用程序以使用 Angular YouTube Player。我们看到了一个简单的示例用法,列出了其完整 API,并讨论了其用例。现在您可以使用 Angular YouTube Player 在 第二部分,使用您学到的 Angular Ivy 功能构建实际应用程序。
接下来,我们将查看官方的 Angular Google Maps 组件。
Angular Google Maps 组件
在本节中,我们将查看名为 Angular Google Maps 的官方 Google 产品组件包。由于 Google Maps API 很大,因此此包包括 GoogleMap 组件以及用于配置和控制其许多功能的几个其他组件。
它由以下 Angular 组件和指令组成:
-
GoogleMap -
MapBicyclingLayer -
MapCircle -
MapGroundOverlay -
MapInfoWindow -
MapKmlLayer -
MapMarker -
MapMarkerClusterer -
MapPolygon -
MapRectangle -
MapTrafficLayer -
MapTransitLayer
我们将探索必要的组件 GoogleMap 以及常用的组件 MapInfoWindow、MapMarker 和 MapMarkerClusterer。
入门
要使用 Angular Google Maps 组件,我们首先必须加载 Google Maps JavaScript API。此示例包装组件说明了在 Google Maps JavaScript API 初始化后如何有条件地渲染 Google Maps 组件:
import { Component, ViewChild } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { GoogleMap }from '@angular/google-maps';
import { Observable, of } from 'rxjs';
import { catchError, mapTo } from 'rxjs/operators';
import { AppConfig } from '../app-config';
@Component({
selector: 'app-map',
templateUrl: './map.component.html',
})
export class MapComponent {
@ViewChild(GoogleMap, { static: false })
map?: GoogleMap;
isGoogleMapsApiLoaded$: Observable<boolean> = this.http.jsonp('https://maps.googleapis.com/maps/api/js?key=${this.config.googleMapsApiKey}','callback').pipe(
mapTo(true),
catchError(() => of(false)),
);
constructor(
private config: AppConfig,
private http: HttpClient,
) {}
}
我们的 MapComponent 有一个名为 isGoogleMapsApiLoaded$ 的可观察 UI 属性,该属性使用预配置的 API 密钥加载 Google Maps JavaScript API。我们使用此属性在组件模板中条件性地渲染 GoogleMap 组件,如下面的代码所示:
<google-map *ngIf="isGoogleMapsApiLoaded$ | async; else spinner"></google-map>
<ng-template #spinner>
<mat-spinner></mat-spinner>
</ng-template>
在 Google Maps JavaScript API 加载之前,会显示一个 Angular Material Spinner 组件。
注意,我们为 GoogleMap 创建了一个视图子查询,并将其存储在 map 属性中。这可以用于从组件模型中编程控制地图。
以下是我们声明的示例 MapComponent 的 Angular 模块:
import { CommonModule } from '@angular/common';
import { HttpClientModule, HttpClientJsonpModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { GoogleMapsModule } from '@angular/google-maps';
import { MatProgressSpinnerModule } from'@angular/material/progress-spinner';
import { MapComponent } from './map.component';
@NgModule({
declarations: [MapComponent],
exports: [MapComponent],
imports: [
HttpClientModule,
HttpClientJsonpModule,
GoogleMapsModule,
MatProgressSpinnerModule,
],
})
export class MapModule {}
现在我们已经拥有了使用 Angular Google Maps API 所需的所有设置,让我们更详细地看看 @angular/google-maps 包中包含的最常见的组件。
GoogleMap 组件
GoogleMap 组件是 Angular Google Maps 包的主要入口点。它是可以包含此包中其他组件的顶层组件。
此组件是 Google Maps JavaScript API 中的 google.maps.Map 类的声明性、Angular 特定的包装器。请参阅 API 参考以获取有关 Map 类的更多详细信息(https://developers.google.com/maps/documentation/javascript/reference/map)。
Google 地图组件具有 center、height、mapTypeId、width 和 zoom 输入属性。它还接受一个 options 输入,其类型为 google.maps.MapOptions。它有 19 个不同的输出属性,所有这些属性都与 Google Maps JavaScript API 参考中描述的 Map 类的 DOM 事件相匹配。它还提供了一系列可用于控制地图的方法。
MapMarker 组件
MapMarker 组件的元素 <map-marker> 要么嵌套在 <google-map> 元素内部,要么嵌套在 <map-marker-clusterer> 元素内部。
此组件代表 Google 地图上的一个标记。我们可以使用标签、标记图标或标记符号来自定义它。
MapMarker 是 Angular 特定的 google.maps.Marker 类的包装器。它具有 clickable、label、position 和 title 输入属性。它还接受一个 options 输入,其类型为 google.maps.MarkerOptions。
我们可以通过标记选项传递自定义图标,例如,如下所示,我们使用海滩旗帜图标:
<google-map
[center]="{ lat: 56.783778, lng: 8.228937 }"
>
<map-marker
[options]="{ icon: 'https://developers.google.com/
maps/documentation/javascript/examples/full/
images/beachflag.png' }"
[position]="{ lat: 56.783778, lng: 8.228937 }"
></map-marker>
</google-map>
请参阅 API 参考以获取有关 Marker 类的更多详细信息 (developers.google.com/maps/documentation/javascript/reference/marker)。
MapMarkerClusterer 组件
MapMarkerClusterer 组件是 Angular 特定的 MarkerClusterer 类的包装器,该类来自 @googlemaps/markerclustererplus 包。其元素 <map-marker-clusterer> 嵌套在 <google-map> 元素内部,并包含多个 <map-marker> 元素。
此组件用于在地图缩放时将许多地图标记分组到簇中。
在我们能够使用它之前,我们必须通过在适当的地点插入以下脚本标签将其加载到全局变量中,为了简单起见,在组件的模板中使用:
<script src="img/index.min.js"></script>
MapMarkerClusterer 组件有 18 个不同的输入属性,例如 minimumClusterSize、maxZoom 和 zoomOnClick。imagePath 输入属性可以用来指定自定义地图标记聚类图像,其中此路径默认自动附加 [1-5].png。
有两个输出属性可用:clusteringbegin 和 clusteringend。它们在标记首次聚类和它们被拆分为单独标记时分别发出。
请参阅 API 参考以获取有关 MarkerClusterer 类的更多详细信息(https://developers.google.com/maps/documentation/javascript/marker-clustering)。
MapInfoWindow 组件
MapInfoWindow 组件是 Angular 特定的 google.maps.InfoWindow 类的包装器。其元素 <map-info-window> 嵌套在 <google-map> 中。
它是一个覆盖层,用于在地图上显示通知或元数据,通常在地图标记附近。
它的 position 输入属性声明了它在地图上的位置。此外,它接受一个类型为 google.maps.InfoWindowOptions 的 options 输入。它有五个不同的输出属性——closeClick、contentChanged、domready、positionChanged 和 zindexChanged——所有这些都与 Google Maps JavaScript API 参考中 InfoWindow 类描述的 DOM 事件相匹配。
MapInfoWindow 组件使用内容投影,这意味着我们放入其自定义元素标签中的内容在打开时将在其覆盖层中渲染。
要显示 MapInfoWindow 组件,调用其 open 方法,该方法可选地接受 MapMarker,信息窗口将附加到该标记上。close 方法隐藏 MapInfoWindow 组件。
请参阅 API 参考以获取有关 InfoWindow 类的更多详细信息(https://developers.google.com/maps/documentation/javascript/reference/info-window)。
现在,你已经了解了官方 Google Maps Angular 组件中最常用的部分,你准备好在 Part 2, Build a Real-World Application with the Angular Ivy Features You Learned 中使用 Google Maps。
在下一节中,我们将了解 Angular CDK 的 Clipboard API。
Clipboard API
Angular CDK 的 Clipboard API 提供了一个指令和一个服务,通过浏览器与操作系统的剪贴板进行交互。CdkCopyToClipboard 指令可以声明式地使用,而 Clipboard 服务用于更适合程序化 API 的用例。Clipboard API 还通过 PendingCopy 类处理长文本。
在本节中,你将学习如何使用 Angular CDK 包中的每个这些类。
CdkCopyToClipboard 指令
CdkCopyToClipboard 指令由 ClipboardModule 导出,该模块位于 @angular/cdk/clipboard 子包中。其指令选择器为 [cdkCopyToClipboard]。该指令有一个与指令同名的输入属性,它接受当附加到其上的元素被点击时复制的文本。
由于浏览器安全考虑,复制文本到剪贴板必须在用户触发的点击事件之后进行。
复制到剪贴板指令还有一个名为 cdkCopyToClipboardAttempts 的输入属性。它接受一个数字,这是指令在放弃之前尝试复制文本的宏任务周期数。这在处理更大的文本时是相关的,因为一个确保跨浏览器兼容性的实现细节,直到即将到来的 Clipboard API 在所有主要浏览器中得到支持。我们将在探索 PendingCopy 类时进一步讨论这个注意事项。
下面的代码片段演示了复制到剪贴板指令及其重试参数:
<button
[cdkCopyToClipboard]="transactionLog"
[cdkCopyToClipboardAttempts]="5"
>
Copy transaction log
</button>
最后,CdkCopyToClipboard 指令有一个名为 cdkCopyToClipboardCopied 的输出属性,每次尝试复制到剪贴板时都会发出一个布尔值,指示复制是否成功。
剪贴板服务
当我们想在复制文本到剪贴板之前或之后执行其他操作,或者文本不易从组件模板中访问,或者我们希望在复制大文本时拥有更精细的控制时,Clipboard 服务非常有用。
剪贴板服务有两种方法。Clipboard#copy 方法接受要复制到剪贴板的文本,并返回一个布尔值,指示复制操作是否成功。
对于某些大文本,Clipboard#copy 方法会失败,我们必须改用 Clipboard#beginCopy 方法。此方法也接受我们想要复制到剪贴板的文本,但返回一个 PendingCopy 类的实例,我们必须进一步与之交互以完成复制到剪贴板的操作。这个类将在下一节中讨论。
PendingCopy 类
Clipboard#beginCopy 方法返回 PendingCopy 类的一个实例。如本节前面所述,这与确保复制大文本的跨浏览器兼容性的实现细节有关。
我们必须了解 PendingCopy 类的第一件事是,一旦我们完成使用它们,就必须通过调用 PendingCopy#destroy 方法来销毁所有实例,否则我们的应用程序将泄漏资源。
PendingCopy#copy 方法不接受任何参数,并返回一个布尔值,指示复制到剪贴板的操作是否成功。如果返回 false,我们应该安排稍后再次尝试。
如本节前面所述,CdkCopyToClipboard 指令通过传递最大尝试次数到其 cdkCopyToClipboardAttempts 输入属性,支持复制大文本的重试策略。
既然我们已经讨论了 Angular CDK 的 Clipboard API 的所有部分,我们就准备好在 第二部分,使用你学到的 Angular Ivy 功能构建真实世界应用程序 中实现一个实际应用的功能。
在下一节中,你将了解组件测试 Harness,这是一个用于测试组件的创新 API,以及为在库包中公开的组件编写测试 API。
使用组件 Harness 进行用户测试
Angular CDK 的用于编写和使用组件测试 Harness 的 API,是一种以“测试即用户”哲学为出发点的新方法。每个组件或相关组件集都可以有一个用于测试的组件 Harness。组件 Harness 是一个用于与这些组件交互的测试 API,可用于单元测试、集成测试和端到端测试。
组件测试 Harness 内部仅依赖于它们所包装组件的单个选择器。库作者可以为他们的 Angular 组件发布组件 Harness。这样,他们的消费者测试(这些测试依赖于库的组件)除了那个选择器之外,不会对 DOM 结构有依赖,而库作者如果需要可以更改该选择器。
这正是 Angular 组件团队为 Angular CDK 和 Angular Material 所做的事情。他们发布并维护所有 Angular 组件的组件 Harness。
Harness 环境和 Harness 加载器
Harness 环境代表使用组件 Harness 运行的测试上下文。对于使用 Karma、Jasmine 或 Jest 等测试运行器的单元和集成测试,我们使用 TestbedHarnessEnvironment,它是 Angular CDK 的一部分。对于 Protractor 端到端测试,我们使用 ProtractorHarnessEnvironment,它也作为 Angular CDK 的一部分发布。
重要提示
根据 Angular 版本的不同,Protractor 支持可能已被弃用或移除。
如果你想要与其他端到端测试框架一起使用组件 Harness,你必须扩展 HarnessEnvironment 基类并实现 TestElement 接口,以便在不同的测试环境中工作。当然,首先确保在 Angular 生态系统中寻找现有的解决方案。
任何时候只能有一个 Harness 环境处于活动状态。我们使用 Harness 环境来创建 Harness 加载器。一个 Harness 加载器具有特定 DOM 元素的上下文,并用于根据选择器查询和创建组件 Harness。
在讨论完作为 Angular Material 部分发布的组件 Harness 的 API 之后,我们将通过一些简单的代码示例来介绍 Harness 环境和 Harness 加载器的使用。
组件 Harness
从技术上讲,组件 Harness 可以代表任何 DOM 元素以及一组用户交互和特性。
为了感受组件 Harness 的使用,让我们首先看看 Angular Material Button 组件的测试 Harness。
以下为 MatButtonHarness 的 API,它不是从公共组件 Harness 基类继承的:
-
blur(): Promise<void>; -
click(relativeX: number, relativeY: number): Promise<void>;click('center'): Promise<void>;click(): Promise<void>; -
focus(): Promise<void>; -
getText(): Promise<string>; -
isDisabled(): Promise<boolean>; -
isFocused(): Promise<boolean>;
它还继承自 Angular CDK 基类的一些其他方法,但我们现在不会讨论这些。
我相信您可以根据它们的名字、参数和返回值猜出这些方法的功能。请注意,它们都是异步的,也就是说,它们都返回一个Promise。
除了getText方法之外,每个方法都代表用户交互。getText方法从 DOM 中读取内容,这是显示给用户的,即按钮的文本。
接下来,让我们探索 Angular Material Select 组件测试 harness 的 API。
以下是与MatSelectHarness特定的 API:
-
blur(): Promise<void>; -
clickOptions(filter?: OptionFilters): Promise<void>;选择与指定过滤器匹配的下拉选项(复选)。对于多选项选择,可以选择多个选项。对于单选项选择,选择第一个匹配的选项。
-
close(): Promise<void>;关闭下拉面板。
-
focus(): Promise<void>; -
getOptionGroups(filter?: OptionGroupFilters): Promise<OptionGroup[]>;读取与指定过滤器匹配的下拉选项组。
-
getOptions(filter?: OptionFilters): Promise<Option[]>;读取与指定过滤器匹配的下拉选项。
-
getValueText(): Promise<string>;读取所选下拉选项的值。
-
isDisabled(): Promise<boolean>; -
isEmpty(): Promise<boolean>;如果没有选择任何值,则解析为
false。如果已选择值,则解析为true。 -
isFocused(): Promise<boolean>; -
isMultiple(): Promise<boolean>;如果 harness 包裹了一个多选项选择组件,则解析为
true。如果它包裹了一个单选项选择组件,则解析为false。 -
`isOpen(): Promise
; -
isRequired(): Promise<boolean>; -
isValid(): Promise<boolean>; -
open(): Promise<void>;
这些方法对应于我们对下拉选择器(如 Angular Material Select组件)的行为和信息表示的预期。
现在我们已经讨论了组件 harness 的最重要的概念并看到了一些组件 harness API,是时候看看一个结合这些概念的测试用例了。
以下是一个在线服装店测试的示例。您将不得不想象ShirtComponent和协作服务的实现。事实上,这正是测试即用户方法的核心所在。它是组件和实现无关的:
-
首先,我们导入必要的 Angular 包:
import { HarnessLoader } from '@angular/cdk/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { TestBed } from '@angular/core/testing'; import { MatButtonModule } from '@angular/material/button'; import { MatButtonHarness } from '@angular/material/button/testing'; import { MatSelectModule } from '@angular/material/select'; import { MatSelectHarness } from '@angular/material/select/testing'; -
接下来,我们导入
ShirtComponent组件、协作的OrderService和OrderSpyService类来替换它:import { OrderService } form './order.service'; import { OrderSpyService } form './order-spy.service'; import { ShirtComponent } from './shirt.component'; -
在我们可以实现测试用例之前,我们通过设置必要的声明式配置和将
OrderService替换为用于测试的间谍服务来配置 Angular 测试模块:describe('ShirtComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ declarations: [ShirtComponent], imports: [MatButtonModule, MatSelectModule], providers: [ { provide: OrderService, useClass: OrderSpyService }, ], }); const fixture = TestBed.createComponent( ShirtComponent); -
我们使用前一步骤中的组件固定工具来创建单元测试的 harness loader:
loader = TestbedHarnessEnvironment. loader(fixture); -
最后,我们将订单间谍服务存储在共享的
orderSpy变量中:orderSpy = TestBed.inject(OrderService) as OrderSpyService; }); let loader: HarnessLoader; let orderSpy: OrderSpyService; -
现在我们加载了 shirt size picker 的组件 harness,该 harness 是通过 Angular Material Select 组件实现的,如本步骤所示:
it('orders a Large shirt', async () => { const shirtSizePicker = await loader.getHarness(MatSelectHarness); -
对于这个测试用例,我们还需要加载一个 购买 按钮的组件 harness,该按钮是通过 Angular Material Button 组件实现的:
constpurchaseButton = awaitloader.getHarness( MatButtonHarness.with({ text: '1-click purchase' }); -
接下来,我们作为用户执行一次销售,选择
Large衣服尺寸并点击 购买 按钮:await shirtSizePicker.clickOptions({ text: 'Large' }); await purchaseButton.click('center'); -
最后,我们断言订单服务间谍已被按预期调用:
expect(orderSpy.purchase). toHaveBeenCalledTimes(1); }); });
我们看到,由于使用了 Angular Material 的组件 harness,测试相对简单。首先,选择了 大号 衣服尺寸,然后点击了一键 购买 按钮,我们期望我们的订单服务间谍已被调用并带有订单。
注意我们是如何在组件测试中像往常一样配置 Angular 测试模块的。在创建组件固定工具后,我们用它来创建一个 harness loader。然后,harness loader 被用来查询 Angular Material Select 和 Button 组件的组件 harness。传递一个过滤器以确保我们与正确的按钮进行交互。
我们使用组件 harness 而不是与组件实例交互或传递选择器到 DOM 查询。我们的测试与结构 DOM 变化和 Angular Material 组件的实现细节解耦。
现在,我们已经探讨了组件 harness 的最重要的概念,你准备好实现自己的 harness 并使用它们在 第二部分,使用你学到的 Angular Ivy 功能构建真实世界应用程序 中作为用户进行测试了。
摘要
在本章中,我们探讨了 Angular YouTube Player 的 API、Google Maps Angular 组件、Angular CDK 的 Clipboard API,以及 Angular CDK 的组件 harness 以及它们是如何被 Angular Material 使用的,反过来我们也可以在我们的应用程序中使用。
YouTube Player 组件是围绕嵌入的 YouTube Player 的 Angular 特定包装器。我们学习了如何初始化它并详细探讨了其 API。
许多官方 Angular 组件包装器可用于创建和交互 Google Maps 的丰富 API。我们学习了 GoogleMap、MapMarker、MapMarkerClusterer 和 MapInfoWindow 组件,它们用于常见的 地理信息系统 (GIS) 用例。
Angular CDK 的 Clipboard API 是一个跨浏览器和跨平台的 API,用于与原生剪贴板交互。我们学习了 CdkCopyToClipboard 指令、Clipboard 服务和 PendingCopy 类。
最后,我们讨论了 Angular CDK 引入的 Angular 组件工具包的主要概念。我们看到了 Angular Material 暴露的组件工具包 API 的示例,以及我们如何使用它们来测试自己的组件,而无需依赖于实现细节或 DOM 结构,这些可能在包的未来版本中发生变化。
在心中牢记所有这些令人兴奋的新特性和 API 之后,让我们继续进入第二部分,使用你学到的 Angular Ivy 特性构建一个真实世界的应用程序,其中我们将向现有的 Angular 应用程序添加新功能。当然,这些知识将会非常有用。
第五章,使用 CSS 自定义属性,通过结合 CSS 自定义属性和 Angular,在第二部分,使用你学到的 Angular Ivy 特性构建一个真实世界的应用程序中开始,为 Angular Academy 应用程序添加一个主题选择器。
第二章:第五章:使用 CSS 自定义属性
现在,我们将深入探讨如何在 Angular Academy 应用程序中实现你在 第一部分 快速且实用的 Angular Ivy 指南 中遇到的功能的实践细节,这将允许你浏览并选择可用的 Angular 视频课程。
我们将首先描述 Angular Academy 应用程序,同时涵盖以下主题:
-
使用自定义 CSS 属性构建主题选择器
-
实现主题服务
-
使用自定义 CSS 属性控制 CSS 网格模板
到本章结束时,你将在实际应用程序中使用自定义 CSS 属性方面获得实践经验。
技术要求
结合并使用 Angular Academy 应用程序中的所有新功能将涉及一系列通常隐藏在 第一部分,快速且实用的 Angular Ivy 指南 描述中的实际选择,这意味着我们需要提供整个应用程序的概述。
因此,在我们深入所有细节描述之前,让我们先查看代码,并在阅读即将到来的章节中的深入描述时进行检查。
打开终端并执行以下命令:
git clone https://github.com/PacktPublishing/Accelerating-Angular-Development-with-Ivy
示例项目源代码将被放置在 projects/demo 目录中,可以在你的开发机器上启动,如下所示:
cd Accelerating-Angular-Development-with-Ivy
npm install
ng serve demo
如果你在你的浏览器中访问 localhost:4200,你现在应该能够看到 Angular Academy 应用程序:

图 5.1 – 在 Angular Academy 应用程序中,你可以看到一系列 YouTube 视频课程列表
当 Angular Academy 应用程序启动时,你将在默认页面上看到一系列 YouTube 课程(如图 5.1 所示)。请随意浏览,以了解我们将在接下来的章节中介绍的内容。例如,尝试点击 编辑主题 菜单项以打开主题选择器。我们将在下一节中探讨这个问题。
使用自定义 CSS 属性构建主题选择器
为你的应用程序提供一个主题是一个常见且重要的用例,这已经被流行的 Angular 库所覆盖。你可能知道 Angular Material 已经支持几个可用的主题(例如,流行的 deeppurple-amber 和 indigo-pink)。当使用 SCSS 时,使用预处理器变量的常见方法已经存在了一段时间。但现在,你可以使用自定义 CSS 属性来支持动态主题,而无需使用预处理器生成 CSS 文件。这为我们提供了新的交互式主题选项,我们将在本节中介绍。
由于可以使用 CSS 自定义属性构建进一步的 CSS 规则,我们现在可以直接从应用程序中的一个组件开始更改多个样式规则。在这里,可以通过--headerbackground自定义属性动态计算一个或多个 CSS 类,或者简单地通过将属性的值作为 CSS 类附加,如下所示:
.mycomponent {
background: var(--headerbackground, white);
}
用户可以使用类似于这样的主题选择器结构来选择headerbackground颜色:
<input name="headerBackground" type="color" />
对于交互式使用,headerbackground自定义属性的值可以是存储在localStorage中的变量,在使用期间可用。然后headergroundcolor可以影响特定瓷砖组件内部元素的样式。
在 Angular Academy 应用程序中,我们将在主题组件模板中使用 Angular Material 表单字段将其包装,如下所示:
<mat-form-field appearance="fill">
<mat-label> Header background </mat-label>
<input
matInput
name="headerBackground"
(blur)="update($event)"
type="color"
[value]="headerBackground"
/>
</mat-form-field>
在主题选择器组件中使用 Material 表单输入字段将看起来像这样:

图 5.2 – 您可以使用主题选择器组件来选择标题的背景颜色
我们可以直接从自定义 SCSS 属性将选定的颜色应用到应用程序中相关组件的 SCSS 文件,或者在应用程序作用域内直接使用 Ivy 实现的样式语法优先级使用,如下所示:
为了实际应用,我们将使用@HostBinding将每个主题设置绑定到应用程序作用域的主题变量,如下所示:
export class AppComponent {
@HostBinding('style.--background')
background: string;
@HostBinding('style.--headerbackground')
headerBackground: string;
@HostBinding('style.--tilebackground')
tileBackground: string;
constructor(themeService: ThemeService) {
this.background = themeService.getSetting(
'background');
this.headerBackground = themeService.getSetting(
'headerBackground');
this.tileBackground = themeService.getSetting(
'tileBackground');
}
}
可以通过 getter 在主题组件中检索 CSS 属性的值,该 getter 通过主题服务检索值,如下所示:
get headerBackground(): string {
return this.themeService.getSetting(
'headerBackground');
}
主题组件上的update($event)回调可以引用主题服务,这将更新幕后选择的值。此外,主题组件将通过绑定到应用程序的作用域来允许访问自定义 CSS 属性的所选值,如下所示:
update(event: any): void {
this.themeService.setSetting(event.target.name,
event.target.value);
}
在应用程序组件中,我们引用相同的主题服务来处理与主题组件相关的数据更新。通过将处理数据更新分离到服务中,我们可以抽象出如何存储和检索主题设置的细节,以便以后使用。
实现主题服务
主题服务负责检索动态主题设置。
让我们从使用localStorage的简单实现开始。在这个实现中,如果值不可用,我们还将提供默认设置:
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class ThemeService {
constructor() {}
public setSetting(name: string, value: string): void {
localStorage.setItem(name, value);
}
public getSetting(name: string): string {
switch (name) {
case 'background':
return localStorage.getItem(name) || 'yellow';
case 'tileBackground':
return localStorage.getItem(name) || '#ffcce9';
case 'headerBackground':
return localStorage.getItem(name) || '#00aa00';
}
return 'white';
}
}
在这里,如果设置没有之前的值,我们将提供示例设置。例如,如果headerBackground之前没有被设置,那么我们将将其设置为#00aa00。
使用主题选择器组件的主题服务的一个好处是,在以后的某个阶段,你可以选择实现主题服务以使用另一种机制来存储和检索设置(例如,你可以选择从可能包含默认设置的机构设计令牌系统中检索值)。此外,你还可以使用服务提供者作用域来共享与使用场景相关的数据,正如你将在第八章,“附加提供者作用域”中看到的。主题的另一个方面是控制屏幕上项目的相对大小和位置,这取决于用户偏好。对此的现代方法是使用 CSS 网格,而且我们发现我们可以很好地使用自定义 CSS 属性封装这些设置。我们将在下一节中介绍如何做到这一点。
使用自定义 CSS 属性控制 CSS 网格模板
假设你想强调视频文本描述的重要性,因此你想增加文本的空间量,并且很可能会减少用于视频的空间量。这可以通过使用一些 TypeScript 逻辑的动态查看器来实现,该查看器可以在运行时执行尺寸计算。鉴于你希望能够在手机上查看内容,你需要为较小屏幕的网格布局考虑。这个额外要求足够复杂,以至于需要自定义主题。然而,实际上,我们可以以既紧凑又易于理解的方式将媒体查询与内联自定义 CSS 属性结合起来。
如果我们为课程视频瓷砖引入video和text CSS 类,那么我们可以使用自定义 CSS 属性和 CSS 网格技术来设置它们的样式,同时参考container网格列,如下所示:
.tile {
background: var(--tilebackground, grey);
padding: 15px 15px 15px;
overflow: hidden;
&.video {
grid-column: span var(--videosize, 9);
}
&.text {
grid-column: span var(--textsize, 3);
}
}
videosize和textsize CSS 属性将控制分配给video和text的列数。container网格的声明如下:
.container {
display: grid;
grid-template-columns: repeat(12, 1fr);
grid-template-rows: 1fr;
grid-auto-flow: dense;
padding: 15px 15px 15px;
align-content: center;
}
这些新的 CSS 类可以在Course组件中组合使用,以渲染视频和相关的文本描述,如下所示:
<div class="container">
<div class="tile video">
<p>
<youtube-player videoId="{{ videoId }}"></youtube-
player>
</p>
</div>
<div class="tile text">
<h3>
<i>{{ title }}</i>
</h3>
<p>
<a href="https://youtube.com/watch?v={{ videoId }}">
Open on youtube</a>
</p>
<p>{{ description }}</p>
</div>
</div>
为了控制课程瓷砖内的尺寸,我们将向主题组件引入两个新变量:视频大小和文本大小。这两个变量将绑定在 CSS 网格(在这种情况下,为 12)的可用列数范围内。此外,它们相加应该等于列数。
视频大小和文本大小滑块可以作为主题选择器的一部分来实现。这看起来是这样的:

图 5.3 – 使用滑块调整视频和文本大小
对于实际应用,我们将绑定 3 和 7(以允许最大值为 5 的 文本大小)。这应该为网格内的视频和文本留下足够的空间。这种简单的方法可以通过 Material Slider 实现,如下所示:
<mat-label> video size </mat-label>
<mat-slider
thumbLabel
min="3" max="7" step="1"
(input)="setSize('videoSize', $event)"
[value]="videoSize"
>
</mat-slider>
在这里,setSize 回调将以与其他变量相同的方式使用主题服务更新变量——只是增加了复杂性,因为我们收到了一个 MatSliderChange:
setSize(name: string, event: MatSliderChange): void {
this.themeService.setSetting(name,
event.value?.toString() || '1');
location.reload();
}
每当我们更改其中一个滑块的值时,网格内组件的布局可能会发生变化。在包含具有自己布局系统的外部组件的输出时,我们应该注意(例如,YouTube Player)。我们将在下一章学习如何集成 YouTube Player,但现在,让我们只满足于简单的 location.reload()。这应该可以说明如何强制渲染网格(以及所有其他组件)。
在我们离开从 Angular 使用自定义 CSS 属性的话题之前,让我们记住,我们还想支持较小的屏幕。实际上,我们可以通过结合自定义 CSS 属性和媒体查询,而不需要引用 Angular 代码,来引入一个优雅的解决方案,如下所示:
.tile {
background: var(--tilebackground, grey);
padding: 15px 15px 15px;
overflow: hidden;
@media screen and (min-width: 768px) {
&.video {
grid-column: span var(--videosize, 9);
}
&.text {
grid-column: span var(--textsize, 3);
}
}
@media only screen and (max-width: 768px) {
grid-column: span 12;
}
}
在这里,我们可以看到直接在应用程序范围内添加对使用自定义 CSS 属性的支持的一个优势:通过结合 CSS 自定义属性和媒体查询,我们可以依赖那些几乎不需要了解 Angular 代码的专门设计师的专长。此外,我们还可以选择创建可以检索企业设计令牌系统主题设置的集成,而无需在设计令牌更改时重新部署应用程序。
摘要
在本章中,您第一次遇到了 Angular Academy 应用程序,并学习了如何使用 CSS 自定义属性通过主题服务实现主题选择器。在这个过程中,您也对课程列表中的 YouTube Player 有了一个简短的初次接触。
在下一章中,我们将更深入地探讨如何使用 YouTube Player,以及您如何可以使用在 第一部分,Angular Ivy 快速实用指南 中学到的 Angular 组件组合 YouTube 视频课程。
第三章:第六章:使用 Angular 组件
在上一章中,我们介绍了 Angular Academy 应用程序,并通过演示如何使用 CSS 自定义属性来控制主题属性和 CSS 网格布局,简要地触及了其表面。在本章中,我们将深入探讨如何使用我们在第四章,探索 Angular 组件功能中介绍的新 Angular 组件来实现应用程序。
注意,在本章中我们将涉及很多内容,所以我建议如果您想了解 Angular 组件或需要复习,请重新阅读第四章,探索 Angular 组件功能。本章将介绍如何使用新的官方 Angular 组件,以及如何在我们的 Angular Academy 示例应用程序中将它们连接起来。
更具体地说,在本章中,我们将涵盖以下主题:
-
理解 Angular Academy 应用程序
-
使用 Angular YouTube Player 显示课程视频
-
使用新的 Clipboard 服务
-
使用 Angular Google Maps 组件查找您的学校
如果您和我们一样,那么您可能急于深入了解如何使用新的官方 Angular 组件,但让我们退一步,反思 Angular Academy 应用程序,以便我们了解我们将要构建的内容。
通过这种方式从头开始,我们希望您能够通过看到如何使用服务和导航将它们连接起来,从而对如何使用新组件有一个更具体的理解。
理解 Angular Academy 应用程序
在使用组件时,您首先应该考虑的是用户通过使用组件能够完成什么,以及在使用案例中需要哪些数据,然后再深入了解您将要使用的组件的细节。
我们对 Angular Academy 应用的主要用途是允许定制视频课程列表,这将向用户提供一个定制的视频列表和视频内容的地理位置信息。为了实现这一点,我们将使用 Angular YouTube Player 来显示视频,并通过 Angular Google Maps(用于如何实现)选择学校。课程将由学校创建,课程将包含用户将观看的一个或多个视频。
在确定用例之后,我们现在可以思考一下我们将使用哪些数据,在描述我们将如何显示或使用数据之前。这将使数据检索和存储与不同组件的实际使用更容易分离。因此,在我们使用新的 Angular 组件之前,让我们先建立一个数据模型。
建立数据模型
我们有多种描述数据模型的方式,但为了简洁起见,我们将限制自己使用简单的 TypeScript 接口来描述模型,并使用 Angular 服务与后端进行通信。
我们将使用以下模型来支持我们的用例:
-
学校
-
课程
-
视频
你在前一章中看到的视频列表与一个由学校制作的课程相关。我们将首先给课程一个标题,一个可选的描述,以及一个用户可以观看的视频列表:
export interface ICourse {
id: string;
title: string;
description?: string;
videos: IVideo[];
}
我们需要的每个视频的基本信息包括在哪里可以访问 YouTube 上的视频,上传日期,制作它的作者,以及一个可选的关于它的描述:
export interface IVideo {
externalId: string; // YouTube ID
title: string;
date: Date;
author?: string;
description?: string;
}
我们将为每个学校附加一个名称以及纬度和经度,这样我们就可以在地图上找到它们:
export interface ISchool {
id: string;
name: string;
longitude: number;
latitude: number;
courses: ICourse[];
}
此外,我们将在courses数组中描述学校提供的课程。请注意,courses数组中的描述将使用 Course 模型作为 School 模型的一部分。Course 接口的共享使用将允许在 Schools 和 Course 组件之间重用逻辑。
将应用程序分解为组件
现在,我们将应用程序分为以下三个主要模块。这些模块将在屏幕上分别显示:
-
课程
-
主题
-
学校
Course 组件将使用我们在第五章,“使用 CSS 自定义属性”中引入的布局网格,以及一个视频组件,该组件将通过 Angular YouTube Player 模块显示 YouTube 视频。
主题组件也应该让你想起第五章,“使用 CSS 自定义属性”,在那里我们使用它来通过 CSS 属性控制主题设置。主题设置应该影响 Schools 和 Course 组件的图形显示。
Schools 组件将允许你通过 Angular Google Maps 组件找到你的学校,并允许你从所选学校选择一个课程来跟随(并将你重定向到 Course 组件)。
每个组件都将映射到app.module.ts中的导航,如下所示:
const routes: Routes = [
{ path: '', redirectTo: 'course/1', pathMatch: 'full' },
{ path: 'course/:id', component: CourseComponent },
{ path: 'schools', component: SchoolsComponent },
{ path: 'theme', component: ThemeComponent },
];
注意,Course 组件将需要一个参数来指定要显示的课程。为了简单起见,我们假设用户已经登录,选择了 ID 为1的课程,并将其显示为默认路由。稍后的实现可以添加登录页面和用户模型,这样可以在启动时将用户重定向到所选课程(所选课程可以存储在用户模型中)。我们将在第八章,“附加提供者作用域”中重新讨论这个问题。
现在我们已经将应用程序分解为组件,是时候开始考虑如何为这些模块包含依赖项了。
使用模块包含依赖项
我们将首先将应用程序指定为一个模块,该模块导入 Course、Schools 和 Theme 模块:
@NgModule({
bootstrap: [AppComponent],
declarations: [AppComponent, NavigationComponent],
imports: [
CommonModule,
BrowserAnimationsModule,
RouterModule.forRoot(routes, { initialNavigation:
'enabledNonBlocking' }),
LayoutModule,
CourseModule,
SchoolsModule,
ThemeModule,
MaterialModule,
],
})
export class AppModule {}
课程模块将包括视频模块:
@NgModule({
declarations: [CourseComponent],
imports: [CommonModule, ThemeModule, VideoModule,
MaterialModule],
exports: [VideoModule],
})
export class CourseModule {}
Video 模块将包括YouTubePlayerModule和ClipboardModule依赖项:
@NgModule({
declarations: [VideoComponent],
imports: [
CommonModule
YouTubePlayerModule,
ClipboardModule,
],
exports: [VideoComponent],
})
export class VideoModule {}
最后,Schools 模块将包括GoogleMapsModule:
@NgModule({
declarations: [SchoolsComponent],
imports: [CommonModule, MaterialModule,
GoogleMapsModule],
})
export class SchoolsModule {}
你注意到我们只包括了所需的特定依赖项吗?这种构建应用程序的方式可以帮助您更清晰地了解应用程序中的依赖关系。
使用服务检索数据
现在我们已经指定了示例数据模型并将应用程序划分为模块,是时候指定我们将如何从组件中访问数据了。我们将使用以下 Angular 服务来完成这项工作:
-
CourseService -
SchoolsService
每个服务都将被设置为异步获取数据。这里的主要区别是CourseService将一次检索一个课程,而SchoolsService将一次检索多个学校。
CourseService将有一个getCourse调用,用于检索单个课程:
@Injectable({
providedIn: 'root'
})
export class CourseService {
constructor() { }
getCourse(courseId: string): Observable<ICourse> {
return of(mockCourse);
}
}
课程模型将包含要显示的课程视频列表。
重要提示
在这里,我们使用模拟数据,但这种方法应该可以说明您如何通过Course组件从服务器实现异步数据检索。
同样,我们将介绍SchoolsService,它将检索提供课程的学校列表:
@Injectable({
providedIn: 'root'
})
export class SchoolsService {
constructor() { }
getSchools(): Observable<ISchool[]> {
return of(mockSchools);
}
}
在这里,我们将通过getSchools调用返回几个学校。我们的想法是,每个返回的学校都应该提供链接到一或多个可以通过CourseService检索的课程。我们将通过在“学校”组件中提供学校提供的课程链接来展示这一点。
连接导航
为了简单起见,我们假设用户已经注册了学校,这样我们就可以将用户引导到所选学校中的课程。在这里,默认链接将在启动时通过默认路由显示所选学校中的课程视频。
我们将首先设置屏幕左侧导航组件中 Material 侧边导航容器的链接,如下所示:
<mat-nav-list>
<a mat-list-item href="/#">Watch course</a>
<a mat-list-item href="/schools">Find school</a>
<a mat-list-item href="/theme">Edit theme</a>
</mat-nav-list>
在这里,您可以看到默认路由/将有一个标题为观看课程。这将在路由描述中映射到课程组件。
在建立了导航和数据模型,并将应用程序划分为模块之后,我们可以开始描述如何在 Angular Academy 应用程序中使用 Angular 组件。我们将首先描述如何使用 Angular YouTube Player 来显示课程视频。
使用 Angular YouTube Player 显示课程视频
在本节中,我们将创建一个单独的视频组件来显示与课程相关联的视频。为了简单起见,我们将在视频组件内部接受@Input信息,如下所示:
@Component({
selector: 'workspace-video',
templateUrl: './video.component.html',
styleUrls: ['./video.scss'],
})
export class VideoComponent implements OnDestroy, OnInit {
private youtubeIframeScript: HTMLScriptElement;
@Input()
public title!: string;
@Input()
public name!: string;
@Input()
public videoId!: string;
@Input()
public description!: string;
@Input()
public snippet!: string;
get youtubeLink () {
return this.title
+ ": https://www.youtube.com/watch?v="+this.videoId;
}
constructor(@Inject(DOCUMENT) private document: Document) {
this.youtubeIframeScript =
this.document.createElement('script');
this.youtubeIframeScript.src =
'https://www.youtube.com/iframe_api';
this.youtubeIframeScript.async = true;
}
ngOnInit(): void {
this.document.body.appendChild(
this.youtubeIframeScript);
}
ngOnDestroy(): void {
this.document.body.removeChild(
this.youtubeIframeScript);
}
}
这段代码应该与您在第四章,“探索 Angular 组件功能”的介绍中很熟悉,在那里我们介绍了如何使用它。现在我们可以编写视频组件的模板来显示 YouTube 视频,如下所示:
<div class="container">
<div class="tile video">
<p>
<youtube-player videoId="{{ videoId }}"></youtube-
player>
</p>
<p>
<button [cdkCopyToClipboard]="youtubeLink">Copy video
link to clipboard</button>
</p>
</div>
<div class="tile text">
<h3>
<i>{{ title }}</i>
</h3>
<p>{{ description }}</p>
</div>
</div>
您还记得我们如何在上一章中使用自定义的 videoSize CSS 属性来调整网格列的大小吗?这项努力正在得到回报 – 我们在这里只需要引用 video 类(根本不直接引用动态大小)。对 tile 类的引用使我们能够使用主题组件来操作瓦片颜色设置。
您也注意到我们在这里如何引入 cdkCopyToClipboard 功能了吗?当您想在桌面应用程序中从运行的应用程序中提取数据到剪贴板时,这个功能可能会很有用。
在建立了 Video 组件之后,我们现在可以从 Course 组件中使用它,如下所示:
<ng-container *ngIf="course$ | async as course">
{{ course.title }}
<div *ngFor="let video of course.videos">
<workspace-video
videoId="{{video.externalId}}"
title="{{video.title}}"
description="{{video.description}}"
>
</workspace-video>
</div>
</ng-container>
注意在 course$ 上使用 async 管道操作符。在这里,我们正在等待数据被检索,以便我们可以开始使用 Video 组件渲染视频。
现在我们已经介绍了如何使用 Angular YouTube Player 显示课程视频,我们将学习如何使用 Angular Google Maps 组件查找学校,并展示从 School 组件到 Course 组件的导航工作方式。
使用 Angular Google Maps 组件查找您的学校
Schools 组件将允许您通过点击学校放置的标记在 Google Maps 中查找学校。这将打开 MapInfo 窗口,您可以在其中点击可以观看的学校课程。点击此课程将带您到之前章节中看到的课程概述。
您可以通过点击 Angular Academy 应用程序中的 查找学校 菜单条目来找到 Schools 组件。这将渲染 Schools 组件,您将看到示例 Angular Advanced 学校。
当您在 Angular Academy 应用程序中打开 Schools 组件时,它应该以红色默认 Google Maps 标记打开。如果您点击它,那么您的显示应该看起来像这样:

图 6.1 – 打开红色 Google Maps 标记
如果您在地图信息窗口中点击 Angular Advanced 链接,您将被转移到具有 Angular Advanced 课程 ID 的课程组件。
我们期望 Schools 组件地图信息窗口中传入的课程数据来自 Schools 服务,作为一个异步调用,如图所示:
@Component({
selector: 'workspace-schools',
templateUrl: './schools.component.html',
styleUrls: ['./schools.component.scss'],
})
export class SchoolsComponent {
@ViewChild(GoogleMap, { static: false }) map!: GoogleMap;
@ViewChild(MapInfoWindow, { static: false }) info!:
MapInfoWindow;
school!: ISchool;
schools$: Observable<ISchool[]>;
constructor(
schoolsService: SchoolsService
) {
this.schools$ = schoolsService.getSchools();
}
openInfo(anchor: MapAnchorPoint, school: ISchool): void {
this.school = school;
this.info.open(anchor);
}
}
在这里,您可以看到我们如何在 openInfo 调用中点击地图锚点时打开 MapInfoWindow。
我们将在 Angular Google Maps 组件的 mapClick 函数上注册 openInfo 调用,并使其打开一个显示学校提供的课程链接的 MapInfo 窗口:
<ng-container *ngIf="schools$ | async as schools">
<google-map [center]="{ lat: 56.783778, lng: 8.228937 }">
<map-marker
*ngFor="let school of schools"
#marker="mapMarker"
[position]="{ lat: school.longitude, lng:
school.latitude }"
(mapClick)="openInfo(marker, school)"
></map-marker>
<map-info-window>
<div *ngFor="let course of school?.courses">
<a href="course/{{ course.id }}"> {{ course.title
}} </a>
</div>
</map-info-window>
</google-map>
</ng-container>
注意在 schools$ 可观察对象上使用异步管道。这将使学校数据在 schools 变量中可用。结合 <ng-container> 上的 NgIf,您可以在数据可用之前停止渲染数据。
如果您还没有尝试过,我建议您尝试通过点击查找您的学校并点击Angular 高级课程来找到课程列表。
您是否注意到课程列表是通过从学校数据模型中检索到的课程 ID 来打开的?在我们的示例应用程序中,我们已经注册了course/:id路由来通过id参数打开课程组件。目前,我们假设用户只有一个课程可用,并且该课程在启动时被选中。这个简单的例子有助于说明 Angular Academy 的基本流程是如何设计的。在更现实的场景中,我们会允许用户登录,并将选中的课程存储在会话中。我们将在第八章,附加提供者作用域中重新讨论这个问题,我们将介绍用户登录。
摘要
在本章中,我们通过提供一个具体的 Angular Academy 应用程序的例子,向您介绍了如何使用新的 Angular 组件。该例子包括如何使用服务检索数据以及如何通过 Angular 模块结构化依赖项的包含。在下一章中,我们将描述如何使用 Angular 组件 Harnesses 的测试作为用户的方法。
第四章:第七章:组件连接器
测试是软件开发的基本部分。它有助于确保交付的代码覆盖了功能需求且没有实现问题。有时,在测试 UI 代码时,很难避免在 DOM 结构上执行紧密耦合的测试。然而,Angular Ivy 为这个问题带来了一个新的解决方案。组件测试连接器使我们能够使用行业标准页面对象模式为我们的组件开发测试 API,但更细粒度。
更进一步,Angular Ivy 已经包含了 Angular Material 指令和组件的组件连接器。在本章中,我们将学习如何使用 Angular 组件中的组件测试连接器,以及如何实现自定义组件连接器以简化我们的组件测试。
我们在本章中将涵盖以下主题:
-
使用 Angular Material 的组件连接器
-
创建组件连接器
到本章结束时,你应该已经了解了如何以及在哪里使用组件连接器。
使用 Angular Material 的组件连接器
你在第四章“探索 Angular 组件功能”中看到了如何使用Material 按钮连接器的示例。现在,让我们探讨如何使用材料测试连接器以及“以用户身份测试”策略来测试主题组件。
如你所记,主题组件让我们可以选择 Angular Academy 的颜色和大小设置。用户可以通过选择颜色并访问具有#headerBackground选择器的MatInputHarness来完成此操作:
it('should be able to read default header background
color theme setting', async () => {
const headerBackground: MatInputHarness = await
loader.getHarness(
MatInputHarness.with({ selector: '#headerBackground'
})
);
expect(await headerBackground.getValue()
).toBe('#00aa00');
});
在这里,我们期望默认设置为'#00aa00'。我们使用getValue方法从测试连接器中检索值。
在这个例子中,我们也可以简单地通过在具有'#headerBackground' ID 的输入字段中找到值并检查其值。所以,让我们构建一个更复杂的测试,其中我们应该能够更改标题背景颜色主题设置:
it('should be able to change the header background color
theme setting', async () => {
const headerBackground: MatInputHarness = await
loader.getHarness(
MatInputHarness.with({ selector: '#headerBackground'
})
);
headerBackground.setValue('#ffbbcc').then(() => {
expect(themeService.getSetting(
'headerBackground')).toBe('#ffbbcc');
});
});
正如我们之前所做的那样,我们将使用组件连接器与'#ffbbccc'进行交互,并检查此设置是否已被主题设置所采用。
你注意到我们没有在这个测试中编写fixture.detectChanges()吗?我们可以避免这样做,因为我们正在使用将处理 DOM 操作的组件连接器。点击这个输入字段并像用户一样与颜色选择器交互是使用 DOM 操作相当复杂的,但在这里,我们正在使用组件测试连接器上的操作。通过这样做,我们可以避免与变更检测相关的测试中的脆弱变化。
同样,我们可以使用MatSliderHarness到MatInputHarness来避免在测试主题组件的视频大小滑块设置时执行 DOM 操作:
it('should be able to check default text and video slider
settings', async () => {
const videoSizeSetting =
Number(themeService.getSetting('videoSize'));
expect(videoSizeSetting).toBe(7);
const videoSizeSlider: MatSliderHarness = await
loader.getHarness(
MatSliderHarness.with({ selector: '#videoSizeSlider'
})
);
expect(await videoSizeSlider.getId()
).toBe('videoSizeSlider');
expect(await videoSizeSlider.getValue()
).toBe(Number(videoSizeSetting));
现在,我们可以从主题服务和使用屏幕组件检索默认的 videoSize 设置,并使用测试 harness 的 API 操作来检查它是否为 7。使用 async/await 构造与测试 harness 结合使用,这里代码相当紧凑。
到目前为止,你应该知道如何使用现有的材料组件 harness。接下来,让我们深入了解如何为 Angular Academy 应用程序构建组件 harness。
创建组件 harness
让我们假设我们希望公开我们的 Video 组件,以便它可以与其他应用程序集成。在这里,为它编写一个测试 harness 是有意义的——但我们应该如何构建它?我们使用 YouTube Player 组件在 Video 组件内部显示 YouTube 视频的示例,该 Video 组件将位于 Course 组件内部,直接在 DOM 中使用“测试作为用户”的方法进行测试证明是困难的。因此,让我们采取分层的方法。
在构建组件时,我们应该努力使每个页面只有一个单一参考点——DOM。采用分层方法,我们从 Course 组件开始测试,该组件了解 Video 组件,而 Video 组件反过来又了解 YouTube Player 组件。通过这样做,我们可以通过暴露封装 workspace-video 选择器的 Video harness 作为每个 Video 组件实例的 Page Object 来从 Course 组件进行测试,如下所示:
export class VideoHarness extends ComponentHarness {
static hostSelector = 'workspace-video';
protected getTextElement = this.locatorFor('.text');
async getText(): Promise<string|null> {
const textElement = await this.getTextElement();
return textElement.text()
}
textEquals(video: IVideo, text: string): boolean {
return
text?.toLowerCase().trim().includes(
video.title.trim().toLowerCase()
);
}
}
现在,我们可以通过从 course.component.spec.ts 文件中调用 getText() 来显示每个视频的文本。然后,我们可以使用提供的 textEquals 调用来测试相等性:
it('should render the video title in text when displaying it', async () => {
const renderedVideos =
await loader.getAllHarnesses(VideoHarness);
courseService.getCourse('1').subscribe((course) => {
renderedVideos.forEach(async (video: VideoHarness) => {
const text = await video.getText()|| "";
expect(course.videos.find((v) =>
video.textEquals(v, text))).toBeTruthy();
});
});
});
在这里,我们遍历了课程组件中渲染的 '1' 课程的所有视频,并检查每个与渲染视频一起出现的文本是否包含我们可以通过课程服务检索的课程标题。请注意,这里的 textEquals 函数是由测试 harness 提供的,这意味着我们可以在组件库的后续版本中更改该函数。
我们将让 Video harness 使用 Page Object 方法隐藏与视频相关的 DOM 操作。然后,Video harness 将了解针对 YouTubePlayer harness 的特定实现,该 harness 封装了 youtube-player 选择器,如下所示:
class YoutubePlayerHarness extends ComponentHarness {
static hostSelector = 'youtube-player';
async getVideoId(): Promise<string|null> {
const host = await this.host();
return await host.getAttribute('ng-reflect-video-id');
}
}
当使用 Angular YouTube Player 渲染视频时,我们期望视频 ID 可用。这里的技巧是我们希望隐藏 Angular YouTube Player 的实现细节,以便于对 Course 组件的测试。因此,让我们在 Video harness 中引入 getVideoId 函数,以便在从 Course 组件进行测试时可用:
it('should have the videoId available when rendering the
video', async() => {
const renderedVideos = await
loader.getAllHarnesses(VideoHarness);
renderedVideos.forEach( async(video: VideoHarness) => {
const videoId = await video.getVideoId();
expect(videoId).toBeTruthy();
})
})
完整的 Video harness 将看起来像这样:
export class VideoHarness extends ComponentHarness {
static hostSelector = 'workspace-video';
protected getTextElement = this.locatorFor('.text');
protected getVideoElement =
this.locatorFor(YoutubePlayerHarness);
async getText(): Promise<string | null> {
const textElement = await this.getTextElement();
return textElement.text();
}
async getVideoId(): Promise<string | null> {
const videoElement = await this.getVideoElement();
return videoElement.getVideoId();
}
textEquals(video: IVideo, text: string): boolean {
return text?.toLowerCase().trim().includes(
video.title.trim().toLowerCase());
}
}
在这一点上,我们可以将 Video test harness 以及 Video 组件公开给任何希望在其应用程序中使用我们的 Video 组件的人。
摘要
在本章中,我们探讨了如何在 Angular Academy 应用程序的环境中,使用一些现有的测试 Material UI 测试工具的示例。此外,我们还介绍了如何实现 Angular Academy 应用程序中使用的 Video 组件的组件工具。
在下一章中,我们将通过向您展示如何使用新的提供者作用域来总结我们的 Angular Academy 应用程序。
第五章:第八章:额外的提供者作用域
本章旨在解释如何使用依赖注入作用域在 Angular Ivy 中开发更精简的组件和功能。为了探索这些功能,我们将学习如何创建非单例服务以及如何在 Angular 元素之间重用依赖项。
我们将通过修改主题服务,使其能够在使用any提供者作用域的不同场景中接受特定配置,并重新配置学校和课程模块以进行懒加载来引入any提供者作用域。
然后,我们将通过构建一个新的登录元素来结束第二部分,使用您学到的 Angular Ivy 功能构建真实世界的应用程序,该元素展示了如何通过使用 Angular Elements 的平台提供者作用域来跨应用程序边界共享信息。
我们在本章中将涵盖以下主题:
-
重新审视根提供者作用域
-
使用任何提供者作用域的可配置主题服务
-
使用平台提供者作用域在应用程序边界之间共享信息
在我们深入探讨新提供者作用域的细节之前,让我们花一点时间回顾一下我们迄今为止使用根提供者作用域引入的服务。
重新审视根提供者作用域
到目前为止,我们在 Angular Academy 应用程序中使用根作用域提供者讨论了以下服务:
-
SchoolsService:检索有关可用学校的详细信息。 -
CourseService:检索有关课程的详细信息。 -
ThemeService:设置和检索有关当前主题的详细信息。
这些服务作为单例在应用程序中一直为我们服务得很好——通过Injectable装饰器标记服务以使用providedIn: 'root'使得它们在标准用例中很容易使用。如果您从 Angular 的早期阶段就开始使用,那么您可能已经习惯了在每个特定模块中将服务作为依赖项进行注入——例如,您可能想知道为什么SchoolsService没有列在学校的模块的提供者数组中:
@NgModule({
declarations: [SchoolsComponent],
imports: [CommonModule, GoogleMapsModule],
exports: [SchoolsComponent],
})
export class SchoolsModule {}
我们不需要在这里插入显式的提供者,因为我们从 Angular 版本 6 以来就有可摇树振的提供者。我们现在可以只依赖injectable装饰器。这使得 Angular 模块更加精简且易于配置,我们可以在以后提供服务的替代实现。
在根作用域上提供单例服务本身听起来很有用(在第六章Chapter 6中效果很好,使用 Angular 组件)。但如果我们想针对每个用例拥有特定的服务实例呢?实际上,我们可以通过使用主题服务的任何提供者作用域并将模块改为懒加载而不是默认的急加载来实现这一点。让我们深入了解如何做到这一点。
使用可配置的 ThemeService 的任何提供者作用域
让我们使用任何提供者作用域为可配置的 ThemeService 注入可配置设置,这些设置取决于我们加载的每个模块的使用情况:
@Injectable({
providedIn: 'any',
})
export class ThemeService {
constructor(@Inject(themeToken) private theme: ITheme) {}
public setSetting(name: string, value: string): void {
this.setItem(name, value);
}
public getSetting(name: string): string {
switch (name) {
case 'background':
return this.getItem(name) ?? this.theme.background;
case 'tileBackground':
return this.getItem(name) ??
this.theme.tileBackground;
case 'headerBackground':
return this.getItem(name) ??
this.theme.headerBackground;
case 'textSize':
return this.getItem(name) ?? this.theme.textSize;
case 'videoSize':
return this.getItem(name) ?? this.theme.videoSize;
}
return 'white';
}
private setItem(name: string, value: string): void {
localStorage.setItem(this.prefix(name), value);
}
private getItem(name: string): string | null {
return localStorage.getItem(this.prefix(name));
}
private prefix(name: string): string {
return this.theme.id + '_' + name;
}
}
我们在第 第五章* 中介绍了主题服务,即使用 CSS 自定义属性*。让我们通过引入一个用于主题的 InjectionToken 实例来使其可配置:
import { InjectionToken } from '@angular/core';
import { ITheme } from './theme/theme.model';
export const themeToken = new InjectionToken<ITheme>('theme');
主题令牌持有实现 ITheme 接口的配置设置:
export interface ITheme {
id: string;
background: string;
headerBackground: string;
tileBackground: string;
textSize: string;
videoSize: string;
}
我们可以在 AppModule 中使用 InjectionToken 令牌 theme 通过这些值使用 green 主题:
import { ITheme } from './app/theme/theme.model';
export const theme: ITheme = {
id: 'green',
background: '#f8f6f8',
tileBackground: '#f4ecf4',
headerBackground: '#00aa00',
textSize: '3',
videoSize: '7',
};
注意,配置的设置将是起始值。用户仍然可以在系统运行时更改它们。
现在,我们可以使用主题服务将主题设置注入到注入器作用域。当我们使用任何提供者作用域时,我们可以为每个注入服务的懒加载模块获取一个实例。以下是一个片段,展示了如何通过应用程序路由模块重新配置模块以进行懒加载,同时在 AppModule 中运行 green 主题:
// (...)
import { themeToken } from './theme.token';
import { theme } from '../green.theme';
import { AppRoutingModule } from './app-routing.module';
// (...)
@NgModule({
declarations: [AppComponent],
imports: [
// (...)
AppRoutingModule,
],
providers: [
{
provide: themeToken,
useValue: theme,
},
],
bootstrap: [AppComponent],
})
export class AppModule {}
在这里,我们在应用程序模块作用域上为 ThemeService 提供默认设置。每个模块的依赖项将像这样从 AppRoutingModule 内部动态加载:
const routes: Routes = [
{path: '', redirectTo: 'login', pathMatch: 'full',},
{
path: 'course',
loadChildren: () =>
import('./course/course.module').then((m) =>
m.CourseModule),
},
{
path: 'login',
loadChildren: () =>
import('./login/login.module').then((m) =>
m.LoginModule),
},
{
path: 'schools',
loadChildren: () =>
import('./schools/schools.module').then((m) =>
m.SchoolsModule),
},
{
path: 'theme',
loadChildren: () =>
import('./theme/theme.module').then((m) =>
m.ThemeModule),
},
];
然后,我们需要为每个模块建立路由模块。以课程模块为例,其路由模块看起来如下所示:
import { CourseComponent } from './course.component';
const routes: Routes = [{ path: ':id', component: CourseComponent }];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class CourseRoutingModule {}
我们已经在应用程序模块的 Provider 作用域中定义了设置,该模块使用课程模块,因此如果我们想为模块使用 green 主题,则不需要在此处重新定义它。但如果我们想使用另一个主题,则可以通过 theme 令牌引入另一个主题配置,如下所示:
import { ITheme } from './app/theme/theme.model';
export const theme: ITheme = {
id: 'metallic',
background: '#ffeeff',
tileBackground: '#ffefff',
headerBackground: '#ccbbcc',
textSize: '3',
videoSize: '7',
};
我们可以通过平台注入器作用域使用 metallic 主题在懒加载的 LoginModule 上,如下所示:
// (...)
import { theme } from '../../metallic.theme'
// (...)
@NgModule({
declarations: [LoginComponent],
imports: [
CommonModule,
MaterialModule,
FormsModule,
ReactiveFormsModule,
LoginRoutingModule,
],
providers: [
{
provide: themeToken,
useValue: theme,
},
],
})
export class LoginModule {}
由于我们正在懒加载登录模块,我们现在将创建主题服务的新的实例 – 这样 login 组件就可以使用 metallic 主题而不是像应用程序其余部分一样使用 green 主题。通过这种方式,我们可以使用主题服务的实例使用 green 主题渲染工具栏,并使用 metallic 主题渲染 login 组件,如下所示:

图 8.1 – 登录屏幕。请注意,这里的背景来自金属主题
这将是您启动 Angular Academy 应用程序时看到的第一个屏幕。请注意,金属卡片背景是在 login.component.scss 文件中设置的,使用了您在 第五章* 中学习到的机制,即使用 CSS 自定义属性*:
.mat-card {
background: var(--background, green);
}
background 变量将在 LoginComponent 中设置,如下所示:
@Component({
selector: 'workspace-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss'],
})
export class LoginComponent {
@HostBinding('style.--background')
background: string;
loginForm: FormGroup;
constructor(
public fb: FormBuilder,
public authService: AuthService,
private themeService: ThemeService
) {
this.background = themeService.getSetting(
'background');
this.loginForm = this.fb.group({
name: [''],
password: [''],
});
}
loginUser(): void {
this.authService.login(this.loginForm.value);
}
}
LoginComponent 将用户信息从 loginForm 传递到 AuthService:
@Injectable({
providedIn: 'platform',
})
export class AuthService {
public loginEvent: EventEmitter<string> = new
EventEmitter();
login(user: IUser): void {
if (user.name === 'demo' && user.password === 'demo') {
this.token = 'thisTokenShouldBeProvidedByTheBackend';
this.loginEvent.emit(this.token);
}
}
logout(): void {
this.token = '';
}
set token(value: string) {
localStorage.setItem('token', value);
}
get token(): string {
return localStorage.getItem('token') ?? '';
}
}
注意,我们在这里使用了一种非常简单的方法来为 demo 用户获取 login 令牌,密码为 demo。这个例子可以被扩展,以调用您选择的后端,并在这样做之后提交 LoginEvent。
策略是这样的:我们可以像这样对 LoginEvent 做出反应:
this.authService.loginEvent.subscribe((token: string) => {
const courseId: string | null =
this.preferenceService.getCourseId();
if (courseId) {
router.navigate(['/course', courseId]);
} else {
router.navigate(['/schools']);
}
});
到目前为止,我们希望您已经有机会尝试使用 Angular Academy 应用程序,看看事物是如何连接在一起的。您注意到在第二次登录后,您会被直接重定向到 Angular Academy 中的课程吗?第一次登录时,您应该有机会从地图中选择一所学校——然后从学校中选择一个课程。当您选择一个课程时,这将通过偏好服务进行存储。然后可以使用这个偏好将用户重定向到课程。
Angular Academy 应用程序使用一些相当复杂的导航逻辑,这些逻辑仅在应用内部相关——但如果我们想在应用外部共享信息呢?我们可以通过结合使用 platform 提供者作用域和 Angular 元素来实现这一点。
使用平台提供者作用域在应用程序边界之间共享信息
我们可以通过创建一个作为 Angular 元素的推文按钮来展示如何在外部共享信息。这个 Angular 元素也可以在应用外部使用。让我们深入了解如何做到这一点。
首先,我们将通过运行以下命令将 Angular 元素添加到应用中:
ng add i @angular/elements
然后,我们像这样在页面中包含 Twitter 小部件 SDK:
<script
src="img/widgets.js"
type="text/javascript"
></script
然后,我们可以使用 TweetCourse 组件构建一个推文标签按钮,如下所示:
<ng-container *ngIf="course$ | async as course">
<a
href="https://twitter.com/intent/tweet?button_hashtag= {{ course.hashtag }}"
class="twitter-hashtag-button"
>Tweet {{ course.hashtag }}</a
>
</ng-container>
TweetCourse 组件使用 CourseService 和平台提供者作用域来检索数据:
@Component({
selector: 'workspace-tweetcourse',
templateUrl: './tweetcourse.component.html',
})
export class TweetCourseComponent implements OnInit {
@Input()
courseId!: string;
public course$: Observable<ICourse> | undefined;
constructor(
private courseService: CourseService
) {
}
ngOnInit(): void{
this.course$ = this.courseService.getCourse(
this.courseId);
}
}
现在,我们像这样注册 TweetCourseComponent 作为 Angular 元素:
export class AppModule implements DoBootstrap {
constructor(private injector: Injector) {
const el = createCustomElement(TweetCourseComponent, {
injector: this.injector,
});
customElements.define('tweet-course', el);
}
ngDoBootstrap(): void {}
}
然后,它可以作为一个 Web 组件使用:
<tweet-course courseId="1"></tweet-course>
由于 CourseService 已在平台提供者作用域上注册,我们现在可以从我们新的 <tweet-course> Angular 元素以及我们的 Angular Academy 应用程序内部使用它。
我们在应用程序桌面版本的导航栏中插入 <tweet-course> 元素,如下所示,在导航组件内部:
<mat-nav-list>
<a *ngIf="!token" mat-list-item
href="/login">Login</a>
<ng-container *ngIf="token">
<a mat-list-item *ngIf="courseId"
routerLink="course/{{ courseId }}"
>Follow course</a
>
<a mat-list-item routerLink="/schools">Find
school</a>
<a mat-list-item routerLink="/theme">Theme</a>
<a *ngIf="token" mat-list-item (click)="logout()"
href="#">Logout</a>
</ng-container>
<ng-container *ngIf="courseId">
<tweet-course courseId="courseId"></tweet-course>
</ng-container>
</mat-nav-list>
在这里,您可以看到,如果您已经选择了一个课程(如果您有一个 courseId 实例),则 <tweet-course> 元素应该在侧边栏中渲染。它应该看起来像这样:

图 8.2 – 介绍 Tweet #AcceleratingIvy 按钮
如果您在点击 #AcceleratingIvy 标签时已登录 Twitter。如果您在课程服务中为您的课程注册另一个标签,那么这个标签将被展示。
也许您有其他想法,可以将组件用于应用程序之外?您注意到我们将 AuthService 标记为 providedIn: 'platform' 吗?您可以导出 Login 组件作为 Angular 元素,并将其更新以集成到您自己的应用程序平台中,以执行单点登录。
摘要
在本章中,我们首先扩展了根提供者作用域的使用,并在 Angular Academy 应用程序上下文中介绍了新的 any 和 platform 提供者作用域。然后,我们通过应用 AppRoutingModule 中的延迟加载来介绍 any 提供者作用域,这使得我们可以为 LoginModule 使用单独的主题。最后,我们看到了如何创建一个可以与平台提供者作用域一起使用的推文按钮。
在下一章中,我们将开始介绍本书的第三部分,即升级您的视图引擎应用程序和开发工作流程到 Angular Ivy,并探讨迁移和使用 Angular Ivy 的更多实际方面。
第六章:第九章: 使用新的 Ivy 运行时 API 进行调试
Angular Ivy 引入了一个新的 API,用于在运行时检查和调试我们的 Angular 应用程序。它替换了之前的NgProbe API,并允许DebugElement进行摇树优化。
我们将探索 Angular 最有用的运行时调试函数,包括以下内容:
-
ng.applyChanges -
ng.getComponent -
ng.getContext -
ng.getListeners
拥有这些调试工具将允许您在运行时验证关于活动组件、它们的模板和它们的 DOM 绑定的假设。
本章涵盖以下主题:
-
新 Ivy 运行时 API 简介
-
检查活动组件
-
检查事件监听器
-
检查嵌入式视图上下文
熟悉这些主题将提高您实现 Angular Ivy 应用程序的开发工作流程。
技术要求
为了支持本章代码示例中使用的所有功能,您的应用程序至少需要以下要求:
-
Angular Ivy 版本 12.0
-
TypeScript 版本 4.2
此外,请注意,运行时调试 API 仅在 Angular 以开发模式运行时才可用。
您可以在本书的配套 GitHub 仓库中找到随机数生成器的完整代码示例,网址为github.com/PacktPublishing/Accelerating-Angular-Development-with-Ivy/tree/main/projects/chapter9/random-number。
新 Ivy 运行时 API 介绍
如果您之前使用过 Angular 版本,您可能熟悉NgProbe API,该 API 在运行时作为全局作用域中的ng.probe函数可用。Angular Ivy 用一组新的运行时调试函数替换了此 API,这些函数仅在 Angular 开发模式下可用。
新 API 包含以下函数:
-
ng.applyChanges(component: {}): void;如果指定的组件正在使用
OnPush变更检测策略,则标记该组件进行脏检查。之后,触发变更检测周期。 -
ng.getComponent<T>(element: Element): T | null;解析附加到指定 DOM 元素的 Angular 组件。
-
ng.getContext<T>(element: Element): T | null;当传递由结构指令(如
NgIf或NgFor)生成的 DOM 元素时,解析嵌入式视图的视图上下文。在其他情况下,解析父组件。 -
ng.getDirectiveMetadata(directiveOrComponentInstance: any): ComponentDebugMetadata | DirectiveDebugMetadata | null;解析指定 Angular 组件或指令实例的元数据。
-
ng.getDirectives(element: Element): {}[];解析附加到指定 DOM 元素的 Angular 指令(但不是组件)。
-
ng.getHostElement(componentOrDirective: {}): Element;解析指定组件或指令附加到的宿主元素。
-
ng.getInjector(elementOrDir: {} | Element): Injector;解决与指定元素、指令或组件关联的注入器。
-
ng.getListeners(element: Element): Listener[];解决附加到指定 DOM 元素的事件监听器。这不包括由指令或组件元数据创建的主监听器,但包括 Angular 未添加的事件监听器。
-
ng.getOwningComponent<T>(elementOrDir: {} | Element): T | null;解决指定 DOM 元素、指令或组件的主组件。
-
ng.getRootComponents(elementOrDir: {} | Element): {}[];解决与指定 DOM 元素、指令或组件关联的根组件,即由 Angular 引导的组件。
由ng.getListeners返回的Listener数据结构具有以下接口:
interface Listener {
callback: (value: any) => any;
element: Element;
name: string;
type: 'dom' | 'output';
useCapture: boolean;
}
由ng.getDirectiveMetadata返回的数据结构具有以下接口:
interface DirectiveDebugMetadata {
inputs: Record<string, string>;
outputs: Record<string, string>;
}
interface ComponentDebugMetadata extends DirectiveDebugMetadata {
changeDetection: ChangeDetectionStrategy;
encapsulation: ViewEncapsulation;
}
在前面的调试元数据接口中定义的inputs和outputs属性包含从数据绑定属性名称到组件属性名称的对象映射。
重要的是要注意,ComponentDebugMetadata#changeDetection和ComponentDebugMetadata#encapsulation属性是数字枚举,因此它们的值在运行时将是数字,而不是字符串。这使得它们在调试时稍微难以解释。
如前所述的概述所示,大多数这些运行时调试实用程序接受一个参数,即 DOM 元素、组件实例或指令实例。从指定的对象中,它们查找 DOM 元素、一个或多个组件实例、一个或多个指令实例、一个注入器或附加到 DOM 的事件监听器。
其中最引人注目的是ng.applyChanges。我们将在下一节讨论何时以及如何使用它。
检查组件实例
为了在运行时以编程方式探索我们的应用程序,我们通常需要一个活动组件实例的引用。一旦我们有了组件引用,我们就可以更改绑定属性并调用事件处理器或其他方法。
然而,首先,我们需要一个对指令实例或附加了组件的 DOM 元素的引用。使用$0变量。或者,我们可以使用document.querySelector或任何其他 DOM 查询或遍历 API。
假设我们有一个生成随机数的组件,如图所示:

图 9.1 – 生成随机数的组件
它具有以下代码块中所示组件模型:
import { Component } from '@angular/core';
@Component({
selector: 'app-random-number',
templateUrl: './random-number.component.html',
styleUrls: ['./random-number.component.css'],
})
export class RandomNumberComponent {
generatedNumber?: number;
onNumberGenerated(generatedNumber: number): void {
this.generatedNumber = generatedNumber;
}
}
在其模板中,它使用 Angular Material 的按钮组件,如下面的代码列表所示:
<ng-container
#generator="appRandomNumber"
appRandomNumber
(numberGenerated)="onNumberGenerated($event)"
></ng-container>
<mat-card>
<mat-card-header>
<mat-card-title>Random number generator</mat-card-
title>
</mat-card-header>
<mat-card-content>
<p>Your random number is {{ generatedNumber }}</p>
</mat-card-content>
<mat-card-actions>
<button mat-button (click)= "generator.generateNumber()">GENERATE</button>
</mat-card-actions>
</mat-card>
由于我们在$0变量中有一个对<button> DOM 元素的引用,我们可以执行以下操作以解决两个不同的组件实例:
ng.getComponent($0);
// -> MatButton
ng.getOwningComponent($0);
// -> RandomNumberComponent
ng.getComponent和ng.getOwningComponent之间存在一个微妙但重要的区别。第一次调用返回一个MatButton组件的实例,该实例附加到<button>DOM 元素上。第二次调用给我们提供了一个RandomNumberComponent活动实例的引用。
我们得出结论,ng.getComponent返回指定 DOM 元素附加的组件,在本例中是MatButton组件。现在,ng.getOwningComponent返回与用于生成指定 DOM 元素的组件模板关联的组件实例,在本例中是一个RandomNumberComponent实例。
generatedNumberUI 属性绑定到 Angular 为随机生成器组件创建的 DOM 中的文本。如果我们想将其更改为特定的值,比如42呢?有了活动组件实例的引用,我们可以直接更改 UI 属性,如下所示:
const component = ng.getOwningComponent($0);
component.generatedNumber = 42;
然而,当我们查看渲染的应用程序时,我们注意到 DOM 尚未更新以反映这个新的组件状态。当使用运行时调试 API 时,我们必须让 Angular 知道我们已经手动更改了状态,并希望 Angular 更新它所管理的 DOM。
我们通过将组件实例传递给ng.applyChanges来通知 Angular 脏状态,如下所示:
ng.applyChanges(component);
在 Angular 完成一个变更检测周期后,我们注意到新的状态已反映在 DOM 中,如下所示:

图 9.2 – 显示手动指定数字的组件
太好了,现在你已经熟悉了最常见的运行时调试函数,这些函数使我们能够控制 Angular 组件。在接下来的章节中,我们将探讨对运行时调试至关重要的调试 API,但它们并不像常见的 API 那样被广泛使用。
检查事件监听器
我们将重用来自“检查组件实例”部分的随机数生成器示例。
为了参考,以下是在随机数组件模板中使用的随机数指令:
import {
Directive, EventEmitter, OnInit, Output
} from '@angular/core';
@Directive({
exportAs: 'appRandomNumber',
selector: '[appRandomNumber]'
})
export class RandomNumberDirective implements OnInit {
#generatedNumber?: number;
@Output()
numberGenerated = new EventEmitter<number>();
ngOnInit(): void {
this.generateNumber();
}
generateNumber(): void {
this.#generatedNumber =
Math.floor(1000 * Math.random());
this.numberGenerated.emit(this.#generatedNumber);
}
}
当其generateNumber方法被调用时,它通过numberGenerated输出属性输出随机生成的数字。随机数组件已将其事件处理程序RandomNumberComponent#onNumberGenerated绑定到这个自定义组件事件上。
DOM 事件监听器
我们在$0中选择了<button>元素。
注意在组件模板中,按钮组件有一个点击事件绑定。我们想要访问它,以便可以触发它。为此,将按钮 DOM 元素传递给ng.getListeners函数,如下所示:
const [onButtonClick] = ng.getListeners($0);
// onButtonClick -> Listener
onButtonClick.callback();
我们解包ng.getListeners返回的第一个也是唯一的事件监听器,当传递按钮 DOM 元素时。这个Listener数据结构存储在onButtonClick变量中。
我们通过调用 onButtonClick.callback 来调用点击事件处理器。这触发了与点击 生成 按钮相同的州更新。然而,Angular 并不知道脏状态。
重要提示
在 Angular 代码之外注册的 DOM 事件监听器也由 ng.getListeners 返回。
您可能还记得在 检查组件实例 部分中,我们必须通过运行时调试 API 通知 Angular 我们通过运行时调试 API 引入的状态变化。我们通过将组件实例传递给 ng.applyChanges 来这样做,如下所示:
const component = ng.getOwningComponent($0);
ng.applyChanges(component);
当 Angular 完成变更检测周期后,新生成的数字将在随机数生成组件的 DOM 中显示,该组件由 Angular 管理。
注意,我们没有向 Listener#callback 方法传递任何参数。在我们的用例中,事件处理器不接受任何参数。如果它接受,我们很可能会需要传递期望类型的参数才能使其工作。例如,一个点击事件监听器可能接受一个类型为 click 的 MouseEvent 事件。
自定义组件事件绑定
通过 Angular 自定义组件事件绑定绑定的事件处理器也被注册为监听器。我们的示例组件为自定义的 numberGenerated 事件绑定了一个内联事件处理器。
我们在 $0 中选择 <span> 元素。
我们将 span 元素传递给 ng.getListeners,并注意到它列出了两个监听器,一个是类型为 "dom" 的监听器,另一个是类型为 "output" 的监听器,如下所示:
const [domListener, outputListener] = ng.getListeners($0);
// domListener -> Listener { element: span, name: "numberGenerated", useCapture: false, type: "dom", callback: ƒ }
// outputListener -> Listener { element: span, name: "numberGenerated", useCapture: false, type: "output", callback: ƒ }
我们通过将 7 传递给 outputListener.callback 来模拟随机数生成,并通过将组件传递给 ng.applyChanges 来运行变更检测。这如下面的浏览器控制台列表所示:
const component = ng.getOwningComponent($0);
outputListener.callback(7);
ng.applyChanges(component);
一旦完成变更检测周期,我们模拟的随机数生成将在 DOM 中显示,这是由 Angular 管理的。
这就是使用 Angular 运行时调试 API 检查原生和自定义事件监听器的全部内容。在本章的最后部分,我们将学习嵌入视图上下文是什么,以及如何使用 Angular Ivy 的运行时调试 API 来检查它。
检查嵌入视图上下文
结构指令用于在组件的生命周期内添加和删除 DOM 元素。它们创建一个嵌入视图,该视图绑定到视图上下文。这是 Angular 框架中 NgIf 和 NgFor 指令的情况。
重要提示
只能将一个结构指令附加到元素上。如果您需要应用多个结构指令,请将元素包裹在特殊的 <ng-container> 元素中,将外部结构指令附加到该元素上,依此类推。
当我们将带有结构指令的元素传递给 ng.getContext 时,它返回视图上下文。例如,当我们传递带有 NgIf 指令的元素时,返回 NgIfContext,它具有以下形状:
interface NgIfContext {
$implicit: boolean;
ngIf: boolean;
}
由 NgIf 动态创建的嵌入视图绑定到 NgIfContext 的 $implicit 属性。
如果我们传递一个带有NgFor指令的元素,则返回NgForOfContext。它具有以下形状:
interface NgForOfContext<T> {
$implicit: T;
count: number;
index: number;
ngForOf: T[];
even: boolean;
first: boolean;
last: boolean;
odd: boolean;
}
NgFor指令为NgForOfContext#ngForOf中的每个项目动态创建一个嵌入式视图。每个嵌入式视图都绑定到特定于该项目的NgForOfContext的$implicit属性。
然而,每个嵌入式视图也可以访问NgForOfContext中指定的其他属性。例如,我们可以遍历用户列表并存储对index和first上下文属性的引用,如下所示:
<ul>
<li *ngFor="let user of users; index as i; first as
isFirst">
{{i}}/{{users.length}}.
{{user}} <span *ngIf="isFirst">(default)</span>
</li>
</ul>
为每个用户创建的嵌入式视图可以访问并使用index和first属性,分别别名为i和isFirst。
将前一个代码列表中的列表项元素传递给ng.getContext会导致返回一个NgForOfContext值,例如以下代码列表中的示例:
const listItems = document.querySelectorAll('li');
ng.getContext(listItems[0]);
// -> NgForOfContext { $implicit: "Nacho", count: 4, index: 0, ngForOf: ["Nacho", "Santosh", "Serkan", "Lars"], even: true, first: true, last: false, odd: false }
ng.getContext(listItems[1]);
// -> NgForOfContext { $implicit: "Santosh", count: 4, index: 1, ngForOf: ["Nacho", "Santosh", "Serkan", "Lars"], even: false, first: false, last: false, odd: true }
同样,如果我们把<span>元素传递给ng.getContext,我们得到一个NgIfContext值,如下所示:
ng.getContext(document.querySelector('span'));
// -> NgIfContext { $implicit: true, ngIf: true }
特别注意传递带有结构指令的元素,否则你将收到最近的组件实例。
现在,你知道如何检查带有结构指令的模板元素的嵌入式视图上下文。
摘要
我希望你喜欢我们在本章中添加到你的工具箱中的新工具。我们从一个概述开始,介绍了 Angular Ivy 的运行时调试 API,该 API 仅在开发模式下可用。
接下来,我们学习了如何使用ng.getComponent和ng.getOwningComponent检查组件实例。我们还更改了组件状态,然后使用ng.applyChanges更新 DOM。
在检查事件监听器部分,我们使用ng.getListeners来检查原生 DOM 事件监听器和自定义组件事件监听器。我们向它们的回调传递参数,并使用ng.applyChanges触发变更检测。
最后,你现在知道什么是嵌入式视图上下文以及如何检查它,例如,它是如何创建并绑定到由NgFor指令管理的每个组件或元素。同样,我们探索了一个由NgIf指令管理的元素的嵌入式视图上下文示例。
在获得所有这些新技能后,你就可以通过直接检查和更新状态或通过事件来调试 Angular 应用程序,然后通过触发变更检测来反映这些更改。
你甚至能够检查那些难以找到的嵌入式视图上下文。太棒了!
在下一章中,你将了解 Angular 兼容性编译器以及何时以及为什么需要它。我们将探索其配置选项,并针对 CI/CD 工作流程进行优化。
第七章:第十章:使用 Angular 兼容性编译器
Angular Ivy 替换了之前一代的 Angular 编译器和渲染运行时,即 Angular View Engine。支持 View Engine 运行时的最后一个版本是 Angular 版本 11.2。
在本章中,我们将学习 npm 上 View Engine 编译的 Angular 包与您的 Angular Ivy 应用程序之间的桥梁,即 Angular 兼容性编译器(ngcc)。
本章涵盖以下主题:
-
介绍 Angular 兼容性编译器
-
使用 Angular 兼容性编译器
-
在您的 CI/CD 工作流程中改进 Angular 兼容性编译器
如果您的 Angular Ivy 应用程序从包注册表中消费 View Engine 编译的库,您必须使用 Angular 兼容性编译器。在了解本章涵盖的主题之后,您将了解您本地开发工作流程中正在发生的事情,并能够微调您的 CI/CD 工作流程中的 Angular 兼容性编译器。
技术要求
对于本章中演示的技术,您的应用程序至少需要以下要求:
-
Angular Ivy 版本 11.1
-
TypeScript 版本 4.0
介绍 Angular 兼容性编译器
Angular 库的源代码在发布到 npm 等包注册表之前进行编译。直到 Angular 版本 12.0,无法使用部分 Angular Ivy 编译来编译 Angular 库;它们必须使用 View Engine 编译器进行编译。作为过渡期的一部分,Angular 使用 Angular 兼容性编译器,允许 Angular Ivy 应用程序使用使用 View Engine 编译器编译并发布到包注册表的库。
截至 Angular 版本 12.2,Angular 兼容性编译器仍然作为 Angular CLI 的一部分包含在内,这意味着我们的 Angular Ivy 应用程序可以消费使用 View Engine 或 Angular Ivy 编译器编译的库。
在 Angular CLI 版本 12.0 中,引入了 Angular 库的局部 Ivy 编译。简而言之,它编译了所有 Angular 特定代码,除了组件模板。然而,局部 Ivy 编译破坏了库的向后兼容性,因为消费者也必须至少使用 Angular CLI 版本 12.0。在此发布之后的时期,我们将看到从 View Engine 编译的 Angular 库向部分 Ivy 编译的 Angular 库的过渡。
好消息是,我们不需要在我们的 Angular Ivy 应用程序中进行任何更改,只需保持 Angular 包更新即可。一旦我们至少有 Angular 12.0,我们的应用程序就通过 Angular 框架的一个内部部分——Angular Linker,支持部分 Ivy 编译的 Angular 库。
Angular Linker 是 Angular 兼容性编译器的替代品,它在将部分 Ivy 编译的 Angular 库包包含到我们的应用程序编译之前,将其转换为完全 Ivy 编译的库包。
因此,Angular 兼容性编译器将在撰写本文时未知的 Angular 版本中移除,但晚于版本 12.2。当这种情况发生时,我们的 Angular 应用程序将只能使用部分 Ivy 编译的 Angular 库。
现在您已经了解了 Angular 兼容性编译器和 Angular 链接器是什么,以及为什么需要它们,在下一节中,我们将讨论如何使用 Angular 兼容性编译器。
使用 Angular 兼容性编译器
在某些 Angular 9 版本发布中,我们必须在构建、测试或提供 Angular Ivy 应用程序之前手动运行 Angular 兼容性编译器。在后续版本中,这发生了变化,使得 Angular CLI 根据需要触发 Angular 兼容性编译器。
仍然可以手动运行 Angular 兼容性编译器。实际上,这允许我们将其微调到最佳编译速度。
在以下操作之前,Angular 兼容性编译器需要至少运行一次:
-
启动开发服务器
-
执行自动化测试
-
构建我们的应用程序
每次我们安装 Angular 库的新版本或从包注册表中安装的附加 Angular 库时,我们必须再次运行 Angular 兼容性编译器。
考虑将 Angular 兼容性编译器作为 Git 仓库的postinstall钩子的一部分运行。当使用此技术时,我们不必等待下一次执行前述列表中提到的任何操作。当 Angular 兼容性编译器正在运行时,我们可以自由更改我们的源代码。
或者,使用下一节中描述的--target选项,即Angular 兼容性编译器选项部分。
Angular 兼容性编译器选项
Angular 兼容性编译器捆绑了一个名为ngcc的可执行文件。当运行此命令时,我们可以传递以下选项:
-
--create-ivy-entry-points在每个 Angular 库包目录内创建一个
__ivy_ngcc_子目录。在此目录内,将创建一个以输出包格式命名的另一个子目录,例如,fesm2015。在包格式文件夹内,将放置 Ivy 编译的包和源映射。如果不传递此选项,则原始包将被覆盖。 -
--first-only当此选项与
--properties结合使用时,Angular 兼容性编译器将根据--properties指定的包属性名称顺序,仅编译库包中识别的第一个模块格式。 -
--properties <package-property-names>此选项指定了使用 Angular 兼容性编译器编译的可接受的库包格式。包属性名称指的是库包的
package.json模块声明中的属性。示例:
--properties es2015 browser module main -
--target <package-name>此选项仅编译指定的包。
示例:
--target @angular/material/button -
--tsconfig <tsconfig-path>您可以使用此选项与
--use-program-dependencies一起使用,以针对您的 Angular 工作空间中的特定项目。示例:
--tsconfig projects/music-app/tsconfig.app.json -
--use-program-dependencies您可以使用此选项根据您的 Angular 工作空间或项目中的源代码来决定您想要使用 Angular 兼容编译器编译哪些库包。
还有一些其他选项,但它们主要用于特殊情况。
手动运行 Angular 兼容编译器,例如,在修改或添加包依赖项之后,可以让我们优化编译速度。当我们手动触发整个工作空间的编译时,我们通常应该使用以下命令:
ngcc --first-only --properties es2015 module fesm2015 esm2015 browser main --create-ivy-entry-points
--first-only 选项确保仅使用 esm2015 捆绑格式将一个包格式编译成与 Angular Ivy 兼容的包捆绑。--properties 选项列出首选的包格式。研究表明,es2015 格式通常是编译从视图引擎兼容捆绑到 Angular Ivy 兼容捆绑的最快包格式,其次是 module 格式。最后,--create-ivy-entry-points 选项通常比原地捆绑替换更快。
重要提示
如果您正在使用 Angular 版本 9.0 或 11.1,请考虑省略 --create-ivy-entry-points 选项,以使用原地捆绑替换。研究发现,在这些特定版本中,此选项略快。
还可以考虑添加 --use-program-dependencies 选项,仅编译应用程序导入的包。当使用此选项时,我们必须在应用程序中首次使用包时运行 Angular 兼容编译器。
--use-program-dependencies 选项在使用 Angular CDK 和 Angular Material 时特别有用,因为它们有许多子包,这些子包都是单独编译的。此外,默认情况下,每个 Angular CDK 和 Angular Material 子包都会被编译,而不仅仅是应用程序使用的那些。这显著影响了编译速度。
重要提示
本章中列出的 ngcc 命令旨在用于 package.json 中 scripts 属性预定义的命令列表中。要从终端运行它们,请在前面加上 npx,例如,npx ngcc --create-ivy-entry-points。
这就是关于您本地开发工作流程中的选项和常见技术的全部内容。在下一节中,您将学习如何优化 CI/CD 工作流程中的 Angular 兼容编译器以提高速度。
在 CI/CD 工作流程中改进 Angular 兼容编译器
如您从 Angular 兼容性编译器支持的一些选项的描述中可以看出,它维护您应用程序的node_modules文件夹中的文件。根据您的 CI 环境,缓存和恢复整个node_modules文件夹可能太慢。在这种情况下,缓存您的包管理器的包缓存文件夹。
可能你的 CI/CD 工作流程中根本未启用缓存。在两种情况下,我们必须在每次 CI/CD 工作流程运行中运行 Angular 兼容性编译器。它从它管理的文件开始从头开始。
对于这个用例,我们使用Angular 兼容性编译器选项章节中描述的指南。我们使用以下postinstall钩子来运行ngcc,在所有参数选项组合中,这是整体最快的组合:
ngcc --first-only --properties es2015 module fesm2015 esm2015 browser main --create-ivy-entry-points
这只编译单个包格式到 Angular Ivy 包格式,并优先考虑编译速度整体最快的格式。包文件被编译到由 Angular 兼容性编译器管理的子文件夹中的新文件,而不是替换现有的、由 View Engine 编译的包文件。
重要提示
如果你使用的是 Angular 版本 9.0 或 11.1,考虑不使用--create-ivy-entry-points选项。研究表明,在这些版本中,原地 Ivy 编译更快。
将 Angular 兼容性编译器作为一个单独的步骤运行而不是按需运行的好处是,它可以像我们刚才做的那样进行微调。此外,它允许我们在排除 View Engine 到 Angular Ivy 编译时间的同时跟踪我们在测试或构建我们的应用上花费的时间。
针对一个 monorepo 工作区中的单个应用
如同在Angular 兼容性编译器选项章节中讨论的那样,Angular CDK 和 Angular Material 是 Angular 库包的例子,它们包含许多子包。如果我们有一个包含几个 Angular 应用的 monorepo 工作区,可能只有其中一些应用使用了 Angular CDK 或 Angular Material。此外,任何一个这样的应用很可能并没有使用 Angular CDK 或 Angular Material 的每一个子包。
如果我们有一个针对单个应用的 CI 或 CD 作业,例如,针对特定应用的测试或构建作业,我们可以考虑这一点。想象一下,我们有一个包含两个 Angular 应用的 monorepo 工作区,一个使用 Bootstrap UI 组件库,另一个使用 Angular Material。在针对使用 Bootstrap 的应用的测试或构建作业中,我们在安装包依赖项的步骤之后使用以下命令:
npx ngcc --first-only --properties es2015 module fesm2015 esm2015 browser main --create-ivy-entry-points --tsconfig projects/bootstrap-app/tsconfig.app.json --use-program-dependencies
我们通过传递其 TypeScript 配置文件的路径到--tsconfig选项来针对 Bootstrap 应用,最后我们添加了--use-program-dependencies选项。
这将在我们的 CI/CD 作业中节省大量的计算时间,因为我们的 CI 服务器将不需要编译 Angular Material 的任何子包。
即使是使用 Angular Material 的应用程序,我们也可以使用类似的命令来节省时间,因为它只会编译由我们的应用程序导入的 Angular Material 子包,而不是所有子包。以下是一个示例命令:
npx ngcc --first-only --properties es2015 module fesm2015 esm2015 browser main --create-ivy-entry-points --tsconfig projects/material-app/tsconfig.app.json --use-program-dependencies
在前面的命令中,我们更改了传递给--tsconfig选项的路径。
现在,你已经学会了 Angular 应用程序 CI/CD 工作流程中最常见的优化技术。
摘要
在本章中,我们首先讨论了 Angular 兼容性编译器在 Angular 库包仍然使用 Angular 视图引擎编译器编译的过渡阶段是一个需要的工具。Angular 兼容性编译器将这些包捆绑编译成 Angular Ivy 格式,以便它们可以被我们的 Angular Ivy 应用程序使用。
此外,我们还讨论了 Angular 的最新版本如何通过 Angular Linker 支持部分 Ivy 编译的 Angular 库包,这最终将完全取代 Angular 兼容性编译器。
在回顾了依赖于 Angular 兼容性编译器的用例之后,我们简要讨论了ngcc命令行工具最有用的选项。随后,我们通过使用这些选项介绍了常见的优化技术。
最后,本章通过考虑如何优化 Angular 兼容性编译器在 CI/CD 工作流程中的速度来结束。我们讨论了针对几个特定和常见用例的解决方案。
现在,你知道如何利用 Angular 兼容性编译器,也知道何时以及如何优化它。
在下一章中,你将指导如何将现有的 Angular 应用程序从视图引擎迁移到 Ivy。你将了解自动和手动迁移,以及从视图引擎迁移到 Angular Ivy 时的其他考虑因素。
第八章:第十一章:从 View Engine 迁移到 Ivy 的 Angular 应用程序
每年都会发布几个 Angular 功能版本。更新我们的 Angular 应用程序需要了解 Angular 更新过程,尤其是在从 Angular View Engine 迁移到 Angular Ivy 时,因为有很多差异,其中大部分由自动化的 Angular 迁移管理。
在本章中,你将了解更新 Angular 应用程序所需的步骤,按照Angular 更新指南的说明,如何管理 Angular 的第三方依赖项,ng update命令最有用的参数,最重要的自动化 Angular Ivy 迁移如何改变我们的应用程序,以及如何应用推荐的但可选的自动化和手动 Angular Ivy 迁移。
在本章中,我们将涵盖以下主题:
-
学习 Angular 更新过程
-
执行自动化的 Angular Ivy 迁移
-
执行手动 Angular Ivy 迁移
技术要求
本章讨论的迁移适用于以下或更高版本的应用程序:
-
Angular Ivy 版本 12.1
-
TypeScript 版本 4.2
确保你已全局安装了 Angular CLI 的最新版本,以便你可以在终端中运行ng update命令。
学习 Angular 更新过程
Angular CLI 为我们提供了一个结构化的方法来更新应用程序的 Angular 特定部分。Angular schematics 的一种类型是迁移,它修改我们的应用程序代码以符合破坏性更改。Angular 的主要和次要版本发布通常伴随着迁移 schematics。
建议按顺序逐个主要版本更新过程。例如,如果我们的应用程序当前正在使用 Angular View Engine 版本 8.2,我们将其更新到 Angular Ivy 版本 9.1,并在我们采取下一步更新从 Angular 版本 9.1 到版本 10.2 之前,验证所有方面是否按预期运行,依此类推,直到我们达到计划更新的 Angular 发布版本。
我们一次执行的更新步骤越少,当事情没有按计划进行时,就越容易识别出了什么问题。
在本节中,我们将首先了解 Angular 更新指南,这是一个官方网络应用程序,列出了逐步说明。之后,我们将讨论 Angular 的第三方依赖项及其发布如何影响我们的 Angular 应用程序。
Angular 更新指南
Angular 更新过程的一个重要工具是 Angular 更新指南。位于update.angular.io,这个网络应用程序提供了更新我们的 Angular 应用程序的逐步说明。
要使用 Angular 更新指南,我们首先选择以下内容:
-
我们目前正在使用哪个 Angular 版本
-
我们想要更新到哪个 Angular 版本
-
我们应用程序的复杂性
-
我们的应用程序是否是使用
ngUpgrade的混合 AngularJS 和 Angular 应用程序 -
我们是否正在使用 Angular Material
即使我们在赶时间,我们也应该选择高级作为我们的应用复杂度,并遍历所有可用的指令,以确保我们不遗漏任何推荐的迁移步骤。
在选择与我们的应用程序匹配的选项后,我们将会看到一个包含以下部分的指令清单:
-
在更新前
-
在更新期间
-
更新后
并非总是清楚哪些指令被列在在更新期间或更新后部分。为了有一个愉快的更新过程,我们在遵循在更新期间部分的指令之前,确保遵循在更新前部分的指令。
在更新期间部分的指令必须按照列出的顺序执行,因为更新和迁移命令通常相互依赖。
ng update命令中的指令,但在 Angular 更新指南中未列出。同样,一些推荐的自动和手动迁移在 Angular 文档中列出,但在 Angular 更新指南中未列出。
管理 Angular 依赖
除了官方的 Angular 包之外,Angular 只有少数依赖。以下包依赖在 Angular 的package.json文件中列出:
-
RxJS
-
tslib
-
Zone.js
从历史上看,这些包依赖的版本由 Angular 更新过程管理。然而,对于破坏性变更的迁移并不总是可用。例如,RxJS 没有计划从 6.x 版本更新到 7.x 版本的迁移。
Zone.js
在撰写本文时,Zone.js 仍处于预发布版本。每个小版本预发布都包含破坏性变更。通常,对于我们的 Angular 应用程序,迁移不是必需的,因为我们没有直接使用 Zone.js。相反,NgZone API 包装了 Zone.js。
然而,我们在几个应用程序文件中导入了 Zone.js,Zone.js 版本 0.11.1 改变了其导入路径。Angular 版本 11 提供了一个自动迁移来更新 Zone.js。
TypeScript
TypeScript 不遵循语义版本控制。每个小版本都包含破坏性变更。没有可用的自动迁移,所以如果我们的应用程序在更新 Angular 后输出编译错误,我们必须参考 TypeScript 官方公告博客文章的破坏性变更部分。
RxJS
可以通过检查@angular/core的package.json文件的dependencies属性来读取 Angular 官方支持的 RxJS 版本。Angular 版本 9.0–10.0 官方支持 RxJS 版本 6.5 和 6.6,而 Angular 版本 10.1–12.1 仅对 RxJS 版本 6.6 提供官方支持。Angular 版本 12.2 可选择支持 RxJS 版本 7.0 及以后的次要版本。
Node.js
Angular CLI 通常对两个主要版本的 Node.js 有官方支持。不稳定的(奇数)主要版本 Node.js 发布版不受 Angular CLI 的官方支持。Angular CLI 版本 9.0–11.2 对 Node.js 10.13 和 12.11 或更高版本的次要版本有官方支持。Angular 版本 12 移除了对 Node.js 10 的支持,但除了对 Node.js 12.14 或更高版本的次要版本外,还增加了对 Node.js 14.15 或更高版本的次要版本的官方支持。
在本节中,我们学习了 Angular 更新指南以及如何管理 Angular 的依赖。在下一节中,我们将学习 ng update 命令和自动化的 Angular Ivy 迁移。
执行自动化的 Angular Ivy 迁移
Angular CLI 支持对 Angular 框架包和第三方 Angular 库的自动化迁移。在本节中,我们将学习如何充分利用 ng update 命令。最后,我们将讨论重要的自动化 Angular Ivy 迁移。
充分利用 ng update 命令
ng update 命令用于更新 Angular 特定的包依赖,包括 Angular 框架包和第三方 Angular 库。在更新到指定包版本时,ng update 命令会在包捆绑中寻找自动迁移。
更新 Angular 时,可以使用以下命令:
ng update @angular/cli @angular/core
这将更新所有主要的 Angular 框架包到最新版本,并执行它们的自动化迁移。Angular CLI 负责工作区迁移,而 Angular 核心包负责迁移到 Angular 的运行时包。
在 学习 Angular 更新过程 这一部分,我们建议一次只更新一个主要版本。例如,要指定 Angular 版本 9,请使用以下命令:
ng update @angular/cli⁹ @angular/core⁹
这将更新主要的 Angular 框架包到最新的版本 9 补丁版本。
可以通过指定 --create-commits 参数来分别在每个提交中执行每个迁移,如下所示:
ng update @angular/cli⁹ @angular/core⁹ --create-commits
建议使用此选项,因为它使得检查每个迁移相关的更改变得更容易,或者可以使用 Git 进行 cherry-pick 我们想要的自动迁移,甚至可以撤销迁移的更改。
如果我们选择通过 Git cherry-pick 撤销或省略迁移,我们通常希望手动执行迁移。或者,我们可以使用以下命令格式重新运行特定的迁移:
ng update <package-name>[@<package-version>] --migrate-only <migration-name>
我们在指定 --create-commits 参数时创建的 Git 提交消息中找到迁移的名称。
在某些情况下,可选的迁移是可用的。例如,Angular 版本 12 引入了一个可选的自动化迁移,用于将 production 构建配置设置为默认:
ng update @angular/cli@¹² --migrate-only production-by-default
至于何时运行主要的 ng update 命令,我们遵循 学习 Angular 更新过程 部分中描述的 Angular 更新指南的说明。
对于通过ng update命令运行的每个迁移,我们都会在显示迁移完成之前看到受迁移影响的文件列表——如果有的话。
一些迁移会引用一个描述迁移的网页。例如,除了在运行迁移前后提供代码片段的示例之外,还需要说明为什么需要这种变化。这是审查自动化迁移所做的更改或手动执行迁移步骤的极好信息。
审查自动化 Angular Ivy 迁移
让我们回顾一些最重要的自动化 Angular Ivy 迁移,以了解它们的重要性。
Angular 工作区版本 9 迁移
命名为workspace-version-9的这次迁移修改了构建配置,使得aot选项被设置为true,即使在默认的开发构建配置中也是如此。实际上,如果我们使用 Angular CLI 12 生成一个新的 Angular Ivy 工作区或应用程序,则没有指定aot选项,因为它的默认值是true。
这次迁移还改变了tsconfig.app.json文件的include属性,以匹配"src/**/*.d.ts"模式。
懒加载语法迁移
这次名为lazy-loading-syntax的迁移将基于字符串的懒加载路由路径更改为使用动态import语句。例如,看看以下路由配置:
{
path: 'dashboard',
loadChildren: './dashboard.module#DashboardModule',
},
迁移后,它被更改为以下内容:
{
path: 'dashboard',
loadChildren: () => import('./dashboard.module')
.then(m => m.DashboardModule),
},
基于字符串的懒加载路由语法已被弃用,必须避免使用。
静态标志迁移
请注意名为migration-v9-dynamic-queries的这次迁移。在 Angular 版本 8 中,将必需的static选项添加到ViewChild和ContentChild查询中。在 Angular 版本 9 中,static选项变为可选,默认为false。
考虑以下 Angular 版本 9 组件:
import { Component, ElementRef, ViewChild } from '@angular/core';
@Component({
selector: 'app-hello',
template: '
<h1 #greeting>
Hello, World!
</h1>
<div #error *ngIf="hasError">
An error occurred
</div>
',
})
export class HelloComponent {
@ViewChild('error')
errorElement?: ElementRef<HTMLElement>;
@ViewChild('greeting', { static: true })
greetingElement?: ElementRef<HTMLElement>;
}
在 Angular View Engine 版本 7 中,在静态option可用之前,其视图查询属性会是这样:
@ViewChild('error')
errorElement?: ElementRef<HTMLElement>;
@ViewChild('greeting')
greetingElement?: ElementRef<HTMLElement>;
迁移到 Angular View Engine 版本 8 之后,我们有了以下视图查询属性,因为需要static选项:
@ViewChild('error', { static: false })
errorElement?: ElementRef<HTMLElement>;
@ViewChild('greeting', { static: true })
greetingElement?: ElementRef<HTMLElement>;
如您可能看到的,Angular 版本 8 的static query migration擅长猜测视图查询和内容查询属性的最佳选项。嵌套在嵌入视图中的项目查询,例如由结构指令创建的视图,被转换为动态查询,即{ static: false }。
当我们迁移到 Angular Ivy 版本 9 时,static选项是可选的,但默认为false,因此我们有以下视图查询属性:
@ViewChild('error')
errorElement?: ElementRef<HTMLElement>;
@ViewChild('greeting', { static: true })
greetingElement?: ElementRef<HTMLElement>;
动态查询自动通过 Angular 版本 9 的static标志迁移移除了static选项。
重要提示
查询列表不受本节中涵盖的历史性变化的影响,因为查询列表始终是动态的。
在迁移到 Angular Ivy 版本 9 之前,请确保审查您的内容和视图查询。请参阅静态查询迁移指南和动态查询标志迁移指南,这些指南在 Angular 版本 12.2 中仍然可在 Angular 文档中找到,分别位于angular.io/guide/static-query-migration和angular.io/guide/migration-dynamic-flag。
异步等待waitForAsync迁移
此迁移,名为migration-v11-wait-for-async,将async测试回调包装器的名称重命名为waitForAsync,以避免与async-await混淆。新名称更好地解释了当我们在这个测试函数中包装测试用例回调时会发生什么。
waitForAsync在完成包装的测试用例之前等待所有微任务和宏任务完成。这有点像注入 Jasmine 和 Jest 的done回调参数,并在测试用例中的最终异步副作用之后调用它。
缺少@Injectable 和不完整提供者定义迁移
这个名为migration-v9-missing-injectable的自动 Angular 版本 9 迁移会进行以下类型的代码更改:
-
使用基于类的模块提供者注册的类中添加了
@Injectable装饰器。 -
不完整的 Angular View Engine 模块提供者被转换为
undefined值的值提供者。
基于类的模块提供者可以有以下格式之一:
@NgModule({
providers: [
DashboardService,
{
provide: weatherServiceToken,
useClass: HttpWeatherService,
},
],
})
export class DashboardServiceModule { }
如果DashboardService或HttpWeatherService没有应用Injectable装饰器,此迁移将向它们的类定义添加Injectable装饰器。
使用以下格式的模块提供者在 Angular View Engine 和 Angular Ivy 中的评估方式不同:
@NgModule({
providers: [
{ provide: MusicPlayerService },
],
})
export class MusicServiceModule { }
Angular View Engine 将提供者评估为以下值提供者:
{ provide: MusicPlayerService, useValue: undefined }
Angular Ivy 将提供者评估为以下类提供者:
{ provide: MusicPlayerService, useClass: MusicPlayerService }
注意,前面的类提供者等同于以下类提供者简写:
@NgModule({
providers: [
MusicPlayerService,
],
})
export class MusicServiceModule { }
由于提供者评估之间的差异,此迁移将不完整的 Angular View Engine 提供者更改为指定undefined值的值提供者。
迁移运行后,请审查所有包含useValue: undefined部分的提供者。这很可能不是我们应用程序的意图。
可选迁移以将 Angular CLI 工作区配置更新为默认生产模式
Angular CLI 版本 12 生成项目构建配置,其中production是默认配置。结果是,我们不需要在ng build命令中指定--configuration=production参数。
然而,现有项目默认不会自动迁移到使用生产配置。使用名为production-by-default的可选迁移将现有项目迁移到这个新默认设置。这主要使用 Angular 版本 12 引入的defaultConfiguration设置来完成。
这些是从 Angular View Engine 更新到 Angular Ivy 时需要注意的一些最值得注意的自动化迁移。在下一节中,我们将讨论可选的手动迁移,以确保我们的 Angular Ivy 应用程序处于最佳状态。
执行手动 Angular Ivy 迁移
在本节中,我们将介绍可选的迁移,使我们的应用程序为未来的 Angular 版本做好准备。我们将讨论微调初始导航、通过配置NgZone优化变更检测以及提高单元测试的类型安全性。
管理初始导航
Angular Ivy 版本 11 移除了RouterModule.forRoot的initialNavigation选项的以下旧值:
-
true -
false -
'legacy_enabled' -
'legacy_disabled'
Angular Ivy 版本 11 也弃用了'enabled'值,但引入了以下新值:
-
'enabledBlocking' -
'enabledNonBlocking'(默认)
'enabledBlocking'与'enabled'等价,并推荐用于 Angular Universal 的服务器端渲染。此值在 Angular 创建我们应用程序根组件实例之前启动初始导航过程,但直到初始导航完成之前阻止根组件的引导。
默认的'enabledNonBlocking'值在 Angular 创建我们应用程序根组件实例之后启动初始导航,但允许在初始导航完成之前引导根组件。这种行为类似于已删除的true值。
'disabled'是第三个可用的、非弃用值。它禁用初始导航过程,并推迟到我们的应用程序代码通过使用Location和Router服务来执行。此值仅应用于高级用例。
通过配置 NgZone 优化变更检测
当我们调用PlatformRef#bootstrapModule方法——通常在我们的应用程序主文件中——我们可以指定编译器和引导选项。截至 Angular 版本 12.2,引导选项未在 Angular 文档中列出。然而,内联文档是可用的。
除了允许我们完全禁用NgZone的传统ngZone选项之外,Angular Ivy 还添加了以下两个选项:
-
ngZoneEventCoalescing -
ngZoneRunCoalescing
它们都接受一个默认为false的布尔值。这两个选项通过将同一 VM 转换中的多个变更检测周期请求合并为一个操作,并使用动画帧来同步变更检测与当前帧率,从而针对特定用例优化变更检测。
事件合并(ngZoneEventCoalescing)指的是原生 DOM 事件冒泡。例如,如果单个用户点击触发了多个点击事件处理器,则变更检测只会触发一次。
ngZoneRunCoalescing管理在同一个 VM 转换中多次调用NgZone#run方法。
由于它们可以提高性能,因此启用这两个选项是一个好的默认设置。然而,它们可能会在某些边缘情况下改变我们的应用程序行为,例如在 Angular 开发模式下抛出NG0100错误,即ExpressionChangedAfterItHasBeenCheckedError。
因此,在启用这些引导设置时,请特别注意。
使用 TestBed.inject 提高单元测试类型安全性
Angular Ivy 引入了静态的TestBed.inject方法,这是一个强类型方法,它取代了弱类型的静态TestBed.get方法。
TestBed.get方法返回一个any类型的值。在下面的示例中,我们看到这迫使我们为存储返回依赖项的变量指定类型注解:
it('displays dashboard tiles', () => {
const dashboardService: DashboardService =
TestBed.get(DashboardService);
// (...)
});
当迁移到TestBed.inject时,我们通常可以省略类型注解,如下面的等效代码片段所示:
it('displays dashboard tiles', () => {
const dashboardService =
TestBed.inject(DashboardService);
// (...)
});
如果提供的类型与提供者令牌不同,我们现在必须在将其转换为注册类型之前将返回的依赖项转换为unknown,如下面的示例所示:
it('displays dashboard tiles', () => {
TestBed.configureTestingModule({
providers: [
{
provide: DashboardService,
useClass: DashboardServiceStub
},
],
});
const dashboardServiceStub =
TestBed.inject(DashboardService)
as unknown as DashboardServiceStub;
// (...)
});
值得注意的是,TestBed.inject在严格性上比TestBed.get更强,因为它只接受类型为Type<T> | AbstractType<T> | InjectionToken<T>的提供者令牌参数,即一个具体类、一个抽象类或依赖注入令牌。
这与TestBed.get不同,它支持类型为any的提供者令牌,例如字符串、数字或符号。
避免使用TestBed.inject不支持提供者令牌,因为这些自 Angular 版本 4 以来已被弃用,例如用于在运行时解析依赖项的弱类型Injector#get签名。
摘要
在本章中,我们讨论了 Angular 更新过程,包括Angular 更新指南、ng update命令以及管理 Angular 的第三方依赖项。
我们学习了如何通过简单的代码示例来审查某些重要的自动化 Angular Ivy 迁移。
最后,我们考虑了几种可选的迁移,包括自动和手动迁移 Angular Ivy。我们学习了如何根据我们的应用程序平台微调 Angular 路由器的初始导航。
之后,我们讨论了两个未记录的NgZone配置设置,这些设置通过合并多个请求的变更检测周期为某些原生事件和用例优化变更检测。
我们讨论的最后一种手动迁移通过使用强类型的静态TestBed.inject方法而不是已弃用的静态TestBed.get方法,提高了我们的单元测试类型安全性。
在下一章中,我们将探讨 Angular 预编译编译器的影响和限制,这是 Angular Ivy 中应用程序的默认编译器。
第九章:第十二章:拥抱即时编译
Angular Ivy 是 Angular 框架的最新一代。它具有一个新的编译器和一个新的运行时,它们都保持了与之前一代 Angular 的编译器和运行时(称为 Angular 视图引擎)使用的多数 API 的兼容性。
在本章中,我们将了解 Angular Ivy 如何使即时编译成为开发所有阶段的默认 Angular 编译器,以及它对我们开发者工作流程的影响。
随后,我们将探讨在使用即时编译的 Angular 编译器时可能出现的元数据错误,以及伴随的修复错误的技术。
最后,我们将演示两种在启动我们的 Angular Ivy 应用程序之前解决异步依赖的技术。
本章我们将涵盖以下主题:
-
在所有开发阶段使用即时编译器
-
处理即时编译器的限制
-
初始化异步依赖
在阅读本章后,你将了解 Angular Ivy 如何使即时编译的 Angular 编译器成为我们开发工作流程所有阶段的默认编译器成为可能。你将理解即时编译器如何影响我们开发工作流程的不同阶段。
本章介绍了与即时编译的 Angular 编译器不兼容的边缘情况,同时也教你如何处理它们或绕过它们。
你将学习两种在启动 Angular 应用程序之前解决异步依赖的技术,以及它们带来的权衡。
技术要求
本章讨论的技术细节适用于以下版本或更高版本的应用程序:
-
Angular Ivy 版本 12.2
-
TypeScript 版本 4.2
在 Angular 和 TypeScript 的早期版本中可能会出现更多的元数据错误。
你可以在本书的配套 GitHub 仓库中找到功能标志和功能标志初始化器的完整代码示例,该仓库地址为 github.com/PacktPublishing/Accelerating-Angular-Development-with-Ivy/tree/main/projects/chapter12。
在所有开发阶段使用即时编译器
在 Angular 的前几代中,即时编译器比即时 Angular 编译器慢得多。由于这个和其他因素,即时编译在所有或几个开发阶段中是默认的编译器,这取决于 Angular 的版本。这反过来又导致了问题,因为错误只有在进行生产构建时或更糟糕的是在生产环境中运行时才会被发现。
Angular Ivy 默认在所有开发阶段使用其即时编译器,包括在运行开发服务器、运行测试、服务器端渲染以及在浏览器中,而不是在运行时捆绑并运行即时编译器。
本节讨论了即时 Angular 编译器如何影响我们开发工作流程的这些阶段。
构建时的即时编译
除了 Angular Ivy 中改进的编译速度外,默认使用即时编译器的另一个关键因素是 Angular Ivy 在某些条件下会减小我们应用的包大小。一般来说,与 View Engine 相比,无论是小型还是大型应用,在 Ivy 编译时都会看到整体大小的减小,而中等大小的应用可能不会看到显著的变化。
更具体地说,当使用 Angular Ivy 时,小型和简单应用的包大小会减小。对于复杂应用,主包大小增加,而与 Angular View Engine 相比,懒加载的块变得更小。这有点权衡,因为更大的主包大小会增加几个性能时间测量指标。
解锁更小包机会的秘诀是从 View Engine 在运行时解释的数据结构过渡到所谓的 Ivy 指令集,它重用运行时命令或指令,而不是像 View Engine 那样为应用每个部分都有一个独特的数据结构。
View Engine 编译的数据结构有一个缺点,即存在一个拐点,此时编译的数据结构比编译器和我们的应用源代码更大。
相比之下,Ivy 指令集是可摇树的,这意味着只有我们应用使用的指令包含在生产包中。例如,如果我们的应用不是多语言,国际化指令将从生产包中移除。同样,如果我们的应用不使用动画,动画指令将排除在生产包之外。
与 View Engine 相比,Ivy 指令集由一个显著更快的运行时使用,因为预编译的指令可以立即执行,而 View Engine 的数据结构必须在运行时被解释为指令后才能执行。
组件模板的即时编译
当使用 Angular Ivy 的即时编译器时,建议启用严格模板类型检查,如第二章中所述,通过工具、配置和便利性提高开发者生产力。
严格的模板类型检查可以捕获 Angular 元数据、组件模型和组件模板中的大多数类型错误。它们将在构建我们的应用时出现,或者在使用 Angular 语言服务时,在我们的代码编辑器中内联出现。
如果没有严格的模板类型检查,这些错误可能在运行时表现为令人沮丧的 bug。
单元测试的即时编译
Angular Ivy 减少了构建时间和重建时间。这对于开发服务器和单元测试都是一个节省时间的改进。除了更快的编译速度外,Angular Ivy 还引入了编译缓存,以便在测试用例之间缓存编译后的 Angular 模块、组件、指令、管道和服务。
在 Angular View Engine 中,不支持单元测试的即时编译。Angular Ivy 引入了单元测试的即时编译支持,同时仍然允许动态创建 Angular 模块、组件、指令和管道以进行测试。单元测试期间的动态创建仍然使用即时 Angular 编译器。
运行时的即时编译
当使用即时 Angular 编译器时,我们的 Angular 应用程序加载速度更快,因为即时 Angular 编译器并未与应用程序捆绑在一起。我们的应用程序启动速度更快,因为编译器是在构建时而不是在运行时运行的。
由于 Ivy 指令集,Angular Ivy 运行时比 Angular View Engine 运行时更快。View Engine 运行时必须在初始化或更新由 Angular 组件模板管理的 DOM 之前解释视图编译器数据结构。相比之下,Ivy 指令立即执行。
在本节中,我们讨论了即时 Angular 编译器对我们开发工作流程不同阶段的影响。在下一节中,我们将探讨即时编译器的限制,并通过简单的代码示例来探讨我们如何解决这些问题。
处理即时编译器的限制
使用 Angular Ivy 的即时编译器的优点是运行时速度更快,捆绑包更小,因为不需要将编译器发送到运行时捆绑包或编译器,在渲染应用程序之前。
当使用即时编译器时,需要注意权衡。声明式组件——即指令、组件和管道——不能依赖于运行时信息,因为它们必须在构建时而不是在运行时编译。
这为在运行时动态创建声明式组件设定了限制,例如,基于服务器端配置或静态配置文件。除非,当然,我们将 Angular 编译器与应用程序捆绑在一起并在运行时使用它,但那样的话,又有什么意义呢?
好消息是,注入的依赖项——即基于类的服务、提供的值或函数——可以在运行时解析。请记住,只有同步解析的值可以直接提供。如果我们需要一个异步过程来解析一个值,我们必须将其包装在一个基于类的服务、一个函数、一个承诺或一个可观察对象中。这一点在本章的最后部分进行了讨论和解决。
在本节中,我们将简要讨论使用提前编译的 Angular 编译器时出现的元数据错误。我们不会讨论通过使用严格的 TypeScript 编译或严格的模板类型检查来解决元数据错误的错误,正如在第二章中讨论的,通过工具、配置和便利提高开发者生产力。
使用函数提供值
将函数调用的结果传递给值提供者是不支持的。相反,我们使用工厂函数并将它们声明为称为工厂提供者的提供者。例如,假设我们有以下与提前编译不兼容的值提供者:
{ provide: timeZoneToken, useValue: guessTimeZone() }
我们可以用以下提前编译兼容的工厂提供者来替换它:
{ provide: timeZoneToken, useFactory: guessTimeZone }
这配置 Angular 在我们应用程序生命周期的适当时间运行工厂函数,以解决由timeZoneToken表示的时间区域依赖性,同时保持与提前编译的 Angular 编译器的兼容性。
使用函数声明元数据
已知的一个提前编译边缘情况是使用函数或静态方法来确定声明的元数据,例如 Angular 模块的imports或declarations。
在以下用例中,我们尝试在 Angular 开发模式下启动一个假根组件:
import { NgModule, Type } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { environment } from '../environments/environment';
import { AppFakeComponent } from './app-fake.component';
import { AppComponent } from './app.component';
function determineAppComponent(isDevelopment: boolean):
Type<any>[] {
if (isDevelopment) {
return [AppFakeComponent];
} else {
return [AppComponent];
}
}
const developmentEnvironment = environment.production === false;
@NgModule({
bootstrap: determineAppComponent(developmentEnvironment),
declarations: determineAppComponent(
developmentEnvironment),
imports: [BrowserModule],
})
export class AppModule {}
当我们尝试提前编译此代码时,我们会遇到 Angular 元数据的限制。用于元数据声明的函数必须只包含一个return语句,不能包含其他内容。
为了符合元数据限制,我们将determineAppComponent函数重构为以下实现:
function determineAppComponent(isDevelopment: boolean):
Type<any>[] {
return isDevelopment ? [AppFakeComponent] :
[AppComponent];
}
函数现在只包含一个表达式,一个评估三元表达式的return语句。这符合提前编译的 Angular 编译器的规范。
在组件模板中使用带标签的模板字面量
不幸的是,提前编译的 Angular 不支持带标签的模板字面量。例如,以下组件会导致编译错误:
import { Component } from '@angular/core';
const subject = 'World';
const greeting = String.raw`Hello, ${subject}!`;
@Component({
selector: 'app-root',
template: '<h1>' + greeting + '</h1>',
})
export class AppComponent {}
相反,我们可以使用常规函数来创建模板的编译时动态部分,如下面的实现所示:
import { Component } from '@angular/core';
const subject = 'World';
const greeting = `Hello, ${subject}!`;
@Component({
selector: 'app-root',
template: '<h1>' + greeting + '</h1>',
})
export class AppComponent {}
为了符合提前编译的 Angular 编译器对 Angular 元数据的限制,我们必须避免在组件模板元数据中使用带标签的模板字面量。然而,我们可以在模板绑定中使用的 UI 属性中使用带标签的模板字面量,如下面的重构实现所示:
import { Component } from '@angular/core';
const subject = 'World';
@Component({
selector: 'app-root',
template: '<h1>{{ greeting }}</h1>',
})
export class AppComponent {
greeting = String.raw`Hello, ${subject}!`;
}
分别选择这两种技术来处理或支持 Angular 组件模板中的带标签的模板字面量。
初始化元数据变量
元数据必须立即对提前编译器可用。以下示例由于延迟初始化而无效:
import { Component } from '@angular/core';
let greeting: string;
setTimeout(() => {
greeting = '<h1>Hello, World!</h1>';
}, 0);
@Component({
selector: 'app-hello',
template: greeting,
})
export class HelloComponent {}
当组件元数据被提前编译的 Angular 编译器转换为注解时,greeting变量尚未初始化。
更令人惊讶的是,以下示例也是无效的:
import { Component } from '@angular/core';
let greeting: string;
greeting = '<h1>Hello, World!</h1>';
@Component({
selector: 'app-hello',
template: greeting,
})
export class HelloComponent {}
虽然这不是一个常见的用例,但请记住这个限制,因为它相当令人惊讶。
让我们先看看固定实现:
import { Component } from '@angular/core';
let greeting = '<h1>Hello, World!</h1>';
@Component({
selector: 'app-hello',
template: greeting,
})
export class HelloComponent {}
greeting 变量现在同时定义和初始化,这样组件模板才能按预期工作。
现在,让我们在初始化 greeting 变量之后立即更改其值:
import { Component } from '@angular/core';
let greeting = '<h1>Hello, World!</h1>';
greeting = '<h1>Hello, JIT compiler!</h1>';
@Component({
selector: 'app-hello',
template: greeting,
})
export class HelloComponent {}
当使用即时 Angular 编译器时,模板是 <h1>Hello, World!</h1>。如果我们切换到即时 Angular 编译器,模板是 <h1>Hello, JIT compiler!</h1>。
当使用即时 Angular 编译器时,用于可声明元数据的变量必须在同一时间定义和初始化。
在本节中,我们探讨了即时编译器兼容性的边缘情况,并学习了如何解决这些问题。在下一节中,我们将学习如何在引导我们的应用程序之前初始化异步依赖项。
初始化异步依赖项
引用异步值是有害的,因为从引用的值计算出的每个值都必须是异步的。有一些技术可以绕过这个问题,但它们都会以延迟应用程序引导直到值被解析为代价。这些技术在本节中进行了演示。
使用静态平台提供者提供异步依赖项
要将异步依赖项解析器转换为静态依赖项,我们可以延迟引导我们的应用程序,以在平台级别提供静态提供者,使其作为静态依赖项在我们的应用程序中可用。
例如,假设我们有一个包含布尔值的对象的 JSON 文件。我们在应用程序项目的 src/app/assets/features.json 文件中创建它。此文件包含我们的功能标志,它们在运行时加载。此文件中的设置可以在编译我们的应用程序后更改。
在我们的应用程序项目的 src/load-feature-flags.ts 文件中,我们添加以下函数:
export function loadFeatureFlags():
Promise<{ [feature: string]: boolean }> {
return fetch('/assets/features.json')
.then((response) => response.json());
}
在我们的应用程序主文件中调用此函数之前,我们创建一个依赖注入令牌来表示功能标志。
以下代码块显示了我们的应用程序项目的 src/app/feature-flags.token.ts 文件:
import { InjectionToken } from '@angular/core';
export const featureFlagsToken =
new InjectionToken<Record<string, boolean>>(
'Feature flags'
);
最后,我们修改我们的主文件,使其包含以下内容:
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
import { featureFlagsToken } from './app/feature-flags.token';
import { environment } from './environments/environment';
import { loadFeatureFlags } from './load-feature-flags';
if (environment.production) {
enableProdMode();
}
loadFeatureFlags()
.then((featureFlags) =>
platformBrowserDynamic([
{ provide: featureFlagsToken, useValue: featureFlags
},
]).bootstrapModule(AppModule)
)
.catch((err) => console.error(err));
注意我们是如何加载功能标志,然后在能够引导我们的 Angular 应用程序模块之前,将它们作为功能标志令牌的平台提供者的值传递。
现在,我们可以在任何 Angular 特定的类中注入功能标志,例如组件,如下面的示例所示:
import { Component, Inject } from '@angular/core';
import { featureFlagsToken } from './feature-flags.token';
@Component({
selector: 'app-root',
template: `
<div *ngFor="let feature of features | keyvalue">
<mat-slide-toggle [checked]="feature.value">
{{ feature.key }}
</mat-slide-toggle>
</div>
`,
})
export class AppComponent {
constructor(
@Inject(featureFlagsToken)
public features: { [feature: string]: boolean }
) {}
}
功能标志是这种技术的良好用例。其他配置也适合这种方法。此外,如果多个应用程序初始化器需要共享依赖项,这种方法是最佳选择。
在下一节中,我们将介绍一种替代技术,并讨论其差异。
使用应用程序初始化器解析异步依赖项
处理异步解析的依赖项的另一种技术是应用程序初始化器。
在引导根应用程序组件之前解析应用程序初始化器。这对于设置不需要其他应用程序初始化器的初始根级状态是理想的。
我们将考虑一种处理功能标志的替代方法。这次,我们使用的是通过应用程序初始化器配置的功能标志服务。
功能标志服务具有以下实现:
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class FeatureFlagService {
#featureFlags = new Map<string, boolean>();
configureFeatures(featureFlags: { [feature: string]:
boolean }): void {
Object.entries(featureFlags).forEach(([feature, state])
=>
this.#featureFlags.set(feature, state)
);
}
isEnabled(feature: string): boolean {
return this.#featureFlags.get(feature) ?? false;
}
}
功能标志初始化器在调用FeatureFlagService#configureFeatures之前使用HttpClient加载功能标志。这如下面的代码示例所示:
import { HttpClient } from '@angular/common/http';
import { APP_INITIALIZER, FactoryProvider } from '@angular/core';
import { Observable } from 'rxjs';
import { mapTo, tap } from 'rxjs/operators';
import { FeatureFlagService } from './feature-flag.service';
function configureFeatureFlags(
featureFlagService: FeatureFlagService,
http: HttpClient
): () => Observable<void> {
return () =>
http.get<{ [feature: string]: boolean
}>('/assets/features.json').pipe(
tap((features) =>
featureFlagService.configureFeatures(features)),
mapTo(undefined)
);
}
export const featureFlagInitializer: FactoryProvider = {
deps: [FeatureFlagService, HttpClient],
multi: true,
provide: APP_INITIALIZER,
useFactory: configureFeatureFlags,
};
最后,我们通过将其添加到providers数组中,在我们的根模块中注册功能标志初始化器,如下面的代码片段所示:
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { featureFlagInitializer } from './feature-flag.initializer';
@NgModule({
bootstrap: [AppComponent],
declarations: [AppComponent],
imports: [BrowserModule, HttpClientModule,
MatSlideToggleModule],
providers: [featureFlagInitializer],
})
export class AppModule {}
在设置好所有这些之后,任何 Angular 特定的类都可以注入FeatureFlagService类的实例,并使用其isEnabled方法来检查功能标志的状态,如下面的代码示例所示:
import { Component } from '@angular/core';
import { FeatureFlagService } from './feature-flag.service';
@Component({
selector: 'app-root',
template: `
<div>
<mat-slide-toggle
[checked]="featureFlagService.isEnabled(
'middleOutCompression')"
>
Middle-out compression
</mat-slide-toggle>
</div>
<div>
<mat-slide-toggle
[checked]="featureFlagService.isEnabled(
'decentralized')"
>
Decentralized application
</mat-slide-toggle>
</div>
`,
})
export class AppComponent {
constructor(public featureFlagService:
FeatureFlagService) {}
}
使用应用程序初始化器的优点是,可以并行运行多个初始化器,这比在前一节中延迟整个引导过程直到响应完成要快。
代价是我们必须将功能标志包装在一个基于服务的类中,该类具有编写和读取功能标志配置的方法,而使用我们之前探索的第一种技术时,功能标志作为静态依赖项可用,这是一个非常简单的对象。选择最适合您用例的技术。
摘要
在本章中,我们学习了增强的 Angular Ivy 编译器和运行时如何使提前时间 Angular 编译器成为所有开发阶段的良好选择。可摇树优化、可重用的 Ivy 指令集为各种应用程序留下了更小的包。
我们讨论了提前时间编译如何影响我们的应用程序构建、组件模板、单元测试以及在运行时的浏览器。
接下来,我们探讨了在使用提前时间 Angular 编译器时出现的元数据错误的解决方案。关于由严格的 TypeScript 和 Angular 编译设置检测到的元数据错误没有讨论。请参阅第二章,通过工具、配置和便利提高开发者生产力中关于严格模板类型检查的内容。
在最后几节中,我们学习了在引导应用程序之前如何使用两种技术来解决和初始化异步依赖:
-
使用静态平台提供者提供异步依赖
-
使用应用程序初始化器解决异步依赖
这些技术对于功能标志和其他配置来说都很棒,但它们各自都有权衡,你现在能够识别出来。
这本书就到这里结束了。我们希望您在学习 Angular Ivy 及其配套版本的 TypeScript 引入的一些最有趣的新特性时感到愉快。Angular 是一个不断发展的框架,每年都会发布几个新特性。
继续学习!

订阅我们的在线数字图书馆,全面访问超过 7,000 本书和视频,以及领先的行业工具,帮助您规划个人发展并推进职业生涯。更多信息,请访问我们的网站。
第十章:为什么订阅?
-
通过来自 4,000 多位行业专业人士的实用电子书和视频,节省学习时间,更多时间编码
-
通过为您量身定制的技能计划提高您的学习效果
-
每月免费获得一本电子书或视频
-
完全可搜索,便于轻松访问关键信息
-
复制粘贴、打印和收藏内容
您知道 Packt 为每本书都提供电子书版本,包括 PDF 和 ePub 文件吗?您可以在 packt.com 升级到电子书版本,并且作为印刷书客户,您有权获得电子书副本的折扣。有关更多详情,请联系我们 customercare@packtpub.com。
在 www.packt.com,您还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。
您可能还会喜欢的其他书籍
如果您喜欢这本书,您可能会对 Packt 的这些其他书籍感兴趣:
Angular Cookbook
穆罕默德·阿桑·艾亚兹
ISBN: 978-1-83898-943-9
-
更好地理解组件、服务和指令在 Angular 中的工作方式
-
了解如何从头开始使用 Angular 创建渐进式网络应用程序
-
在您的 Angular 应用程序中构建丰富的动画并将其添加进去
-
使用 RxJS 管理您的应用程序的数据反应性
-
使用 NgRx 为您的 Angular 应用程序实现状态管理
Angular Projects - 第二版
阿里斯泰迪斯·班帕科斯
ISBN: 978-1-80020-526-0
-
使用 Angular CLI 和 Nx 控制台设置 Angular 应用程序
-
使用 Jamstack 和 SPA 技术创建个人博客
-
使用 Angular 和 Electron 构建桌面应用程序
-
使用 PWA 技术在离线模式下增强用户体验(UX)
-
使用服务器端渲染使网页对搜索引擎优化友好
-
使用 Nx 工具和 NgRx 进行状态管理创建单仓库应用程序
-
使用 Ionic 专注于移动应用程序开发
-
通过扩展 Angular CLI 开发自定义脚图
Packt 正在寻找像您这样的作者
如果您有兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并今天申请。我们已与成千上万的开发者和技术专业人士合作,就像您一样,帮助他们将见解与全球技术社区分享。您可以提交一般申请,申请我们正在招募作者的特定热门话题,或提交您自己的想法。
嗨!
我们是 Lars Gyrup Brink Nielsen 和 Jacob Andresen,著有《使用 Ivy 加速 Angular 开发》。我们真心希望您喜欢阅读这本书,并发现它对提高您在 Angular 中的生产力和效率很有帮助。
如果您能在 Amazon 上留下对《使用 Ivy 加速 Angular 开发》的评价,分享您的想法,这将对我们(以及其他潜在读者!)非常有帮助。
点击以下链接或扫描二维码留下您的评价:

您的评价将帮助我们了解这本书哪些地方做得好,以及未来版本可以改进的地方,所以您的评价真的非常受重视。
祝好,
|
|
|
|
| Jacob Andresen |


浙公网安备 33010602011771号