在 React 中实现 Angular 的依赖注入

3,341 阅读15分钟

翻译自implementing Angular's Dependency Injection in React. Understanding Element Injectors.

最近我一直在写关于Angular的博客,这不是偶然的! Angular是一个了不起的框架,为前端技术带来了大量创新,背后有一个伟大的社区。 与此同时,我正在开展的项目有各种不同的需求,有时我需要考虑不同的选择。

我过去使用的另一项伟大技术是React。 我不想将它与Angular进行比较; 我敢肯定,当其中一个比另一个更适合时,有各种各样的情况,反之亦然。 我尊重Angular和React的理念,我喜欢看他们如何推动Web向前发展!

这篇博文与我最近做的一个有趣的实验有关—— 在 React 中实现 Angular 的依赖注入机制。 在我的 GitHub 帐户上可以找到一个包含 react-dom 的分支的演示。

React DI

免责声明

对于下面的帖子,我并不是在暗示在 React 中使用 Angular 的 DI 是一个好主意还是一个坏主意; 这完全取决于最适合你的编程风格。 这里的例子不是我在生产中使用的,我不建议你这样做,因为它没有经过很好的测试,并且直接修改了 React 的内部。

最后,我并不是暗示 Angular 的依赖注入是我们可以用来编写完全解耦的代码的唯一方法,或者我们需要面向对象的范例来做到这一点。 如果我们在设计过程中投入足够的精力,我们可以在任何范例和框架中编写高质量的代码。

这篇文章是基于我在周日下雨的晚上做的一个小实验。 这篇文章只是为了学习。 它可以帮助你理解依赖注入如何用于现代用户界面的开发,最终,让你对 React 和 Angular 的内部结构有一些了解。

依赖注入入门

如果你已经熟悉依赖注入的概念,以及如何使用它,你可以直接跳到 “Element injectors”.

依赖注入是一个强大的工具,它带来了很多好处。 例如,DI 有助于遵循单一责任原则(Single Responsibility Principle,SRP) ,它不会将给定的实体与其依赖关系的实例化逻辑耦合起来。 开闭原则是另一个 DI 摇滚的地方! 我们可以使给定的类仅依赖于抽象接口,通过配置它的注入器,我们可以传递不同的抽象实现。

接下来,让我们来看看依赖反转原则 是怎么说的:

A.高级模块不应该依赖于低级模块。 两者都应该依赖于抽象。

B.抽象不应该依赖于细节。 细节应该依赖于抽象。

虽然 DI 不直接强制执行它,但它可以使我们倾向于编写遵循这一原则的代码。

几天前,我发布了一个名为 injection-js 的库。 这是一个提取的Angular的依赖注入机制。 由于 injection-js 来自 Angular 的源代码,它经过了良好的测试并且已经成熟,因此您可以尝试一下!

$ npm i injection-js --save

使用依赖注入

现在,让我们看看如何使用这个库! 但在此之前,让我们熟悉其背后的核心概念。 injection-js (和 Angular)的依赖注入的是 injector。 它负责为单个依赖项的实例化保存不同的providers。 这些依赖项被称为providers。 对于每个providers,我们都有一个相关的token。 我们可以将标记看作单个依赖关系和提供者的标识符(提供者和依赖关系之间有1:1的映射或双映射)。 我们使用注入器的标记来请求任何依赖项的实例。

下面是一个例子:

// 我们可以使用'@angular/core' 导入相同的代码。
import { ReflectiveInjector, Injectable } from 'injection-js';

class Http {}

@Injectable()
class UserService {
  constructor(private http: Http) {}
}

const injector = ReflectiveInjector.resolveAndCreate([
  { provide: Http, useClass: Http },
  { provide: UserService, useClass: UserService },
]);

injector.get(UserService);

下面的示例使用 injection-js,但我们也可以使用@angular/core。 以上我们引入 ReflectiveInjector@injectableReflectiveinjector 有一个名为 resolveAndCreate 的工厂方法,它允许我们通过传递一组providers来创建一个injector。 在这种情况下,我们为类 HttpUserService 提供providers。

我们通过设置提供者的提供属性的值来声明与给定provide关联的token。 上面我们指示注入器通过直接调用它们的构造函数来实例化各个依赖项。 这意味着,如果我们想获得一个 Http 实例,注入器将返回new Http()。 如果我们想获得一个UserService,注入器将查看其构造函数的参数,并首先创建一个 Http 实例(或者使用一个已经存在的实例,如果它已经可用的话)。 之后,它可以使用已经存在的 Http 实例调用 UserService 的构造函数。

最后,decorator@Injectable 什么也不做。 它只是强制 TypeScript 生成关于 UserService 接受的依赖项类型的元数据。

