前言
Angular 2.0 于去年 9 月正式发布。
尽管同一时间里 React/Vue 大放光彩,圈子社区也是热闹非凡,他们的优点不言而喻,不过这里不谈 React / Vue,更不是要比出个谁优谁劣(知乎上的对比已经很多了[Facepalm]),这篇文章只关注 Angular 本身,好好地聊聊 Angular 这个迟来的平台。
新版 Angular 推出后,官方不再取用 AngularJS 这个名称了,而是推荐直接使用 Angular,AngularJS 变为专指第一代框架,所以后文里采用 Angular 均指代 Angular 2+。
我们知道,Angular 彻底重写了 AngularJS,这多少会给社区带来了不便,好在 Angular 团队在无缝升级方面下了不少功夫,而且更重要的是重写能让 Angular 抛掉老版的包袱。采用新架构设计的 Angular 代码更简洁易读、性能更高,更加贴合新时代前端的发展趋势,如基于组件的设计、响应式编程等。除此之外,Angular 适用场景更广,如支持服务端渲染,能更好地适配 Mobile 应用(Mobile Toolkit)、支持离线编译等。谈及 Angular,他实际上包含了一整套解决方案,而这篇文章重点放在 Angular 自身的核心概念介绍,目的是帮助大家入门 Angular 平台,所以文章的深度与广度都经过反复推敲,适合 Angular 初学者。
核心概念
Angular 官方文档列出了 8 个核心概念,分别是模块、组件、模板、元数据、数据绑定、指令、服务、依赖注入。如果你熟悉 React/Vue 开发,部分概念其实是暗合相通的。如果把这些概念串联起来,从总览的角度看各个概念在应用中所处的位置,大概是这样子:
借助上图,我们来大致解读一下这些核心概念:
- 与用户直接交互的是模板,模板并不是独立的模块,它是组成组件的要素之一。另一要素是组件类,用以维护组件的数据模型及功能逻辑;
- 模板是通过元数据指定的,元数据还包含很多其他的重要信息,这些信息是用来告诉 Angular 如何去解释一个普通的类,如上图所示,元数据结合普通类而构成组件;
- 指令是 Angular 里的独立构成,他与模板密切关联,用来增强模板特性,间接扩展了模板的语法;
- 服务也是 Angular 里的独立构成,他是封装单一功能逻辑的单元,通常为组件提供功能扩展;
- 服务要能为组件所使用,是通过“依赖注入”机制把服务引入到组件内部,服务既可以单独注入到某一组件,亦可注入到模块,两种注入方式使得服务的作用域不一样,后文详解。
Angular 的概念虽多,但理解起来并不难,这些概念中最重要的就是组件。 纵观整个 Angular 应用,接收用户指令,加工处理后输出相应视图的过程中,组件始终处于这个交互的出入口,这正是 Angular 基于组件设计的体现。下面以组件为切入点,逐一揭开这些核心概念的面纱。
组件
Angular 框架基于组件设计,其应用由一系列大大小小的组件构成。 Angular 组件的含义实际上跟 React/Vue 的组件是类似的,下面沿用《揭秘Angular 2》书籍里的通讯录例子(源码地址)来说明,其 Demo 的效果图如下所示:
所有框起来的部分均是由相应的组件所渲染,并且这些组件层层嵌套,自上而下构成组件树。如最外层的方框为根组件,包含了 Header、ContactList 以及 Footer 三个子组件,其中 ContactList 又有自己的子组件。
先来聚焦单个组件,每个 Angular 组件内部除了有独立的 JavaScript 逻辑,还包含有 HTML(即模板)及 CSS 代码。所以每个组件不仅有自己独立的业务逻辑,也有属于自己的视图层来渲染自己。
举一个简单的 Contact 组件示例代码:
import { Component } from '@angular/core';
@Component({
selector: 'contact',
template: '<p>张三</p>'
})
class ContactComponent {
constructor() { }
}
可以看出 Angular 组件由两部分组成,@Component() 和 TS/ES6 的类。TS/ES6 类是处理组件的业务逻辑,而 @Component() 这部分称为装饰器,装饰器是 TypeScript 提供的一种语言特性,用来往类、函数等注入额外的信息,这些额外的信息实际上就是 Angular 核心概念——元数据。在 Angular 里,元数据主要以装饰器的函数参数指定。上例中定义了两个重要元数据 selector 和 template,template 顾名思义即为模板,selector 声明的是一个 CSS3 选择器,应用运行时匹配模板上的DOM元素,简单理解其实就是组件的标签名。
装饰器实际上是一个自定义函数,Angular 的各种装饰器处理逻辑在 Angular 源码:modules/@angular/core/src/util/decorators.ts。
关系示意图如下所示:
如果我们仅仅定义了一个类,Angular 并不知道该如何解释这个类。当往这个类里注入组件元数据后,Angular 才知道把这个类解释为组件。类似的还有指令元数据,把普通类解释为一个指令。
如果想了解元数据是如何注入到类里,可深入了解 reflect-metadata 这个 polyfill。
虽然每个组件各司其职,但组件以树的形式来组织意味着,组件不可能是孤立的存在,父子组件之间存在着双向的数据流动。每个组件均可以定义自己的输入输出属性,这些属性成为了组件的对外接口,负责跟父组件进行交互。
我们来完善 Contact 组件,添加输入输出属性,伪代码如下:
// import Component, Input, Output, EventEmitter, etc
@Component({
selector: 'contact',
template: '<p>张三</p>'
})
export class ContactComponent {
@Input() item: ContactModel; // 输入属性
@Output() update: EventEmitter<ContactModel>; // 输出属性
constructor() { }
modify() {
// ...
this.update.emit(newValue);
}
}
@Input() 和 @Output() 也是装饰器,装饰的目标为类的成员属性,而 @Component() 装饰的目标是类。
@Input() 和 @Output 声明了组件 Contact 的输入输出接口,item 变量用来接收来自父组件的数据源输入,update 事件用于向父组件发送数据。输入输出属性分开,这跟 React 的 props 属性略有不同。
定义好 Contact 组件输入输出接口后,接下来就与父组件 ContactList 交互。ContactList 父组件的示例代码如下:
// import statement
@Component({
selector: 'contact-list',
template: `
<!-- 使用 <contact> 标签调用 Contact 组件 -->
<contact [item]="items[0]" (update)="doUpdate(newValue)"></contact>
`
})
export class ContactListComponent {
items: ContactModel[]
constructor() {}
doUpdate(item: ContactModel) {
// ...
}
}
父组件 ContactList 的模板里要能直接使用子组件 Contact 定义的标签,需要有一个导入的过程,需要依赖“模块”的特性,后文展开。
ContactList 组件的模板调用了 Contact 组件,其中 [item] 称为属性绑定,数据从父组件流向子组件;(update) 称为事件绑定,数据从子组件流向父组件。
属性绑定的 [] 和事件绑定 () 不能省略,这是语法的重要组成部分。
属性绑定和数据绑定均称为数据绑定,这个 Angular 强调的核心概念之一。细心的读者可能已经发现,属性绑定和数据绑定是可以直接引用组件的成员属性,如 listItem 和 doUpdate()。属性绑定和事件绑定既用于组件数据模型和模板视图之间的数据传递,也同时用于父子组件的数据传递,在父子组件通信的过程中,模板充当类似于桥梁的角色,连接着二者的功能逻辑。
这种通讯方式适用于层级相隔不远的组件,层级太深或者不同分支的组件通讯通常采用其他方式,例如利用服务作为中介。
这就是 Angular 的数据流动机制,然而流动并不是自发形成,流动需要一个驱动力,这个驱动力即是 Angular 的变化监测机制。Angular 是一个响应式系统,每次数据变动都几乎能实时处理,并更新对应视图。那么 Angular 是如何感知数据对象发生变动呢?ES5 提供了 getter/setter 语言接口来捕捉对象变动,这是 VueJS 采用的方式,然而 Angular 并没有采用之。Angular 是以适当的时机去检验对象的值是否被改动,这个适当的时机并不是以固定某个频率去执行,而通常是在用户操作事件(如点击),setTimeout 或 XHR 回调等这些异步事件触发之后。Angular 捕获这些异步事件的工作是通过 Zones 库实现的,变化监测事件图如下所示:
从上图可以看出,每个组件背后都维护着一个独立的变化监测器,这个变化监测器记录着所属组件的数据变更状态。由于应用是以组件树的形式组织,因此每个应用也有着对应的一棵变化监测树。当 Zones 捕获到某异步事件后,通常它都会通知 Angular 执行变化监测操作,每次变化监测操作都始于根组件,并以深度优先的原则向叶子组件遍历执行,而且每个组件的变化监测器都对其组件的数据模型经过优化,检测的性能非常高。
智能化的变化监测机制使得开发者不必关心数据何时何地被变动,他总是能找到适当的时机去触发数据检测,这就是 Angular 强大的数据变化监测机制。而当检测到数据发生变动时,结合数据绑定从而驱动模板视图的实时更新,这就是我们所看到的实时更新的效果。
那么我们继续延伸,倘若在发现输入数据有变动的时机里,我们需要去做一些额外的处理,怎么办?Angular 提供了完善的生命周期钩子来解决这个问题,如 ngOnChanges 可以满足刚提到的捕获输入数据变动时机的要求,使用方法也很简单,直接定义一个同名实例方法即可:
export class ContactComponent {
@Input() item: ContactModel; // 输入属性
@Output() update: EventEmitter<ContactModel>; // 输出属性
constructor() { }
modify() {
// ...
this.update.emit(newValue);
}
ngOnChanges(changes: SimpleChanges) { // 变化检测钩子,item值变动时触发
// changes 包含了变动前后状态
// ...
}
}
常用的生命周期钩子有:
- 最先触发的是构造函数,你可以做些组件类的初始化工作,例如类变量初始赋值等。
- 接下来会触发 ngOnChanges 钩子,这是 ngOnChanges 钩子的第一次触发,主要用来接收来自父组件传入的数据,为接下来的组件的初始化工作提供数据支持。
- 然后就到了 ngOnInit 钩子,这个才是实际意义的组件初始化阶段,Angular 不推荐在构造器初始化阶段处理一些业务逻辑相关的工作,更好的方式是放在 ngOnInit 阶段来处理。
- 接下来,组件就进入稳定期,这个时期 ngOnChanges 钩子可以反复触发。只要从输入属性获取到的数据的发生变化,ngOnChanges 钩子就会触发一次。
- 最后,在组件销毁之前会触发 ngOnDestroy 钩子,在这个阶段可以用来做一些清理工作,如事件解绑,取消数据订阅等等。
以上便是组件的简述,同时简单介绍了元数据和数据绑定,作为组件的重要要素模板并没有展开,Angular 为模板提供了强大的功能特性,我们继续。
模板
Angular 模板基于 HTML,普通的 HTML 亦可作为模板输入:
@Component({
template: `<p>张三</p>`
})
但 Angular 模板不止于此,Angular 为模板定制出一套强大的语法体系,涉及内容颇多,这也是为什么将模板单独列出的原因。数据绑定是模板最基本的功能,除了前述提到的属性绑定和事件绑定,插值也是很常见的数据绑定语法,示例代码如下:
// import statement
@Component({
selector: 'contact',
template: '<p>{{ item.name }}</p>'
})
export class ContactComponent {
@Input() item: ContactModel;
// ...
}
插值语法是由一对双大括号 {{}} 组成,插值的变量上下文是组件类本身,如上例中的 item,插值是一种单向的数据流动 —— 从数据模型到模板视图。
上面提到的三种数据绑定(即属性绑定、事件绑定以及插值)语法的数据流动都是单向的,在某些场景下需要双向的数据流动支持(如表单)。结合属性绑定和事件绑定,Angular 模板可实现双向绑定的功能,如:
<input [(ngModel)]="contact.name"></input>
[()] 是实现双向绑定的语法糖,ngModel 是辅助实现双向绑定的内置指令。上述代码执行后,Input 控件和 contact.name 之间就形成双向的数据关联,Input 的值发生变更时,可自动赋值至 contact.name,而 contact.name 的值被组件类改变时,亦可实时更新 Input 的值。
由上可知,数据绑定负责数据的传递与展示,而针对数据的格式化显示,Angular 提供了一种叫管道的功能,使用竖线 | 来表示,示例代码如下:
<span>{{ contact.telephone | phone }}</span>
假设上述 contact.telephone 的值是 18612345678,这一串数字并不太直观,管道命令 phone 可以将其进行美化输出,如 “186-1234-5678”,而不影响 contact.name 本身的值。管道支持开发者定制开发,phone 即属于自定义管道。Angular 也提供了一些基本的内置管道命令,如格式化数字的 number、格式化日期的 date 等。
上述是 Angular 模板主要的语法特性,这篇综述文意在帮助入门理解,不会面面俱到。除了基本的语法特性,模板还有一套强大的 “指令” 机制,来简化一些特定的交互场景,如样式处理、数据遍历以及表单处理等。
指令
指令与模板关系密切,指令可以与 DOM 进行灵活交互,它或是改变样式,或是改变布局。了解过 AngularJS 的开发者可能会有疑问,这里的指令跟 AngularJS 的指令是一回事么? 虽然 Angular 指令跟 AngularJS 指令在功能有类似之处,但二者并不完全是同一个概念。Angular 指令的范畴很广,实际上组件也是指令的一种。组件与一般指令的区别在于:组件带有单独的模板,即 DOM 元素,而一般的指令是作用在已有的 DOM 元素上。一般的指令分为两种:结构指令和属性指令。
结构指令能够添加、修改或删除 DOM,从而改变布局,如 ngIf:
<button *ngIf="canEdit"> 编辑 </button>
当 canEdit 的值为 true 时,button 按钮会显示到视图上;若 canEdit 为 false 时,button 按钮会从 DOM 树上移除。
注意结构指令的 * 号不能丢掉,这是 Angular 为了使用简便实现的语法糖。
属性指令用来改变元素的外观或行为,使用起来跟普通的 HTML 元素属性非常相似,如 ngStyle 指令,用于动态计算样式值,示例代码如下:
<span [ngStyle]="setStyles()">{{ contact.name }}</span>
标签的样式由 setStyles() 函数计算得出,setStyles() 是其组件类的成员函数,返回一个计算好的样式对象,示例代码如下:
class ContactComponent {
private isImportant: boolean;
setStyles() {
return {
'font-size': '14px',
'font-weight': this.isImportant ? 'bold' : 'normal'
}
}
}
上面列举的 ngIf 和 ngStyle 都是 Angular 的内置指令,类似的还有 ngFor、ngClass 等,这些内置指令为模板提供了强大语法支持。指令更具吸引力的地方在于支持开发者自定义,自定义指令能最大限度地实现 UI 层面的逻辑复用。
ngIf 和 ngStyle 等这些内置指令要能在组件模板里直接使用,需要有一个声明导入的过程,这个过程是借助模块的特性,后文展开。
服务
服务是封装单一功能的单元,类似于工具库,常被引用于组件内部,作为组件的功能扩展。那服务包含什么?它可以是一个简单的字符串或是 JSON 数据,也可以是一个函数甚至是一个类,几乎所有的对象都可以封装成服务。以日志服务为例,一个简单的日志服务如下所示:
// import statement
@Injectable()
export class LoggerService {
private level: string;
setLevel(level: string) {
this.level = level;
}
debug(msg: string) { }
warn(msg: string) { }
error(msg: string) { }
}
@Injectable() 是服务类装饰器。
这个服务的功能很简单,只专注于日志功能,Angular 应用里每个组件都可以复用到这个日志服务给自己新增日志记录的能力,而不需要每个组件重复实现,这就是设计服务的主要原则。那么服务怎么样为组件所使用?这就需要引入依赖注入机制。
依赖注入
在服务小节里会提到过“注入”这个概念,依赖注入一直都是 Angular 的卖点。通过依赖注入机制,服务等模块可以被引入到任何一个组件(或模块,或其他服务)中,而开发者无须关心这些模块是如何被初始化。因为 Angular 已经帮你处理好,包括该模块本身依赖的其他模块也会被初始化。如下图所示,当组件注入日志服务后,日志服务以及它所依赖的基础服务都会被初始化。
可以说,依赖注入是一种帮助开发者管理模块依赖的设计模式。在 Angular 中,依赖注入与 TypeScript 相结合提供了更好的开发体验。在 TypeScript 中,对象通常被明确赋以类型,通过类型匹配,组件类便可知道该用哪种类型实例去赋值变量。一个简单的依赖注入例子如下所示:
import {LoggerService} from './logger-service';
// other import statement
@Component({
selector: 'contact',
template: '...'
providers: [LoggerService]
})
export class ContactListComponent {
constructor(logger: LoggerService) {
logger.debug('xxx');
}
}
@Component 装饰器中的 providers 元数据是依赖注入操作的关键,它会为该组件创建一个注入器对象,并新建 LoggerService 实例存储到这个注入器里。组件需要引入 LoggerService 实例时,只需在构造函数声明 LoggerService 类型的参数即可,Angular 自动地通过类型匹配,找出注入器里预先实例化好的 LoggerService 对象,在组件实例化化时作为参数传入,这样组件便获得了 LoggerService 的实例引用。
值得注意的是,组件上创建的这个注入器对象是可以被子组件复用的,这就意味着我们只需在根组件上注入一次服务,即在根组件的 providers 声明注入该服务,整棵组件树上的组件都能使用这个服务,并且保持单例。这个特性非常有用,大大节省了服务的内存占用,并且由于服务是单例的,注入到组件后,可以作为中转桥梁,实现这些组件之间的数据传递。
这时候,大家可能会有个疑问,在某个组件分支里,我不想继续沿用这个实例了,我希望使用一个新实例,这种场景其实并不少见,那么这种情况 Angular 怎么解决?答案是分层注入。组件树上的每个组件都能单独注入服务,服务的每一次注入(也就是使用 providers 声明),该服务都会被创建出新的实例,该组件及其所有子组件都会转而使用这个新的实例。举个例子:
我在根组件注入了 LoggerService 日志服务,并设置日志级别 level 为 warn 级别。
// import statement
// 根组件
@Component({
selector: 'app',
template: '...',
providers: [LoggerService]
})
class AppComponent {
constructor(logger: LoggerService) {
logger.setLevel('warn');
}
}
那么组件树上的所有组件都能使用到这个 warn 级别的日志服务:
// ContactList 组件
@Component({
selector: 'contact-list',
template: '...'
})
class ContactListComponent {
constructor(logger: LoggerService) { // 在构造器里声明即可
}
}
接下来,随着业务发展,我希望在 ContactList 组件分支上能输出更高级别(如 debug 级别)的日志信息,很显然,通过在 ContactList 组件里修改 level 会有一些副作用:
class ContactListComponent {
constructor(logger: LoggerService) {
logger.setLevel('debug'); // 这个 logger 是全局实例
}
}
他获取到的 logger 是根组件注入的实例,在任何一个子组件调用 setLevel() 都是全局生效的,使得根组件也输出了 debug 级别的信息。这时候我们只需要在 ContactList 组件里重新注入 LoggerService 实例,即可满足需求:
// ContactList 组件
@Component({
selector: 'contact-list',
template: '...',
providers: [LoggerService] // 重新注入
})
class ContactListComponent {
constructor(logger: LoggerService) {
logger.setLevel('debug');
}
}
ContactList 分支使用的是新的 debug 级别的日志服务,而根组件和 Header 等其他组件依然能继续使用 warn 级别的日志服务。
组件以树的形式组织,使得组件背后的注入器对象也可以抽象为一颗树,称为注入树。Angular 首先会从宿主组件对应的注入器查找匹配的服务实例,若找不到,则继续往父组件的注入器里查找,一直找到最顶层的注入器为止,若都找不到匹配的实例,则抛出错误。这种灵活的注入方法可以适应多变的应用情景,既可配置全局单例服务(在应用的根组件注入即可),亦可按需注入不同层级的服务,彼此数据状态不会相互影响。
前面提到过,依赖注入除了可以作用于组件,也可以作用于模块,要理解模块的依赖注入,首先理解模块是什么,我们继续。
模块
首先说明的一点,模块有两层含义:
- 框架代码以模块形式组织(物理模块)
- 功能单元以模块形式组织(逻辑模块)
物理模块是 TS/ES6 提供的文件模块特性,并不是本文重点,这里重点剖析的是逻辑模块,下面逻辑模块直接称为模块。
一个大型应用由大量组件、指令、管道、服务构成,这些构件中有些是没有交集的,而有些则协同工作来完成某个特定的功能,我们希望把这些有关联的构件包装到一块,形成一个比较独立的单元,这样的单元在实际意义上就称为模块。 所以简单的说,模块就是对应用内零散的组件、指令、服务按功能进行归类包装。其关系示意图如下:
除此之外,模块还有一个重要的实际意义。因为默认情况下,一个组件是不能直接引用其他组件,也不能直接使用其他指令的功能,要想使用需要先导入,其他前面讲父子组件时候已经提到过,这个导入的过程就是应用模块实现的。 总结来说,一个组件可以任意使用同模块的其他组件和指令。 但是,跨模块里的组件指令则不能直接相互使用,如模块A的组件不能直接使用模块C的指令,若要跨模块访问,则需结合模块的导入导出功能,要理解导入导出的内容,先来看一个简单的模块例子:
// import statement
@NgModule({
imports: [SomeModule], // 导入其他模块
declarations: [SomeComponent, SomeDirective, SomePipe], // 引入组件、指令、管道
providers: [LoggerService], // 依赖注入
exports: [SomeComponent, SomeDirective, SomePipe] // 导出组件、指令、管道
// bootstrap: [AppComponent] // 根模块才有,标记哪个组件是根组件
})
export class AppModule { }
可以看出,声明模块使用的是 @NgModule() 装饰器。先来看 imports 和 exports 属性,他们即为模块的导入导出属性,模块间的导入导出关系如下图所示:
由图可知,模块A 导入了 模块B,模块B 通过 exports 属性暴露了 组件B1 和 指令B2。 很显然,组件B1 和 指令B2 能够被 组件A1 使用,而 组件B3 并不能。 所以可以看出,Angular 模块既可以对外暴露出一些构件,同时又有一定的封装性,能够隐藏内部的一些实现。
讲完了模块内的组件和指令(管道的访问方式跟组件指令一致),接下我们来看一下服务,接上文依赖注入抛过来的水球。服务既可注入到组件也可注入到模块,二者的使用方法大致相同,区别在于作用域。所有的模块上都共享着一个应用级别的注入器,这就意味着注入到任何一个模块的服务可以在应用全局(所有模块)里使用,而注入到组件里的,仅能在该组件以及它的子组件上使用。
关于应用级注入器和组件级注入器的关系如下所示:
应用级注入器的子节点除了有组件级注入器,还包含懒加载模块级注入器,懒加载的模块的注入器是独立生成的,为模块级别注入器,这个不在本文讲解范围内。
可以看出,组件级注入器是全局注入器的一个子注入器,所以回看上面这个例子,模块B 里的 服务B4 既可以在 模块A 里使用,也可以 模块C 里使用。
这里大家可能会有疑问,如果不同的模块里都注入了相同标识的服务,由于模块都共享同一个注入器,免不了会发生冲突。只要记住一个原则即可,后初始化的服务会覆盖先初始化的服务,举个例子,模块A 和 模块C 都注入了 LoggerService,并且模块A 导入了 模块C,由于 模块C 会先初始化,然后才到 模块A,所以 模块A 注入的 LoggerService 会被应用到全局。特别特别提醒的一点是,即使 模块C 也注入了 LoggerService,该模块里生效的实例也会是 模块A 里注入的那个实例,一定要记住这点。按照这个理论来推导,根模块里注入的服务始终是最高优先级的。
上述主要介绍了模块的特性,接下来看一下 Angular 给我们推荐的模块使用的最佳实践。
首先,Angular 要能成功运行,至少需要定义一个模块,因为需要有一个模块作为应用启动的入口,这样的模块就称为根模块。
然后,我们的应用会不断的添加新的功能。这些新增的功能可以封装到一个新的模块里。这些新增加的模块在 angular 里称为特性模块。有了特性模块之后,根模块原来承载在功能逻辑也可以抽离出来,放到某个特性模块里,使根模块保持简洁。
接下来,我们添加的特性模块越来越多,他们之间可以抽出一些相似功能的组件或指令,这些公共的部分也可以封装成一个独立的模块,这样的模块在逻辑意义上不能称为特性模块,Angular 把他称为为共享模块。
最后,还有核心模块,我们知道,一个应用里总有一些全局的组件或服务等,他们只需要在应用启动时候初始化一次即可,例如,维护登录信息的服务,或者是,公共的头部和尾部组件等。虽然我们可以把他们放到根模块里,但更好的设计是把这些逻辑也抽离出来,放到一个独立的模块,这个模块即为核心模块。核心模块要求只导入到根模块里,而尽量不要导入到特性模块或者共享模块里,这是为了在协同工作时候避免出现一些不可预料的结果。
这就是 Angular 给我们推荐最佳实践。最终我们看到,处于总指挥地位的根模块非常简洁,没有繁琐的业务细节。,应用的功能特性被切分为各个大大小小的模块,逻辑结构非常清晰。
Angular 已经封装了不少常用的模块,如:
- ApplicationModule:封装一些启动相关的工具;
- CommonModule:封装一些常用的内置指令和内置管道等;
- BrowserModule:封装在浏览器平台运行时的一些工具库,同时将 CommonModule 和 ApplicationModule 打包导出,所以通常在使用时引入 BrowserModule 就可以了;
- FormsModule 和 ReactiveFormsModule:封装表单相关的组件指令等;
- RouterModule:封装路由相关的组件指令等;
- HttpModule:封装网络请求相关的服务等。
所以,如果你想使用 ngIf 和 ngStyle 等这些内置指令,记得先导入 CommonModule,其他的模块使用方法一致。
应用启动
上述已提及,Angular 通过引导运行根模块来启动应用,引导的方式有两种:动态引导和静态引导。要理解二者的区别,先来简述 Angular 应用的启动过程,Angular 应用运行之前,都需要经过编译器对模块、组件等进行编译,编译完后才开始启动应用并渲染界面。
动态引导和静态引导的区别就在编译的时机不同,动态引导是将所有代码加载到浏览器后,在浏览器进行编译;而静态引导是将编译过程前置到开发时的工程打包阶段,加载到浏览器的将是编译后的代码。
假设我们的根模块为 AppModule,动态引导的示例代码如下:
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app.module';
platformBrowserDynamic().bootstrapModule(AppModule);
动态引导是从 platformBrowserDynamic 函数启动,该函数从 @angular/platform-browser-dynamic 文件模块(关于 Angular 文件模块将在下一小结讲述)中导入。动态引导启动的模块 AppModule 即是我们编写的模块,再来看看静态引导的示例代码:
import { platformBrowser } from '@angular/platform-browser';
import { AppModuleNgFactory } from './app.module.ngfactory';
platformBrowser().bootstrapModuleFactory(AppModuleNgFactory);
静态引导以 platformBrowser 函数启动,这个函数是从 @angular/platform-browser 文件模块中导入的,跟动态引导的不是同一个。静态引导启动的是 AppModuleNgFactory 模块,这是 AppModule 经过编译处理后生成的模块(app.module 文件编译后生成 app.module.ngfactory 文件)。由于整个应用已经被预先编译,所以编译器并不会打包到项目代码,代码包体更小,加载更快,而且也省去了浏览器编译这个步骤,因此应用启动的速度也会更快
动态引导开发流程简单明了,适合小型项目或者大型应用的开发阶段使用,而静态引导需要在开发阶段加入预编译流程,稍显复杂但性能提升明显,任何时候都推荐使用。
小结
这就是 Angular 的概览,实际上它已不仅仅是简单的框架,更像是个平台。不同的项目倾向的技术口味并不相同,技术选型时我们都希望性价比最大化,无论是React,还是Vue,抑或是Angular,都能解决我们的主要问题,而Angular提供更广泛的多端支持,一站式解决方案,加上精心的架构设计、成熟的 Angular 生态、对标准的拥抱,还有 Google 和微软的联手支持,这些都给了开发者足够的信心,Angular 将会是一个非常棒的平台,不妨试试!