用前端的角度来看看控制反转和依赖注入,一步步告别API调用工程师!

2,280 阅读7分钟

在前面的文章中已经多次提及到 控制反转依赖注入 这两个概念了,在这之前也只是随便说了两句,并没有深入讲解这两个陌生的概念到底是啥,那么从今天的文章中将会讲解一下这两个概念。

控制反转和依赖注入的四个问题

当我刚开始看到这短短的八个字的时候,我的脑海里已经出现了四个问题,它们分别是:

  • 控制什么?
  • 反转什么?
  • 依赖什么?
  • 注入什么?

那么接下来我们就带着这四个疑问开始本章内容的学习。

控制反转和依赖注入

控制反转(Inversion of Control,缩写为 IoC),是面向对象编程中的一种设计原则,可以用来减低计算机代码之间的耦合度。

在开发中,IoC 意味着你设计好的对象交给容器控制,而不是使用传统的方式,在对象内部直接控制。

在传统的程序设计中,我们直接在对象内部通过 new 的方式创建对象,是程序主动创建依赖对象,而 IoC 是有专门一个容器来创建这些对象,即由 Ioc 容器控制对象的创建,使得 IoC 容器控制了外部资源的获取。通过容器来帮忙创建以及注入依赖对象,对象只是被动的接受依赖对象,所以是反转了,而反转的对象是依赖对象的获取。

在软件工程中,依赖注入(dependency injection,缩写为 DI)是一种软件设计模式,也是实现控制反转的其中一种技术。这种模式能让一个对象接收它所依赖的其他对象。依赖 是指接收方所需的对象,注入 是指将 依赖 传递给接收方的过程。

语言不好解释,直接看代码吧:

// 安全气囊
class AirBags {
  public count: number;
  public delay: number;

  constructor(count: number, delay: number) {
    this.count = count;
    this.delay = delay;
  }

  deploy(event: string) {
    console.log(`${this.count} AriBag deployed ${event}`);
  }
}

// 引擎
class Engine {
  public displacement: number;
  public cylinder: number;
  public status: "started" | "stopped" = "stopped";

  constructor(displacement: number, cylinder: number) {
    this.displacement = displacement;
    this.cylinder = cylinder;
  }

  start() {
    console.log("engine started");
    this.status = "started";
  }

  stop() {
    console.log("engine stopped");
    this.status = "stopped";
  }
}

class Car {
  public brand: string;
  public engine: Engine;
  public airBags: AirBags;

  constructor(
    brand: string,
    displacement: number,
    cylinder: number,
    count: number,
    delay: number
  ) {
    this.brand = brand;
    this.engine = new Engine(displacement, cylinder);
    this.airBags = new AirBags(count, delay);
  }

  run() {
    this.engine.start();
  }

  accident() {
    this.engine.stop();
    this.airBags.deploy("accident");
  }
}

const car = new Car("拖拉机", 8, 3000, 4, 30);

car.run();
car.accident();

在上面的代码中定义了一个车的实例,这是一个传统的开发方式,这时拖拉机已经跑起来了,当拖拉机撞到了大树上安全气囊会弹出。

在这里的 Car 类控制的是两个子类,假如当我们需要修改安全气囊,那么我们就要不断的往安全气囊的类中添加属性和方法,当然这也是应该的。

但是要改变这些,我们也不得不改变这个 Car 类,因为它也要修改,从而传入不同的参数,这个时候的控制权已经全部掌握在 Car 类,把这个控制权交给另外那两个类,那么这个实现方法就是依赖注入

接下来我们对这些代码进行修改,前面两个子类不用修改,只需修改 Car 类就可以了,具体代码如下所示:

class Car {
  public brand: string;
  public engine: Engine;
  public airBags: AirBags;

  constructor(brand: string, engine: Engine, airBags: AirBags) {
    this.brand = brand;
    this.engine = engine;
    this.airBags = airBags;
  }

  run() {
    this.engine.start();
  }

  accident() {
    this.engine.stop();
    this.airBags.deploy("accident");
  }
}

const engine = new Engine(8, 3000);
const airBags = new AirBags(4, 30);
const car = new Car("拖拉机", engine, airBags);

car.run();
car.accident();

现在这个时候控制权已经由 Car 转换为 Engine 来实现了依赖注入。这个设计模式就相当于你电脑的接口,它给你提供了这些接口,你可以扩展不同的东西,你可以添加鼠标,你也可以添加键盘,你还可以添加一个音响。

