Angular 服务

1,211 阅读15分钟

本文从下面几个方面全面、深入的阐述 Angular service 中的相关概念:

  • 创建一个 Angular Service
  • 配置服务的生效范围
  • 确认注入服务的结构上的层级
  • 创建异步服务
  • 使用内置服务

1. Module 1

1.1 什么是 service?

下面是使用 Angular service 或者将部分逻辑剥离 component 的原因:

image.png

而 Angular 官网上给出的解释为:

Do limit logic in a component to only that required for the review. All other logic should be delegated to services.

看起来 component 中处理的是样式逻辑,而 services 中处理的则是除了样式之外的逻辑!因此你也可以根据下面的依据决定这部分逻辑应该写在哪里。

image.png

1.2 加强的 npm start

concurrently是一个运行在Node.js上的命令行工具,它允许你同时运行多个命令。这在开发过程中非常有用,特别是当你需要同时执行多个任务,例如同时进行前端构建和后端服务器运行。

完整命令:

concurrently --kill-others \"ng build --watch --no-delete-output-path\" \"node server.js\"

命令执行流程:

  1. 构建Angular应用ng build --watch --no-delete-output-path 命令启动Angular应用的构建过程,并在文件变化时自动重新构建,但不删除输出路径中的已生成文件。

  2. 运行Node.js服务器node server.js 命令启动你的Node.js服务器,它将提供你的应用或API。

  3. 并发执行concurrently确保这两个命令同时运行。如果Angular构建过程中发生错误并终止,--kill-others选项会触发,Node.js服务器也会停止运行。

  4. 监视文件变化:在开发过程中,当你修改了Angular应用的源代码或服务器端的代码时,相关的命令(Angular构建或Node.js服务器)会捕获这些变化并重新执行,以确保你的应用总是最新的。

使用场景:

这种命令模式在开发中非常有用,特别是当你需要快速迭代前端和后端代码时。例如,前端开发者可以更改Angular代码并立即看到更新,而后端开发者可以更改服务器逻辑并立即测试其效果,而不必分别手动重启构建过程或服务器。

注意事项:

  • 确保server.js是可执行的,并且位于执行该命令的相同目录中,或者提供正确的路径。
  • 使用concurrently时,所有子命令都会共享同一个控制台/终端会话,这有助于你监控所有进程的输出。
  • 如果你使用的是集成开发环境(IDE)或代码编辑器,可能存在内建的任务运行器,可以实现类似的并发执行功能。

2. Module 2 -- 初见服务

本节内容有:

  • 了解 service 的组成部分
  • 创建一个 service
    • 手动方式
    • 使用脚手架的方式
  • 将服务注入到组件中
  • 使用服务分享数据

2.1 service 的组成部分

一个完整的 service 由以下几个部分组成:

image.png

2.2 service 的原理

Angular服务利用依赖注入(DI)系统实现。服务定义时,可通过providedIn指定提供位置,如rootplatform或特定模块。DI系统负责创建服务实例,并根据服务的提供范围(如根服务或模块服务)缓存实例。组件或其他服务通过构造函数或方法中声明的依赖项,请求服务实例。若服务配置为单例,DI系统将提供同一缓存实例,确保整个应用或模块内服务实例的唯一性。这使得服务可在整个应用范围内共享状态和功能,同时保持代码的整洁和可维护性。

2.3 嵌套的 services

一个服务中可以嵌套其他服务,这一点至关重要。

2.4 使用服务共享数据

由于服务的实例是单例,因此可以用来在不同的组件之间共享数据。

举一个例子,如果我想实现国际化,那么新建一个服务并在其中维护一个私有的字段保存当前使用的语言是一个很不错的选择。因为服务被注入到 root 中,所以在整个 root 范围内都可以获取此服务的实例,并且是一个单例。于是单例中维护的私有变量经过接口函数就可以被全局范围的组件获取和设置了。

如下代码所示:

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

@Injectable({
    providedIn: 'root'
})
export class LangStorageService {
    private current_lang_key = 'CURRENT_LANG';
    private default_lang = 'EN';
    private current_lang = '';

    constructor() {
        this.initLanguage();
    }

