Angular/TypeScript中那些值得了解的知识——依赖注入

1,133 阅读11分钟

写这篇文章的初衷是为了打个广告,文章写得好不好不重要,请大家至少看看文末的广告😂

前段时间我看到 @rhlin 老师发表的文章,写得很好,不过我觉得她介绍的依赖注入比较进阶,我想补充介绍一些基础知识;又想到了依赖注入对前端同学来说,是个“舶来品”,可能不那么好理解。这也很像Angular的现状:很多人都觉得Angular太难了,且国内绝大多数公司都不用,不值得学。不过,Angular的难实际上是因为它设计时就考虑到了很多复杂场景,我私以为,不想了解Angular算是人之常情,不过这些好的设计,比如依赖注入,RxJs,面向切面编程,还是值得了解下的。我准备写个系列的文章,结合我自己的理解,由浅到浅的给大家介绍下这些优秀的思想、由来、应用场景等,今天就来说下————依赖注入。

依赖注入的背景——面向对象

“依赖注入”这个名词来自Java,Java是著名的面向对象语言,也就是这样的思维:比如有个Person对象——PersonComponent,它需要其他对象为它提供服务,如出行——那它就需要一个Car对象(姑且叫它CarService)来为它服务,而Person对象为了很好的使用Car对象的方法,往往需要写出类似这样的代码:

class PersonComponent {
 private car: CarService;
 
 constructor() {
  this.car = new CarService('aito', 'm7');
 }
 
 go(currnt: string, target: string) {
  this.car.run(current, target);
 }
}

简单说下这段代码,PersonComponent相当于我们的业务代码,CarService是依赖的服务,相当于前端组件中依赖的提供i18n的Service或者后端组件中依赖操作数据库的Service,CarService作为PersonComponent的一个成员,在Person被创建时被一同创建,并在该业务组件执行相应代码逻辑时调用该成员方法实现,如上述代码中PersonComponent的go方法,实际通过了CarService的run方法取实现。前面的话不重要,重要的是————我们称CarService是PersonComponent的依赖

依赖注入的引入——面向对象的写法有什么问题

我们普遍认为,上面的写法不好,这其中最大的缺点是:业务组件需要关心依赖的实现方式。以上述实例为例,如果CarService的代码逻辑做了变动,比如当前构造一个CarService,需要传入车企品牌和具体型号两个参数,如果随着代码演变,构造CarService时还需要传入第三个参数——消耗能源类型,上述构造函数就要改成:

class PersonComponent {
 // ...
 
 constructor() {
  this.car = new CarService('aito', 'm7', 'electric');
 }
 
 // ...
}

而如果PersonComponent和CarService是两个人看护的,CarService因为业务调整造成的修改,就会造成PersonComponet的代码报错,简而言之,业务代码最好能够不关心依赖是如何被构造出来的,而是直接使用就好。于是就催生了类似这样的写法:

class PersonComponent {
 private car: CarService;
 // 在构造时传入car
 constructor(car: CarService) {
  this.car = car;
 }
 
 go(currnt: string, target: string) {
  this.car.run(current, target);
 }
}

在需要使用PersonComponent时,可以在使用处先创建一个CarService,传给PersonComponent即可

main() {
 // 假设业务需要在这里调用PersonComponent
 const car = new CarService('aito', 'm7', 'electric');
 const person = new PersonComponent(car);
}

这样不管CarService怎么变,首先,PersonComponent都不会报错啦,因为对于PersonComponent而言,它是接受了一个创建好的CarService对象,它的构造函数中就不需要关注CarService的实现了,这就是依赖注入的思想————本质上,就是解耦。把PersonComponent对CarService的依赖,通过其他各类方式注入进来,总之,不在业务代码中直接构建依赖服务,屏蔽业务代码对服务的依赖。

为什么需要依赖注入框架——将依赖构造问题向上抛的做法还有什么不便之处

