1 注入,一种组件树状层级通信模式 & 设计模式
1.1 组件通信模式
在Angular工程开发中,通常我们使用Input属性绑定和Output事件绑定进行组件通信,然而Input和Output却只能在父子组件中传递信息。组件根据调用关系形成一棵组件树,如果只有属性绑定和事件绑定,那么两个非直接关系组件要通信,需要通过各个连接点本身,中间人需要不断处理和传递一些它本身不需要知道的信息(如图1左)。而Angular中提供的Injectable的Service,可以在模块、组件或者指令等提供,搭配在构造函数的注入,正好能解决这个问题(图1右)。
图1 组件通信模式
左图只通过父子组件传递信息,节点a和节点b进行通信就需要经过诸多节点;如果节点c想要通过一些配置控制节点b,他们中间的节点也必须设置额外的属性或者事件来透传对应的信息。右图的依赖注入模式节点c可以提供一个供节点a、b通信的服务,节点a直接和节点c提供 服务通信,节点b也直接和节点c提供的服务通信,最后通信就被简化了,中间节点也没有耦合该部分内容,对上下层组件发生的通信无明显的感知。
1.2 使用依赖注入实现控制反转
依赖注入(DI)并不是Angular特有的,它是实现控制反转(IOC)设计模式的手段,依赖注入的出现解决手动实例化过分耦合的问题,所有资源不由使用资源的双方管理,而由不使用资源资源中心或者第三方提供,这样能带来很多好处。第一,资源集中管理,实现资源的可配置和易管理。第二,降低了使用资源双方的依赖程度,也就是我们说的耦合度。
类比现实世界就是,我们去购买商品比如一支铅笔,我们只需要找个商店购买一支类型为铅笔的商品,我们不关心这支铅笔产地是哪里,木头和铅笔芯都是怎么粘合的,我们只需要它能完成铅笔的书写功能即可,我们不会和具体的铅笔制造商或者工厂有联系。而对于商店,它就可以自己去合适的渠道采购铅笔,实现资源的可配置。
结合编码场景,更具体的说,使用者不需要显式创建实例(new操作),就能注入并使用实例,实例的创建由提供商(providers)决定。资源的管理是通过令牌(token),由于不关心提供商,不关心实例的创建,使用方就可以通过一些局部注入的手段(对token进行二次配置),最终实现替换实例,依赖注入模式的应用和切面编程(AOP)相辅相成。
2 Angular中的依赖注入
依赖注入是Angular框架最重要几个的核心模块之一,Angular不仅提供Service类型的注入,本身组件树就是一颗注入依赖树, 函数和值也可以被注入。也就是说在Angular框架中,子组件是可以通过父组件的token(通常为类名),注入父组件实例的。在组件库开发中有大量案例是通过注入父组件,实现交互和通信的,包括参数挂载,状态共享,甚至获取父组件所在节点的DOM等等。
2.1 解析依赖
要使用Angular的注入,首先就要明白它的注入解析的过程。类似于node_modules的解析过程,当找不到依赖都有找不到依赖会一直冒泡到父层去找依赖。旧版(v6前)的Angular会将注入解析的过程分为多级模块注入器,多级组件注入器和元素注入器。新版(v9后)简化为两级模型,第一个查询链是静态DOM层级的元素注入器、组件注入器等统称为元素注入器,另一个查询链是模块注入器。解析的顺序和解析失败后的默认值官方的这个代码注释文档(provider_flag)里讲的比较清楚了。
图2 两级注入器查找依赖过程 ( 图片来源)
也就是说组件/指令以及在组件/指令层级提供注入内容会优先在组件视图中元素里寻找依赖一直到根元素,如果没有找到则接着在元素当前所在模块,引用(包含模块引用和路由懒加载引用)该模块的父级模块一次往上找直到根模块和平台模块。
注意这里注入器是有继承的,元素注入器可以创建并继承父元素的注入器的查找函数,模块注入器也类似。当不断继承之后,就有点像js对象的prototype链了。
2.2 配置提供商
明白了依赖解析的顺序优先级,我们就可以在合适的层级对内容进行提供。我们已经知道它有两种类型:模块注入和元素注入。
- 模块注入器:在@NgModule的元数据属性里可以配置providers,还可以使用v6以后提供的@Injectable声明provideIn声明为模块名、'root'等。(实际上在root根模块之上还有两个注入器,Platform和Null,这里不讨论它们。)
- 元素注入器:在组件@Component的元数据属性里可以配置providers,viewProviders, 或者在指令的@Directive元数据里的providers.
另外,实际上@Injectable装饰器除了用了声明模块注入器外,也可以声明为元素注入器。更经常会将其声明为在root提供,以实现单例。它通过类自己集成元数据来避免模块或者组件直接显式声明provider,这样如果该类没有任何组件指令服务等类注入它,就没有代码链接到该类型声明,就可以被编译器忽略,从而实现了摇树。
还有一种提供方法是声明InjectionToken的时候直接给出值。
这里给出这几种方式的速写模板:
@NgModule({
providers: [
// 模块注入器
]
})
export class MyModule {}
@Component({
providers: [
// 元素注入器 - 组件
],
viewProviders: [
// 元素注入器- 组件视图
]
})
export class MyComponent {}
@Directive({
providers: [
// 元素注入器 - 指令
]
})
export class MyDirective {}
@Injectable({
providedIn: 'root'
})
export class MyService {}
export const MY_INJECT_TOKEN = new InjectionToken('my-inject-token', {
providedIn: 'root',
factory: () => {
return new MyClass();
}
});
提供依赖的位置不同的选择会带来一些差异,最终影响着包的大小,依赖的能被注入的范围和依赖的生命周期。对于不同的场景,如单例(root),服务隔离(module),多重编辑窗(component)等都有不同的适用方案,应当选择合理的位置,避免共享的信息不当,或者代码打包的冗余。
2.3 多样的值函数工具
如果只是提供实例的注入,那还显示不出Angular框架依赖注入的灵活性。Angular提供了很多灵活的注入工具,useClass 自动创建新实例,useValue 使用静态值, useExisting 可以复用已有的实例,useFactory 通过函数来构造,搭配指定 deps 指定构造函数参数,这些组合起来玩法可以非常花样。可以半路截胡一个类的token令牌替换成另一个自己准备好的实例,可以造一个token先保存起来值或者实例,然后再在后面需要用到的时候重新替换回去,甚至可以用工厂函数返回实例的局部信息实现映射成另一个对象或者属性值。这里的玩法会通过后面的案例进行阐述,这里就先不展开。官网也有很多例子可以参考。
2.4 注入消费和装饰器
Angular中的注入可以在构造函数constructor内注入,也可以拿到注入器injector通过get方法获取已有的注入元素。
Angular支持在注入的时候增加装饰器进行标记,
- @Host() 来限制冒泡
- @Self() 限制为元素自身
- @SkipSelf() 限制为元素自身以上
- @Optional() 标记为可选
- @Inject() 限制为自定义Token令牌
这里有一篇文章《@Self or @Optional @Host? The visual guide to Angular DI decorators.》非常生动形象地展示父子组件间如果使用了不同的装饰器,最后会命中的实例有什么不同。
图3 不同注入装饰器的筛选结果
2.4.1 补充:宿主视图和@Host
这几个装饰器里面,最不好理解的可能就是@Host了,这里补充一些@Host的具体说明。
官方对@Host装饰器的解释是
...retrieve a dependency from any injector until reaching the host element
Host在这里是宿主的意思,@Host这个装饰器将会限定查询的范围在宿主元素(host element)以内。什么是宿主元素呢?假如B组件是A组件模板使用的组件,那么A组件实例就是B组件实例的宿主元素。组件模板产生的内容称为View(视图),同一个View对于不同组件来说可能是不同视图。如果A组件在自己的template范围内使用B组件(见图4),A的模板内容形成的视图(红框部分)对A组件来说就是A的内嵌视图,B组件在这个视图内,所以对B来说这个视图就是B的宿主视图。装饰器@Host就是限定搜索范围为宿主视图之内,找不到不会再进行冒泡了。
图4 内嵌视图和宿主视图
3 案例和玩法
下面我们通过真实的案例,来看看依赖注入到底是怎么运转起来的,怎么排查错误,以及还能怎么玩。
3.1 案例一: 模态窗创建动态组件,找不到组件问题
DevUI组件库的模态窗组件提供了一个服务ModalService,该服务可以弹出一个模态框,而且可以配置为自定义的组件。业务的同学经常在使用这个组件的时候报错,包找不到自定义的组件。
比如以下的报错:
图5 使用ModalService的时候创建引用EditorX的组件的报错找不到对应服务提供商
分析ModalService是如何创建自定义组件的,ModalService源码Open函数 第52行和第95行。能看到,componentFactoryResolver
如果没有传入就使用ModalService注入的componentFactoryResolver
。而大多数情况下,业务会在根模块引入一次DevUIModule,但是不会在当前模块里引入ModalModule。也就是现状图6是这样的。根据图6,ModalService的injector内是没有EditorXModuleService的。
图6 模块服务提供关系图
根据注入器的继承,解决办法有四个:
- 把 EditorXModule 放到 ModalModule 声明的地方,这样注入器就能找到EditorXModule提供的EditorModuleService —— 这是最糟糕的一种解法,本身loadChildren实现的懒加载就是为了减少首页模块的加载,结果是子页内需要用到的内容却放在AppModule,首次加载就把富文本的大模块给加载了,加重了FMP(First Meaningful Paint),不可采取。
- 在引入 EditorXModule 且使用 ModalService 的模块里引入 ModalService —— 可取。仅有一种情况不太可取,就是调用 ModalService 的是另一个靠顶层的公共 Service,这样还是把不必要的模块放在了上层去加载。
- 在触发使用ModalService的组件,注入当前模块的
componentFactoryResolver
,并传给ModalService的open函数参数 —— 可取, 可以在真正使用的地方再引入EditorXModule。 - 在使用的模块里,手动提供一个ModalService —— 可取,解决了注入搜索的问题。
四种方法其实都是在解决 ModalService 所用的componentFactoryResolver
的injector内部链式上有EditorXModuleService问题。保证在两层搜索链上,这个问题就可以解决了。
知识点小结:模块注入器继承和查找范围。
3.2 案例二:CdkVirtualScrollFor找不到 CdkVirtualScrollViewport
通常我们多个地方使用同一个模板的时候,会通过 template 提取公共部分,之前 DevUI Select组件开发的时候开发者想将共用的部分抽取出来报错了。
图7 代码移动和找不到注入报错
这里是由于 CdkVirtualScrollFor指令需要注入一个CdkVirtualScrollViewport,然而元素注入injector继承体系是继承静态AST关系的DOM,动态的不行,所以发生以下查询行为,查找后报失败。
图8 元素注入器查询链查找范围
最后解法::要么1)保持原代码位置不变,要么2)需要把整个模板内嵌就能找到了。
图9 内嵌整块模块使得能CdkVitualScrollFo能找到CdkVirtualScrollViewport(解法二)
知识点小结:元素注入器的查询链条是静态模板的DOM元素祖先。
3.3 案例三: 表单校验的组件被封装到子组件内无法校验问题
这个案例来自这篇博客《Angular: Nested template driven form》。
在使用表单校验的时候我们也遇到了一样的问题。如图10所示,由于某些原因我们把三个字段的地址封装成一个组件以供复用。
图10 把表单的地址三个字段封装成一个子组件
这时候我们会发现报错了,ngModelGroup
需要一个host内部的ControlContainer
,也就是ngForm指令提供的内容。
图11 ngModelGroup 找不到ControlContainer
查看ngModelGroup代码可以看到它只添加了host装饰器的限制。
图12 ng_model_group.ts限定了注入ControlContainer的范围
这里可以使用viewProvider搭配usingExisting给AddressComponent的宿主视图增加ControlContainer的Provider
图13 使用viewProviders给嵌套组件提供外部的Provider
知识点小结:viewProvider 和 usingExisting 搭配的妙用。
3.4 案例四:拖拽模块提供的Service,由于懒加载,不是单例了,导致无法互相拖拽
内部的业务平台有涉及跨多个模块的拖拽,由于涉及了loadChildren懒加载,每个模块会单独打包DevUI组件库的DragDropModule,该Module提供了一个DragDropService。拖拽指令分为可拖起指令Draggable和可放置指令Droppable,两个指令通过DragDropService进行通信。 本来引入同一个模块使用模块提供的Service是可以通信的,但是懒加载后DragDropModule模块被打包了两次,也对应产生两份隔离的实例。这时候处于一个懒加载模块里的Draggable指令就无法与另一个懒加载模块里的Droppable指令进行通信了,因为此时DragDropService并不是同个实例了。
图14 懒加载模块导致服务不是同一实例/单例
这里明显我们的述求是需要单例,而单例的做法通常就是providerIn: 'root'
就好了,那么是不是就让组件库的DragDropService不要提供在模块级别,直接提供root界别的可好。但是细细想下来,这里面又会有其他的问题。组件库本身是提供给多种多样的业务使用的,万一有的业务在页面的两个地方分别有两组对应的拖拽并不想要联动起来。这时候单例反而就破坏了这种基于模块的天然隔离。
那么要实现单例由业务侧来做替换会更合理。记得我们前面提到的依赖查询链,元素的注入器是优先被查找的,找不到才开始找模块注入器。所以替换思路就是我们提供元素级别的provider即可。
图15 用扩展方法获得一个新的DragDropService并把它标记为在root级别提供
图16 利用同个selector可以叠加重复指令,给组件库的Draggable指令和Droppable指令叠加一个额外的指令并把DragDropService的token替换成已经在root提供单例的DragDropGlobalService
如图15和16, 我们通过元素注入器,叠加了指令,把DragDropService这个令牌替换成我们自己的全局单例的实例。这时候需要使用这个全局单例的DragDropService的地方,我们只需要引入声明并导出了这两个extra指令的模块就是使得组件库的Draggable指令Droppable指令能够跨懒加载模块进行通信了。
知识点小结:元素注入器优先级高于模块注入器。
3.5 案例五: 局部主题功能场景怎么让下拉菜单附着在局部问题
DevUI组件库的主题化是使用了CSS自定义属性(css变量)声明:root的css变量值从而实现了主题切换。如果我们要在一个界面内同时展示不同主题的预览,我们可以在DOM元素局部重新声明css变量从而达到局部主题的功能。之前在做主题仿色生成器的时候就用了这样一个办法来是局部应用一个主题。
图17 局部主题功能
但是仅仅局部应用css变量值还不够,有一些下拉弹出层它是默认附着在body最后面的,也就是说它的附着层在局部变量的外部,这将会导致一个非常尴尬的问题。局部主题的组件的下拉框下拉出来是外部的主题的样式。
图18 局部主题内组件附着外部的叠加层下拉框主题不正确
这时候怎么办?我们应该把附着点移动回局部主题dom内部。
已知DevUI组件库的DatePickerPro组件的Overlay使用的是Angular CDK的Overlay,经过一轮分析我们用注入替换如下:
1)首先我们继承OverlayContainer并实现自己的ElementOverlayContainer如下图。
图19 自定义ElementOverlayContainer并替换掉_createContainer逻辑
2)然后在预览的组件侧,直接提供我们新ElementOverlayContainer,并提供新的Overlay,以便新的Overlay能使用我们的OverlayContainer。原本Overlay和OverlayContainer都提供在root上,这里我们需要覆盖这两个。
图20 替换OverlayContainer为自定义的ElementOverlayContainer,提供一个新的Overlay
这时候再去预览网站,弹出层的DOM就顺利被附着到component-preview这个元素里面了。
图21 cdk的Overlay容器被附着到指定的dom内部, 局部主题预览成功
DevUI组件库内部还有自定义的OverlayContainerRef用于部分组件和模态框抽屉板凳,也需要进行相应的替换。最终能实现弹窗弹出层等完美支持局部主题。
知识点小结:好的抽象模式可以使得模块可替换,实现优雅的切面编程。
3.6 案例六: CdkOverlay要求在滚动条地方加上CdkScrollable指令,但无法给入口组件最外层加上该指令如何处理
到了最后一个案例,想讲一点不太正规的做法,以方便大家理解provider的本质,配置provider本质上就是让它帮你做实例化或者映射到某个存在的实例。
我们知道如果使用了cdkOverlay,如果我们想要弹出框跟随滚动条滚动也能悬浮在正确的位置的话,我们就需要给滚动条加上cdkScrollable的指令。
还是上一个例子的场景。我们整个页面是通过路由加载进来的,贪图简便我把滚动条写在了组件的host了。
图22 内容溢出滚动条把overflow:auto 写在了组件:host里
这样我们就遇到了一个比较难搞的问题,模块是router定义指定过来的,也就是没有任何地方显式地调用
,那cdkScrollable指令该怎么加进去呢?解法如下,这里隐藏掉了部分代码只留下核心代码。
图23 通过注入创建实例并手动调用生命周期
这里通过注入生成了一个cdkScrollable的实例,并在组件的生命周期阶段同步地调用生命周期。
这种解法不是正规手段,但确实解决了问题,这里就作为一种思路和探索留给读者品味。
知识点小结: 依赖注入配置提供商可以实现创建实例,但要注意实例将当做普通Service类对待,无法拥有用完整生命周期。
3.7 更多玩法: 自定义替换platform,实现让Angular框架跑在terminal终端上的交互
可以参考这篇博文:《Rendering Angular applications in Terminal》
图24 替换RendererFactory2渲染器等内容, 让Angular运行在终端terminal上
作者通过替换RendererFactory2等渲染器,让Angular应用可以跑在终端terminal上。这就是Angular设计的灵活度,连platform都可以替换掉的强大的灵活。详细的替换细节可以查看原文章,这里就不展开了。
知识点小结:依赖注入的强大之处,在于提供商可以自行配置,最后实现替换逻辑。
4 总结
本文介绍了控制反转的依赖注入模式及其好处,介绍了Angular中依赖注入是如何查找依赖,如何配置提供商,如何用限定和过滤作用的装饰器拿到想要的实例,进一步通过N个案例分析如何结合依赖注入的知识点来解决开发编程中会遇到的问题。
正确的理解依赖查找过程,我们便能在准确的位置配置上提供商(案例一二),截胡替换其他实例为单例(案例四、五),甚至能跨嵌套组件包裹的限制衔接上提供的实例(案例三)或者用提供的方法曲线实现指令实例化(案例六)。
其中案例五看似是简单的替换,但是要能写出能被替换的代码结构需要对注入模式有深入的了解,并对各个功能有比较好的合理的抽象,抽象不得当,就无法发挥依赖注入的最大功效了。注入模式为模块可插拔,插件化,零件化提供了更多可能的空间,降低耦合度,增加灵活性,是模块之间能更加优雅、协调地一起工作。
依赖注入功能的强大,除了能完成优化组件通信路径,更重要的是还能实现控制反转,给封装好的组件暴露更多切面编程的切面,一些业务特殊逻辑的实现也可以变得灵活起来。
往期文章推荐