Angular 学习笔记(一)

2,469 阅读21分钟

前言:本文是我在工作中应用和实践Angular时的一系列知识总结,本文是作为我在团队内做的一次技术分享的辅助和大纲。虽然没办法分享现场的音频,但是我也花了不少心思来准备这篇文章,希望能对刚接触Angular的同学有所帮助。 本次先带来第一阶段的分享:Angular初探-应用架构,适合在通读Angular官方文档(中文)至少1遍后参考本文来梳理一些比较大的概念。

一 Angular 应用架构

理解 Angular,首先需要理解三大核心概念:模块组件服务,其余的特性都是基于这三大概念衍生出来的。比如组件与服务之间有依赖注入特性,模块为组件和服务提供了编译的上下文以及一些功能(指令管道等)支持。视图的更新依赖于双向绑定,视图的变换对应着组件的切换,而组件的切换需要路由机制......

1. 模块

1.1 什么是 Angular 模块?

Angular 应用是模块化的,它拥有自己的模块化系统,称作 NgModule。

一个 NgModule 就是一个容器,用于存放一些内聚的代码块,这些代码块专注于某个应用领域、某个工作流或一组紧密相关的功能。 它可以包含一些组件、服务或其它代码文件,他们的作用域由包含它们的 NgModule 定义。

他作为模块还可以导入一些由其它模块中导出的功能,同时自身也可以导出一些指定的功能供其它 NgModule 使用。

实现上,NgModule 是一个带有 @NgModule 装饰器的类:

关于 es7 装饰器,可以参考我翻译的这篇文章:探索 EcmaScript 装饰器

import { NgModule }      from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
@NgModule({
  imports:      [ BrowserModule ], // 导入了本模块中的组件模板所需的类的其它模块
  providers:    [ Logger ],        // 本模块向应用全局贡献的服务。 这些服务能被本应用中的任何部分使用。
  declarations: [ AppComponent ],  // 属于本 NgModule 的组件、指令、管道
                                   // 每个组件都应该(且只能)声明在一个 NgModule 类中。
  exports:      [ AppComponent ],  // 能在其它模块的组件模板中使用的可声明对象的子集
  bootstrap?:    [ AppComponent ]  // 应用的主视图,称为根组件。它是应用中所有其它视图的宿主。
                                   // Angular 创建它并插入 index.html 宿主页面。
                                   // 只有根模块才应该设置这个 bootstrap 属性。
  entryComponents?: [SomeComponent]
})
export class AppModule { }

@NgModule 的参数是一个元数据对象,用于描述如何编译组件的模板,以及如何在运行时创建注入器。

它会标出该模块自己的组件、指令和管道,通过 exports 属性公开其中的一部分,以便外部组件使用它们。 NgModule 还能把一些服务提供商添加到应用的依赖注入器中。

1.2 NgModule 与 JavaScript 模块的区别

Angular 应用中使用的模块其实有两种,一种是 JavaScript 模块,一种是 NgModule。

关于 js 模块系统,可以参考我翻译的这篇文章:模块系统

  • js 模块是一个包含代码的独立文件,通过导入导出机制为该文件内的代码加上独立的命名空间,避免了变量的冲突。
  • NgModule 是一个带装饰器的,是 Angular 内部的一个概念。其作用也是把某一部分的特性代码组织起来,比如组件,服务,指令等,形成一个大的应用单元。只不过这些特性代码存在不同的文件内,或者说存在于不同的类中。
  • 应该说,js 模块包含了 NgModule,NgModule 通过特定的语法(装饰器 + 元数据)将一些代码组织起来,然后通过 js 模块将其导入/导出。
  • 另外,NgModule 只在声明 Angular 模块时会用到,而 js 模块贯穿了整个项目,因为每一块特性代码都需要通过 js 模块的导入导出机制来将其串在一起。

1.3 NgModule 的种类