看完上面介绍的依赖注入,大家会发现这样的问题:比如CarService的构造方式改成通过单例模式创建了,无法直接调用其构造方法,PersonComponent是没有报错,但是调用PersonComponent的,也即上述的main函数,就会报错了。也就是说,依赖注入的思想,其实是把问题抛给业务代码的上一级?或者说,是甩锅行为?

确实可以这么说(甚至可以说,很多设计模式、原则全都是甩锅行为😂)。我们可以认为依赖注入的思想就是把问题向上抛,而向上抛的尽头,就是程序的入口函数。也就是需要在应用启动时,将所有依赖构造完毕,并将他们推给所有需要的子类。当然,手动地去这样管理所有能提供服务的依赖太过于繁琐,因此,我们往往会通过一些框架实现注册依赖,供子类使用。没错,这类依赖注入框架就是类似Java中的Spring,或者前端的Angular了。它用起来的效果大概是这样的(下图来自 @rhlin 老师的文章):

1_di_1659753683136.png

依赖注入框架怎么用——在Angular中的使用简述和涉及的设计模式

以我们上面的CarService为例,怎样告知Angular,我需要在程序入口中构造该依赖,并被其它组件使用呢?你需要写出类似这样的代码:

// src/app/serivce/car.service.ts
// 重点是要写接下来这4行
import { Injectable } from '@angular/core';
@Injectable({
 providedIn: 'root',
})
export class CarService {
 // ...
}

这样写就相当于告诉Angular,我需要在 Root 注入器中完成对这个依赖的创建工作,也即在整个应用任何地方都可以使用,而且处处使用的都是这一个对象。这里它就又带来了一个额外的好处:单例模式。也就是说整个应用中我需要的这个依赖,只需要创建一次,减少了频繁创建、销毁对象的内存开销。当然,如果你不希望你的依赖是单例的,也可以额外修改。不过providedIn: root和单例模式,已经足以满足大多数的使用场景了。同时,使用 providedIn 的写法相比传统的在 Module 中设置 providers 数组的写法,可以让服务在真正被其它组件或服务注入时才编译到最后的 js 中,相当于提供了tree shaking机制。

感受一下,依赖注入框架的成份

林语堂先生在其著作《苏东坡传》中这样描绘苏轼的魅力:

正如女人的风情、花朵的美丽与芬芳,容易感受,却很难说出其中的成份。

前面介绍了依赖注入的思想和用法,接下来我们来感受下,它是怎样的成份:它是一个巨大的Map,我们每往依赖注入框架中加入一个依赖的对象,都伴随了一个唯一的id(或者叫token),使得框架可以快速通过id找到需要使用的依赖。所以你会发现,早先的Angular版本中,依赖注入大多是这样在module文件中写的:

providers: [CarService]

这时Angular框架会把它转为

providers: [{provide: CarService, useClass: CarService}]

相当于通过CarService这个整个应用中“独一无二”的类型,或者说是token,就能知道任何时候有需要用到CarService时,用的是上面写了providedIn: root的那个服务。它的实现大致如下:

// src/app/app.component.ts
import { Component, ReflectiveInjector } from '@angular/core';
// ...export class AppComponent {
 // ...
 // 假设需要在app.component.ts这个入口自行实现依赖注入功能
 constructor() {
  const injector = ReflectiveInjector.resolveAndCreate([
  {
    provide: PersonComponent, useClass: PersonComponent
  }, {
    provide: CarServiceuseFactory: () => {
      return new CarService('aito', 'm7', 'electric');
   }
  }
 ]);
  
  // 通过该map构造Person
  const person = injector.get(PersonComponent);
 }
}

简单解释下上述代码:首先我们有个巨大的Map盛放需要被管理的对象,并且能够确认清楚如何找到想要的某个依赖——ReflectiveInjector就能完成这个工作,上面的provide和useXXX可以看成键值对,获取其中一个值时只需要通过Map的get方法即可,键值对值一样时,可以用类似解构的方式简写。这也是它是单例的原因之一,每个类型的构造只做一次,如果需要变成多例,可以在return时返回一个function,而不是具体的对象。

依赖注入思想在前端其他场景的使用

依赖注入的思想还是更常用于后台,那么有更贴近前端的使用场景吗?

