首页 / 文章

Angular 的依赖注入是如何通过 inject() 解决服务问题的

了解 Angular DI 如何通过 inject()、providers 以及分层注入器来提供服务,以及如何控制实例的作用域并避免因缺少提供者而产生的错误。

发布者:REACTAPP.TOP阅读约 12 分钟2666 词

来源: Angular 09: Dependency Injection in Angular

一旦开始将逻辑从 Angular 组件中移出到服务中,就会出现一个实际问题:当组件编写 inject(UserService) 时,这个对象究竟从何而来?是谁创建了它?组件收到的是哪个实例?答案就是 Angular 的依赖注入(DI)系统。本指南将详细介绍 DI 是什么,inject() 和提供者是如何工作的,层级注入器如何决定返回哪个实例,以及如何避免初学者最常犯的错误,从而让你能够自信地在自己的应用程序中共享或限制服务的使用范围。

通常引发这个疑问的代码行如下:

userService = inject(UserService);

组件中并没有任何代码创建 UserService,但该字段却包含了一个可直接使用的对象。这是由 Angular 的 DI 系统提供的。

为什么组件不应自行创建服务

显而易见的另一种选择是在组件内部自行构建该服务:

userService = new UserService();

这种方法确实可行,但会将组件与某个具体的类以及构建该类的方式紧密绑定。现在由组件来决定服务的构造方式、其存在所需的条件,以及是否每次都需要创建新的实例。如果该服务日后需要自身的依赖项,那么所有调用 new 的地方也都必须随之修改。

依赖注入则改变了这种责任分配。组件只需声明需要 UserService 即可。Angular 会找到对应的提供者,创建实例或重用现有的实例,然后将其交给组件使用:

Component
    │
    │ "I need UserService"
    ↓
Angular DI System
    │
    ↓
UserService

名称中的两个词正好描述了这一概念。dependency指的是类完成工作所需的一切。而Injection则表示框架将这类依赖项提供给类,而非由类自行获取或创建。

首次端到端的注入示例

从一个小型服务开始。@Injectable装饰器用于标记该类,表明它是Angular依赖注入系统可以创建的对象,而providedIn: 'root'则用于在整个应用程序中注册该类:

import { Injectable } from '@angular/core';
@Injectable({
  providedIn: 'root'
})
export class UserService {
  getUserName() {
    return 'Rahul';
  }
}

组件通过从@angular/core中导入的inject()方法来请求该服务,该方法与Component一同被导入:

import { Component, inject } from '@angular/core';
import { UserService } from './user.service';
@Component({
  selector: 'app-root',
  templateUrl: './app.html',
  styleUrl: './app.css'
})
export class App {
  userService = inject(UserService);
}

当Angular构建App时,它会计算这个表达式:

inject(UserService)

Angular会将其理解为“该类需要一个UserService”,进而解析该依赖关系并将结果赋值给对应字段。由于该字段是公开的,模板可以直接调用该服务:

<h1>{{ userService.getUserName() }}</h1>

注意组件中缺少了什么——没有任何构造函数调用:

new UserService()

创建并传递实例完全是Angular的工作。

使用inject()

inject()是用于从Angular的依赖注入系统中获取依赖项的现代API。你只需传入一个令牌,通常是服务类本身,即可获得该实例:

userService = inject(UserService);

该函数来自Angular的核心包:

import { Component, inject } from '@angular/core';

当在类体中为该字段赋值后:

userService = inject(UserService);

类中的所有方法都可以通过this访问该服务:

this.userService

例如,一个用于读取名称并记录它的方法:

loadUser() {
  const name = this.userService.getUserName();
  console.log(name);
}

需要记住的一条规则是:inject()仅能在注入上下文中使用。实际上,这意味着可以在字段初始化器、构造函数以及框架管理的某些地方(如工厂函数和函数式守卫)中使用它。如果在之后,例如在点击处理程序或setTimeout回调中调用它,就会引发错误。应先将依赖项存储在字段中,然后在需要时使用该字段。

inject()与构造函数注入的比较

较旧的教程以及许多现有的代码库都是通过构造函数参数来请求依赖项的:

constructor(private userService: UserService) {}

这种基于构造函数的风格仍然得到完全支持。而基于字段的实现方式则如下所示:

userService = inject(UserService);

这两种方式都使用相同的依赖注入系统,且会产生相同的结果。对于新代码而言,inject()通常更简洁,能与独立组件及其他现代 Angular API自然结合,同时还能避免冗长的构造函数参数列表——在使用继承时这一点尤为明显。典型的类结构如下所示:

export class App {
  userService = inject(UserService);
}

不过,语法是最不重要的部分。关键在于这一原则:组件从 Angular 获取依赖项,而非自行创建。

提供者:告知 Angular 如何供应依赖项

只有当 Angular 知道如何生成某个依赖项时,才能请求该依赖项。这一信息存储在提供者中。声明提供者的最常见方式是在@Injectable装饰器中使用:

