理论 + 案例,带你掌握 Angular 依赖注入模式的应用
本文分享自华为云社区《Angular依赖注入模式的应用和玩法案例》,作者:DevUI 。
注入,一种组件树状层级通信模式 & 设计模式
组件通信模式
在 Angular 工程开发中,通常我们使用 Input 属性绑定和 Output 事件绑定进行组件通信,然而 Input 和 Output 却只能在父子组件中传递信息。组件根据调用关系形成一棵组件树,如果只有属性绑定和事件绑定,那么两个非直接关系组件要通信,需要通过各个连接点本身,中间人需要不断处理和传递一些它本身不需要知道的信息(如图 1 左)。而 Angular 中提供的 Injectable 的 Service,可以在模块、组件或者指令等提供,搭配在构造函数的注入,正好能解决这个问题(图 1 右)。
图 1 组件通信模式
左图只通过父子组件传递信息,节点 a 和节点 b 进行通信就需要经过诸多节点;如果节点 c 想要通过一些配置控制节点 b,他们中间的节点也必须设置额外的属性或者事件来透传对应的信息。右图的依赖注入模式节点 c 可以提供一个供节点 a、b 通信的服务,节点 a 直接和节点 c 提供 服务通信,节点 b 也直接和节点 c 提供的服务通信,最后通信就被简化了,中间节点也没有耦合该部分内容,对上下层组件发生的通信无明显的感知。
使用依赖注入实现控制反转
依赖注入(DI)并不是 Angular 特有的,它是实现控制反转(IOC)设计模式的手段,依赖注入的出现解决手动实例化过分耦合的问题,所有资源不由使用资源的双方管理,而由不使用资源资源中心或者第三方提供,这样能带来很多好处。第一,资源集中管理,实现资源的可配置和易管理。第二,降低了使用资源双方的依赖程度,也就是我们说的耦合度。
类比现实世界就是,我们去购买商品比如一支铅笔,我们只需要找个商店购买一支类型为铅笔的商品,我们不关心这支铅笔产地是哪里,木头和铅笔芯都是怎么粘合的,我们只需要它能完成铅笔的书写功能即可,我们不会和具体的铅笔制造商或者工厂有联系。而对于商店,它就可以自己去合适的渠道采购铅笔,实现资源的可配置。
结合编码场景,更具体的说,使用者不需要显式创建实例(new 操作),就能注入并使用实例,实例的创建由提供商(providers)决定。资源的管理是通过令牌(token),由于不关心提供商,不关心实例的创建,使用方就可以通过一些局部注入的手段(对 token 进行二次配置),最终实现替换实例,依赖注入模式的应用和切面编程(AOP)相辅相成。
Angular 中的依赖注入
依赖注入是 Angular 框架最重要几个的核心模块之一,Angular 不仅提供 Service 类型的注入,本身组件树就是一颗注入依赖树, 函数和值也可以被注入。也就是说在 Angular 框架中,子组件是可以通过父组件的 token(通常为类名),注入父组件实例的。在组件库开发中有大量案例是通过注入父组件,实现交互和通信的,包括参数挂载,状态共享,甚至获取父组件所在节点的 DOM 等等。
解析依赖
要使用 Angular 的注入,首先就要明白它的注入解析的过程。类似于 node_modules 的解析过程,当找不到依赖都有找不到依赖会一直冒泡到父层去找依赖。旧版(v6 前)的 Angular 会将注入解析的过程分为多级模块注入器,多级组件注入器和元素注入器。新版(v9 后)简化为两级模型,第一个查询链是静态 DOM 层级的元素注入器、组件注入器等统称为元素注入器,另一个查询链是模块注入器。解析的顺序和解析失败后的默认值官方的这个代码注释文档(provider_flag)里讲的比较清楚了。
图 2 两级注入器查找依赖过程 ( 图片来源)
也就是说组件/指令以及在组件/指令层级提供注入内容会优先在组件视图中元素里寻找依赖一直到根元素,如果没有找到则接着在元素当前所在模块,引用(包含模块引用和路由懒加载引用)该模块的父级模块一次往上找直到根模块和平台模块。
注意这里注入器是有继承的,元素注入器可以创建并继承父元素的注入器的查找函数,模块注入器也类似。当不断继承之后,就有点像 js 对象的 prototype 链了。
配置提供商
明白了依赖解析的顺序优先级,我们就可以在合适的层级对内容进行提供。我们已经知道它有两种类型:模块注入和元素注入。
模块注入器:在 @NgModule 的元数据属性里可以配置 providers,还可以使用 v6 以后提供的 @Injectable 声明 provideIn 声明为模块名、'root’等。(实际上在 root 根模块之上还有两个注入器,Platform 和 Null,这里不讨论它们。)
元素注入器:在组件 @Component 的元数据属性里可以配置 providers,viewProviders, 或者在指令的 @Directive 元数据里的 providers.
另外,实际上 @Injectable 装饰器除了用了声明模块注入器外,也可以声明为元素注入器。更经常会将其声明为在 root 提供,以实现单例。它通过类自己集成元数据来避免模块或者组件直接显式声明 provider,这样如果该类没有任何组件指令服务等类注入它,就没有代码链接到该类型声明,就可以被编译器忽略,从而实现了摇树。
还有一种提供方法是声明 InjectionToken 的时候直接给出值。
这里给出这几种方式的速写模板:
提供依赖的位置不同的选择会带来一些差异,最终影响着包的大小,依赖的能被注入的范围和依赖的生命周期。对于不同的场景,如单例(root),服务隔离(module),多重编辑窗(component)等都有不同的适用方案,应当选择合理的位置,避免共享的信息不当,或者代码打包的冗余。
多样的值函数工具
如果只是提供实例的注入,那还显示不出 Angular 框架依赖注入的灵活性。Angular 提供了很多灵活的注入工具,useClass 自动创建新实例,useValue 使用静态值, useExisting 可以复用已有的实例,useFactory 通过函数来构造,搭配指定 deps 指定构造函数参数,这些组合起来玩法可以非常花样。可以半路截胡一个类的 token 令牌替换成另一个自己准备好的实例,可以造一个 token 先保存起来值或者实例,然后再在后面需要用到的时候重新替换回去,甚至可以用工厂函数返回实例的局部信息实现映射成另一个对象或者属性值。这里的玩法会通过后面的案例进行阐述,这里就先不展开。官网也有很多例子可以参考。
注入消费和装饰器
Angular 中的注入可以在构造函数 constructor 内注入,也可以拿到注入器 injector 通过 get 方法获取已有的注入元素。
Angular 支持在注入的时候增加装饰器进行标记,
@Host() 来限制冒泡
@Self() 限制为元素自身
@SkipSelf() 限制为元素自身以上
@Optional() 标记为可选
@Inject() 限制为自定义 Token 令牌
这里有一篇文章《@Self 还是 @Optional @Host?《非常生动形象地展示父子组件间如果使用了不同的装饰器,最后会命中的实例有什么不同。
图 3 不同注入装饰器的筛选结果
补充:宿主视图和 @Host
这几个装饰器里面,最不好理解的可能就是 @Host 了,这里补充一些 @Host 的具体说明。
官方对 @Host 装饰器的解释是
...从任何注入器检索依赖项,直到到达主机元素
Host 在这里是宿主的意思,@Host 这个装饰器将会限定查询的范围在宿主元素(host element)以内。什么是宿主元素呢?假如 B 组件是 A 组件模板使用的组件,那么 A 组件实例就是 B 组件实例的宿主元素。组件模板产生的内容称为 View(视图),同一个 View 对于不同组件来说可能是不同视图。如果 A 组件在自己的模板范围内使用 B 组件(见图 4),A 的模板内容形成的视图(红框部分)对 A 组件来说就是 A 的内嵌视图,B 组件在这个视图内,所以对 B 来说这个视图就是 B 的宿主视图。装饰器 @Host 就是限定搜索范围为宿主视图之内,找不到不会再进行冒泡了。
图 4 内嵌视图和宿主视图
案例和玩法
下面我们通过真实的案例,来看看依赖注入到底是怎么运转起来的,怎么排查错误,以及还能怎么玩。
案例一: 模态窗创建动态组件,找不到组件问题
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 问题。保证在两层搜索链上,这个问题就可以解决了。
知识点小结:模块注入器继承和查找范围。
案例二:CdkVirtualScrollFor 找不到 CdkVirtualScrollViewport
通常我们多个地方使用同一个模板的时候,会通过 template 提取公共部分,之前 DevUI Select 组件开发的时候开发者想将共用的部分抽取出来报错了。
图 7 代码移动和找不到注入报错
这里是由于 CdkVirtualScrollFor 指令需要注入一个 CdkVirtualScrollViewport,然而元素注入 injector 继承体系是继承静态 AST 关系的 DOM,动态的不行,所以发生以下查询行为,查找后报失败。
图 8 元素注入器查询链查找范围
最后解法::要么 1)保持原代码位置不变,要么 2)需要把整个模板内嵌就能找到了。
图 9 内嵌整块模块使得能 CdkVitualScrollFo 能找到 CdkVirtualScrollViewport(解法二)
知识点小结:元素注入器的查询链条是静态模板的 DOM 元素祖先。
案例三: 表单校验的组件被封装到子组件内无法校验问题
这个案例来自这篇博客《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 搭配的妙用。
案例四:拖拽模块提供的服务,由于懒加载,不是单例了,导致无法互相拖拽
内部的业务平台有涉及跨多个模块的拖拽,由于涉及了 loadChildren 懒加载,每个模块会单独打包 DevUI 组件库的 DragDropModule,该 Module 提供了一个 DragDropService。拖拽指令分为可拖起指令 Draggable 和可放置指令 Droppable,两个指令通过 DragDropService 进行通信。本来引入同一个模块使用模块提供的服务是可以通信的,但是懒加载后 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 指令能够跨懒加载模块进行通信了。
知识点小结:元素注入器优先级高于模块注入器。
案例五: 局部主题功能场景怎么让下拉菜单附着在局部问题
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 用于部分组件和模态框抽屉板凳,也需要进行相应的替换。最终能实现弹窗弹出层等完美支持局部主题。
知识点小结:好的抽象模式可以使得模块可替换,实现优雅的切面编程。
案例六: CdkOverlay 要求在滚动条地方加上 CdkScrollable 指令,但无法给入口组件最外层加上该指令如何处理
到了最后一个案例,想讲一点不太正规的做法,以方便大家理解 provider 的本质,配置 provider 本质上就是让它帮你做实例化或者映射到某个存在的实例。
我们知道如果使用了 cdkOverlay,如果我们想要弹出框跟随滚动条滚动也能悬浮在正确的位置的话,我们就需要给滚动条加上 cdkScrollable 的指令。
还是上一个例子的场景。我们整个页面是通过路由加载进来的,贪图简便我把滚动条写在了组件的 host 了。
图 22 内容溢出滚动条把 overflow:auto 写在了组件:host 里
这样我们就遇到了一个比较难搞的问题,模块是 router 定义指定过来的,也就是没有任何地方显式地调用<app-theme-picker-customize></app-theme-picker-customize>,那 cdkScrollable 指令该怎么加进去呢?解法如下,这里隐藏掉了部分代码只留下核心代码。
图 23 通过注入创建实例并手动调用生命周期
这里通过注入生成了一个 cdkScrollable 的实例,并在组件的生命周期阶段同步地调用生命周期。
这种解法不是正规手段,但确实解决了问题,这里就作为一种思路和探索留给读者品味。
知识点小结: 依赖注入配置提供商可以实现创建实例,但要注意实例将当做普通 Service 类对待,无法拥有用完整生命周期。
更多玩法: 自定义替换 platform,实现让 Angular 框架跑在终端上的交互
可以参考这篇博文:《在终端中渲染角度应用程序》
图 24 替换 RendererFactory2 渲染器等内容, 让 Angular 运行在终端终端终端上
作者通过替换 RendererFactory2 等渲染器,让 Angular 应用可以跑在终端终端终端上。这就是 Angular 设计的灵活度,连 platform 都可以替换掉的强大的灵活。详细的替换细节可以查看原文章,这里就不展开了。
知识点小结:依赖注入的强大之处,在于提供商可以自行配置,最后实现替换逻辑。
总结
本文介绍了控制反转的依赖注入模式及其好处,介绍了 Angular 中依赖注入是如何查找依赖,如何配置提供商,如何用限定和过滤作用的装饰器拿到想要的实例,进一步通过 N 个案例分析如何结合依赖注入的知识点来解决开发编程中会遇到的问题。
正确的理解依赖查找过程,我们便能在准确的位置配置上提供商(案例一二),截胡替换其他实例为单例(案例四、五),甚至能跨嵌套组件包裹的限制衔接上提供的实例(案例三)或者用提供的方法曲线实现指令实例化(案例六)。
其中案例五看似是简单的替换,但是要能写出能被替换的代码结构需要对注入模式有深入的了解,并对各个功能有比较好的合理的抽象,抽象不得当,就无法发挥依赖注入的最大功效了。注入模式为模块可插拔,插件化,零件化提供了更多可能的空间,降低耦合度,增加灵活性,是模块之间能更加优雅、协调地一起工作。
依赖注入功能的强大,除了能完成优化组件通信路径,更重要的是还能实现控制反转,给封装好的组件暴露更多切面编程的切面,一些业务特殊逻辑的实现也可以变得灵活起来。
版权声明: 本文为 InfoQ 作者【华为云开发者联盟】的原创文章。
原文链接:【http://xie.infoq.cn/article/29d8636913da0ec936498fd06】。文章转载请联系作者。
评论