前端开发中的依赖倒置与控制反转(IoC):理念与实践

324 阅读11分钟

各位大佬早上好,中午好,晚上好

要说前端三大框架中,谁的思想最先进,谁应用的设计模式最多,那非Angular莫属了,Angular框架以其创新的设计理念和广泛采用的设计模式而著称。志哥我曾经使用Angular进行过组件库的封装、第三方库的集成,并且用Angular成功开发了一个从零到一构建的低代码平台。Angular不管是从设计还是组件化模块化的封装都比较科学和超前,遵循Angular的代码组织规范就可以写出易于维护和模块化的代码。

今天我们从学习设计模式IoC然后怎么通过Vue3和Angular落地,写出领导夸赞,同事羡慕的代码。

什么是IoC

控制反转(IoC),其完整表述为 Inversion of Control,亦可称作“依赖倒置”。我的理解就是:控制权的转移。这一设计理念遵循以下三项核心原则:

  1. 在模块化设计中,高层模块不应直接依赖于底层模块,而应共同依赖于抽象层。
  2. 抽象层不应绑定于具体实现,相反,具体实现应当基于抽象层构建。
  3. 编程实践中,应优先考虑接口编程,而非针对具体实现编程。

举一个例子,子组件(底层模块)所需要的数据、服务、逻辑等自己不提供,而是由父组件通过父子传参或者依赖注入再或者一个容器来提供(高层模块),而提供给底层模块需要按照一定的规则来提供(抽象层)。

这里的依赖关系是:底层模块 --> 高层模块 --> 抽象层

下面我将用常规的写法和IoC理念来举一个实例:

某天小米汽车的产品经理说我们要开发一个三维场景来给客户实现小米汽车SU7的选配,要求123。。。

作为牛马的我们很容易就想到用threejs来实现这个功能需求。

这里我找到了一个炫酷动画:gamemcu.com/su7/

传统方式实现

在Three.js中,实例化一个应用通常涉及到创建一个场景(Scene)、相机(Camera)、渲染器(Renderer)以及添加各种物体和光源。

在主文件中:

import * as THREE from 'three';

// 创建场景
const scene = new THREE.Scene();

// 创建相机
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);

// 创建渲染器
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 创建一个立方体(引入汽车模型,这里用立方体代替)
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

// 设置相机位置
camera.position.z = 5;

// 渲染场景
function animate() {
  requestAnimationFrame(animate);

  // 立方体旋转动画
  cube.rotation.x += 0.01;
  cube.rotation.y += 0.01;

  renderer.render(scene, camera);
}

animate();

如果上面的代码是你写的,在某次代码评审时:你的领导如果熟悉IoC的理念,他会告诉你:你的代码有以下几个问题:

  1. 紧密耦合:在这个例子中,cube 立方体(车模型)直接依赖于 scene 来添加到场景中。如果我们要更换场景或者换一辆车模型的实现,我们必须直接修改代码,这表明 cube 和 scene 之间是紧密耦合的。
  2. 难以扩展:如果我们想要添加更多的物体到场景中(两辆车),我们需要在同一个文件中继续添加代码,这可能导致文件变得非常长和复杂。如果我们想要改变渲染器或相机的设置,我们同样需要在同一个文件中找到并修改相应的代码。
  3. 缺乏灵活性:假设我们想要更换渲染器,例如从 WebGLRenderer 更换到 CSS3DRenderer,我们需要在多个地方修改代码:创建渲染器的地方,以及可能涉及到渲染器配置的其他地方。
  4. 难以追踪依赖关系:在这个简单的例子中,依赖关系可能还比较容易追踪,但在更大的项目中,直接实例化和使用组件可能会导致依赖关系变得复杂和难以管理,在迭代过程中随着需求的增加,维护难度呈指数级增加,可怕!!
  5. 不利于模块化:由于所有组件都在同一个文件中创建和配置,这使得将特定功能(如立方体的创建)作为独立模块重用变得困难。
  6. 单元测试困难:由于组件直接依赖于全局状态(如场景、相机和渲染器),编写单元测试变得困难。我们可能需要模拟整个Three.js环境来测试一个简单的组件,如 cube
  7. 代码复用性低:如果我们要在其他地方使用立方体,我们可能需要复制和粘贴创建立方体的代码,这违反了DRY(Don’t Repeat Yourself)原则。

巴拉巴拉一大堆,你若有所思摸了摸头并表示:要不你帮我重构一下吧!!

c14d98afd066073fcdd543b3d93b3154.gif

IoC方式实现

先看效果:

使用IoC的方式的话,我们可以将组件的创建和配置分离出来,通过依赖注入的方式来实例化Three.js应用。

首先,定义一个IoC容器(高层模块):

class IoCContainer {
  constructor() {
    this.services = {};
  }

  register(name, factory) {
    this.services[name] = factory;
  }

  get(name) {
    if (!this.services[name]) {
      throw new Error(`No service registered with name ${name}`);
    }
    return this.services[name]();
  }
}

