HarmonyOS开发者限时福利来啦!最高10w+现金激励等你拿~ 了解详情
写点什么

在 Angular 中实现自定义独立 API 的模式

作者:Manfred Steyer

  • 2023-02-10
    北京
  • 本文字数:9383 字

    阅读完需:约 31 分钟

在Angular中实现自定义独立API的模式

与独立组件(Standalone Component)一起,Angular 团队引入了独立 API(Standalone API)。它支持以一种更加轻量级的方式实现库。目前,提供独立 API 的样例是HttpClientRouter。另外,NGRX 也是该理念的早期采用者。


在本文中,我会介绍几种编写自定义独立 API 的模式,这些模式的灵感来源于上述的几个库。对于每个模式,我都会讨论如下话题:模式背后的意图、描述、实现样例、在上述库中出现的样例场景以及实现细节的变种。


大多数模式对库作者会特别有用。对库的消费者来说,它们有助于改善 DX。但是,对于应用来说,这就有点大材小用了。


本文的源码请参考该地址:https://github.com/manfredsteyer/standalone-example-cli.git。


样例


为了阐述这些推断出来的模式,我会使用一个简单的 Logger 库。这个库尽可能简单,但又足以阐述这些模式。



每条日志消息都有一个LogLevel,它是由枚举定义的:


export enum LogLevel {  DEBUG = 0,  INFO = 1,  ERROR = 2,}
复制代码


为了简单起见,我们将 Logger 库限制为只有三个日志级别。


我们会使用一个抽象LoggerConfig来定义可能的配置选项:


export abstract class LoggerConfig {  abstract level: LogLevel;  abstract formatter: Type<LogFormatter>;  abstract appenders: Type<LogAppender>[];}
复制代码


我们有意将其定义为抽象类,因为接口无法作为 DI 的令牌(token)。该类的一个常量定义了配置选项的默认值:


export const defaultConfig: LoggerConfig = {  level: LogLevel.DEBUG,  formatter: DefaultLogFormatter,  appenders: [DefaultLogAppender],};
复制代码


LogFormatter用于在通过LogAppender发布日志消息之前对其进行格式化:


export abstract class LogFormatter {  abstract format(level: LogLevel, category: string, msg: string): string;}
复制代码


LoggerConfiguration类似,LogFormatter是一个抽象类,可以用作令牌。Logger 库的消费者可以通过提供自己的实现来调整格式,也可以使用库提供的默认实现:


@Injectable()export class DefaultLogFormatter implements LogFormatter {  format(level: LogLevel, category: string, msg: string): string {    const levelString = LogLevel[level].padEnd(5);    return `[${levelString}] ${category.toUpperCase()} ${msg}`;  }}
复制代码


LogAppender是另一个可替换的概念,它会负责将日志消息追加到日志中:


export abstract class LogAppender {  abstract append(level: LogLevel, category: string, msg: string): void;}
复制代码


默认实现会将日志消息打印至控制台。


@Injectable()export class DefaultLogAppender implements LogAppender {  append(level: LogLevel, category: string, msg: string): void {    console.log(category + ' ' + msg);  }}
复制代码


尽管我们只能有一个LogFormatter,但是这个库支持多个LogAppender。例如,第一个LogAppender可以将消息写到控制台,而第二个可以将消息发送至服务器。


为了实现这一点,各个LogAppender是通过多个提供者(provider)注册的。所以,Injector 在一个数组中将它们全部返回。因为数组无法作为 DI 令牌,所以样例使用了一个InjectionToken来代替:


export const LOG_APPENDERS = new InjectionToken<LogAppender[]>("LOG_APPENDERS");
复制代码


LoggserService本身会通过 DI 来接收LoggerConfigLogFormatter和包含LogAppender的数组,并允许为多个LogLevel记录日志信息:


