Angular复习笔记6-依赖注入
依赖注入(DependencyInjection)是Angular实现重要功能的一种设计模式。一个大型应用的开发通常会涉及很多组件和服务,这些组件和服务之间有着错综复杂的联系,如何很好地管理它们之间的依赖关系成了一个棘手的问题,而这也正是一个框架是否强大的硬指标。Angular提供的依赖注入机制,可以优雅地解决上面提到的问题。在传统的开发模式中,调用者负责管理所有对象的依赖,其中的循环依赖一直是梦魇。而在依赖注入模式中,这个管理权就交给了注入器(Injector),它在应用运行时(而不是发生在编译时)负责替换依赖对象,这称为控制反转(Inversion of Control,缩写为IoC),是依赖注入的精华所在。
依赖注入利用面向对象的法则来降低应用程序耦合程度,IOC强调的是将对代码的引用的控制权交给外部的容器进行处理,在运行时通过某种(反射)方式注入进来,实现了控制的反转,因为控制反转强调的就是上层代码和下层代码不互相依赖,他们都应该依赖接口。依赖注入是最常用的一种实现IOC的方式。
在依赖注入模式中,应用组件无须关注所依赖对象的创建或初始化过程,可以认为框架已经初始化好了,开发者只管调用即可。依赖注入有利于应用程序中各模块之间的解耦,使得代码更容易维护。这种优势可能一开始体现不出来,但随着项目复杂度的增加,当各模块、组件、第三方服务等相互调用更频繁时,依赖注入的优点就体现得淋漓尽致。开发者可以专注于所依赖对象的消费,无须关注这些依赖的生产过程,这无疑将大大提升开发效率。
关于依赖注入的原理以及设计到的模式就不再赘述,但不赘述不意味着不重要,而是太重要了,一句两句说不清楚,要先了解清楚这个概念再来看看angular中是如何实现的吧。
概况
Angular的依赖注入框架(Dependency Injection Framework)提供了注入器(Injector),它会帮助开发者创建所需要的类实例。例如要创建一个Robot类的实例,示例代码如下:
const injector = Injector.create([
{ provide: 'reboot', useClass: Reboot }
]);
const reboot = injector.get('reboot');
实际上这个Reboot内部还有很多其他的依赖,但是不要担心,Injector已经为我们创建好了一切,我们只需要直接调用get方法就能创建好我们的reboot。
为了更好地理解Angular的依赖注入,首先介绍三个重要概念。
- 注入器(Injector):就像制造工厂,提供了一系列的接口用于创建依赖对象的实例。
- Provider:用于配置注入器,注入器通过它来创建被依赖对象的实例。Provider把标识(Token)映射到工厂方法,被依赖的对象就是通过该方法来创建的。上面代码中的Injector.create()方法传入的就是一个provider数组。
- 依赖(Dependence):指定了被依赖对象的类型,注入器会根据此类型创建对应的对象。
标识(token)是Angular中Provider的重要概念,将在本章后面进行讲解。
他们之间的关系如图所示,注入器是一个粘合剂,也就是中间层,调用发通过注入器来获得所需的依赖。
Provider对象字面量(如{provide:Head,useClass:Head,deps:[]})把一个标识映射到一个可配置的对象,这个标识可以是一个类名,也可以是一个字符串。有了Provider,Angular不仅知道使用了哪些依赖,也知道了这些依赖是如何被创建的,后面的内容将对Provider的注册方式做详细说明。deps显式指定了类的依赖项,使用Injector时依赖需要显式指定。在Angular中依赖Injector来创建注入器对象主要有三个地方,分别是Platform、Compiler和NgZone。以Platform为例,在如下的启动代码中:
加入需要添加平台依赖模块Reboot,可以在platformBrowserDynamic()方法中插入provider:
但是如果开发者通过在装饰器中指定依赖是不需要指定deps的:
通过装饰器创建的模块或组件,其依赖实际还是借助于Reflect类库寻找的,但是经过AoT编译后依赖数据已经生成好,所以在这种情况下应用代码也不需要引入Reflect。而自AngularCLI1.5.0后,在开发阶段也可以使用ngserve--aot在AoT模式下开发调试,这样便完全移除了Reflect依赖。
在组件中注入服务
如果你在组件中的元数据上定义了providers,那么angular会根据providers为这个组件创建一个注入器,这个组件的子组件也会共享这个注入器,如果没有定义,那么组件会根据组件树逐级向上查找合适的注入器来创建组件的依赖。
现在的服务的注入非常的方便,因为你在定义一个服务的时候就能在元数据中定义好这个服务是被注入到了哪里,例如下面的代码:
@Injectable({
providedIn: 'root'
})
export class AccountService {}
这样你的服务就会被自动的注入到根节点上。当组件需要这个服务的时候,会逐级追溯直到找到合适的注入器来注入这个服务。
@Injectable()装饰器对于服务来说不是必须的,当一个服务依赖另一个服务的时候,前者才需要使用@Injectbale装饰器来装饰。
模块中注入服务
前面提到过,在根组件中注入的服务,在所有的子组件中都能共享这个服务,当然在模块中注入服务也可以达到这样的效果。在模块中注入服务和之前的注入场景稍有不同。Angular在启动程序时会启动一个根模块,并加载它所依赖的其他模块,此时会生成一个全局的根注入器,由该注入器创建的依赖注入对象在整个应用程序级别可见,并共享一个实例。同时,根模块会指定一个根组件并启动,由该根组件添加的依赖注入对象在组件树级别可见,在根组件及子组件*享一个实例。更多的关于共享实例的内容可参考下一节“层级注入”,下面先来看看在模块中添加依赖注入的例子。示例代码如下:
@NgModule({
//.....其他代码.....
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: HttpRequsetInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: HttpResponseInterceptor, multi: true },
{ provide: MAT_DIALOG_DEFAULT_OPTIONS, useValue: { minLength: '20%', minWidth: '20%', hasBackdrop: true } },
{ provide: MatPaginatorIntl, useValue: myPaginator() }
],
//....其他代码....
})
export class AppModule{}
需要注意的是在angular中没有模块级的作用域,只有系统级别的和组件级别的,模块级别的注入会被当做应用级别的作用来看待。这种考虑主要是针对模块的可扩展性,angular是由多个模块组成的,在@NgModule中注册的服务默认在应用级别可见。
延迟加载的模块是个例外,后面的路由章节会对延迟加载进行详细介绍,这里不再赘述。模块的延迟加载使得应用程序在启动时不被载入,而是结合路由配置,在需要时才动态加载相应的模块。Angular会对延迟加载模块初始化一个新的执行上下文,并创建一个新的注入器,在该注入器中注入的依赖只在该模块内部可见,这算是模块级作用域的一个特例。
如果在多个模块中都注入了相同标识的服务怎么办?假设在根模块中先后导入了ContactModule和MsgModule两个模块。示例代码如下:
在ContactModule和MsgModule模块中都注入了相同Token标识的服务。因为根注入器只有一个,后面初始化的模块服务会覆盖前面初始化的模块服务,如上例MsgModule中初始化的服务会覆盖ContactModule中初始化的服务,而且即便是ContactModule模块里的组件,且这些组件引入的是同一个Token标识的服务,那么这些组件所引入的服务也依然会是MsgModule模块里注入的那个服务实例,这种情况需要特别注意。
层级注入
实际上,每个组件都有自己的注入器(但并不是每个组件都会为自己创建独立的注入器,也有可能是共享其他组件的注入器),由这个注入器创建的Provider都是单例,这是组件级别的单例。
上面提到注入可以发生在整棵组件树的任一层级,并在各层级组件的注入器中维持单例。更进一步来说,依赖注入可以传递到子孙组件中,子组件可以共享父组件中注入的实例,无须再创建。如果注入了多个实例,这些后代(子孙)组件也都可以共享这些实例,如下图所示。
Angular这种灵活的设计引出了这样一个思考:是在根组件还是在子组件中进行服务注入,该怎么选择呢?这取决于想让注入的依赖服务具有全局性还是局部性。由于每个注入器总是将它提供的服务维持单例,因此,如果不需要针对每个组件都提供独立的服务单例,就可以在根组件中注入,整个组件树共享根注入器提供的服务实例,如在上述一些组件中使用的日志工具类等;如果需要针对每个组件创建不同的服务实例,就应该在各子组件中配置providers元数据来注入服务。另一个问题是,Angular是如何查找到合适的服务实例的呢?当组件的构造函数试图注入某个服务的时候,Angular会先从当前组件的注入器查找,找不到就继续到父组件的注入器查找,直到根组件注入器,最后到应用根注入器(即模块注入器),此时找不到的话就报错,下图展示了组件往上查找服务的过程。当然,后面内容介绍的限定依赖注入是个例外,它可以控制查找的范围,即使找不到也不报错。
注入到派生组件
组件本质上是一个类,组件可以从另一个组件派生,但派生组件不能继承父组件的注入器。二者的注入器没有任何的关联。
限定方式的依赖注入
到目前为止,注入都是假定依赖对象是存在的,然而实际情况往往并非如此,比如上层提供的Provider被移除,导致之前注入的依赖可能已经不存在了,此时再按照前面讲的依赖注入方式进行相关服务的调用,应用就会出错。幸运的是,Angular依赖注入框架提供了@Optional和@Host装饰器来解决上面提到的问题。
Angular的限定注入方式使得开发者能够修改默认的依赖查找规则,@Optional可以兼容依赖不存在的情况,提高系统的健壮性。@Host可以限定查找规则,明确实例初始化的位置,避免一些莫名的共享对象问题。
这里有一篇文章详细的描述了限定方式的依赖注入:https://blog.csdn.net/weixin_34194317/article/details/88010658
我本身对于这种限定方式的依赖注入理解的不是很清楚,而且限于篇幅,先跳过这部分的内容吧。
Provider
Provider设计模式由来已久,在前后台各种技术领域中被广泛使用,如.Net的MembershipProvider、Hibernate的ConnectionProvider,以及Android的ContentProvider等。Provider实现了逻辑操作或数据操作的封装,以接口的方式提供给调用方使用,Provider模式提供了很好的可扩展性和灵活性。在Angular中,Provider描述了注入器如何初始化标识(Token)所对应的依赖服务,它最终被用于注入到组件或者其他服务中,这个过程好比厨师(注入器)根据菜谱(Provider)制作一道名为LoggerService(标识)的菜(依赖服务)。Provider提供了一个运行时所需的依赖,注入器依靠它来创建服务对象的实例。
在上面的日志服务LoggerService例子中,LoggerService被注册到了providers数组(元数据)中。示例代码如下:
上面代码的完整形式采用了对象字面量的方式来描述一个Provider的构成要素。其中provide属性可以理解为这个Provider的唯一标识,用于定位依赖值,以及注册Provider,也就是应用中使用的服务名,而useClass属性则代表使用哪个服务类来创建实例。
Angular的Provider引进标识机制解决了AngularJS1.x版本中存在的几个痛点:
- 标识是字符串,作为唯一标识(Angular会有一个标识映射表,保证唯一性),不再依赖具体的类,可避免命名空间污染。
- 代码只依赖一个抽象的标识,不再依赖具体的实现,可以在运行时动态替换。
事实上,标识可以是字符串,也可以是其他数据类型。当多个字符串同时映射到同一个标识的时候,会以最后一个为准,这可能会导致一些隐含的缺陷。为了解决标识命名冲突的问题,Angular引入了OpaqueToken(不透明标识),可以保证所生成的标识都是唯一的。关于详细的OpaqueToken用法,读者可以在官网上进一步了解。
Provider注册形式
Provider的主要作用是注册并返回合适的服务对象。Angular提供了如下四种常见的Provider注册形式,开发者可以结合不同的场景来选择使用。
- 类Provider
- 值Provider
- 别名Provider
- 工厂Provider
类provider
类Provider基于标识来指定依赖项,这种方式可以使得依赖项能够被动态指定为其他不同的具体实现,只要接口不变,对于使用方就是透明的。一个典型的场景就是数据渲染服务(Render),Render服务对上层应用提供的接口是固定的,但底层可以用DOM渲染方式(DomRender),也可以用Canvas渲染方式(CanvasRender),还可以用Angular Universal实现服务端渲染(ServerRender),如图所示。
然后通过useClass属性来指定使用哪种渲染方式。因为渲染服务的最终接口并没有变化(例子中还是Render),这对于调用者来说,业务代码无须修改,从而带来了极大的便利性。示例代码如下:
值Provider
在实际项目中,依赖对象不一定是类,也可以是常量、字符串、对象等其他数据类型,以方便用在全局变量、系统相关参数配置等场景中。在创建Provider对象时,只需使用useValue就可声明一个值Provider。示例代码如下:
值Provider依赖的值(通过useValue指定的值)必须在当前或者providers元数据配置之前定义。
别名Provider
有了别名Provider,就可以在一个Provider中配置多个标识,其对应的对象指向同一个实例,从而实现多个依赖、一个对象实例的作用。useExisting可以用来指定一个别名Provider。假如应用已有一个日志服务OldLoggerService,现在开发了有相同接口的新版服务NewLoggerService,考虑到重构代价等原因,并不想去替换OldLoggerService服务被使用的地方。此时为了让新旧服务同时可用,可以用useClass来解决这个问题。示例代码如下:
但是,上述两个NewLoggerService是不同的实例,这显然不是我们预期的效果。幸运的是,Angular已经考虑到这种情况,可以在创建Provider对象时使用useExisting,使得多个标识指向同一个实例。示例代码如下:
工厂Provider
有时候依赖对象是不明确且动态变化的,可能需要根据运行环境、执行权限来生成,Provider需要一种动态生成依赖对象的能力。Angular提供的工厂Provider可以解决这个问题,它通过暴露一个工厂方法,返回最终依赖的对象。在通讯录例子中,假设有这样一个场景:有些联系人的信息是保密的,只有拥有特定权限的人才能看到,所以需要对每个登录用户进行鉴权。要达到这样的目的,可以在构造函数中通过一个布尔值来判断是否有权限并返回对应的服务,在返回的服务中可以根据这个布尔值来判断是否显示联系人信息。示例代码如下:
使用工厂Provider的注册方式需要用useFactory来声明Provider是一个工厂方法,如上例中指定具体的实现方法是contactServiceFactory();deps是一个数组属性,指定了所需要的依赖,可以注入到工厂方法中。上面几种Provider的注册方式可根据不同的场景选择使用,为开发大型复杂的应用提供了便利。