Angular复兴
Angular

高级进阶

Guo Eagle
#Angular

接下来我简单的列举一些使用 Angular 依赖注入比较重要的高级技巧,也是官方文档中涵盖的知识点:

image.png

轻量级注入令牌

在我们开发类库的时候,支持摇树优化是一个重要的特性,要减少体积,那么在 Angular 类库中需要做以下几点:

令牌什么时候会被保留

那么在同一个组件模块中,提供了很多个组件,如果只想打包被使用的组件如何做呢?

比如我们定义如下的一个 card 组件,包含了 header,同时 card 组件中需要获取 header 组件实例引用。

<lib-card>
  <lib-header>...</lib-header>
</lib-card>
@Component({
  selector: 'lib-header',
  ...,
})
class LibHeaderComponent {}

@Component({
  selector: 'lib-card',
  ...,
})
class LibCardComponent {
  @ContentChild(LibHeaderComponent)
  header: LibHeaderComponent | null = null;
}

因为<lib-header>是可选的,所以元素可以用最小化的形式<lib-card></lib-card>出现在模板中,在这个例子中, <lib-header>没有用过,你可能期望它会被摇树优化掉。

但是因为代码中出现了如下的一段导致无法被优化:

@ContentChild(LibHeaderComponent) header: LibHeaderComponent;

编译器对这些位置的令牌引用的处理方式时不同的。

什么时候使用轻量级注入令牌模式

当一个组件被用作注入令牌时,就会出现摇树优化的问题,有两种情况:

class MyComponent {
  constructor(@Optional() other: OtherComponent) {}

  @ContentChild(OtherComponent)
  other: OtherComponent | null;
}

使用轻量级注入令牌

解决上述的问题最好就是引入轻量级注入令牌设计模式: 使用一个小的抽象类作为注入令牌,并在稍后为它提供实际实现,该抽象类固然会被留下(不会被摇树优化掉),但它很小,对应用程序的大小没有任何重大影响。

abstract class LibHeaderToken {}

@Component({
  selector: 'lib-header',
  providers: [
    {provide: LibHeaderToken, useExisting: LibHeaderComponent}
  ]
  ...,
})
class LibHeaderComponent extends LibHeaderToken {}

@Component({
  selector: 'lib-card',
  ...,
})
class LibCardComponent {
  @ContentChild(LibHeaderToken) header: LibHeaderToken|null = null;
}

总结一下,轻量级注入令牌模式由以下几部分组成。

使用轻量级注入令牌进行 API 定义

为了有类型提示,我们可以为这个轻量级令牌定义函数和属性,不管这个抽象类加多少个 API 定义都不会影响体积,因为 TS 编译后类型都会丢失,加类型只是为了在开发模式下类型更加安全而已。

abstract class LibHeaderToken {
  name: string;
  abstract doSomething(): void;
}

@Component({
  selector: 'lib-header',
  providers: [
    {provide: LibHeaderToken, useExisting: LibHeaderComponent}
  ]
  ...,
})
class LibHeaderComponent extends LibHeaderToken {
  doSomething(): void {
    // Concrete implementation of `doSomething`
  }
}

@Component({
  selector: 'lib-card',
  ...,
})
class LibCardComponent implement AfterContentInit {
  @ContentChild(LibHeaderToken)
  header: LibHeaderToken|null = null;

