基本概念
“
在软件工程中,控制反转 (IoC) 是一种编程原则。与传统控制流相比,IoC 反转了控制流。 在 IoC中,计算机程序的自定义编写部分从通用框架接收控制流。与传统的过程编程相比,具有这种设计的软件架构颠倒了控制:在传统编程中,表达程序目的的自定义代码调用可重用的库来处理通用任务,但在控制反转时,它是框架调用自定义或特定于任务的代码。
——维基百科
换句话说就是:把流程的控制从应用程序转移到框架之中。以前,应用程序掌握整个处理流程;现在,控制权转移到了框架,框架利用一个引擎驱动整个流程的执行,框架会以相应的形式提供一系列的扩展点,应用程序则通过定义扩展的方式实现对流程某个环节的定制,“框架Call应用”。
“
以上是对IOC的普遍理解,最常见的IOC实现方式即为DI(依赖注入)。而我更倾向于将IOC从“依赖注入”或者“依赖查找”中泛化出来,他是一种将“控制”从高级模块儿中剥离出来的设计理念。
比如:反转框架对具体代码的依赖,一个服务对别的具体的服务的依赖,让二者皆依赖于抽象。这里被依赖的对象为低级模块,另外一个则为高级模块。
基本原理
IOC是一种设计模式,遵从依赖倒置原则(DIP);依赖注入(DI)是实现IOC的一种具体方式。
例如:衣食住行同每一个人的生活息息相关,我们每天穿什么衣服出门、坐什么样的车、吃什么样的美食都是不确定的。假设我们有一个Person类,依赖了Dress、Food、House、Car类,如下所示:
class Dress {
constructor(public name: string) {}
}
class Food {
constructor(public name: string) {}
}
class House {
constructor(public name: string) {}
}
class Car {
constructor(public name: string) {}
}
class Person {
private dress: Dress;
private food: Food;
private house: House;
private car: Car;
constructor() {
this.dress = new Dress('LV');
this.food = new Food('michelin');
this.house = new House('villa');
this.car = new Car('bmw');
}
}
那假如我今天不想穿LV、不想开宝马了,我想穿香奈儿、开奥迪,又或者说Dress、Food、House、Car类添加了新的参数,那阁下该如何应对呢?是不是必须得改Person类?
那么我们要怎样做到后面四个类的的更改和迭代与Person类无关?
DIP—依赖倒置
软件设计六大设计原则之一
- 高级模块不应该依赖于低级模块,二者都应该依赖于抽象
- 抽象不应该依赖于细节,细节应该依赖于抽象
- 面向接口编程而非面向过程编程
因此对于我们这个例子,需要解耦Person对其他四类的具体依赖,让他们都依赖于相应的抽象接口:
interface IDress {
name: string;
}
interface IFood {
name: string;
}
interface IHouse {
name: string;
}
interface ICar {
name: string;
}
class Dress implements IDress {
constructor(public name: string) {}
}
class Food implements IFood {
constructor(public name: string) {}
}
class House implements IHouse {
constructor(public name: string) {}
}
class Car implements ICar {
constructor(public name: string) {}
}
class Person {
private dress: IDress;
private food: IFood;
private house: IHouse;
private car: ICar;
constructor() {
this.dress = new Dress('LV');
this.food = new Food('michelin');
this.house = new House('villa');
this.car = new Car('bmw');
}
}
看上去没什么变化,但我们已经迈出了重要的一步,Person类和其他四类都通过接口协议完成了解耦。但他们依然有很强的依赖关系,因此需要通过IOC的方式将他们解耦。
IOC—控制反转
定义:为相互依赖的组件提供抽象,将依赖(底层模块)对象的获取交给第三方(系统)来控制,也就是说不在高层模块中进行获取底层模块对象的操作。即依赖对象不在被依赖模块的类中直接通过new来获取。
因此这一步的关键在于,刚才是Person类自己去实例化对应的依赖,变成从构造函数里传入,无论Dress、Food、House、Car进行怎样的变化都不会影响到Person类
class Person {
constructor(
private dress: IDress,
private food: IFood,
private house: IHouse,
private car: ICar,
) {}
}
DI—依赖注入
我们通过DIP、IOC实现了Person对Dress、Food、House、Car的解耦,但是Dress、Food等如果还有其他依赖,那整个实例化过程还是比较难处理的。
这里涉及到一系列依赖的查找,用户需要关注的东西也会越来越多,我们需要一套机制来完成对依赖自动注入,用户只需要知道程序在运行过程中可以获取到相应的依赖即可。
要完成依赖注入,那么对于Person来说,则不能是抽象的依赖,而是具体的某个类,因此我们将Person的抽象依赖改为了具象依赖,那么与此同时,当前的Person类则与具体的具有某些职责的类相关。
当然我们也可以定义另外一个OrdinaryPerson(普通人),他的依赖则同样是实现了IDress、IFood、IHouse、ICar的类,对应的属性可能为:’地摊上买的衣服’、’路边买的煎饼果子’、’自如租的房子’、’共享单车’等。
// es7的提案,ts1.5+版本已经支持,可以通过他给类或者类的原型属性上添加元数据
import 'reflect-metadata';
const INJECTED = '__inject__';
type Constructor<T = any> = new (...args: any[]) => T;
// 定义一个装饰器,他可以在类的构造函数上定义元数据,定义的元数据是构造函数的所有参数
const Injectable = (): ClassDecorator => (constructor) => {
Reflect.defineMetadata(
INJECTED,
Reflect.getMetadata('design:paramtypes', constructor), // 这里还支持两外两种内置元数据定义,一个是design:type获取属性类型,一个是design:returntype获取返回值类型
constructor,
);
};
interface IDress {
name: string;
}
interface IFood {
name: string;
}
interface IHouse {
name: string;
}
interface ICar {
name: string;
}
@Injectable()
class Dress implements IDress {
name = 'LV';
}
@Injectable()
class Food implements IFood {
name = 'michelin';
}
@Injectable()
class Car implements ICar {
name = 'BMW';
}
@Injectable()
class House implements IHouse {
name = 'villa';
// 家里停着一辆宝马,还有很多lv的包包
constructor(private car: Car, private dress: Dress) {}
}
@Injectable()
class Person {
constructor(
private dress: Dress,
private food: Food,
private house: House,
private car: Car,
) {}
}
const getInstance = <T>(target: Constructor<T>): T => {
// 获取所有注入的服务
const providers = Reflect.getMetadata(INJECTED, target);
const args =
providers?.map((provider: Constructor) => {
return getInstance(provider); // 递归实例化所有依赖
}) ?? [];
return new target(...args);
};
console.log(getInstance(Person));
输出结果如下:
整体过程
我们从例子中初始化了Person类,并依赖了Dress、Food、House、Car类。
一开始Person直接依赖这四个类,并且每套衣服,每个房子等都是具体的,与他们强耦合。
于是我们通过DIP原则,将Person依赖这四者的抽象(IDress、IFood、IHouse、ICar),让每一个人不再“穿定制的衣服”、“住定制的房子”等。
但是我们仍然需要在初始化的时候去具体的实例化对应的IDress、IFood等,仍然与对应的类耦合(我们需要知道类的具体参数等)。
于是我们采用IOC原则,往Person类的构造函数注入相应的依赖,并通过DI原则,将”衣食住行“注入,从而解耦对他们的依赖。
工程化应用
生命周期hook
许多框架都提供了生命周期hook,便于用户在特定时机注入特定逻辑。通过这些hook,也就是相应的接口,或者说是框架与应用达成的协议,使满足这套协议的应用有更高的扩展性和自由度,也将框架与应用解耦。
框架 | 描述 | 协议 |
---|---|---|
Vue 生命周期 |
每个 Vue 组件实例在创建时都需要经历一系列的初始化步骤,比如设置好数据侦听,编译模板,挂载实例到DOM,以及在数据改变时更新DOM。 在此过程中,它也会运行被称为生命周期钩子的函数,让开发者有机会在特定阶段运行自己的代码。 |
![]() |
React 生命周期 | React组件从装载至卸载的全过程,这个过程内置多个函数供开发者在组件的不同阶段执行需要的逻辑。 | ![]() |
Webpack 生命周期 | Webpack 是一个流行的前端打包工具,它提供了许多生命周期钩子来帮助开发者在构建项目时进行定制化处理。以下是Webpack的Compiler Hooks | ![]() |
插件机制
Vue install模式
Vue可以通过Vue.use来进行插件注册,对于一个插件,他需要满足以下协议:
- 是一个对象,必须有方法,并传入Vue函数
- 是一个函数,那么会调用这个函数,并传入Vue函数
export type PluginFunction<T> = (Vue: typeof _Vue, options?: T) => void;
export interface PluginObject<T> {
install: PluginFunction<T>;
[key: string]: any;
}
那么对于一个Vue插件,满足上述协议即可,比如Cube UI的组件注册:
import Button from '../../components/button/button.vue'
Button.install = function (Vue) {
Vue.component(Button.name, Button)
}
export default Button
那么我们如果需要在项目中使用Cube UI的Button组件,只需要通过以下方式即可:
import Vue from 'vue'
import { Button } from 'cube-ui'
Vue.use(Button)
Vue-cli-service plugin模式
vue-cli-service通常用于启动我们的vue项目,执行特定的指令:serve
、build
等。然而我们也可以对他进行扩展,写一些插件。
vue-cli-service在初始化的时候,执行run
命令之前,会先查找所有的自定义插件和内置插件,自定义插件就包括我们在项目的dependencies
和devDependencies
里面安装的,与vue-cli-service相关的自定义插件。
插件需要满足的协议比较简单:
- 必须是一个模块(js文件)
- 默认导出一个函数
那么vue-cli-service在初始化时就会去调用这些插件函数,并给插件传入该插件实例,以及一些配置参数。我们通过插件实例便可以做一些特定的操作,比如:registerCommand
、chainWebpack
、configureWebpack
等。从而灵活的注册一些自定义命令、对webpack配置做修改或者扩展等。整个过程中,框架并不知道具体的插件内容,是一种扩展性强的设计。
Webpack plugin模式
对于webpack插件想必大家都不陌生,webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条生产线中,去改变生产线的运作。而自定义一个插件也需要遵从以下协议:
- 一个命名的 Javascript 方法或者 JavaScript 类。
- 它的原型上需要定义一个叫做 的方法。
一个基本的 plugin 代码结构大致长这个样子:
class MyPlugin {
apply(compiler) {
compiler.hooks.done.tap('My Plugin', (stats) => {
console.log('Bravo!');
});
}
}
module.exports = MyPlugin;
契约与回调
迭代器模式
定义:提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示
比如数组相关方法:map
、forEach
、filter
、some
、every
等
这里的接口便为相关回调函数的定义:
map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[];
forEach(callbackfn: (value: T, index: number, array: T[]) => void, thisArg?: any): void;
filter<S extends T>(predicate: (value: T, index: number, array: T[]) => value is S, thisArg?: any): S[];
...
babel-traverse
babel-traverse可以遍历所有的ast节点,每找到一个节点,就会根据节点的类型调用对应的回调函数,例如:
traverse(ast, {
VariableDeclarator: function(path) {},
FunctionDeclaration: function(path) {},
Identifier: function(path) {}
});
优雅的hook弹窗
对于弹窗组件,我们一般有两种实现方式:
- template组件式调用
- 命令式调用
对于第一种方式,我们需要在使用弹窗的组件的中,额外维护一个变量“visible
”,这种模版式的代码会使我们的组件变得很杂乱,开关变量很多,于是就有了ref调用组件方法的方式来打开弹窗($refs.dialog.show()
、$refs.dialog.hide()
),这其实也是一种协议,但并不完美,父子组件存在弱耦合关系。
基于这种现状,命令式Dialog应运而生,他完美解决了以上缺点。但这种方式有一点麻烦的就是需要定制化,需要为特定的弹窗封装特定的函数式调用方法。
有没有一种方式可以不用关心组件,以工厂的方式创建弹窗组件?
答案是有,我们只需要一套接口,所有的弹窗组件遵从这套接口,那么就能实现。比如:
- 弹窗组件的需要有一个名为的属性,用于驱动弹窗的打开和关闭。
- 弹窗组件需要一个事件,用于弹窗关闭时处理命令式弹窗。
基于本文的核心思想——面向接口编程,参照这篇文章,完成了基于hook调用的弹窗实现
而Cube-UI的createAPI,则是更为泛化的调用,没有额外的协议(除了Vue自身相关的接口),它不仅仅局限于弹窗的命令式调用,还有普通组件的调用,值得研究。
组件设计原则
对于组件设计开发,我认为应当遵从以下原则:
总结
- 核心诉求:内聚和解耦,模块儿之间互不影响
- 契约精神,面向接口编程
- 好的架构依赖于好的抽象
- 在传统的软件工程中有一个重要的组成部分——UML图,上学的时候嫌这个东西麻烦,着急写代码,导致面条式的代码越来越多。
- 实际上建模是为了理解事物而对事物作出的一种抽象,我们对系统有好的设计后开始动工往往会事半功倍,虽然在前期会花大量时间做抽象。
- 同时这也是我喜爱TS的核心原因之一,即”设计先行“。