const container = new IoCContainer();

然后,定义各个组件的工厂函数,并注册到IoC容器(抽象层):

// 场景工厂
function createScene() {
  return new THREE.Scene();
}

// 相机工厂
function createCamera() {
  const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
  camera.position.z = 5;
  return camera;
}

// 渲染器工厂
function createRenderer() {
  const renderer = new THREE.WebGLRenderer();
  renderer.setSize(window.innerWidth, window.innerHeight);
  document.body.appendChild(renderer.domElement);
  return renderer;
}

container.register('scene', createScene);
container.register('camera', createCamera);
container.register('renderer', createRenderer);

添加立方体工厂函数(底层模块):

// 立方体工厂
function createCube(scene) {
  const geometry = new THREE.BoxGeometry();
  const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
  const cube = new THREE.Mesh(geometry, material);
  scene.add(cube);
  return cube;
}

最后,通过IoC容器实例化应用并运行:

const scene = container.get('scene');
const camera = container.get('camera');
const renderer = container.get('renderer');
const cube = createCube(scene); // 这里直接调用工厂函数,因为需要传入scene参数

function animate() {
  requestAnimationFrame(animate);

  cube.rotation.x += 0.01;
  cube.rotation.y += 0.01;

  renderer.render(scene, camera);
}

animate();

在这个IoC的例子中,我们将每个组件的创建逻辑封装在工厂函数中,并通过IoC容器来管理这些工厂函数。这样,我们可以通过改变注册的工厂函数来轻松地替换组件的实现,而不需要修改应用的其他部分。这样就变成了面向对象而非面向实现的代码组织方式。

完成了上面的重构,领导问你这样写有什么优点,你答到:你刚刚说的弊端反过来就是IoC的优点了!!

src=http___c-ssl.duitang.com_uploads_item_201810_04_20181004183600_lPunl.thumb.400_0.gif&refer=http___c-ssl.duitang.gif

一阵哈哈哈过后,领导随即补充:

  • 动态依赖注入:在IoC方式中,createCube 函数接收 scene 作为参数,这使得我们可以轻松地在不同的场景中使用相同的 createCube 函数,而不必担心场景的具体实现。
  • 集中管理:IoC容器提供了一个中心点来管理所有服务和组件的创建,这有助于维护和更新依赖关系。
  • 更好的维护性:当项目增长时,IoC可以帮助保持代码的组织和结构,使得维护变得更加容易。

Angular中的依赖注入

这里说下为什么开头一大段要说Angular的好呢,因为Angular的依赖注入是应用IoC最成功的例子,换句话说是IoC理念的完美实现。

这里引用官网的话:

当你开发系统的某个较小部件时(例如模块或类),你可能需要使用来自其他类的特性。例如,你可能需要 HTTP 服务来进行后端调用。依赖注入或 DI 是一种设计模式和机制,用于创建应用程序的某些部分并将其传递到需要它们的应用程序的其他部分。Angular 支持这种设计模式,你可以在应用程序中使用它来提高灵活性和模块化程度。

在 Angular 中,依赖项通常是服务,但它们也可以是值,例如字符串或函数。应用程序的注入器(在引导期间自动创建)会在需要时使用已配置的服务或值的提供者来实例化依赖项。

Angular的方式重写上面的例子

import { Injectable, Inject } from '@angular/core';
import * as THREE from 'three';

// Scene服务
@Injectable({
  providedIn: 'root'
})
export class SceneService {
  private scene: THREE.Scene;
  constructor() {
    this.scene = new THREE.Scene();
  }

  getScene(): THREE.Scene {
    return this.scene;
  }
  
  ... // 这里定义更多抽象方法用于对场景的操作
}

// Camera服务
@Injectable({
  providedIn: 'root'
})
export class CameraService {
  private camera: THREE.PerspectiveCamera;
  constructor() {
    this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
    this.camera.position.z = 5;
  }

  getCamera(): THREE.PerspectiveCamera {
    return this.camera;
  }
  
  ... // 定义更多的方法用于操作相机
}

// Renderer服务
@Injectable({
  providedIn: 'root'
})
export class RendererService {
  private renderer: THREE.WebGLRenderer;
  constructor() {
    this.renderer = new THREE.WebGLRenderer();
    this.renderer.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild(this.renderer.domElement);
  }

  getRenderer(): THREE.WebGLRenderer {
    return this.renderer;
  }

  animate(scene: THREE.Scene, camera: THREE.PerspectiveCamera) {
    requestAnimationFrame(() => this.animate(scene, camera));
    this.renderer.render(scene, camera);
  }
}

// CubeComponent组件 业务实现
import { Component, OnInit, Inject } from '@angular/core';
import { SceneService, CameraService, RendererService } from './services';

@Component({
  selector: 'app-cube',
  template: `<div></div>`,
  providers: [SceneService, CameraService, RendererService]
})
export class CubeComponent implements OnInit {
  constructor(
    private sceneService: SceneService,
    private cameraService: CameraService,
    private rendererService: RendererService
  ) {}