注意,为了让 TypeScript 生成这样的元数据,我们需要将 tsconfig.json 中的 emitDecoratorMetadata 属性设置为 true

由于配置注入器的语法看起来有点多余,我们可以使用以下提供者的定义:

const injector = ReflectiveInjector.resolveAndCreate([
  Http, UserService
]);

在某些情况下,我们要声明的依赖项仅仅是需要注入的值。 例如,如果我们想注入一个常量,使用该常量的构造函数作为标记是不方便的。 在这种情况下,我们可以将该标记设置为任何其他值 -remember- 该标记只不过是一个标识符:

const BUFFER_SIZE = 'buffer-size';

const injector = ReflectiveInjector.resolveAndCreate([
  Socket,
  { provide: BUFFER_SIZE, useValue: 42 }
]);

在上面的示例中,我们为BUFFER_SIZE 标记创建了一个提供者。 我们声明,一旦需要token BUFFER_SIZE,我们希望注入器返回值42。 下面是一个例子:

injector.get(BUFFER_SIZE); // 42

在上面的例子中还有两个细节:

  1. 如果我们与另一个名为buffer-size的令牌发生名称冲突怎么办?
  2. 如果它的类型不明确,我们应该如何声明给定类接受BUFFER_SIZE作为依赖。

We can handle the first problem by using OpaqueToken. This way our BUFFER_SIZE definition will be: 我们可以使用 OpaqueToken 来处理第一个问题。 这样我们的BUFFER_SIZE定义就是:

const BUFFER_SIZE = new OpaqueToken('BufferSize');

OpaqueToken 的实例是 uniques 值,当我们不能使用类型时,Angular 的 DI 机制使用它们来表示标记。

对于第二个问题,我们可以使用 angular/injection-js@inject 参数修饰符来声明一个依赖项,该依赖项的令牌不是一个类型:

const BUFFER_SIZE = new OpaqueToken('BufferSize');

class Socket {
  constructor(@Inject(BUFFER_SIZE) public size: number) {}
}

const injector = ReflectiveInjector.resolveAndCreate([
  Socket,
  { provide: BUFFER_SIZE, useValue: 42 }
]);

injector.get(Socket).size; // 42

注入器的层次结构

在 AngularJS 中,所有的提供者都存储在一个扁平的结构中。在依赖注入机制的Angular2 和以上 有一个大的改进,我们可以建立一个分层结构的注入器。 例如,让我们看看下面的图片:

Dependency Injection Hierarchy

我们有一个根部注入器称为House,这是父级的注入器Bathroom, KitchenGarageGarage是父级的CarStorage。 例如,如果我们需要来自注入器 Storage 的 token tire 依赖项,那么 Storage 将尝试在其注册的提供程序集中找到它。 如果在那里找不到,它就会去Garage找。 如果它不在那里,Garage将在House寻找。 如果 House 找到了依赖项,它将返回给 Garage,然后返回给 Storage

上面的那棵树看起来眼熟吗? 最近大多数用于构建用户界面的框架都将其结构为组件树。 这意味着我们可以有一个负责实例化各个组件及其依赖关系的注入器树。 这样的注入器称为element injectors

Element injectors

让我们搭建一个简短的示例看看element injectors在Angular怎么做的。 我们将在 React 实现中重用相同的模型,所以让我们探索一个简单的例子: 假设我们有一个有两种模式的博弈:

  • 单人模式
  • 多人模式

当用户以单人模式玩游戏时,我们希望通过 websocket 向应用程序服务器发送一些元数据。 但是,如果我们的用户与另一个玩家对战,我们希望在两个玩家之间建立 WebRTC 数据通道,以便同步游戏。 当然,将数据发送到应用服务器也是有意义的。 使用 angular/injection-js,我们可以在多个提供者中处理这个问题,但是为了简单起见,让我们假设对于多玩家,我们只需要 p2p 连接。

因此,我们有了我们的 DataChannel,它是一个抽象类,只有一个方法和一个Observable:

abstract class DataChannel {
  dataStream: Observable<string>;
  abstract send(data: string);
}

稍后,这个抽象类可以由 WebRTCDataChannelWebSocketDataChannel 类实现。 SinglePlayerGameComponent将使用 WebSocketDataChannel,而MultiPlayerGameComponent将使用 webtcdatachannel。 但是GameComponent呢? 可以依赖于 DataChannel。 这样,根据使用的上下文,其关联的元素注入器可以通过其父组件配置的正确实现。 我们可以用下面的伪代码片段来看看 Angular 中的效果如何:

@Component({
  selector: 'game-cmp',
  template: '...'
})
class GameComponent {
  constructor(private channel: DataChannel) { ... }
  ...
}

