MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Angular依赖注入机制深入浅出

2022-08-284.2k 阅读

一、Angular 依赖注入基础概念

1.1 什么是依赖注入

在软件开发中,依赖注入(Dependency Injection,简称 DI)是一种设计模式,它通过将对象所依赖的其他对象(依赖)通过外部传递进来,而不是在对象内部创建这些依赖。这有助于提高代码的可测试性、可维护性和可扩展性。

在 Angular 中,依赖注入是其核心特性之一。它允许开发者将组件、服务等对象所依赖的其他对象(如服务、值、函数等)以声明的方式注入到需要它们的地方,而不是让这些对象自己去创建或查找依赖。

1.2 依赖注入的优势

  1. 可测试性:通过依赖注入,我们可以轻松地为测试目的替换真实的依赖为模拟对象。例如,在测试一个组件时,如果该组件依赖一个后端服务来获取数据,我们可以注入一个模拟的服务,这个模拟服务返回预先定义的数据,从而使测试不受真实后端服务的影响,更稳定和可重复。
  2. 可维护性:当依赖关系发生变化时,例如需要更换一个服务的实现,只需要在注入的地方进行修改,而不需要在所有使用该依赖的地方修改代码。这使得代码的维护变得更加容易,尤其是在大型项目中。
  3. 可扩展性:依赖注入使得代码更加灵活,易于扩展。可以在不改变现有代码结构的情况下,引入新的依赖或替换现有依赖,以满足不同的业务需求。

二、Angular 依赖注入的关键概念

2.1 注入器(Injector)

注入器是 Angular 依赖注入系统的核心。它负责创建、查找和提供依赖对象。每个注入器都维护一个自己的依赖映射表,该表记录了它所管理的依赖令牌(Token)和对应的依赖实例。

当一个组件或服务请求一个依赖时,注入器会首先在自己的依赖映射表中查找。如果找到了匹配的依赖实例,就直接返回该实例;如果没有找到,它会尝试向其父级注入器查找。如果所有的注入器都找不到该依赖,就会抛出一个错误。

在 Angular 应用中,有一个根注入器(Root Injector),它是整个应用中所有注入器的顶级注入器。根注入器负责创建和管理应用中最顶层的依赖,如全局服务等。同时,每个组件都可以有自己的注入器(称为组件注入器),组件注入器继承自其父级注入器(通常是父组件的注入器或根注入器),并可以在其基础上管理自己的依赖。

2.2 令牌(Token)

令牌是用于标识依赖的对象。在 Angular 中,令牌可以是以下几种类型:

  1. 类(Class):最常见的令牌类型。当我们使用一个类作为令牌时,Angular 会使用这个类来创建依赖实例。例如,我们有一个 UserService 类,我们可以直接使用 UserService 作为令牌来注入该服务的实例。
  2. 字符串(String):可以使用字符串作为自定义令牌。这种方式通常用于注入一些简单的值或服务,并且这些值或服务没有对应的类。例如,我们可以定义一个字符串令牌 'apiUrl',并使用它来注入 API 的 URL。
  3. InjectionToken 类的实例InjectionToken 是 Angular 提供的一个专门用于创建令牌的类。它可以创建一个唯一的令牌,特别是在需要注入一些非类类型的对象,或者需要在不同地方使用相同类型但不同实例的依赖时非常有用。例如:
import { InjectionToken } from '@angular/core';
export const MY_CUSTOM_TOKEN = new InjectionToken<string>('My Custom Token');

2.3 提供者(Provider)

提供者用于告诉注入器如何创建和提供依赖对象。提供者通常由一个对象来表示,该对象包含了令牌和创建依赖实例的逻辑。常见的提供者类型有:

  1. 类提供者(Class Provider):使用类作为令牌,并使用该类的构造函数来创建依赖实例。例如:
import { NgModule } from '@angular/core';
import { UserService } from './user.service';

@NgModule({
  providers: [UserService]
})
export class AppModule {}

这里 UserService 既是令牌也是用于创建实例的类。

  1. 值提供者(Value Provider):用于提供一个已经创建好的值,而不是通过类的构造函数来创建。例如:
import { NgModule } from '@angular/core';
import { InjectionToken } from '@angular/core';

export const apiUrl = new InjectionToken<string>('API URL');

@NgModule({
  providers: [
    {
      provide: apiUrl,
      useValue: 'https://example.com/api'
    }
  ]
})
export class AppModule {}

这里使用 useValue 来指定要提供的值。

  1. 工厂提供者(Factory Provider):通过一个工厂函数来创建依赖实例。这种方式适用于需要更复杂的创建逻辑,或者依赖于其他已有的依赖。例如:
import { NgModule } from '@angular/core';
import { InjectionToken } from '@angular/core';

export const SOME_SERVICE = new InjectionToken<string>('Some Service');

function createSomeService(): string {
  // 复杂的创建逻辑
  return 'Some value created by factory';
}

@NgModule({
  providers: [
    {
      provide: SOME_SERVICE,
      useFactory: createSomeService
    }
  ]
})
export class AppModule {}

这里通过 useFactory 来指定工厂函数。

  1. 别名提供者(Alias Provider):用于为一个已有的令牌提供一个别名。例如:
import { NgModule } from '@angular/core';
import { UserService } from './user.service';
import { AnotherUserService } from './another - user.service';

@NgModule({
  providers: [
    {
      provide: UserService,
      useExisting: AnotherUserService
    }
  ]
})
export class AppModule {}

这里 UserService 成为了 AnotherUserService 的别名,当请求 UserService 时,实际上会得到 AnotherUserService 的实例。

三、在 Angular 组件和服务中使用依赖注入

3.1 在服务中注入依赖

假设我们有一个 LoggerService 用于记录日志,还有一个 UserService 需要使用 LoggerService 来记录用户相关的操作。 首先,创建 LoggerService

import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class LoggerService {
  log(message: string) {
    console.log(`[LOG] ${message}`);
  }
}

然后,创建 UserService 并注入 LoggerService

import { Injectable } from '@angular/core';
import { LoggerService } from './logger.service';

@Injectable({
  providedIn: 'root'
})
export class UserService {
  constructor(private logger: LoggerService) {}

  getUser() {
    this.logger.log('Getting user...');
    // 实际获取用户的逻辑
    return { name: 'John Doe' };
  }
}

UserService 的构造函数中,通过 private logger: LoggerService 声明了对 LoggerService 的依赖,Angular 会自动注入 LoggerService 的实例。

3.2 在组件中注入服务

假设我们有一个 UserComponent 需要使用 UserService 来获取用户信息并显示。

import { Component } from '@angular/core';
import { UserService } from './user.service';

@Component({
  selector: 'app - user',
  templateUrl: './user.component.html',
  styleUrls: ['./user.component.css']
})
export class UserComponent {
  user;
  constructor(private userService: UserService) {}

  ngOnInit() {
    this.user = this.userService.getUser();
  }
}

UserComponent 的构造函数中,注入了 UserService,然后在 ngOnInit 生命周期钩子中调用 UserServicegetUser 方法获取用户信息。

四、依赖注入的作用域

4.1 根作用域(Root Scope)

当一个服务在根模块(AppModule)中通过 providers 数组提供,或者在服务类上使用 @Injectable({ providedIn: 'root' }) 时,该服务处于根作用域。根作用域的服务在整个应用中只有一个实例,所有需要该服务的组件和服务都共享这个实例。

例如,上面的 LoggerServiceUserService 都处于根作用域,无论在哪个组件或服务中注入它们,得到的都是同一个实例。

4.2 组件作用域(Component Scope)

如果我们想为每个组件创建一个独立的服务实例,可以将服务提供在组件的 providers 数组中。例如:

import { Component } from '@angular/core';
import { UserService } from './user.service';

@Component({
  selector: 'app - user',
  templateUrl: './user.component.html',
  styleUrls: ['./user.component.css'],
  providers: [UserService]
})
export class UserComponent {
  user;
  constructor(private userService: UserService) {}

  ngOnInit() {
    this.user = this.userService.getUser();
  }
}

在这种情况下,每个 UserComponent 实例都有自己独立的 UserService 实例。这在某些场景下非常有用,比如每个组件需要维护自己独立的状态,而不是共享全局的状态。

4.3 模块作用域(Module Scope)

在 Angular 模块(除根模块外)的 providers 数组中提供的服务,具有模块作用域。在该模块及其子模块中的组件和服务共享这些服务实例,但与其他模块中的实例是隔离的。

例如,我们有一个 FeatureModule

import { NgModule } from '@angular/core';
import { UserService } from './user.service';

@NgModule({
  providers: [UserService]
})
export class FeatureModule {}

FeatureModule 及其子模块中的组件和服务会共享同一个 UserService 实例,而与 AppModule 或其他模块中的 UserService 实例不同。

五、高级依赖注入技巧

5.1 多级注入器和依赖查找顺序

在 Angular 应用中,注入器形成了一个树形结构。根注入器位于树的顶端,每个组件注入器是其子节点。当一个组件请求一个依赖时,注入器的查找顺序如下:

  1. 组件自身的注入器查找。如果找到了依赖实例,就返回该实例。
  2. 如果组件自身的注入器没有找到,就向其父组件的注入器查找。这个过程会一直向上进行,直到根注入器。
  3. 如果根注入器也没有找到依赖,就会抛出一个错误。

例如,我们有一个 ParentComponent 和一个 ChildComponentChildComponent 依赖一个 SharedService。如果 ChildComponent 自身的注入器没有提供 SharedService,它会向 ParentComponent 的注入器查找,如果 ParentComponent 的注入器也没有,就会向根注入器查找。

5.2 使用 InjectionToken 注入复杂类型

InjectionToken 不仅可以用于创建字符串类型的令牌,还可以用于注入复杂类型,如函数、对象等。例如,假设我们有一个函数,用于格式化日期,我们想通过依赖注入来使用它。 首先,创建 InjectionToken

import { InjectionToken } from '@angular/core';

export const DATE_FORMATTER = new InjectionToken<(date: Date) => string>('Date Formatter');

然后,提供这个函数:

import { NgModule } from '@angular/core';
import { DATE_FORMATTER } from './date - formatter.token';