在 angular 内部,将 NgModule 划分为了两种(注意,无论根模块还是特性模块,其 NgModule 结构都是一样的,只是从功能上划分出这两个概念):

  1. 根模块:顾名思义,“根”模块必然为整个 Angular 应用的基础和核心,他的功能就是将所有开发者自创的特性模块组织起来,接收一些全局的配置项,以及引导应用在浏览器中启动。根模块与其他任何特性模块的一个定义方式上的差异就是根模块装饰器的元数据中多了一个 bootstrap 属性,通过 bootstrap 属性声明一个应用的根组件(入口组件),然后基于这个根组件来生长整个应用的组件树,呈现出一个完整的应用。

    这里插播一个组件的概念:入口组件(entry component)。他是命令式生成的一种组件,区别于声明式(开发者显式地在模板中引用某一个组件)的方式,入口组件可能是根组件(由 Angular 的启动机制自动加载到 index.html 模板中),也可以是定义在路由中的组件(此时由路由器根据相应的路由规则来动态插入到当前视图中)。

    理论上说,所有的入口组件都应该声明到@NgModule 装饰器的 entryComponents 元数据对象属性中,但是 Angular 编译器会隐式地把根组件和路由定义的组件添加为 entryComponents,所以我们不需要显示声明这一类入口组件了。

    Angular 编译器只会为那些可以从 entryComponents 中直接或间接访问到的组件生成代码,内部使用摇树优化(tree shaker)来剥离那些无关的组件,保持应用的精简和高效。基于这个原理,有时候我们可能会使用到一些 UI 库提供的组件(弹窗),为了避免这类组件被编译器排除,我们就需要手动在 entryComponents 中添加这些组件的引用声明。

  2. 特性模块:可以理解为开发者创建的其他 NgModule 的统称。根据不同特性模块的功能特征,又可以分为:

    • 领域特性模块
    • 带路由的特性模块
    • 路由模块
    • 服务特性模块
    • 可视部件特性模块

    这个分类其实只是为了区分不同 NgModule 的功能,是一个组织应用的最佳实践准则,并不需要严格划分。举个例子,几乎每一个复杂的模块都需要定义路由,但是我们可以把路由这个关注点专门抽离出来,统一到一个路由特性模块中,这样方便我们统一定义和划分应用路由层次。这样就可以在应用不断成长时保持应用的良好结构,并且当复用本模块时,你可以轻松的让其路由保持完好。

2. 组件

组件控制屏幕上被称为视图的一小片区域。我们在类中定义组件的应用逻辑,为视图提供数据和行为支持。 组件通过一些由属性和方法组成的 API 与视图交互。

实现上,通过@Component 装饰器和元数据来将一个类标记为组件:

@Component({
  selector: 'app-hero-list',
  templateUrl: './hero-list.component.html',
  providers: [HeroService]
})
export class HeroListComponent implements OnInit {
  /* . . . */
}
  • selector:是一个选择器,它会告诉 Angular,一旦在模板 HTML 中找到了这个选择器对应的标签,就创建并插入该组件的一个实例。
  • templateUrl:该组件的 HTML 模板文件地址。 或者,使用 template 属性的值来直接定义内联的 HTML 模板。 这个模板定义了该组件的宿主视图。
  • providers 是当前组件所需的依赖注入提供商的一个数组。在这里声明的依赖注入会提供一个与组件一同创建和销毁的实例。

2.1 组件的模板

模板很像标准的 HTML,但是它还包含 Angular 的模板语法,这些模板语法可以根据应用逻辑、应用状态和 DOM 数据来修改这些 HTML。 模板可以使用数据绑定来协调应用和 DOM 中的数据,使用管道在显示出来之前对其进行转换,使用指令来把程序逻辑应用到要显示的内容上。

下面的例子:

<h2>Hero List</h2>

<p><i>Pick a hero from the list</i></p>
<ul>
  <li *ngFor="let hero of heroes" (click)="selectHero(hero)">
    {{hero.name}}
  </li>
</ul>

<app-hero-detail *ngIf="selectedHero" [hero]="selectedHero"></app-hero-detail>

这个模板使用了典型的 HTML 元素,比如 <h2><p>,还包括一些 Angular 的模板语法元素,如 *ngFor{{hero.name}}(click)[hero]<app-hero-detail></app-hero-detail>。这些模板语法元素告诉 Angular 该如何根据程序逻辑和数据在屏幕上渲染 HTML。

模板语法分成三类:数据绑定,管道和指令。

2.1.1 数据绑定

通过特定的绑定语法,Angular 可以自动地将组件中的属性和方法与模板对应起来,建立一种带有方向的映射关系。

上面的例子中,{{hero.name}}的语法称为插值表达式,他是一种从组件类绑定属性到 DOM 的模板语法。

[hero]="selectedHero"的语法称为属性绑定,他也是一种将组件类的属性绑定到 DOM 的模板语法。他们之间的区别在于,使用插值表达式是为了显示对应的数据到视图中,而属性绑定是一种数据的传递,将宿主组件的属性传递到子组件或者指令中,然后由接受者决定如何使用这部分数据,某种意义上说,属性绑定是一种通信机制。