@Component({
  selector: 'single-player',
  providers: [
    { provide: DataChannel, useClass: WebSocketDataChannel }
  ],
  template: '<game-cmp></game-cmp>'
})
class SinglePlayerGameComponent { ... }


@Component({
  selector: 'multi-player',
  providers: [
    { provide: DataChannel, useClass: WebRTCDataChannel }
  ],
  template: '<game-cmp></game-cmp>'
})
class MultiPlayerGameComponent { ... }

在上面的例子中,我们有 GameComponentSinglePlayerGameComponent 和 MultiPlayerGameComponent 的声明。 Gamecomponent 只有一个 DataChannel 类型的依赖项(我们不需要@injectable decorator,因为@Component 已经强制 TypeScript 生成元数据)。 在后面的 SinglePlayerGameComponent 中,我们将类WebSocketDataChannelGameComponent 接受的依赖标记(即 DataChannel)关联起来。 最后,在 MultiPlayerGameComponent 中,我们将 DataChannelwebtcdatachannel 关联起来。 What will happen behind the scene is shown on the image below:

Element Injectors

SinglePlayerGameComponentMultiPlayerGameComponent的组件注入器将有一个父注入器。 为了简单起见,让我们假设两者都有相同的父节点,因为这对于我们的讨论来说并不有趣。 Singleplayergamecomponent 将注册一个提供者,该提供者将把 DataChannel 令牌与 WebSocketDataChannel 类关联起来。 这个提供程序,连同 SinglePlayerGameComponent 组件的提供程序,将作为single注入器注册到图中显示的注入器中(Angular 寄存器在元素注入器中有更多的提供程序,但为了简单起见,我们可以忽略它们)。 另一方面,在图中的multi注入器中,我们将注册一个用于 MultiPlayerGameComponent 的提供者,以及一个将 DataChannelwebtcdatachannel 关联的提供者。

最后,我们有两个game注入器。 一个是在SinglePlayerGameComponent的背景下,另一个是在MultiPlayerGameComponent的背景下。 两个game注入器将注册相同的一套供应商,但将有不同的父母。 在这种情况下,game中唯一的提供者就是 GameComponent。 当我们需要游戏注入器中与 DataChannel 令牌相关联的依赖项时,首先它将查看其注册的提供程序集。 因为我们在游戏中没有 DataChannel 的提供者,它会询问它的父提供者。 如果game的父注入器是single注入器(如果我们使用 GameComponent 作为 SinglePlayerGameComponent 的视图子注入器,就会发生这种情况) ,那么我们将获得 WebSocketDataChannel 的一个实例。 如果我们需要来自game注入器的与 DataChannel 令牌相关的依赖关系,作为父注入器的是多注入器,我们将获得一个 WebRTCDataChannel 的实例。

就是这样! 现在是时候将这些知识应用到“React”的环境中了。

在React中实现依赖注入

我们需要为 React 应用程序中的组件实例化实现一个控制反转控制器(IoC)。 这意味着注入器应该负责实例化用户界面的各个构建块。 整个过程如下:

  • 每个组件仅通过在其构造函数中指定其类型,或使用@Inject参数装饰器
  • 我们将为每个部件创建一个注入器,并称之为元素注入器。 这个注入器将负责相应组件的实例化及其依赖项的实例化(它可以查询其父注入器)
  • 每个组件可以声明一组提供程序,这些提供程序将被包含到相关的元件注入器中
  • 我们将为常用的注入到任何 React 组件(例如,props、 context 和 updateQueue)的属性添加一组预定义的提供程序
  • 对于每个嵌套组件,我们将其设置为其父注入器,即其最近父亲的注入器

就是这样! 现在让我们实现它。

声明组件的服务提供商(providers)

为了声明给定组件的服务提供商,我们将使用类似于在 Angular 中使用的方法。 Angular 的组件将它们的提供者声明为传递给@Componentdecorator 的 object literal 的 providers 属性的值:

@Component({
  selector: 'foo-bar',
  providers: [Provider1, Provider2, ..., ProviderN]
})
class Component {...}

We will declare a class decorator called @ProviderConfig which using the ES6 Reflect API associates the providers to the corresponding component. 我们将声明一个名为@ProviderConfig 的类装饰器,它使用 ES6 Reflect API 将服务提供商关联到相应的组件。

export function ProviderConfig(config: any[]) {
  return function (target: any) {
    Reflect.set(target, 'providers', config);
    return target;
  };
};

可以使用如下装饰器:

@ProviderConfig([ Provider1, Provider2, ..., ProviderN ])
class Component extends React.Component {
  ...
}

创建元素注入器