function formatDate(date: Date): string {
  return date.toISOString();
}

@NgModule({
  providers: [
    {
      provide: DATE_FORMATTER,
      useValue: formatDate
    }
  ]
})
export class AppModule {}

最后,在组件中注入并使用:

import { Component } from '@angular/core';
import { DATE_FORMATTER } from './date - formatter.token';

@Component({
  selector: 'app - date - component',
  templateUrl: './date - component.html'
})
export class DateComponent {
  constructor(@Inject(DATE_FORMATTER) private dateFormatter: (date: Date) => string) {}

  format() {
    const now = new Date();
    return this.dateFormatter(now);
  }
}

这里通过 InjectionToken 注入了一个函数,使得代码更加灵活和可维护。

5.3 条件注入

有时候,我们可能需要根据不同的条件注入不同的依赖。例如,在开发环境中,我们可能想使用一个模拟的服务来进行快速开发和测试,而在生产环境中使用真实的服务。

我们可以通过在模块的 providers 数组中根据环境变量来动态地提供不同的服务。假设我们有一个 environment.ts 文件来存储环境变量:

export const environment = {
  production: false
};

然后,在 AppModule 中:

import { NgModule } from '@angular/core';
import { environment } from '../environments/environment';
import { RealUserService } from './real - user.service';
import { MockUserService } from './mock - user.service';

@NgModule({
  providers: [
    {
      provide: 'UserService',
      useClass: environment.production? RealUserService : MockUserService
    }
  ]
})
export class AppModule {}

这样,在开发环境中会注入 MockUserService,而在生产环境中会注入 RealUserService

六、依赖注入与测试

6.1 测试组件时模拟依赖

在测试组件时,通常需要模拟其依赖,以确保测试的独立性和稳定性。例如,在测试 UserComponent 时,我们不想依赖真实的 UserService,因为它可能涉及网络请求或其他复杂操作。

我们可以使用 Angular 的 TestBed 来配置测试环境,并提供模拟的 UserService

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UserComponent } from './user.component';
import { UserService } from './user.service';

describe('UserComponent', () => {
  let component: UserComponent;
  let fixture: ComponentFixture<UserComponent>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [UserComponent],
      providers: [
        {
          provide: UserService,
          useValue: {
            getUser: () => ({ name: 'Mock User' })
          }
        }
      ]
    });
    fixture = TestBed.createComponent(UserComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should display the user name', () => {
    expect(component.user.name).toBe('Mock User');
  });
});

这里通过 TestBedproviders 数组提供了一个模拟的 UserService,使得测试可以独立运行,不受真实 UserService 的影响。

6.2 测试服务时注入依赖

当测试一个依赖其他服务的服务时,同样需要正确地注入依赖。例如,测试 UserService,它依赖 LoggerService

import { TestBed } from '@angular/core/testing';
import { LoggerService } from './logger.service';
import { UserService } from './user.service';

describe('UserService', () => {
  let service: UserService;
  let loggerService: LoggerService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [UserService, LoggerService]
    });
    service = TestBed.inject(UserService);
    loggerService = TestBed.inject(LoggerService);
  });

  it('should log when getting user', () => {
    spyOn(loggerService, 'log');
    service.getUser();
    expect(loggerService.log).toHaveBeenCalledWith('Getting user...');
  });
});

这里通过 TestBed 注入了 UserServiceLoggerService,并使用 spyOn 来验证 LoggerServicelog 方法是否被调用。

通过合理地使用依赖注入和模拟依赖,我们可以写出高质量、可靠的测试代码,确保应用的稳定性和正确性。

七、依赖注入的最佳实践

7.1 保持依赖简单和明确

尽量使每个组件和服务的依赖保持简单,避免过多的间接依赖。过多的依赖会使代码难以理解和维护。同时,在声明依赖时,要明确依赖的用途,例如通过合理命名变量和使用注释。

7.2 合理选择依赖作用域

根据业务需求,合理选择依赖的作用域。如果一个服务需要在整个应用中共享状态,应该将其提供在根作用域;如果每个组件需要独立的状态,应该将服务提供在组件作用域。对于模块内共享的服务,可以提供在模块作用域。

7.3 使用别名和 InjectionToken 提高代码可维护性

在需要为依赖提供别名或注入复杂类型时,使用别名提供者和 InjectionToken。这可以使代码更加清晰,易于维护和扩展。例如,在不同模块中可能需要使用不同的实现来提供相同功能的服务,通过别名和 InjectionToken 可以轻松实现这种替换。

7.4 在测试中充分利用依赖注入

在编写测试时,充分利用依赖注入来模拟依赖。这不仅可以提高测试的速度和稳定性,还可以确保测试只关注被测试对象本身,而不受外部依赖的干扰。同时,通过合理的测试设置,可以验证依赖注入的正确性和依赖之间的交互。

总之,Angular 的依赖注入机制是一个强大而灵活的工具,通过深入理解和合理使用它,可以开发出高质量、可维护、可测试的 Angular 应用。在实际项目中,遵循最佳实践,不断优化依赖注入的使用,将有助于提升整个项目的开发效率和质量。