(click)="selectHero(hero)"的语法称为事件绑定,这里的事件是一系列用户触发的 UI 行为,鼠标操作,键盘行为等,这些行为附带一些信息。我们需要从 DOM 监听到这些行为和信息,然后传递给我们的组件类中相应的处理函数,在组件类中来处理这些行为,比如更新视图中的部分属性,向服务器请求一些数据,甚至是引起视图的变换等。很明显,它的绑定方向是从 DOM 到组件的,与前两种相反。

实际上事件绑定同样支持开发者自己定义的事件,这部分会在深入使用部分详细介绍。

还有一种绑定形式是 angular 最为著名的双向绑定。他是属性绑定和事件绑定的结合,语法如下:

<input [(ngModel)]="hero.name">

在 Angular 内部会将这种语法做即时、自动的处理(隐式),用户的输入会自动更新到组件类中对应的属性上,而组件类中对应属性的变化也会立即反映到视图中来。

2.1.2 管道

管道是一类转换函数,他接收一个输入,通过预先设定的处理规则对输入做加工和转化,然后输出结果,并用结果替换掉原本的输入。 管道的使用需要与插值表达式和管道操作符( | )结合起来:

<p>Today is {{today | date}}</p> 通过管道我们可以很方便地对视图内一些数据进行统一转化,以更为友好的方式来展示这些数据。同时他又具有通用性,他从本该做这部分工作的组件类中分离出来,让我们的组件类更加精简和专注于业务。

2.1.3 指令

指令就是一个带有 @Directive 装饰器的类。和组件一样,指令的元数据把指令类和一个选择器关联起来,选择器用来把该指令插入到 HTML 中。 他们都是 Angular 编译器对模板做解析和编译的基础,Angular 编译器找到这些选择器 指令分为结构型指令和属性性指令,结构型指令通过添加、移除或替换 DOM 元素来修改布局,属性型指令会修改现有元素的外观或行为。

上文中提到的*ngIf就是一个结构型指令,而 ngModel 则是一个属性型指令。

2.1.4 总结一下

模板会把 HTML 和 Angular 的模板标记语法组合起来,这些模板标记语法可以在 HTML 元素显示出来之前修改它们。

模板中的指令会提供程序逻辑,而绑定语法会把你应用中的数据和 DOM 连接在一起。

  • 属性绑定让你将从应用数据中计算出来的值插入到 HTML 中,显示给用户。
  • 事件绑定让你的应用可以通过更新应用的数据来响应目标环境下的用户输入。

在视图显示出来之前,Angular 会先根据你的应用数据和逻辑来运行模板中的指令并解析绑定表达式,以修改 HTML 元素和 DOM。

Angular 支持双向数据绑定,这意味着 DOM 中发生的变化(比如用户的选择)同样可以反映回你的程序数据中。

模板也可以用管道转换要显示的值以增强用户体验。

2.2 路由

在用户使用应用程序时,Angular 的路由器能让用户从一个视图导航到另一个视图。

首先分析一下浏览器的导航模式:

  • 在地址栏输入 URL,浏览器就会导航到相应的页面。
  • 在页面中点击链接,浏览器就会导航到一个新页面。
  • 点击浏览器的前进和后退按钮,浏览器就会在你的浏览历史中向前或向后导航。

Angular 的 Router(即“路由器”)借鉴了这个模型:

  • 它把浏览器中的 URL 看做一个指南, 据此导航到一个由客户端生成的视图,并可以把参数传给支撑视图的相应组件,帮它决定具体该展现哪些内容。
  • 你可以为页面中的链接绑定一个路由,这样,当用户点击链接时,就会导航到应用中相应的视图。 当用户点击按钮、从下拉框中选取,或响应来自任何地方的事件时,你也可以在(组件)代码控制下进行导航。
  • 路由器还在浏览器的历史日志中记录下这些活动,这样浏览器的前进和后退按钮也能照常工作。

每个带路由的 Angular 应用都有一个 Router(路由器)服务的单例对象。 当浏览器的 URL 变化时,路由器会查找对应的 Route(路由),并据此决定该显示哪个组件。

2.2.1 路由配置

我们使用路由配置来声明应用中我们预设的一些路由规则,这样 Angular 的路由器就可以按照这些预设的规则将 URL 的变化与我们的应用视图变换对应起来。

const appRoutes: Routes = [
  { path: 'crisis-center', component: CrisisListComponent },
  { path: 'hero/:id', component: HeroDetailComponent },
  {
    path: 'heroes',
    component: HeroListComponent,
    data: { title: 'Heroes List' }
  },
  {
    path: '',
    redirectTo: '/heroes',
    pathMatch: 'full'
  },
  { path: '**', component: PageNotFoundComponent }
];