    initLanguage(): void {
        const storedLang = localStorage.getItem(this.current_lang_key);
        if (storedLang) {
            this.current_lang = storedLang;
        } else {
            this.current_lang = this.default_lang;
            localStorage.setItem(this.current_lang_key, this.default_lang);
        }

    }

    getLanguage(): string {
        return this.current_lang;
    }

    setLanguage(current_lang: string): string {
        this.current_lang = current_lang;
        localStorage.setItem(this.current_lang_key, current_lang);
        return current_lang;
    }

    clearLanguage(): void {
        localStorage.removeItem(this.current_lang_key);
    }
}

2.5 小结一下

在Angular中,服务是类,由注入器按需提供实例。保持服务命名的一致性至关重要,推荐使用骆驼式命名法(CamelCase)。服务是数据共享的理想选择,确保了应用各部分之间的数据一致性。

3. Module 3

在 Angular 中,注入的是服务单例;而不是注入服务类然后再在组件内实例化这个服务类。

Angular服务注入的设计原理

Angular服务之所以采用依赖注入形式,基于以下设计理念:

  1. 控制反转(IoC):Angular通过依赖注入实现控制反转,将获取依赖的控制权从组件转移到框架,简化组件间的依赖管理。
  2. 解耦合:服务与组件之间的依赖通过接口定义,降低了它们之间的耦合度,使得代码更加模块化。
  3. 生命周期管理:依赖注入系统自动处理服务的生命周期,确保服务在需要时可用,且在不再需要时可以被适当地销毁。
  4. 测试性:服务的依赖通过构造函数注入,便于在单元测试中模拟依赖,提高测试的覆盖率和可靠性。
  5. 可配置性:依赖注入允许开发者通过提供不同的实现来配置服务,适应不同的运行环境或条件。
  6. 作用域控制:服务的作用域可以精确控制,可以是全局的,也可以限定在特定的模块或组件。

Angular服务的注入形式是为了提供一种标准化、自动化的依赖管理方式,从而提高开发效率,降低错误率,并增强代码的可维护性和可测试性。

3.1 provider 之于 service 的意义

A provider tells an injector how to create the service.

token & recipe

image.png

如上图所示,你可以将 token 和 recipe 设置成相同的字段,然后简写。但是这两个绝对不是一回事。

3.2 使用不同的 recipe

recipe 不一定要和 token 相同,只需要保证 recipe 的值实现了 token 的接口即可,如下所示:

image.png

你甚至可以写成这样:

image.png

image.png

也可以这样:

image.png

image.png

3.3 Injectors 的作用

Injectors 有以下四个作用:

image.png

3.4 注入器的种类

在Angular中,注入器(Injector)层次结构是一种设计,用于管理依赖项的提供和检索。这个层次结构确保了依赖项的解析是按照它们声明的上下文进行的。

Angular注入器层次结构的关键点:

  1. 根注入器(Root Injector)

    • 每个Angular应用都有一个根注入器,它是应用中所有其他注入器的起点。
  2. 平台注入器(Platform Injector)

    • 平台注入器通常用于提供平台级别的服务,比如HttpClient
  3. 模块注入器(Module Injector)

    • 每个NgModule有自己的注入器,它用于提供该模块范围内的服务。
  4. 组件注入器(Component Injector)

    • 每个Angular组件都有自己的注入器,用于提供或检索与该组件相关的服务。
  5. 视图注入器(View Injector)

    • 与组件注入器相关联,通常用于提供与特定视图相关的服务。
  6. 指令注入器(Directive Injector)

    • 对于带有providers属性的指令,Angular会创建一个局部注入器,用于提供指令特有的服务。

解析依赖项:

  • 当请求服务时,Angular首先在当前注入器中查找。
  • 如果当前注入器没有提供该服务,Angular会向上查找其父注入器。
  • 这个过程会一直持续,直到根注入器被检查过。

层次结构的好处:

  • 封装性:服务可以在它们被使用的地方局部提供,避免全局污染。
  • 重用性:服务可以在多个层次上提供,增加代码的重用性。
  • 灵活性:允许在不同的层次上覆盖服务,例如在特定组件或模块中使用不同的服务实现。

示例:

// 根模块提供根服务
@NgModule({
  providers: [RootService],
  // ...
})
export class AppModule { }

// 特定模块覆盖根服务
@NgModule({
  providers: [{ provide: RootService, useClass: ModuleSpecificService }],
  // ...
})
export class SpecificModule { }

// 组件提供自己的服务
@Component({
  providers: [ComponentService],
  // ...
})
export class SomeComponent { }

在上述示例中,RootService在应用的根模块中提供,ModuleSpecificService在特定模块中覆盖了RootService,而ComponentService是特定于组件的服务。

通过这种层次化的注入器系统,Angular提供了一种强大且灵活的方式来管理依赖项,使得应用的架构更加清晰和可维护。

3.5 service 注入位置

基于下面的准则去注入服务。

image.png

关于上面的第三条,这里详细说明一下:

在定义服务的时候不去指定元信息中的注入位置,也就是只装饰,而不提供装饰信息。然后在 component 中直接引入,而不是在某个模块中。

image.png

而对于第四条,我们创建 core module 之后,在其中注入各个服务;最后再在跟模块中引入此 core module.

image.png

image.png

注意,使用这种方法注入的服务 LoggerServiceDataService 都没有在元信息中指定注入的位置。

3.6 模块加载守卫

给我们的 core module 增加守卫,防止懒加载模块中出现重复加载的问题。

image.png

image.png

4. Module 4 -- 异步服务

异步服务的根本特点是:服务提供的方法具有类型为 Promise 或者 Observable 类型的返回值。

4.1 异步服务的重要性

异步服务的重要性可以通过下图展示:

image.png

4.2 处理异步方法的返回值

异步服务的典型应用场景就是发起各种客户端的网络请求,下面展示了如何处理异步服务方法返回值。

image.png

而对应的服务异步方法则可以写成:

image.png

4.3 处理异步方法的错误或者异常

处理异步方法的错误,首先需要明确的是,我们不应该将浏览器的错误对象直接暴露出来,而是应该先定义一个错误类,这个错误类的作用是整理过滤最初的错误信息。

image.png

然后使用这个自定义错误类:

image.png

使用方就可以捕获自定义的错误对象了:

image.png

实际上这并不是好的实践,一旦错误外溢到 subscribe 中,就会导致 的断裂,导致后面的数据无法跟上,所以就算出了问题,我们必须使用 pipe 将其尽快处理掉,而返回符合数据结构的 的信息。

4.4 关于 Promise

并不是所有的异步方法的返回值都是 observable 对象。有的返回值是 promise. 对于这种情况只有一个点需要说明。那就是 promise 链的最后一个总是 catch(). 这样才是比较保险的做法。

总结一下:异步服务指的就是那些有着异步方法,即返回值为 promise 或者 observable 类型的服务。这类服务相当重要,因为永远都不要将 long task 放在 component class 中,而是应该使用异步的 service 来执行它们。

5. Module 5 -- 使用内置服务

你可以在这个网站找到一些非常好用的 Angular 内置服务:angular.io/api

image.png

在这个网站上,搜索 Title Version ErrorHandle 这三种服务最为常见。

关于 ErrorHandle 这个服务,主要将其作为接口来实现。通过使用 ErrorHandle 我们可以很容易的将之前自定义的错误类型封装成一个服务,然后注入到需要使用的位置:

image.png

image.png

6. 实践/总结

  1. 练习 ErrorHandle 的服务继承。 创建 errorHandlers/DeviceDetailsErrorHandler.ts 文件。在其中实现名为 ErrorHandler 的接口 (即实现名为 handleError 的方法) 即可。作为服务,一定要记住元信息的标识,即:@Injectable({}):