本节的目的是应用前一节中列出的要点,并在 React 代码中进行最小量的更改。 此外,修改应该尽可能隔离,以便可以将它们作为单独的模块分发,从而允许使用 React with injection-js。 最后,实现是不完整的,它忽略了工厂组件的情况。 支持工厂组成部分是可能的,但不是必要的。

在内部,React 将每个组件包装在一起,再加上一大堆其他的东西,形成了一个ReactElement。 然后,它使用单个的 ReactElement 来创建特定的组件实例。

这两种情况都发生在以下文件中(我们将只探索 react-dom,忽略其他平台) :

  • react/lib/ReactElement.js - 包含用于实例化 ReactElement (createElement).
  • react-dom/lib/ReactCompositeComponent.js - 包含用于构造组件的方法

出于我们的目的,我们只对 react-dom/lib/ReactCompositeComponent.js 进行一些修改。 Js. 让我们一起探索吧!

require('reflect-metadata');
var ReflectiveInjector = require('injection-js').ReflectiveInjector;
...

_constructComponentWithoutOwner: function (doConstruct, publicProps, publicContext, updateQueue) {
  var Component = this._currentElement.type;
  var providers = [
    Component, {
      provide: 'props',
      useValue: publicProps
    }, {
      provide: 'context',
      useValue: publicContext
    }, {
      provide: 'update',
      useValue: updateQueue
    }
  ].concat(Reflect.get(Component, 'providers') || []);
  var injector;
  if (!this._currentElement._owner) {
    injector = ReflectiveInjector.resolveAndCreate(providers);
  } else {
    injector = Reflect.get(this._currentElement._owner._currentElement.type, 'injector').resolveAndCreateChild(providers);
  }
  Reflect.set(Component, 'injector', injector);

  if (doConstruct) {
    if (process.env.NODE_ENV !== 'production') {
      return measureLifeCyclePerf(function () {
        return injector.get(Component);
      }, this._debugID, 'ctor');
    } else {
      return injector.get(Component);
    }
  }
  ...

这是 React 15.4.2的分支。 上面的代码展示了我为了为每个组件创建一个注入器而必须进行的所有修改,然后使用相应的注入器实例化该组件。 让我们一步一步地研究这个代码片段。

首先,我们获得对组件类的引用。 这是通过获取属性值来实现的。 this._currentElement.type。 后来我们注册了一组提供程序。 默认设置包含组件的classproviderspropscontextupdateQueue。 后三个提供程序在实例化期间默认传递给每个组件的构造函数。 稍后,我们还向这组提供程序添加@ProviderConfig 声明的提供程序。 为此,我们使用 ES6 Reflect API

作为下一步,我们检查当前组件的元素是否具有所有者。 如果没有,这意味着我们处于根组件,我们需要创建根注入器。 为此,我们使用 ReflectiveInjector 类的静态 resolveAndCreate 方法。如果当前元素具有所有者,我们通过调用所有者的注入器的 resolveAndCreateChild 实例化一个子注入器。

因为我们希望创建的注入器对子组件可用,所以我们将其作为 Reflected API 中的一个条目。

请注意,这段代码操纵 React 的内部并使用私有属性,前缀为_ 我不推荐它用于生产,因为它没有经过很好的测试,不包括工厂组件,并且很可能在将来的 React 版本中不起作用。

使用 React with DI

下面是一个快速演示,演示了我们如何在 React 中使用 DI 和所描述的实现:

import * as React from 'react';
import {Inject} from 'injection-js';
import {ProviderConfig} from '../providers';
import {WebSocketService} from '../websocket.service';

@ProviderConfig([ WebSocketService ])
export default class HelloWorldComponent extends React.Component<any, any> {
  constructor(@Inject('update') update, ws: WebSocketService, @Inject('props') props: any) {
    super(props);
  }
  
  render(){
    return <div>Hello world!</div>;
  }
}

与传统方法相比,我们使用启用 DI 的组件的方式有一个重要的区别——目标组件接受的参数不是定位注入的,而是基于它们在构造函数中声明的顺序。

从上面的例子可以看出,HelloWorldComponent 接受 树 参数,所有参数都通过注入injection-js 的 DI 机制注入。 与原始组件 API 不同,依赖项将按照其声明的顺序注入。

总结

在这个实验中,我们看到了如何使用反应中的角依赖注入机制。 我们解释了 DI 是什么以及它带来的好处。 我们还看到了如何在使用元素注入器开发用户界面的上下文中应用它。

在此之后,我们通过直接修改库的源代码来完成 React 中元素注入器的进行实现。

虽然这个想法看起来很有趣,并且可能适用于现实应用程序,但是本文的示例还没有为生产做好准备。 如果您能在下面的评论部分提供反馈和想法,我将不胜感激。