2.2.2 路由出口

路由出口就是用于告诉 Angular 路由器,当 URL 的变化匹配到路由配置里的某一项的path时,在模板中的何处插入component对应的组件实例。

<router-outlet></router-outlet>
<!-- 路由命中时的组件会被插入到此处 -->

2.2.3 路由链接

稍微了解过 html 的话,就会知道页面中的跳转、链接是以<a href="SOME_URL">标签的形式来实现的,这种方式在 Angular 里有另一种形式,通过一个叫routerLink的指令来做这件事,因为 Angular 是单页应用,尽管看起来在一个 Angular 应用里可以到处穿梭,页面不断变化,但这只是一个障眼法,是 Angular 通过不断修改同一个页面中的 DOM,不断创建、更新、隐藏、销毁一个个组件来实现的。所以要想在 Angular 里做“跳转”的操作,也需要一个障眼法机制,他就是路由链接。

<a routerLink="/heroes" routerLinkActive="active">Heroes</a>

3 服务

理解服务这个概念,可以从他的词性来入手。名词性质的服务是服务这个概念的具体实现,狭义地讲,他就是一个类,这个类有一些明确的用途。

服务做了一类事情,比如从服务器获取数据并预处理,验证用户的输入,或者是写日志,在内存中缓存一些可以被全局应用共享的数据或状态,还可以作为组件之间通信的中间介质。

动词性质的服务则与组件关联起来,依赖注入(Dependency Injection)是他们之间的桥梁,服务是生产者,组件是服务的消费者。

Angular 里将组件和服务区分开,是为了提高模块性和复用性,同时进一步将组件的功能划分出来,让组件更加专注于特定的业务。

3.1 依赖注入

依赖注入是 Angular 另一个著名的特性,他是很多面向对象语言框架设计原则中的“控制反转”的一种实现方式。在依赖注入模式中,应用组件无需关注所依赖对象的创建和初始化过程,可以认为框架已初始化好了,开发者只管调用即可。

依赖注入有利于应用程序中各模块之间的解耦,使得代码更容易维护。这种优势可能一开始体现不出来,但随着项目复杂度的增加,各模块、组件、第三方服务等相互调用更频繁时,依赖注入的优点就体现出来了。开发者可以专注于所依赖对象的消费,无需关注这些依赖对象的产生过程,这将大大提升开发效率。

举个例子:

export class Car {

  public engine: Engine;
  public tires: Tires;
  public description = 'No DI';

  constructor() {
    this.engine = new Engine();
    this.tires = new Tires();
  }
}

Car 类在自己的构造函数中创建了它所需的一切。这样做的话 Car 类是脆弱、不灵活以及难于测试的。

为什么?

Car 类需要一个引擎 (engine) 和一些轮胎 (tire),它没有去请求现成的实例, 而是在构造函数中用具体的 Engine 和 Tires 类实例化出自己的副本。如果 Engine 类升级了,它的构造函数要求传入一个参数,此时这个 Car 类就被破坏了,在更新 Engine 实例化方式之前 Car 类都不可用,本来是 Engine 类的更改,引起了连锁反应,连累了 Car 类也要跟着修改,Car 类变得脆弱了。 另一方面,测试的时候更麻烦了,你会受制于它背后的那些依赖,你需要考虑 Engine 类的创建是否成功了,如果 Engine 类还有依赖,那就得一层一层继续深入,本来测试一个 Car 类,结果你没法控制这辆车背后隐藏的依赖。 当不能控制依赖时,类就会变得难以测试。

如何让 Car 类更强壮、可测试?

答案是把 Car 的构造函数改造成 DI 的版本:

constructor(public engine: Engine, public tires: Tires) { }

然后我们在创建一辆车的时候就可以往构造函数中传入 Engine 和 Tire 的实例来实例化 Car 类:

let car = new Car(new Engine(), new Tires());

这样一来引擎和轮胎这两个依赖的定义与 Car 类本身解耦了。如果有人扩展了 Engine 类,那就不再是 Car 类的烦恼了。

刚刚了解了什么是依赖注入:它是一种编程模式,可以让类从外部源中获得它的依赖,而不必亲自创建它们。他是用来创建对象及其依赖的其它对象的一种方式。 当依赖注入系统创建某个对象实例时,会负责提供该对象所依赖的对象(称为该对象的依赖)。