@Injectable({
  providedIn: 'root'
})
export class UserService {
}

关键选项就在这一行代码中:

providedIn: 'root'

它将UserService注册到应用程序的根注入器中。从那时起,任何类似以下的请求都能得到满足:

inject(UserService)

将提供者想象成Angular保存在文件中的食谱,是一种很好的理解方式:

"When someone asks for UserService,
here is how you should provide it."

使用providedIn在根级别注册

在创建服务时,您可能已经见过这种配置:

@Injectable({
  providedIn: 'root'
})

当使用providedIn: 'root'时,该服务属于根级注入器。Angular在首次有请求时创建一个实例,之后所有请求都会复用同一个实例,因此它成为整个应用程序的单例。完整的服务代码如下:

import { Injectable } from '@angular/core';
@Injectable({
  providedIn: 'root'
})
export class UserService {
  getUserName() {
    return 'Rahul';
  }
}

这样,任何组件都可以在不将服务列在providers数组中的情况下请求它:

userService = inject(UserService);

对于大多数应用级服务而言,这是最简单且首选的方法。它还有一个实用的副作用:如果没有任何代码注入该服务,构建工具就可以将其从打包文件中移除,因为服务的注册信息位于服务本身,而非某个模块或组件中。

在组件上注册服务

也可以通过某个组件的providers数组将提供者绑定到该组件上:

@Component({
  selector: 'app-user',
  providers: [UserService],
  templateUrl: './user.html'
})
export class User {
}

在这种情况下,UserService是由该组件自身的注入器提供的,而不仅仅是由根层的注入器提供:

providedIn: 'root'

这两种注册方式之间的差异是理解分层注入器的起点。

分层注入器

Angular 并不使用单一的全局容器。注入器构成了一棵大致与组件树对应的树结构,根注入器位于最顶部:

Root Injector
      │
      ├── App
      │
      ├── Component A
      │
      │    └── Child Component
      │
      └── Component B

在根层级提供的服务几乎可以在任何地方使用。不过,组件也可以为同一服务注册自己的提供者:

@Component({
  selector: 'app-user',
  providers: [UserService],
  templateUrl: './user.html'
})
export class User {
}

这样,Angular 会为该组件的注入器维护一个对应的提供者。当组件或其任何子组件请求 UserService 时,Angular 会使用该层级中的实例。每个组件实例都会拥有自己的一份服务副本,这些副本会随组件的创建而生成,并在组件销毁时一同被释放。树结构中其他位置的组件仍然会看到根层级的实例。

为何多层级结构很有用

以 SettingsService 为例。大多数界面都应该使用同一组设置,因此在根层级提供该服务是合理的:

Root
 │
 ├── Home
 ├── Products
 └── Profile

现在假设管理区域需要自身的设置,而这些设置不应泄露到应用程序的其他部分:

Root
 │
 ├── Home
 │
 └── Admin
       │
       └── Admin Settings Service

通过在管理组件中提供该服务,该子树会获得一个独立的实例,而其他部分则继续使用共享的实例。按功能状态、对话框状态以及列表项状态管理都是这种模式的常见应用场景,而如果每个组件都直接调用new,则无法实现这些功能。

Angular如何处理请求

当Angular遇到如下类型的请求时:

userService = inject(UserService);

它会从距离请求者最近的注入器开始,向外查找相应的提供者:

Component requests UserService
             ↓
  Check current injector
             ↓
  Provider found?
       ↙           ↘
     Yes            No
      ↓              ↓
Provide it      Look at parent
                     ↓
              Continue upward

第一个找到匹配提供者的注入器会获胜。如果搜索到顶部仍未找到合适的提供者,Angular就会抛出依赖注入错误,通常表现为NullInjectorError: No provider for UserService。正因如此,每个可注入的服务都必须注册在请求者能够访问的位置。

注入多个依赖项

一个组件可以根据需要依赖任意数量的服务。此处有一个组件同时请求了用户服务和通知服务:

import { Component, inject } from '@angular/core';
import { UserService } from './user.service';
import { NotificationService } from './notification.service';
@Component({
  selector: 'app-root',
  templateUrl: './app.html',
  styleUrl: './app.css'
})
export class App {
  userService = inject(UserService);
  notificationService = inject(NotificationService);
}

随后可以通过某个方法将它们组合起来,例如用来向当前用户打招呼:

showWelcomeMessage() {
const name = this.userService.getUserName();
  this.notificationService.show(
    `Welcome ${name}`
  );
}

这两个服务都不是由组件创建的;而是由Angular构建并注入的。

服务与依赖注入相关但有所区别

虽然很容易将这两个术语互换使用,但它们描述的是不同的概念。服务是一种用于存储可重用逻辑或共享状态的类,而依赖注入则是将此类服务提供给应用程序中需要它的各个部分的机制:

Service
  ↓
Provides functionality

Dependency Injection
  ↓
Provides the service where needed
Component
  ↓