有的。

比如在深层次的子孙组件需要的数据,可能是在父级组件中获得的,按照固有的思维,需要这样一层层传递到子孙组件中:

2_page1_1659685083064.png 通过React中的useContext,使用Provider和Consumer,即可写出这样的代码:

import React, { Component, useState, createContext } from 'react';
const MyContext = createContext();
function App() {
  const [xxx, setXxx] = useState(0);
  return (
    <div>
     // 通过如下Context,可以在其子组件中随时获取value的值
      <MyContext.Provider value={xxx}>
        <Child />
      </MyContext.Provider>
    </div>
 )
}
​
export default App;

无须通过层层传递传到子孙组件,通过Provider和Consumer即可获取值。

function Child() {
    return(
      <MyContext.Consumer>
       { value => <h1>value: { value }</h1> }
      </MyContext.Consumer>
   )
}

相当于优化成这样:

3_page2_1659685100072.png

怎么样,是不是和上面 @rhlin 老师的图意思一样?其实所有类似这样的,将数据和组件关系解耦的思路,都暗合了依赖注入的思想。

从“前端人做前端事”到“更多的前端人做更广的前端事”

如果说谁是最能代表前端人做前端事的框架,我选React。它的jsx令无数前端人感觉灵活,畅快,自由,对jsx的使用体现着你对Javascript的热爱,这也能解释为什么solid.js算得上是前端世界风头无两的框架,因为首先它就很像React;而Angular大概就是反向React了,它本来总想给你讲讲什么叫controller什么叫service,好不容易改头换面了,又要先跟你唠唠什么是泛型什么是强类型,这确实太劝退前端人了。相较而言,Vue的一些语法和Angular相似,但对前端人而言,就没有那么多舶来的概念,就更容易受到大家的喜爱。

不过,如果你对React关注足够多,你会发现,React的演进过程中,也在不停地引入新概念,比如React 16的调度器Scheduler,React 18的ConCurrent、Transition,都是在将其他领域的概念引入前端世界。当然,换个角度讲,这说明前端世界的边界越来越大了,它包含的内容越来越多了,同样的,可能需要前端人了解的概念、涉足的领域也就更多了。举个最简单的例子,十年前,用户请求你的网站首页需要跳转到相应index页面这件事,是Java等后端框架判断、处理的,而现在,这种事都交给前端框架了。服务端框架的这个能力被前端框架生生“抢”走了。就像有人问“魔术师”约翰逊,你和伯德是怎么把代表NBA最强者的“权杖”传到后来者乔丹手里的,他坦言,没有和谐的交接仪式,权杖是从他手上被粗鲁地“抢”走的。

4.png

如果要用一句专业的话解释“前端人做前端事”,我觉得可能是:

Any application that can be written in JavaScript, will eventually be written in JavaScript.

(任何可以用JS来编写的应用,最终都将用JS来编写)

如果要说前端世界的未来,那我觉得是

对不起,英语水平欠奉,此处略去英文

(任何可以用JS来编写的应用,最终都将用JS来编写;伴随着的是JS能做得更多、更好,且JS的使用者能做的更多、更好)

打个广告

如果你看到这里,觉得是可以多了解一些Angular,欢迎你关注我们的掘金账号,还会介绍更多Angular/TypeScript中值得了解的概念,也欢迎你的下个项目中使用它。当然,使用前端框架,一定需要一个好的UI组件库。很多时候一门语言的兴起往往伴随着一个成熟配套的组件库,比如AntD之于React,ElemenUI之于Vue,我们团队也有个Angular的UI组件库————TinyUI————即将开源,在你使用开发下一个Angular的项目时,完全可以考虑使用它。它随Angular从v1走到v14,八年历经4个重大版本迭代,服务了无数华为内部项目。下个月,它终于准备好和更多的朋友见面了,欢迎对Angular、UI组件库感兴趣的小伙伴了解,使用,以及和我们互动。当然,你也可以微信搜索我们的小助手: opentiny,拉你进群,了解它最新的动态。