Angular中的控制反转与依赖注入

Angular 2+ 的版本中,控制反转与依赖注入便是基于此实现,现在,我们来实现一个简单版:

import "reflect-metadata";

type Constructor<T = any> = new (...args: any[]) => T;

const Injectable = (): ClassDecorator => (target) => {};

class OtherService {
  moment = 7;
}

@Injectable()
class TestService {
  constructor(public readonly otherService: OtherService) {}

  testMethod() {
    console.log(this.otherService.moment);
  }
}

const Factory = <T>(target: Constructor<T>): T => {
  const providers = Reflect.getMetadata("design:paramtypes", target);
  const args = providers.map((provider: Constructor) => new provider());

  return new target(...args);
};

Factory(TestService).testMethod(); // 7

在 React 中使用依赖注入

在我们的 React 项目中有以下这段代码:

import React, { useState } from "react";

const App = () => {
  console.log("大靓仔也被渲染了");
  const [count, setCount] = useState(175);
  return (
    <>
      <h1>moment 的身高{count}</h1>
      <button onClick={() => setCount(count + 1)}>点击增加身高</button>
      <Component />
    </>
  );
};

export default App;

const Component = () => {
  console.log("靓仔被渲染了");
  return <div>我是靓仔,你是叼毛</div>;
};

我现在的需求就是希望父组件 state 更新的时候不会触发子组件重新渲染,因为我们并没有使用过子组件,现在这些代码存在的问题就是当父组件发送渲染的时候子组件也跟着父组件一起渲染。

image.png

那么有什么办法可以避免重复渲染呢?我们使用控制反转就可以解决这个问题。

在这里我们对这些代码进行修改,首先我们要在父组件和子组件中间添加一个 IoC 组件:

import React, { useState } from "react";

const App = () => {
  console.log("大靓仔也被渲染了");

  return (
    <Factory>
      <Component />
    </Factory>
  );
};

export default App;

const Component = () => {
  console.log("靓仔被渲染了");
  return <div>我是靓仔,你是叼毛</div>;
};

const Factory = ({ children }) => {
  const [count, setCount] = useState(175);

  return (
    <>
      <h1>moment 的身高{count}</h1>
      <button onClick={() => setCount(count + 1)}>点击增加身高</button>
      {children}
    </>
  );
};

在这个 IoC 组件中,我们把原先父组件中的内部状态迁移到了 IoC 组件中,其余不依赖的部分则通过组件的 children 属性传递给 IoC 组件,具体效果如下图所示:

动画.gif

React 中组件最终都会被转换为一个 fiber 节点,最终形成一棵 Fiber 树,判断节点是否可以复用有以下几个条件:

  • Fiber 节点的 type 属性是否发生变化;
  • Fiber 节点的 props 属性是否发生变化;
  • Fiber 节点的 state 是否发生变化;
  • Fiber 节点的 context 是否发生变化;

如果以上的条件都为否,那么就可以判断这个节点没有发生变化不需要重新渲染,React 每次触发更新都会用上一次生成的旧 Fiber 树与新的 Fiber 树,过程中再判断上述几个条件,找出发生了变化的 Fiber 节点,打上标记,最后再将其更新到页面上。

React 触发更新之后,会从当前触发更新的节点开始向上对其所有的父节点打上有节点需要更新的标记,并判断子节点是否需要更新,用当前这个判断条件再加上前面的四个条件,如果只满足那四个条件,则表示当前节点没有发生变化可以直接进行复用,如果连同满足第五个则表示整个 Fiber 树都可以跳过对比直接复用。

在这个代码例子中,父组件中满足上述原有条件中的四个,但 Factory 组件中存在状态更新,并不满足第五个额外条件,所以会进入复用逻辑但不会跳过后续对比,在复用逻辑中复制上一个更新它的 Factory 作为本次更新的子 fiber 节点,这个复制的过程会连带旧节点中的 props 一起赋值给新节点。

等对比进行到 Factory 组件时,此时它的 Fiber 节点是由父组件在上一次更新中直接复制的 Fiber 节点,所以 props 会完全相同,但是自身的 state 发生了变化,不会进入复用逻辑,而是重新调用生成新的 Fiber 节点。而 children 来自于父组件,并不会重新创建。

等对比进行到 Component 属性对应的节点时,发现其完全满足上述判断的条件包括第 5 个额外条件,就会跳过其所有的子节点对比直接复用。

参考

本文正在参加「金石计划」