Uses the service

应用于具体的服务:

UserService
    ↓
Angular DI
    ↓
User Component
    ↓
Uses UserService

将不同角色分开,才能在不影响使用该服务的组件的情况下,改变提供服务的方式或位置。

将依赖注入与信号结合使用

对于共享的响应式状态,依赖注入能与信号自然搭配使用。下面的服务拥有一个计数器信号以及用于更新该计数的方法:

import { Injectable, signal } from '@angular/core';
@Injectable({
  providedIn: 'root'
})
export class CounterService {
  count = signal(0);
  increase() {
    this.count.update(value => value + 1);
  }
}

组件可以像使用其他服务一样注入该服务:

import { Component, inject } from '@angular/core';
import { CounterService } from './counter.service';
@Component({
  selector: 'app-root',
  templateUrl: './app.html',
  styleUrl: './app.css'
})
export class App {
  counterService = inject(CounterService);
}

其模板通过调用该信号来读取数据,而按钮则负责调用服务中的方法:

<h1>Count: {{ counterService.count() }}</h1>
<button (click)="counterService.increase()">
  Increase
</button>

这里有三个概念在协同工作:

Dependency Injection
        ↓
    CounterService
        ↓
    Signal
        ↓
    Component
        ↓
    Template

服务负责管理状态,依赖注入让所有消费者都能使用同一个服务实例,而信号机制则确保当数值发生变化时模板能及时更新。由于在根层级提供了 CounterService,因此注入该服务的所有组件都会看到相同的计数值;如果将其放在某个组件内部提供,则每个子树都会有独立的计数器。

依赖注入带来的好处

随着应用程序的不断发展,其优势会愈发显著:

  • 结构化:组件无需了解其依赖项是如何构建的,也不需要知道这些依赖项的需求是什么。
  • 可重用性:一个服务可以为多个组件提供服务。
  • 可测试性:在测试中,可以用虚拟或模拟的提供者替代真实服务,例如返回固定数据而非调用 API 的提供者。
  • 灵活性:注入器层级结构让你可以在共享实例与作用域实例之间进行选择。
  • 松耦合:组件依赖于服务所提供的功能,而非其实现方式。
  • 以购物应用为例

    想象一个拥有若干服务的在线商店,每个服务仅承担一项职责:

    ProductService
        ↓
    Gets products from API
        ↓
    CartService
        ↓
    Manages cart state
    AuthService
        ↓
    Manages authentication
    NotificationService
        ↓
    Displays notifications
    

    各个组件只注入它们所需的功能;例如,导航栏可能需要身份验证信息和购物车状态:

    ProductComponent
          ↓
    ProductService
          ↓
    CartComponent
          ↓
    CartService
    Navbar
          ↓
    AuthService
          ↓
    CartService
    

    Angular 将所有这些功能整合在一起,从而使每个组件都能专注于自身的用户界面,而无需处理其背后的对象创建与管理工作。

    最常见的错误:没有提供者的服务

    初学者常常只编写一个普通类:

    export class UserService {
    }
    

    然后试图注入它:

    userService = inject(UserService);
    

    由于没有任何地方注册提供者,Angular 不知道如何处理该类,因此会出现前面提到的“缺少提供者”错误。对于全局服务,解决办法是在根节点处添加装饰器进行注册:

    @Injectable({
      providedIn: 'root'
    })
    export class UserService {
    }
    

    这样根注入器就知道如何创建该服务了。或者,如果需要作用域内的实例,可以将该类列在组件的providers数组中。

    总结

    在现代 Angular 中,类可以通过inject()获取依赖项:

    userService = inject(UserService);
    

    而服务则可以通过以下方式在整个应用中可用:

    @Injectable({
      providedIn: 'root'
    })
    

    providedIn 设置决定了服务注册的位置,而分层注入系统允许你在整个应用中共享一个实例,或者为某个组件及其子组件分别创建独立的实例。完整的请求流程如下:

    Component
        ↓
    Requests Dependency
        ↓
    Angular DI
        ↓
    Provider
        ↓
    Service
    

    此外,还通过信号机制实现了响应式状态管理:

    Component
        ↓
    inject()
        ↓
    Service
        ↓
    Signal
        ↓
    Reactive UI
    

    关键要点

    • 组件仅请求依赖项,而非自行创建;Angular会找到相应的提供者并供给实例。
    • 仅应在注入上下文中调用 inject(),例如在字段初始化器或构造函数中。
    • providedIn: 'root' 可生成延迟创建、可进行代码剪枝的单例,是适用于全应用范围服务的合适默认值。
    • 在组件级别的 providers 配置中会为该组件及其子树创建独立的实例。
  • 解析过程会沿着注入器树逐层进行;若该路径上的任何位置缺少提供者,就会引发 NullInjectorError 错误。
  • DI plus signals 是一种用于实现共享状态的简单高效模式,而通过更换提供者则可以在测试中实现组件的隔离。