  ngAfterContentInit(): void {
    this.header && this.header.doSomething();
  }

轻量级注入令牌命名

轻量级注入令牌只对组件有用。

解决组件循环引用

使用轻量级 Token 不仅仅可以减少体积,还可以解决循环引用的问题,具体可以查看 https://angular.cn/errors/NG3003

惰性加载特性模块

默认情况下,NgModule都是急性加载的,也就是说它会在应用加载时尽快加载。

对于带有很多路由的大型应用,肯定会使用惰性加载(一种按需加载 NgModule 的模式)。

惰性加载入门

路由定义时使用loadChildren,动态 import 并返回模块:

const routes: Routes = [
  {
    path: 'items',
    loadChildren: () => import('./items/items.module').then((m) => m.ItemsModule)
  }
];

惰性加载模块使用forChild定义路由:

RouterModule.forChild([
   {
     path: '',
     component: ItemsComponent
   }
]),

确保从AppModule中移除了ItemsModule模块。

如何设置惰性加载

建立惰性加载的特性模块有两个主要步骤:

懒加载和急性加载的区别?

唯一区别就是会:创建子ModuleInjector

意味着所有的 providers 和 imports 模块的 providers 都是独立的,急性模块并不知道懒加载模块的 providers。

forRoot() 模式

forRoot()forChild()的区别?

如果模块同时定义了providers(服务)和declarations(组件、指令、管道),那么,当你同时在多个懒加载的特性模块中引入此模块时,这些服务就会被注册在多个地方。这会导致出现多个服务实例,并且该服务的行为不再像单例一样。

防止这种现象:

static forRoot(config: UserServiceConfig): ModuleWithProviders<GreetingModule> {
  return {
    ngModule: GreetingModule,
    providers: [
      {provide: UserServiceConfig, useValue: config }
    ]
  };
}

我们在开发类库或者使用第三库时经常会用到forRoot模式,比如官方的路由模块,这种模式的本质还是因为惰性加载的模块会独立创建子模块注入器,但是模块中的组件/指令/管道和服务处理模式不一样导致,这也是 Angular 模块一大难点之一(也可以说是坑)。

providedIn: 'any'

通过使用providedIn: 'any',所有急性加载的模块都会共享同一个服务单例,但是惰性加载模块各自有它们自己独有的单例。和在模块中使用providers提供依赖的效果是类似的,区别就是会摇树优化。

image.png

ReflectiveInjector 和 StaticInjector

在 Angular V5 版本之前,内部的注入器是 ReflectiveInjector ,服务不需要通过 @Injectable 标记也可以被使用

class B {}

class A {
  constructor(@Inject(B) b) {}
}

const i = ReflectiveInjector.resolveAndCreate([A, B]);
const a = i.get(A);

下面是Inject装饰器实现的代码片段:

function ParamDecorator(cls: any, unusedKey: any, index: number) {
  ...
  // parameters here will be [ {token: B} ]
  Reflect.defineMetadata('parameters', parameters, cls);
  return cls;
}

ReflectiveInjector依赖于Reflect对象提供的反射能力,来搜集隐式依赖,并通过 reflect-metadata 增强包实现相关功能,但是这种处理方式有一些问题:

使用StaticInjector的代码如下:

class B {}
class A { constructor(b) {} }
const i = Injector.create([{provide: A, useClass: A, deps: [B]]};
const a = i.get(A);

为什么叫静态注入器,是因为很多依赖关系在编译时就已经确定,我不需要在运行时通过反射获取。

注入组件/指令/模块/管道

在 Angular 中不仅仅服务可以注入,所有的内置装饰器@Component()@Directive()@Module()@Pipe()等都可被注入,注入的解析逻辑和服务一样,先从 ElementInjector层级找,再从ModuleInjector层级找,这些都是 Angular 框架底层提供的能力,其实已经超出了依赖注入本身的范畴,所以为什么很难把StaticInjector独立出去呢,因为很多功能和 Angular 的视图强绑定的。

防止重复导入 CoreModule

只有根模块AppModule才能导入CoreModule,如果一个惰性加载模块也导入了它, 该应用就会为服务生成多个实例,要想防止惰性加载模块重复导入CoreModule,可以添加如下的CoreModule构造函数。

// src/app/core/core.module.ts
constructor(@Optional() @SkipSelf() parentModule?: CoreModule) {
  if (parentModule) {
    throw new Error(
      'CoreModule is already loaded. Import it in the AppModule only');
  }
}

派生类注入服务

如果基础组件有依赖注入,必须要在派生类中重新提供和重新注入它们,并将它们通过构造函数传给基类。

原则:

image.png image.png

forwardRef 打破循环

这是一个很有意思的问题,本质上和 Angular 无关,应该是 JS 特性有关。

Javascript 中的 Hoisting(变量提升)

我们简单通过下面三个示例了解一下 JS 中的变量提升

console.log(num); // 打印 undefined
var num;
num = 6;
console.log(square(5)); // 会打印出 25
/* ... */
function square(n) {
  return n * n;
}
console.log(square); // 打印 undefined
console.log(square(5)); // 抛出异常 Uncaught TypeError: square is not a function
const square = function (n) {
  return n * n;
};

通过上述的示例可以得出一下结论:

那么 class 是 ES 2015 的新特性,它的行为和函数不一样,class 不会被提升

因为提升会带来一些列问题,比如如下代码,是否还有其他原因暂时没有过多了解。

const Foo = class {};
class Bar extends Foo {}
// class 如果提升的话这段代码就会报错

组件注入 NameService

既然 class 不会提升变量,那么如果我在组件后面加一个服务,在 providers 中设置注入提供者就会报错: Class ‘NameService’ used before its declaration.

@Component({
  selector: 'app-forward-ref',
  templateUrl: './forward-ref.component.html',
  styleUrls: ['./forward-ref.component.scss'],
  providers: [
    {
      provide: NameService,
      useClass: NameService
    }
  ]
})
export class ForwardRefComponent {}

@Injectable()
class NameService {
  name = 'why520crazy';
}

解决这个问题有 2 个办法:

forwardRef实现原理很简单,就是让provide存储一个闭包的函数,在定义式不调用,在注入的时候获取 Token 再调用闭包函数返回NameService的类型,此时 JS 已经完整执行过,NameService已经定义。

那么此处大家可以想一个有意思的问题,如果在AClass的装饰器MyDecorator传入参数AClass会和上面的结果一样,报 **Class ‘AClass’ used before its declaration. ** 错误吗?

@MyDecorator(AClass)
class AClass {}

function MyDecorator(type: Function) {
  return function (target: any) {};
}

答案是: 不会

原因是: TypeScript 在转换装饰器的时候,会把装饰器函数放到类定义的后面。

← 返回博客