import { ErrorHandler, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';

@Injectable({
    providedIn: 'root'
})
export class DeviceDetailsErrorHandler implements ErrorHandler {
    constructor(private matSnackBar: MatSnackBar) { }

    handleError(error: Error): void {
        console.error('Error occurred:', error);

        const message = error.message || 'An unknown error occurred.';
        this.matSnackBar.open(message, 'Close', {
            duration: 10000,
        });
    }
}

接下来,在想要注入的地方,放入 providers 数组中即可。这个地方可能是:模板、组件类、模块、根模块等。由于并不是直接使用 ErrorHandler, 因此一定要写清楚 token 和 recipe.

import { DeviceDetailsErrorHandler } from '../errorHandlers/DeviceDetailsErrorHandler';
...
  providers: [
    { provide: ErrorHandler, useClass: DeviceDetailsErrorHandler },
  ]
  ...
...
  1. 练习在 Service 中注入 Material 组件,比如全局的 MatSnackBar
  • 首先需要将注入到根组件中去,否则这个 Service 是找不到的;
import { MatSnackBarModule } from '@angular/material/snack-bar';
...
@NgModule({
  declarations: [],
  imports: [
    ...
    MatSnackBarModule,
  ],

  providers: [],
  bootstrap: [BootstrapComponent]
})
  • 然后就如同正常在组件中使用一样了。具体来说就是在服务 class 中增加 constructor 函数并在其中注入 MatSnackBar 这个服务;对的本质上是服务套服务
import { ErrorHandler, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';

@Injectable({
    providedIn: 'root'
})
export class DeviceDetailsErrorHandler implements ErrorHandler {
    constructor(private matSnackBar: MatSnackBar) { }

    handleError(error: Error): void {
        // 记录错误到控制台
        console.error('Error occurred:', error);

        // 显示错误消息
        const message = error.message || 'An unknown error occurred.';
        this.matSnackBar.open(message, 'Close', {
            duration: 10000, // 持续时间2秒
        });
    }
}
  1. 练习并证明 Service 注入的时候的覆盖性,例如 component 级别的同名 Service 会覆盖 module 级别的,而 module 级别的则会覆盖 App.module.ts (根模块)的同名 Service. 证明思路如下:
  • 创建一个 errorHandlers 目录,然后在其中创建 DeviceDetailsErrorHandler.ts 和 UnImplementErrorHandler.ts 两个文件。
  • 这两个文件的内容其实就是对 ErrorHandler 类的扩展,或者对 ErrorHandler 服务的实现。内容如下:
// DeviceDetailsErrorHandler.ts
import { ErrorHandler, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';

@Injectable({
    providedIn: 'root'
})
export class DeviceDetailsErrorHandler implements ErrorHandler {
    constructor(private matSnackBar: MatSnackBar) { }

    handleError(error: Error): void {
        console.error('Error occurred:', error);

        const message = error.message || 'An unknown error occurred.';
        this.matSnackBar.open(message, 'Close', {
            duration: 10000,
        });
    }
}

// UnImplementErrorHandler.ts
import { ErrorHandler, Injectable } from "@angular/core";

@Injectable({
    providedIn: 'root'
})
export class UnImplementErrorHandler implements ErrorHandler {
    handleError(error: Error): void {
        console.error('Something goes wrong:', error);
    }
}
  • 然后在 根目录模块、 feature 模块 以及 组件的 provides 中分别使用相同 token 但是不同 recipe 的服务。
// App.module.ts
import { 
  ErrorHandler, 
  ...
} from '@angular/core';
...
  ...
  providers: [
      ...
      ErrorHandler,
  ],
  ...
...
// feature.module.ts
import { UnImplementErrorHandler } from '../errorHandlers/UnImplementErrorHandler';
...
  providers: [
       { provide: ErrorHandler, useClass: UnImplementErrorHandler },
  ]
  ...
...
// feature.component.ts
import { DeviceDetailsErrorHandler } from '../errorHandlers/DeviceDetailsErrorHandler';
...
  providers: [
    { provide: ErrorHandler, useClass: DeviceDetailsErrorHandler },
  ]
  ...
...
  • 最后在 components 的构造函数中注入 ErrorHandle 服务,并执行其上的函数,观察到底是哪个服务的方法被调用了。
  constructor(
    private _liveAnnouncer: LiveAnnouncer,
    public inventory: InventoryService,
    private fetchClient: FetchClient,
    private router: Router,
    private _snackBar: MatSnackBar,
    private routeStorageService: RouteStorageService,
    private langStorageService: LangStorageService,
    private deviceDataService: DeviceDataService,
    private errorHandler: ErrorHandler,
    private titleService: Title,
  ) {
    try {
      throw new Error('111')
      const currentPath = this.routeStorageService.getRouteStack().at(-1);
      this.deviceID = currentPath.split('/').at(-1);
      this.deviceDetails$ = this.deviceDataService.getDeviceData(this.deviceID);
    } catch (error) {
      this.deviceDetails$ = of({} as IDeviceDatials);
      this.errorHandler.handleError(new Error('Device id is not valid!'));
    }
  }

这里直接说结论哈:虽然注入代码是:private errorHandler: ErrorHandler, 但起作用的服务是:DeviceDetailsErrorHandler

  • 完成之后,沿相反路径依次注注释 provides 看看此时又如何? 会发现优先级规律为:component > feature module > app module
  1. 练习使用内置第三方服务 Title 并在合适的时机使用 Title Service 修改网页名称。这个本身不是很难,难点在于可能使用的其它平台框架中也使用了此服务修改网页名称,这个时候就需要一些雷霆手段了。直接在 component 中注入并使用即可,Title 这个服务应该是注入到全局的。
...
import { Title } from '@angular/platform-browser';
...
  constructor(
    ...
    private titleService: Title,
  ) {...}
  
  ngAfterViewInit() {
    setTimeout(() => this.titleService.setTitle(this.cardTitle), 5000);
  }
  ...
  1. 使用服务的时机:(1) 被多个组件所共享的逻辑功能应该放在服务中去;(2) 对于独立并且单一的功能的后续处理逻辑应该放在服务中去;(3) 能够被封装并且随处随时使用的功能和逻辑;(4) 和组件视图无关的代码应该放到服务中去;(5) 需要在不同的组件中共享的数据应该由服务完成。

  2. Injectors 的作用:(1) 向声明了需要使用某个服务类的组件,查找并提供此类的单例;(2) 正如(1)中所言,injectors 保证了提供的实例是单例;(3) 决定提供的单例是父类的哪个子类的单例(这里在强调可扩展性,即相同的 token 使用不同的 recipe 并且采用就近原则 recipe 之间相互覆盖); (4) 提供服务声明的查找路径,直到根模块。

  3. Injector 提供的服务声明查找路径:在 Angular 中,服务( Service)的提供和查找可以通过多种方式进行,每种方式对应不同的查找路径。示例:

    7-1. 组件视图提供: 在组件的模板中,你可以直接使用ViewChildrenContentChildren来查询并提供服务。

    @Component({
      selector: 'app-child-component',
      template: `
        <ng-container *ngFor="let service of services">
          <app-service-component [service]="service"></ng-container>
        </ng-container>
      `
    })
    export class ChildComponent {
      @ViewChildren(ServiceComponent) services: QueryList<ServiceComponent>;
    }
    

    7-2. 组件构造函数提供: 在组件的构造函数中直接注入服务。

    @Component({
      selector: 'app-my-component',
      template: `<p>My Component</p>`,
    })
    export class MyComponent {
      constructor(private myService: MyService) {}
    }
    

    7-3. 父组件提供: 在父组件中提供服务,子组件可以通过依赖注入获得。

    @Component({
      selector: 'app-parent-component',
      template: `<app-my-component></app-my-component>`,
    })
    export class ParentComponent {
      constructor(private myService: MyService) {}
    }
    

    7-4. 模块提供: 在模块的providers数组中声明服务,这样服务可以在模块内任何地方获得。

    @NgModule({
      providers: [MyService],
      declarations: [MyComponent],
      bootstrap: [MyComponent]
    })
    export class AppModule {}
    

    7-5. 自定义注入器提供: 创建自定义的Injector并提供服务。

    const customInjector = Injector.create({
      providers: [MyService],
      parent: platformBrowserDynamic().bootstrapModule(AppModule)
    });
    

    7-6. 根注入器提供: 使用providedIn: 'root'在服务的@Injectable()装饰器中声明,服务将在应用的根注入器中提供。

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

    7-7. 应用引导阶段提供: 在引导应用时,通过bootstrapApplication函数(或之前的bootstrap)提供服务。

    platformBrowserDynamic()
      .bootstrapModule(AppModule, {
        providers: [MyService]
      })
      .catch(err => console.error(err));