SOLID 中的最后一个字母是 Dependency Inversion Principle。它可以帮助我们解耦软件模块,以便更容易地用另一个模块替换一个模块。依赖注入模式使我们能够遵循这个原理。
在这篇文章中,我们将了解什么是依赖注入,为什么它很有用,何时使用它,哪些工具可以帮助前端开发人员使用这种模式。
我们假设你了解 JavaScript 的基本语法,并熟悉面向对象编程的基本概念,例如类和接口。不过,你不需要详细了解类和接口的TypeScript 语法,因为我们会在这篇文章中用到它。
一般来说,依赖关系的概念依赖于上下文,但为了简单起见,我们将依赖关系称为模块所使用的任何模块。当我们开始在代码中使用一个模块时,这个模块就变成了一个依赖项。
我们使用函数参数来模拟依赖。这样,无需深入学术定义,我们可以将依赖关系与函数参数进行比较。两者都以某种方式使用,两者都会影响依赖于它们的软件的功能和可操作性。
// random 函数在没有 'min' 和 'max' 参数的情况下无法工作
function random(min, max) {
if (typeof min === 'undefined' || typeof max === 'undefined') {
throw new Error('All arguments are required');
}
return Math.random() * (max - min) + min;
}
在上面的例子中,random 函数有两个参数:min 和 max。如果我们不通过其中一个,函数将抛出一个错误。我们可以得出结论,这个函数取决于这些参数。
然而,这个函数不仅取决于这两个参数,而且依赖于 Math.Random 函数。这是因为如果 Math.Random 没有定义,random 函数也不能工作,所以 Math.Random 也是一种依赖。
如果我们将它作为参数传递给函数,可以使它更清楚:
function random(min, max, randomSource) {
if (typeof min === 'undefined' || typeof max === 'undefined' || typeof randomSource === 'undefined') {
throw new Error('All arguments are required');
}
return randomSource.random() * (max - min) + min;
}
现在很明显,random 函数不仅使用 min 和 max,还有随机数生成器。这类函数将被这样调用:
const randomBetweenTenAndTwenty = random(10, 20, Math);
或者如果我们不想每次都手动传递 Math 作为最后一个参数,我们可以在函数参数声明中使用它作为默认值:
function random(min, max, randomSource = Math) {
// ...code
}
// 调用random函数
const randomBetweenTenAndTwenty = random(10, 20);
这就是基本的依赖注入。当然,它还没有得到”规范“,这是非常原始的,它必须用手完成,但关键的思想是一样的:我们将它工作所需要的一切传递给模块。
random 函数示例中的代码更改似乎是不必要的。实际上,为什么我们要把 Math 提取到参数中,并像那样使用它?为什么我们不直接在函数体中使用它呢?有两个原因。
当模块明确声明它需要的所有东西时,这个模块测试起来要简单得多。我们看到需要准备好的需要立即运行测试。我们知道是什么影响了这个模块的功能,如果需要,可以用另一个实现替换它,甚至是假实现来替换它。
看起来像依赖性的对象,但是做不同的东西被称为 Mock 对象。当运行测试时,它们可能会跟踪某个函数被调用了多少次,模块的状态是如何改变的,这样以后我们就可以检验预期的结果了。
一般来说,他们使测试模块更简单,有时它们是测试模块的唯一方法。random 函数是这种情况,我们不能检查这个函数应该返回的最终结果,因为每次调用这个函数都是不同的。然而,我们可以检查这个函数如何使用它的依赖项并从中得出结果。
// 我们可以创建一个Mock对象,它将总是返回0.1而不是一个随机数:
const fakeRandomSource = {
random: () => 0.1,
}
// 然后,我们将调用函数,并将这个Mock对象作为依赖项而不是Math:
const randomBetweenTenAndTwenty = random(10, 20, fakeRandomSource);
// 既然函数的算法是确定的并且不变, 我们可以预期结果总是一样的:
randomBetweenTenAndTwenty === 11; // true
在测试时替换依赖项只是一种特殊情况。通常,我们可能出于任何原因想要用另一个模块替换一个模块。如果一个新模块的行为与前一个模块相同,我们可以在没有任何问题的情况下做到这一点:
// 如果一个新对象包含 `random` 方法,我们可以把它当作一种依赖。
const otherRandomSource = {
random() {
// 自定义随机数生成的实现。
}
}
const randomNumber = random(10, 20, otherRandomSource);
当我们想让我们的模块尽可能地彼此分开时,这是非常方便的。然而,是否有一种方法可以保证新模块包含 random 方法?(这是至关重要的,因为我们以后在函数随机中依赖这个方法)显然是有的,我们可以通过接口来实现。
接口是一种功能契约。它限制了模块的行为,它必须做什么,以及它不应该做什么。在我们的案例中,为了保证随机方法的存在,我们可以使用接口。
为了确定模块应该有一个返回数字的 random 方法,我们定义了一个接口:
interface RandomSource {
random(): number;
}
为了确定一个具体的对象必须有这个方法,我们声明这个对象实现了这个接口:
// 使用冒号声明
// 这个对象实现了一个 “RandomSource” 接口
// 因此,必须以这种接口中描述的方式行事。
const otherRandomSource: RandomSource = {
random = () => {
// 它必须返回一个数字,否则 TypeScript 编译器会抛出一个错误。
return 42;
}
}
现在我们可以声明我们的 random 函数只接受一个实现 RandomSource 接口的对象作为最后一个参数:
function random(min: number, max: number, source: RandomSource = Math): number {
if (typeof min === 'undefined'
|| typeof max === 'undefined'
|| typeof source === 'undefined') {
throw new Error('All arguments are required');
}
return source.random() * (max - min) + min;
}
如果我们现在试图传递一个没有实现 RandomSource 接口的对象,TypeScript 编译器会抛出一个错误。
const randomNumber1 = random(1, 10, Math);
// `Math` 包含一个 `random` 方法,没有错误。
const randomNumber2 = random(1, 10);
// `Math` 被用作默认参数值,没有错误。
const randomNumber3 = random(1, 10, otherRandomSource);
// 没有错误,因为`otherRandomSource`实现`RandomSource`接口。
const otherObject = {
otherMethod() {};
};
const randomNumber4 = random(1, 10, otherObject);
// 错误,'otherObject' 没有实现所需的接口
乍一看,这似乎有点过分。然而,这可以帮助我们获得很多好处。
当我们预先设计一个系统时,我们倾向于使用抽象的契约。使用这些契约,我们为第三方代码设计我们自己的模块和适配器。这解锁了与其他模块交换的能力,而不改变整个系统,而只是改变一部分。
特别是当模块比上面例子中的模块更复杂时,它就变得非常方便。例如,当一个模块具有内部状态时。
在 TypeScript 中,有很多方法可以创建有状态对象,例如使用闭包或类。在这篇文章中,我们将使用类。
作为一个例子,我们将使用一个计数器。作为一个类,它应该写成这样:
class Counter {
private state: number = 0;
public increase = (): void => {
this.state++;
}
public decrease = (): void => {
this.state--;
}
get stateOf(): number {
return this.state;
}
}
它的方法为我们提供了一种改变其内部状态的方法:
const counter = new Counter();
counter.stateOf; // 0
counter.increase();
counter.stateOf; // 1
counter.decrease();
counter.stateOf; // 0
当像这样的一些物体取决于其他物品时,它就会得到。让我们假设这个计数器不仅应该保持和更改它的内部状态,而且还应该在每次更改时将它记录到一个控制台中。
class Counter {
private state: number = 0;
// 添加日志记录方法。
private log = (): void => {
console.log(this.state);
}
public increase = (): void => {
// 现在当状态发生变化时…
this.state++;
this.log();
}
public decrease = (): void => {
// 现在当状态发生变化时…
this.state--;
this.log();
}
get stateOf(): number {
return this.state;
}
}
在这里,我们看到了与本文开头所看到的相同的问题。计数器不仅使用它的状态,而且还使用另一个模块 console。理想情况下,它还应该是明确的,或者换句话说,注入式的。
可以使用 setter 或 constructor 在类中注入一个依赖项。我们使用 constructor 。
constructor (构造函数)是在创建对象时调用的一种特殊方法。通常在对象初始化时指定要执行的所有操作。
例如,如果我们想在创建对象时将问候信息打印到控制台,我们可以使用下面的代码:
class Counter {
constructor() {
console.log('Hello world!');
}
// ...code.
}
const counter = new Counter();
// "Hello world!"
使用构造函数,我们还可以注入所有需要的依赖项。
我们想将类以与前面例子中的函数相同的方式处理依赖关系。
因此,我们的类 Counter 使用 Console 对象的 log 方法。这意味着该类期望依赖一个具有 log 方法的对象。它是 Console 对象还是其他对象并不重要,这里唯一的条件是对象有一个 log 方法。
当我们想要限制行为时,我们需要使用接口。因此,Counter 的构造函数应该接受一个对象作为参数,该对象实现了一个带有 log 方法的接口。
interface Logger {
log(message: string): void;
}
class Counter {
// 这个私有字段将保留一个引用到 logger 对象
private logger: Logger;
constructor(logger: Logger) {
// 我们将在初始化时设置
this.logger = logger;
}
// ...code.
}
// 或者使用字段自动分配
class Counter {
// 在以这种方式写入时,构造函数中的参数将自动分配给`logger`私有字段。
constructor(private logger: Logger) {}
// ...code.
}
要初始化类实例,我们将使用以下代码:
const counter = new Counter(console);
如果我们想要,比方说,使用 alert 而不是 console,我们会这样改变依赖对象:
// 这就足够确保依赖对象 拥有所有必需的方法,或者实现所需的接口。
const customLogger: Logger = {
log(message: string): void {
alert(message);
}
}
const counter = new Counter(customLogger);
现在,我们的 Counter 类没有使用任何隐式依赖关系。这很好,但是这种注入不方便。
实际上,我们想让它自动化。有一种方法可以做到这一点,它被称为 DI 容器。
总的来说,DI 容器是只做一件事的模块-它为系统中的其他每个模块提供依赖关系。容器确切地知道模块需要哪些依赖项,并在需要时注入它们。这样我们就解放了其他模块来解决这个问题,然后控制到一个特殊的地方。这是 SOLID 在 SRP 和 DIP 原则中描述的行为。
在实践中,为了使其工作,我们需要另一层抽象接口。(Typescript 有这个概念,Javascript 没有)这里的接口是不同模块之间的链接。
容器知道模块需要什么样的行为,知道哪些模块实现它,当创建一个对象时,它会自动提供对它们的访问。
在伪代码中,它看起来像这样:
// 嘿,容器!
// 当你被问到一个实现 `SomeInterface` 的对象时,你应该给访问 `SomeClass` 的一个实例。
container.register(SomeInterface, SomeClass);
尽管这段代码不是真实的,但它离现实并不遥远。
TypeScript 有很棒的工具,它可以做我们上面描述的事情。它们都是使用泛型函数来绑定接口和实现。
当然,在前端有强大框架 angular,它有核心特性就是依赖注入。在后端也有强大框架 Nest,它有核心特性也是依赖注入。Nest 依赖注入也是参考 Angular 实现。
Angular 爱好者把依赖注入特性从 Angular 的 ReflectiveInjector 中提取出来的,创建一个独立库 injection-js。这意味着它设计得很好,功能齐全,快速、可靠,而且经过了很好的测试。有很多库内部使用 injection-js,最有名当属将库编译为 Angular 包格式 ng-packagr(官方 Angular CLI 的一部分)。
在这里使用一个简单的 DI 库,使用此工具的代码如下所示:
import {DIContainer} from '@wessberg/di';
// 创建 DI 容器
const container = new DIContainer();
// 创建注入接口
interface Logger {
log(message: string): void;
}
// 实现注入接口
export class ConsoleLogger implements Logger {
public log = (message: LogEntry): void => console.log(message);
}
// 声明当有模块访问一个实现 `Logger` 接口的对象容器时,它应该返回 `ConsoleLogger` 类的一个实例。
container.registerSingleton<Logger, ConsoleLogger>();
// `<Logger, ConsoleLogger>` 语法是一个泛型函数。它使用类型参数将 `Logger` 类型与 `ConsoleLogger` 类型绑定。
// `Logger` 是一个抽象接口,`ConsoleLogger` 是一个更具体的类。
// 由于 TypeScript 将它们都视为类型,所以我们可以在泛型函数中将它们用作类型参数。
现在,如果我们想访问 Counter 类中的依赖项,我们可以通过编写下面的代码来实现:
class Counter {
constructor(private logger: Logger) {}
private log = (): void => {
this.logger.log(this.state);
}
// ... code.
}
container.registerSingleton<Counter>();
最后一行在容器本身中注册 Counter 类。这样容器就知道 Counter 可以从中寻求依赖关系。
首先,我们现在只需改变一行就可以改变整个项目的实现。
例如,如果我们想在每个使用它的地方更改 Logger 实现,只需更改模块注册就足够了:
// 自定义日志实现
class CustomLogger implements Logger {
public log = (message: LogEntry): void => alert(message);
}
// 替换旧的 `ConsoleLogger` 我们只更改下面一行的注册:
container.registerSingleton<Logger, CustomLogger>();
此外,我们不必手动传递依赖项,我们不必再保持依赖项的顺序,因此模块之间的耦合会变得更少。
这个容器的杀手锏是它不使用装饰器(如果喜欢装饰器,可以使用 inverse.js)。类型参数注册使得区分基础结构代码和生产代码更加容易。
单例和临时是对象的生命状态类型。
registerSingleton 只创建一个对象,之后它会传递到每个需要它的地方。registerTransient 每次都会创建一个新对象。
临时对象用于处理一些独特的场景,比如每次都应该从头创建的网络请求对象。当我们可以使用相同的实例(例如,用于记录日志)时,就使用单例对象。
我写了一个小应用程序,点击时候 alert 提示唯一ID,此外,它每 5 秒在控制台显示一条 Hello world 日志。
export class Application {
constructor(
private dateTimeSource: DateTimeSource,
private idGenerator: UuidGenerator,
private clickHandler: EventHandler<MouseEvent>,
private logger: Logger,
private timer: Timer,
private env: Window
) {}
private greet = (): void => this.logger.log('Hello world!');
private setupTimer = (): void => this.timer.invokeEvery(this.greet, 5000);
private registerClicks = (): void => this.clickHandler.on('click', this.handleClick);
private handleClick = (e: MouseEvent): void => {
const position = [e.pageX, e.pageY];
const datetime = this.dateTimeSource.toString();
const eventId = this.idGenerator.generate();
this.env.alert(`${eventId}, ${datetime}: Mouse was clicked at ${position} `);
};
public init = (): void => {
this.setupTimer();
this.registerClicks();
};
}
container.registerSingleton<Application>();
所有有趣的东西都在类构造函数中。在那里,我们向一个容器请求所有依赖项。
这些是主要模块取决于的依赖关系:
为了访问日期和时间,我们使用 BrowserDateTimeSource,它被注册为 DateTimeSource 的实现。请注意,当我们要求这种依赖时,我们使用了接口,因为接口是所有东西都应该依赖于抽象的关键点。
export interface DateTimeSource {
source: Date;
toString: () => string;
valueOf: () => string;
}
export class BrowserDateTimeSource implements DateTimeSource {
get source() {
return new Date();
}
public toString = (): UtcDateTimeString => this.source.toUTCString();
public valueOf = (): TimeStamp => this.source.getTime();
}
container.registerSingleton<DateTimeSource, BrowserDateTimeSource>();
唯一的 ID 生成器是第三方的适配器。注意,我们只在注册适配器时引用这个第三方模块一次。如果我们决定用另一个 UUID 生成器可以随时替换,这是很方便的。
export interface UuidGenerator {
generate:() => string;
}
export class IdGenerator implements UuidGenerator {
constructor(private adaptee: ThirdPartyGenerator) {}
generate = () => this.adaptee();
}
container.registerSingleton<ThirdPartyGenerator>(() => uuid);
container.registerSingleton<UuidGenerator, IdGenerator>();
事件处理程序使用通用接口 EventHandler<MouseEvent>。稍后从容器中请求这种依赖关系是很重要的。如果在这个接口中传递另一个类型参数,容器将搜索使用该参数注册的模块。当我们处理类似的对象类型时,这是很方便的。
export class ClickHandler implements EventHandler<MouseEvent> {
constructor(private env: Window) {}
public on = (event: EventKind, callback: EventCallback<MouseEvent>): () => void {
this.env.addEventListener(event, callback);
return () => {
this.env.removeEventListener(event, callback);
}
}
public off = (event: EventKind, callback: EventCallback<MouseEvent>): void =>
this.env.removeEventListener(event, callback);
}
container.registerSingleton<EventHandler<MouseEvent>, ClickHandler>();
这个我们已经实现过了:
export class ConsoleLogger implements Logger {
public log = (message: LogEntry): void => console.log(message);
}
container.registerSingleton<Logger, ConsoleLogger>();
模拟一个定时器,在间隔时间内执行回调函数。
export interface Timer {
invokeEvery:(fn: (...args: any[]) => void, delay: number) => () => void;
}
export class BrowserTimer implements Timer {
constructor() {}
invokeEvery = (fn: (...args: any[]) => void, delay: number) => () => void{
let timer = setInterval(fn, delay);
() => {
clearInterval(timer);
timer = null;
}
}
}
container.registerSingleton<Timer, BrowserTimer>();
它们是依赖项的依赖项,例如,ClickHandler 类中的 env 或 IdGenerator 中的 adaptee。
对于容器来说,依赖于什么级别并不重要。容器可以毫无问题地提供所有依赖项。(除非有循环依赖,那是另外一个值得深入探讨的话题)
// 对于 `idgenerator`,我们注册了依赖项,如:
container.registerSingleton<ThirdPartyGenerator>(() => nanoid);
// 对于“ClickHandler”(需要 `Window`)
container.registerSingleton<Window>(() => window);
DI 容器的主要问题是,当使用它时,必须注册那里的所有依赖项。它有时并不像我们想要的那样灵活。
另一个缺点是只能从容器访问入口点,这可能看起来有点脏代码。(不过,对于入口点来说,这是可以接受的)
const app= container.get<Application>();
app.init();
今天就到这里吧,伙计们,玩得开心,祝你好运。
来自:https://github.com/jiayisheji/blog/issues/45
前端的技术的极速发展,对前端同学来说也是一个不小的挑战,有各种各样的东西需要学,在开发过程中经常会被后端同学嘲讽,对于前端来讲根本就不存在类的概念,很多时候需要把大量的业务代码堆积在页面或者组件中
在组件上面使用 ref 这个属性绑定,属性值自取,然后就可以通过 $refs.属性名 这种方式去获取到指定组件的实例了。其实不仅仅是组件能够使用 ref ,标签元素也能使用。
依赖注入是一种软件设计模式,其中一个或多个依赖项(或服务)被注入或通过引用传递到依赖对象中。
Vue SFC 样式提供了直接的 CSS 搭配和封装,但它是纯粹的静态的 —— 这意味着到目前为止,我们没有能力在运行时根据组件的状态动态更新样式。现在,随着大多数现代浏览器支持原生 CSS 变量,我们可以利用它来轻松连接组件的状态和样式。
内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!