但是上面的尝试,只做到了 Car 类的可维护性,但是对于要实例化 Car 类的外部环境来说,工作量反而多了:原本只关心 Car 类的构造,现在连 Engine 和 Tire 类的构造工作也要做了,WTF?

如果,有一种机制,我们只需要直接列出想要的东西,而不用管这些东西如何建造,直接拿现成的,该多好。

像这样:let car = injector.get(Car);,使用 Car 的消费者不需要知道如何创建 Car 类,Car 类也不需要知道如何创建 Engine 和 Tire 类,这些工作都交给注入器,不管是消费者还是 Car 类,都直接向注入器索要需要的依赖。

这种机制就是“依赖注入框架”。A 依赖于 B,那 B 就是 A 的依赖,同时 A 和 B 都会被这个 DI 框架维护起来。

3.2 Angular 中的 DI

首先介绍几个简单的概念。

  • 注入器(Injector): 就像制造工厂,提供了一系列的接口用于创建依赖对象的实例。
    • 我们不需要不用自行创建 Angular 注入器,Angular 会在启动过程中为你创建全应用级注入器。该注入器维护一个包含它已创建的依赖实例的容器,并尽可能复用它们。
  • 提供商(Provider):用于配置注入器,注入器通过它来创建被依赖对象的实例,Provider 把标识映射到工厂方法中,被依赖的对象就是通过该方法创建的。
    • 对于 Angular 服务来说,Provider 通常就是这个服务类本身。
  • 依赖(Dependence):指定了被依赖对象的类型,注入器会根据此类型创建对应的对象。

在把 Angular 中的服务类注册进依赖注入器之前,它只是个普通类而已。Angular 本身没法自动判断你是打算自行创建服务类的实例,还是等注入器来创建它。你必须通过为每个服务指定服务提供商来配置它。提供商会告诉注入器如何创建该服务,然后 Angular 的依赖注入器负责创建服务的实例,并把它们注入到组件类中。你很少需要自己创建 Angular 的依赖注入器。 当 Angular 运行本应用时,它会为你创建这些注入器,首先会在引导过程中创建一个根注入器。

依赖注入机制

有很多方式可以为注入器注册服务提供商。

3.2.1 在服务类前加上装饰器@Injectable 和相应的元数据来标记出该服务类是用来注入的,也即配置了一个提供商。

import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root' // providedIn 告诉 Angular,它的根注入器要负责调用 HeroService 类的构造函数来创建一个实例,并让它在整个应用中都是可用的。
})
export class HeroService {
  constructor() {}
}

3.2.2 在 NgModule 的 providers 元数据中注册提供商

providers: [
  UserService, // { provide: UserService, useClass: UserService }
  { provide: APP_CONFIG, useValue: SOME_DI_CONFIG }
],

3.2.3 在组件中注入服务

Angular 在底层做了大量的初始化工作,这大大简化了创建依赖注入的过程,在组件中使用依赖注入需要完成以下三个步骤:

  • 通过 import 导入被依赖对象的服务
  • 在组件中配置注入器。在启动组件时,Angular 会读取@Component 装饰器里的 providers 元数据,它是一个数组,配置了该组件需要使用到的所有依赖,Angular 的依赖注入框架就会根据这个列表去创建对应对象的实例。
  • 在组件构造函数中声明所注入的依赖。注入器就会根据构造函数上的声明,在组件初始化时通过第二步中的 providers 元数据配置依赖,为构造函数提供对应的依赖服务,最终完成注入过程。

当 Angular 创建组件类的新实例时,它会通过查看该组件类的构造函数,来决定该组件依赖哪些服务或其它依赖项。

当 Angular 发现某个组件依赖某个服务时,它会首先检查是否注入器中已经有了那个服务的任何现有实例。如果所请求的服务尚不存在,注入器就会使用以前注册的服务提供商来制作一个,并把它加入注入器中,然后把该服务返回给 Angular。

当所有请求的服务已解析并返回时,Angular 可以用这些服务实例为参数,调用该组件的构造函数。

import { Component } from '@angular/core';
import { SomeService } from './some-service.service';

@Component({
  selector: 'app-some-component',
  templateUrl: './some-component.component.html',
  styleUrls: ['./some-component.component.less']
})
export class SomeComponent {
  constructor(private _someService: SomeService) {}
}

4. 例子应用

基于以上知识,我们来分析一个最小化的 Angular 应用,看看以上内容是怎么有机结合起来的。

整体架构

这部分内容不便于文字展示,这里就省略了。