  ngOnInit() {
    const scene = this.sceneService.getScene();
    const camera = this.cameraService.getCamera();
    const renderer = this.rendererService.getRenderer();

    // 创建立方体 业务实现
    const geometry = new THREE.BoxGeometry();
    const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
    const cube = new THREE.Mesh(geometry, material);
    scene.add(cube);

    // 开始动画循环
    this.rendererService.animate(scene, camera);
  }
}

在这个例子中,我创建了三个服务(SceneServiceCameraServiceRendererService)和一个组件(CubeComponent)。每个服务都通过Angular的DI系统注入到组件中。 可以看到:使用Angular我们很容易就写出了模块化的代码。可拓展性也非常强,添加新的服务或组件时,只需定义它们并注册到DI系统中,而不需要修改现有组件。

而且默认情况下,Angular的服务是单例的,这意味着在整个应用程序中只有一个实例,这有助于减少资源消耗并保持状态一致性。

DI作为IOC的具体实现方式,通过依赖注入的方式实现了对象之间的解耦,提高了系统的灵活性和可维护性

Vue3重写上面的例子

在Vue3中,我们可以利用其Composition API来实现依赖注入的概念,类似于Angular中的服务。

// useThree.js
import { ref, onMounted, onUnmounted } from 'vue';
import * as THREE from 'three';

function useScene() {
  const scene = ref(new THREE.Scene());
  return scene;
}

function useCamera() {
  const camera = ref(new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000));
  camera.value.position.z = 5;
  return camera;
}

function useRenderer() {
  const renderer = ref(null);
  onMounted(() => {
    renderer.value = new THREE.WebGLRenderer();
    renderer.value.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild(renderer.value.domElement);
  });
  onUnmounted(() => {
    if (renderer.value) {
      document.body.removeChild(renderer.value.domElement);
    }
  });
  return renderer;
}

function useCube(scene) {
  const cube = ref(null);
  onMounted(() => {
    const geometry = new THREE.BoxGeometry();
    const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
    cube.value = new THREE.Mesh(geometry, material);
    scene.value.add(cube.value);
  });
  return cube;
}

function useAnimation(renderer, scene, camera) {
  const animate = () => {
    requestAnimationFrame(animate);
    renderer.value.render(scene.value, camera.value);
  };
  onMounted(() => {
    animate();
  });
}

然后,在Vue的业务组件中使用这些函数:

<template>
  <div ref="threeContainer"></div>
</template>

<script>
import { ref, onMounted } from 'vue';
import * as THREE from 'three';
import { useScene, useCamera, useRenderer, useCube, useAnimation } from './useThree';

export default {
  setup() {
    const threeContainer = ref(null);

    const scene = useScene();
    const camera = useCamera();
    const renderer = useRenderer();
    const cube = useCube(scene);
    useAnimation(renderer, scene, camera);

    return {
      threeContainer
    };
  }
};
</script>

可以看到利用Composition API也能达到组件之间的解耦,提高了代码的模块化。

如果需要单例模式可以使用provideinject

志哥我想说

为什么前端开发中大多数情况下,感受不到控制反转原则的应用?

在探讨前端开发中为何较少感受到依赖倒置原则的应用时,我们需要先理解设计模式的概念。设计模式,简而言之,是一系列在面向对象编程实践中,为了应对软件的复杂性变化而提炼出来的最佳实践和原则。关键在于“面向对象”这一概念。

然而,在前端开发领域,我们日常接触最多的技术是JS、HTML和CSS。这里存在两个关键点:

  1. HTML和CSS与面向对象编程并无直接联系。
  2. 虽然JavaScript具备面向对象的特性,但在实际编码过程中,我们往往不常使用其class语法,使得JavaScript的面向对象特性并不显著。

这两个因素共同作用,导致依赖倒置原则在前端开发中的直观感受并不强烈。

再来看前端开发的特点,我们通常面对的是业务需求的不断变化。面向对象的核心理念在于寻找共性,减少变化带来的影响。但是,如果面临的都是独特且多变的需求,那么封装和抽象的动机就可能不那么明显。在这种情况下,设计模式的运用自然也就不那么频繁。

过去,前端开发者有更多机会亲身体验设计模式的应用,例如封装组件库、构建框架或工具库。但随着开源社区的蓬勃发展,这些工作很大程度上已被社区所完成。因此,前端开发者如今主要聚焦于业务逻辑的实现,而非设计模式的运用,这也解释了为何依赖倒置原则在前端开发中的感知度较低。

借用LOL里螳螂的台词:改变就是好事

  1. 学习一些js的设计模式:juejin.cn/post/684490…
  2. 写代码或者封装组件之前先设计,再动手码,这点很关键
  3. 参与一些开源项目或者多看看大佬写的代码
  4. 保持持续学习的心,每天花个半小时逛逛Github,或者掘金~

5741ca31-2081-44ba-b37b-c10f30579551.jpg