@Injectable()export class LoggerService {  private config = inject(LoggerConfig);  private formatter = inject(LogFormatter);  private appenders = inject(LOG_APPENDERS);
log(level: LogLevel, category: string, msg: string): void { if (level < this.config.level) { return; } const formatted = this.formatter.format(level, category, msg); for (const a of this.appenders) { a.append(level, category, formatted); } } error(category: string, msg: string): void { this.log(LogLevel.ERROR, category, msg); }
info(category: string, msg: string): void { this.log(LogLevel.INFO, category, msg); }
debug(category: string, msg: string): void { this.log(LogLevel.DEBUG, category, msg); }}
复制代码


黄金法则


在开始介绍推断出的模式之前,我想强调一下提供服务的黄金法则:


只要有可能,就使用 @Injectable({providedIn: ‘root’})


在库中有些场景也应该使用这种方式,它提供了一些我们想要的特征:简单、支持摇树(tree-shakable),并且能够与懒加载协作。最后一项特征与其说是 Angular 的优点,不如说是底层打包器的优点:在懒加载包(bundle)中需要的所有内容都会放在这里。


模式:提供者工厂(Provider Factory)


意图


  • 为可重用库提供服务

  • 配置可重用库

  • 替换预定义的实现细节

描述


提供者工厂是一个函数,它会为给定的库返回一个包含提供者的数组。这个数组会被转换为 Angular 的EnvironmentProviders类型,以确保提供者只能在环境作用域内使用,具体来讲就是根作用域以及懒路由配置引入的作用域。


Angular 和 NGRX 将这些函数放在provider.ts文件中。


样例


如下的提供者函数(Provider Function)provideLogger会接收一个LoggerConfiguration,并使用它来创建一些提供者:


export function provideLogger(  config: Partial<LoggerConfig>): EnvironmentProviders {  // using default values for missing properties  const merged = { ...defaultConfig, ...config };
return makeEnvironmentProviders([ { provide: LoggerConfig, useValue: merged, }, { provide: LogFormatter, useClass: merged.formatter, }, merged.appenders.map((a) => ({ provide: LOG_APPENDERS, useClass: a, multi: true, })), ]);}
复制代码


缺失的配置会使用默认配置的值。Angular 的makeEnvironmentProviders会将Provider数组包装到一个EnvironmentProviders实例中。


这个函数允许消费库的应用在引导过程中像使用其他库(如HttpClientRouter)那样设置 logger:


bootstrapApplication(AppComponent, {  providers: [
provideHttpClient(),
provideRouter(APP_ROUTES),
[...]
// Setting up the Logger: provideLogger(loggerConfig), ]}
复制代码


使用场景和变种


  • 在所有经过验证的库中,这是一个常用的模式。

  • RouterHttpClient的提供者工厂有第二个可选参数,以提供额外的特性(参见下文的特性模式)。

  • NGRX 支持为 reducer 提供令牌或具体的对象,而不是传入具体的服务实现(如LogFormatter)。

  • HttpClient能够通过with函数(参见下文的特性模式)获取函数化拦截器的数组。这些函数也会被注册为服务。

模式:特性(Feature)


意图


  • 激活或配置可选的特性

  • 使这些特性支持摇树

  • 通过当前环境作用域提供底层服务

描述


提供者工厂会接收一个包含特性对象的可选数组。每个特性对象都有一个叫做kind的标识符和一个providers数组。kind属性允许校验传入特性的组合。比如,可能会存在互斥的特性,如为HttpClient同时提供配置 XSRF 令牌处理和禁用 XSRF 令牌处理的特性。


样例


我们的样例使用了一个着色的特性,为不同的LoggerLevel显示不同的颜色:



为了对特性进行分类,我们使用了一个枚举:


export enum LoggerFeatureKind {    COLOR,    OTHER_FEATURE,    ADDITIONAL_FEATURE}
复制代码


每个特性都使用LoggerFeature对象来表示:


export interface LoggerFeature {  kind: LoggerFeatureKind;  providers: Provider[];}
复制代码


为了提供着色特性,引入了遵循with Feature命名模式的工厂函数:


export function withColor(config?: Partial<ColorConfig>): LoggerFeature {  const internal = { ...defaultColorConfig, ...config };
return { kind: LoggerFeatureKind.COLOR, providers: [ { provide: ColorConfig, useValue: internal, }, { provide: ColorService, useClass: DefaultColorService, }, ], };}
复制代码


提供者工厂通过可选的第二个参数接收多个特性,它们定义为rest数组:


export function provideLogger(  config: Partial<LoggerConfig>,  ...features: LoggerFeature[]): EnvironmentProviders {  const merged = { ...defaultConfig, ...config };
// Inspecting passed features const colorFeatures = features?.filter((f) => f.kind === LoggerFeatureKind.COLOR)?.length ?? 0;
// Validating passed features if (colorFeatures > 1) { throw new Error("Only one color feature allowed for logger!"); }
return makeEnvironmentProviders([ { provide: LoggerConfig, useValue: merged, }, { provide: LogFormatter, useClass: merged.formatter, }, merged.appenders.map((a) => ({ provide: LOG_APPENDERS, useClass: a, multi: true, })),
// Providing services for the features features?.map((f) => f.providers), ]);}
复制代码


特性中kind属性用来检查和验证传入的特性。如果一切正常的话,特性中发现的提供者会被放到返回的EnvironmentProviders对象中。


DefaultLogAppender能够通过依赖注入获取着色特性提供的ColorService


export class DefaultLogAppender implements LogAppender {  colorService = inject(ColorService, { optional: true });
append(level: LogLevel, category: string, msg: string): void { if (this.colorService) { msg = this.colorService.apply(level, msg); } console.log(msg); }}
复制代码


由于特性是可选的,DefaultLogAppenderoptional: true传入到了inject中。如果特性不可用的话会遇到异常。除此之外,DefaultLogAppender还需要对null值进行检查。


使用场景和变种


  • Router使用了它,比如用来配置预加载或激活调试跟踪。

  • HttpClient使用了它,比如提供拦截器、配置 JSONP 和配置 / 禁用 XSRF 令牌的处理。

  • RouterHttpClient都将可能的特性组合成了一个联合类型(如export type AllowedFeatures = ThisFeature | ThatFeature)。这能够帮助 IDE 提示内置特性。

  • 有些实现注入到了当前Injector,并使用它来查找配置了哪些特性。这是对使用optional: true的一种命令式替换。

  • Angular 的特性实现在kindproviders属性上添加了ɵ前缀,因此将它们声明成了内部属性。


模式:配置提供者工厂(Configuration Provider Factory)


  • 配置现存的服务。

  • 提供额外的服务并将它们与现存的服务注册到一起。

  • 在嵌套环境作用域中扩展服务的行为。

描述


配置提供者工厂能够扩展现存服务的行为。它们可以提供额外的服务,并使用ENVIRONMENT_INITIALIZER来获取所提供的服务以及要扩展的现存服务的实例。


样例


我们假设有个扩展版本的LoggerService,可以为每个日志类别定义一个额外的LogAppender


@Injectable()export class LoggerService {
private appenders = inject(LOG_APPENDERS); private formatter = inject(LogFormatter); private config = inject(LoggerConfig); [...]
// Additional LogAppender per log category readonly categories: Record<string, LogAppender> = {};
log(level: LogLevel, category: string, msg: string): void {
if (level < this.config.level) { return; }
const formatted = this.formatter.format(level, category, msg);
// Lookup appender for this very category and use // it, if there is one: const catAppender = this.categories[category];
if (catAppender) { catAppender.append(level, category, formatted); }
// Also, use default appenders: for (const a of this.appenders) { a.append(level, category, formatted); }
}
[...]}
复制代码


为了给某个类别配置LogAppender,可以引入另外一个提供者工厂:


export function provideCategory(  category: string,  appender: Type<LogAppender>): EnvironmentProviders {  // Internal/ Local token for registering the service  // and retrieving the resolved service instance  // immediately after.  const appenderToken = new InjectionToken<LogAppender>("APPENDER_" + category);
return makeEnvironmentProviders([ { provide: appenderToken, useClass: appender, }, { provide: ENVIRONMENT_INITIALIZER, multi: true, useValue: () => { const appender = inject(appenderToken); const logger = inject(LoggerService);
logger.categories[category] = appender; }, }, ]);}
复制代码


这个工厂为LogAppender类创建了一个提供者。但是,我们并不需要这个类,而是需要它的一个实例。同时,我们还需要Injector解析该示例的依赖。这两者均需要在通过注入检索LogAppender时提供。


确切地讲,这是通过ENVIRONMENT_INITIALIZER实现的,它是绑定到ENVIRONMENT_INITIALIZER令牌并指向某个函数的多个提供者。它能够获取注入的LogAppenderLoggerService。然后,LogAppender会被注册到 logger 上。


这样,就能扩展甚至来自父作用域的现有LoggerService。例如,如下的样例假设LoggerService在根作用域中,而额外的日志级别是在懒加载路由中设置的:


export const FLIGHT_BOOKING_ROUTES: Routes = [  {    path: '',    component: FlightBookingComponent,
// Providers for this route and child routes // Using the providers array sets up a new // environment injector for this part of the // application. providers: [ // Setting up an NGRX feature slice provideState(bookingFeature), provideEffects([BookingEffects]),
// Provide LogAppender for logger category provideCategory('booking', DefaultLogAppender), ], children: [ { path: 'flight-search', component: FlightSearchComponent, }, [...] ], },];
复制代码


使用场景和变种


  • @ngrx/store使用该模式来注册特性切片(slice)。

  • @ngrx/effects使用该模式来装配特性提供的效果。

  • withDebugTracing特性使用该模式订阅Routerevents Observable。

模式:NgModule 桥


意图


  • 当切换到独立 API 时,不能破坏使用NgModules的现有代码。

  • 支持上述类型的应用通过来自提供者工厂的EnvironmentProviders设置应用的部分功能。

描述


NgModule 桥是一个通过提供者工厂衍生的 NgModule。为了让调用者对服务有更多的控制权,可以使用像forRoot这样的静态方法。这些方法也可以接收一个配置对象。


样例


如下的NgModules以传统的方式设置 Logger。


@NgModule({  imports: [/* your imports here */],  exports: [/* your exports here */],  declarations: [/* your delarations here */],  providers: [/* providers, you _always_ want to get, here */],})export class LoggerModule {  static forRoot(config = defaultConfig): ModuleWithProviders<LoggerModule> {    return {      ngModule: LoggerModule,      providers: [        provideLogger(config)      ],    };  }
static forCategory( category: string, appender: Type<LogAppender> ): ModuleWithProviders<LoggerModule> { return { ngModule: LoggerModule, providers: [ provideCategory(category, appender) ], }; }}
复制代码


当使用 NgModules 时,这种方式是很常用的,所以消费者可以利用现有的知识和惯例。


使用场景和变种


  • 我们检查过的库均使用这种模式以保持向后兼容性。

模式:服务链


意图


  • 使服务能够委托给父作用域中的另一个实例。

描述


当同一个服务被放在多个嵌套的环境 injector 中时,我们通常只能得到当前作用域的服务实例。因此,在嵌套作用域中,对服务的调用无法反映到父作用域中。为了解决这个问题,服务可以在父作用域中查找自己的实例并将调用委托给它。


样例


假设为一个懒加载的路由再次提供了日志库:


export const FLIGHT_BOOKING_ROUTES: Routes = [  {    path: '',    component: FlightBookingComponent,    canActivate: [() => inject(AuthService).isAuthenticated()],    providers: [      // NGRX      provideState(bookingFeature),      provideEffects([BookingEffects]),
// Providing **another** logger for this part of the app: provideLogger( { level: LogLevel.DEBUG, chaining: true, appenders: [DefaultLogAppender], }, withColor({ debug: 42, error: 43, info: 46, }) ),
], children: [ { path: 'flight-search', component: FlightSearchComponent, }, [...] ], },];
复制代码


在这里,我们在懒加载路由及其子路由中的环境 injector 中设置了另外一套 Logger 的服务。该服务会屏蔽掉父作用域中对应的服务。因此,当懒加载作用域中的组件调用LoggerService时,父作用域中的服务不会被触发。


为了防止这种情况,可以从父作用域中获取LoggerService。更准确地说,这不一定是父作用域,而是提供LoggerService的“最近的祖先作用域”。随后,该服务可以委托给它的父服务。这样,服务就被链结起来了。


@Injectable()export class LoggerService {  private appenders = inject(LOG_APPENDERS);  private formatter = inject(LogFormatter);  private config = inject(LoggerConfig);
private parentLogger = inject(LoggerService, { optional: true, skipSelf: true, }); [...]
log(level: LogLevel, category: string, msg: string): void {
// 1. Do own stuff here [...]
// 2. Delegate to parent if (this.config.chaining && this.parentLogger) { this.parentLogger.log(level, category, msg); } } [...]}
复制代码


当使用inject来获取父 LoggerService 时,我们需要传递optional: true,避免祖先作用域在没有提供LoggerService时出现异常。传递skipSelf: true能够确保只有祖先作用域会被搜索。否则,Angular 会从当前作用域开始进行搜索,因此会返回调用服务本身。


另外,上述的样例允许通过LoggerConfiguration中的新标记chaining启用或停用这种行为。


使用场景和变种


  • HttpClient使用这种模式可以在父作用域中触发HttpInterceptor。关于链式HttpInterceptor的更多细节,可以参阅 该文。在这里,链式行为可以通过一个单独的特性来激活。从技术上讲,这个特性注册了另一个拦截器,将调用委托给了父作用域中的服务。

模式:函数式服务


意图


  • 通过使用函数即服务,使库的使用更加轻量级。

  • 通过使用临时函数来减少间接影响。

描述


库能够避免强迫消费者按照给定的接口实现基于类的服务,而是允许使用函数。在内部,它们可以使用useValue注册服务。


样例


在本例中,消费者可以直接传入一个函数,作为LogFormatter传递给provideLogger


bootstrapApplication(AppComponent, {  providers: [    provideLogger(      {        level: LogLevel.DEBUG,        appenders: [DefaultLogAppender],
// Functional CSV-Formatter formatter: (level, cat, msg) => [level, cat, msg].join(";"), }, withColor({ debug: 3, }) ), ],});
复制代码


为了允许这样做,Logger 需要使用LogFormatFn类型来定义函数的签名:


export type LogFormatFn = (  level: LogLevel,  category: string,  msg: string) =>
复制代码


同时,因为函数不能用作令牌,所以需要引入InjectionToken


export const LOG_FORMATTER = new InjectionToken<LogFormatter | LogFormatFn>(  "LOG_FORMATTER");
复制代码


这个InjectionToken既支持基于类的LogFormatter,也支持函数式的LogFormatter


这可以防止破坏现有的代码。为了支持这两种情况,providerLogger需要以稍微不同的方式处理这两种情况:


export function provideLogger(config: Partial<LoggerConfig>, ...features: LoggerFeature[]): EnvironmentProviders {
const merged = { ...defaultConfig, ...config};
[...]
return makeEnvironmentProviders([ LoggerService, { provide: LoggerConfig, useValue: merged },
// Register LogFormatter // - Functional LogFormatter: useValue // - Class-based LogFormatters: useClass (typeof merged.formatter === 'function' ) ? { provide: LOG_FORMATTER, useValue: merged.formatter } : { provide: LOG_FORMATTER, useClass: merged.formatter },
merged.appenders.map(a => ({ provide: LOG_APPENDERS, useClass: a, multi: true })), [...] ]);}
复制代码


基于类的服务是用useClass注册的,而对于函数式服务,则需要使用useValue


此外,LogFormatter的消费者需要为函数式和基于类的方式进行调整:


@Injectable()export class LoggerService {  private appenders = inject(LOG_APPENDERS);  private formatter = inject(LOG_FORMATTER);  private config = inject(LoggerConfig);
[...]
private format(level: LogLevel, category: string, msg: string): string { if (typeof this.formatter === 'function') { return this.formatter(level, category, msg); } else { return this.formatter.format(level, category, msg); } }
log(level: LogLevel, category: string, msg: string): void { if (level < this.config.level) { return; }
const formatted = this.format(level, category, msg);
[...] } [...]}
复制代码


使用场景和变种


  • HttpClient允许使用函数式拦截器。它们是通过一个特性注册的(参见特性模式)。

  • Router允许使用函数来实现守卫和解析器。


原文链接:


https://www.angulararchitects.io/en/aktuelles/patterns-for-custom-standalone-apis-in-angular

相关阅读:

2023 重学 Angular

Angular v15 发布:可以脱离 NgModules 构建组件了

AngularJS 进阶 (二十五)requirejs + angular + angular-route 浅谈 HTML5 单页面架

谈谈企业级前端 Angular 应用的定制化二次开发话题

2023-02-10 13:504890

评论

发布
暂无评论
发现更多内容

Android事件分发机制三:事件分发工作流程

android 程序员 移动开发

Android低版本上APP首次启动时间减少80%(一)

android 程序员 移动开发

基于 Istio 的全链路灰度方案探索和实践

阿里巴巴云原生

阿里云 云原生 istio 灰度 全链路

Android内存泄漏问题

android 程序员 移动开发

Android动画之补间动画

android 程序员 移动开发

android各种提示Dialog 弹出框(1)

android 程序员 移动开发

Android平台HTTPS抓包全方案

android 程序员 移动开发

Android体系化进阶学习图谱:我们究竟还要学习哪些Android知识?(某大厂内部资料

android 程序员 移动开发

Android使用ViewPager实现图片轮播系列之四:手动滑动 + 左右箭头 + 删除数据

android 程序员 移动开发

Android开发3年,4个月面试,终于拿到小米、京东

android 程序员 移动开发

Android企业级实战-界面篇-2

android 程序员 移动开发

Android屏幕适配方案

android 程序员 移动开发

Android属性动画——ObjectAnimator类及浮动菜单的实现

android 程序员 移动开发

Android仿QQ锁屏状态下消息提醒(震动+提示音)

android 程序员 移动开发

Android刘海屏、水滴屏全面屏适配方案

android 程序员 移动开发

基于 OpenYurt & EdgeX Foundry 的云边端一体化解决方案

阿里巴巴云原生

云原生 边缘计算 openyurt EdgeX Foundry

Android刘海屏、水滴屏全面屏适配详解

android 程序员 移动开发

Android厂商推送冲突了。。。

android 程序员 移动开发

android各种提示Dialog 弹出框

android 程序员 移动开发

Android备忘录《内存泄漏》

android 程序员 移动开发

Android岗高频面试题二集,看你能答出几题?(含答案

android 程序员 移动开发

Android常见问题及开发经验总结(一)

android 程序员 移动开发

Android开发 申请Mob的SMSSDK的短信验证码功能中获取MD5签名(更新中)

android 程序员 移动开发

android图片加载库Glide4使用教程(项目中如何快速将Glide3替换成Glide4)

android 程序员 移动开发

消息队列RocketMQ应对双十一流量洪峰的“六大武器”

阿里巴巴云原生

阿里云 RocketMQ 云原生 消息队列 流量

Android中自定义下拉样式Spinner

android 程序员 移动开发

android之Fragment(官网资料翻译)

android 程序员 移动开发

Android修炼系列(十二),自定义一个超顺滑的回弹RecyclerView

android 程序员 移动开发

Android命令Monkey压力测试,详解

android 程序员 移动开发

Android应用进程间通信之Messenger信使使用及源码浅析

android 程序员 移动开发

Android开发3年,九月份面试12家大厂跳槽成功,我有一些面试经验想分享给你们

android 程序员 移动开发

在Angular中实现自定义独立API的模式_大前端_InfoQ精选文章