现代Angular是一个功能强大的框架,具有许多功能,乍一看,随之而来的是复杂的概念和机制。 对于刚开始在原理上,特别是在Angular上开始工作的人们来说,这一点尤其明显。
大约两年前,当我来到Tinkoff担任Junior Frontend Developer的职位并跳入Angular的世界时,我也遇到了同样的问题。 因此,我为您提供有关五件事的简短故事,一开始对它们的理解将极大地促进我的工作。

依赖注入(DI)
最初,我进入组件,发现类构造函数中有一些参数。 我对类方法的工作进行了一些分析,很明显,这些是一些外部依赖关系。 但是他们如何上课的呢? 构造函数在哪里叫?
我建议立即理解一个示例,但是为此,我们需要一堂课。 如果在“普通” JavaScript OOP中存在某些“ hacks”,则与ES6一起使用“真实”语法。 Angular直接使用TypeScript,其语法大致相同。 因此,我建议进一步使用它。
想象一下,我们的应用程序中有一个JokerService
类来管理笑话。 getJokes()
方法返回一个笑话列表。 假设我们在三个地方使用它。 如何在代码中的三个不同地方开玩笑? 有几种方法:
- 在每个地方创建该类的实例。 但是,为什么我们需要堵塞内存并创建这么多相同的服务? 如果有100个座位?
- 使方法静态,并使用JokerService.getJokes()检索数据。
- 实现一种设计模式。 如果我们需要将服务作为整个应用程序的一部分,那么它将是Singleton。 但是为此,您需要在类中编写新的逻辑。
因此,我们有三个非常可行的选择。 第一个不适合我们-在这种情况下,它是无效的。 我们不想创建多余的副本,因为它们将完全相同。 剩下两个选择。
让我们复杂化任务以了解哪种方法最适合我们。 假设在第三位,由于某种原因,我们需要使用某些参数创建自己的服务。 这可能是特定作者,玩笑的时长,语言等等。 那我们该怎么办?
对于static方法,您必须在每次调用时传递设置,因为该类在所有地方都是通用的。 也就是说,在每次对getJokes()
调用中,我们都将传递该位置唯一的所有参数。 当然,最好在实例化时传递它们,然后只调用getJokes()
方法。
事实证明,第二种选择也不适合我们:它将使我们总是在每个地方重复很多代码。 它仍然仅是Singleton,这将再次需要更新逻辑,但要有所不同。 但是如何理解我们需要哪种选择呢?
如果您认为您可以简单地创建一个对象并使用该键来提供所需的服务,那么我可以向您表示祝贺:您刚刚意识到依赖注入通常是如何工作的。 但是,让我们更深入一点。
为了确保需要一种机制来帮助我们获取必要的实例,请想象JokerService还需要另外两个其他服务,其中一个是可选的,第二个应该在特定位置给出特殊结果。 这并不困难。
Angular中的依赖注入
如文档所述 ,DI是应用程序的重要设计模式。 Angular有自己的依赖框架,Angular本身使用了它来提高效率和模块化。
一般来说, 依赖注入是一种强大的机制,其中类从外部某个地方接收必要的依赖,而不是自己创建实例。
让语法和带有html
扩展名的文件不要让您感到困惑。 Angular中的每个组件都是一个常规的JavaScript对象,它是一个类的实例。 一般而言:将组件插入模板时,将创建组件类的实例。 因此,此刻,您可以将必要的依赖项传递给构造函数。 现在考虑一个示例:
@Component({ selector: 'jokes', template: './jokes.template.html', }) export class JokesComponent { private jokes: Observable<IJoke[]>; constructor(private jokerService: JokerService) { this.jokes = this.jokerService.getJokes(); } }
在组件构造函数中,我们仅表示需要JokerService
。 我们不是自己创建的。 如果还有五个使用它的组件,那么它们都将引用同一实例。 所有这些使我们节省了时间,省去了样板并编写了高效的应用程序。
提供者
现在,我建议在需要获取服务的不同实例时处理该情况。 首先,看一下服务本身:
@Injectable({ providedIn: 'root', // , «» }) export class JokerService { getJokes(): Observable<IJoke[]> { // } }
如果服务是整个应用程序的一项,则此选项就足够了。 但是,如果说有两个JokerService
实现, JokerService
怎么JokerService
? 还是仅出于某种原因,特定组件需要其自己的服务实例? 答案很简单: provider
。
为了方便起见,我将provider
provider ,然后将检查将值替换为类的过程。 因此,我们可以在不同的地方和不同的地方提供服务。 让我们从最后一个开始。 共有三个选项:
- 进入整个应用程序-在服务装饰器本身中指定
provideIn: 'root'
。 - 在模块中-将服务装饰器中的提供者指定为
provideIn: JokesModule
或者将@NgModule providers: [JokerService]
模块的装饰器中的@NgModule providers: [JokerService]
。 - 在组件中-与模块一样,在组件的装饰器中指定提供程序。
选择的地方取决于您的需要。 我们找出了位置,让我们继续进行机制本身。 如果我们只是在服务中指定了provideIn: root
,那么这将等效于模块中的以下条目:
@NgModule({ // ... providers: [{provide: JokerService, useClass: JokerService}], }) //
可以这样写:“如果请求了JokerService
,则提供JokerService»
类的实例JokerService»
从这里您可以通过各种方式获取特定实例:
通过令牌-您需要指定InjectionToken
并在其上获得服务。 请注意,在下面provide
的示例中,您可以传递相同的令牌:
const JOKER_SERVICE_TOKEN = new InjectionToken<string>('JokerService'); // ... [{provide: JOKER_SERVICE_TOKEN, useClass: JokerService}];
按班级-您可以替换班级。 例如,我们将请求JokerService
,并提供JokerHappyService
:
[{provide: JokerService, useClass: JokerHappyService}];
按值-您可以立即返回所需的实例:
[{provide: JokerService, useValue: jokerService}];
按工厂-您可以用访问工厂时将创建所需实例的工厂替换类:
[{provide: JokerService, useFactory: jokerServiceFactory}];
仅此而已。 也就是说,要使用特殊实例解决该示例,可以使用上述任何一种方法。 选择最适合您的需求。
顺便说一下,DI不仅适用于服务,而且通常适用于在组件构造函数中获得的任何实体。 这是一个非常强大的机制,应发挥其全部潜力。
小总结
为了全面理解,我建议使用服务示例逐步考虑Angular中简化的依赖注入机制:
- 初始化应用程序时,服务具有令牌。 如果我们未在提供程序中专门指定它,则为JokerService。
- 当在组件中请求服务时,DI机制将检查是否存在转移的令牌。
- 如果令牌不存在,则DI将引发错误。 在我们的例子中,令牌存在并且JokerService位于其上。
- 创建组件时,会将JokerService的实例作为参数传递给构造函数。
变更检测
作为使用框架的论据,我们经常听到诸如“框架将为您做所有事情-更快,更高效。 您无需考虑任何事情。 只需管理数据即可。” 对于一个非常简单的应用程序,也许是正确的。 但是,如果必须使用用户输入并不断处理数据,则只需要知道检测更改和渲染的过程是如何工作的即可。
在Angular中, 变更检测负责检查变更。 由于进行了各种操作(更改类属性的值,完成异步操作,响应HTTP请求等),因此验证过程在整个组件树中开始。
由于该过程的主要目标是了解如何重新呈现组件,因此本质是验证模板中使用的数据。 如果它们不同,则模板将标记为“已更改”,并将重新绘制。
Zone.js
了解Angular如何跟踪类属性和同步操作非常简单。 但是,它如何跟踪异步? 由Angular开发人员之一创建的Zone.js库对此负责。
这就是它。 区域本身就是一个“执行上下文”,说白了就是执行代码的位置和状态。 异步操作完成后,回调函数将在其注册的同一区域中执行。 因此,Angular会发现更改发生的位置以及要检查的内容。
Zone.js用其实现替换了几乎所有本机异步函数和方法。 因此,它可以跟踪何时callback
异步函数的callback
。 也就是说,Zone告诉Angular何时何地开始更改验证过程。
变更检测策略
我们了解了Angular如何监视组件并运行更改检查。 现在,假设您有一个包含数十个组件的大型应用程序。 对于每次单击,每个异步操作,每个成功执行的请求,都会在整个组件树中启动检查。 这样的应用程序很可能会出现严重的性能问题。
Angular开发人员对此进行了思考,并为我们提供了建立更改检测策略的机会,正确选择更改策略可以显着提高生产率。
有两个选项可供选择:
- 默认-顾名思义,这是为每个操作启动CD时的默认策略。
- OnPush是一种仅在少数情况下启动CD的策略:
- 如果
@Input()
的值已更改; - 如果组件或其后代内部发生了事件;
- 如果检查是手动开始的;
- 如果新事件到达异步管道。
根据我自己在Angular上的开发经验以及我的同事的经验,我可以肯定地说,指定OnPush
策略总是更好,除非确实需要default
。 这将为您带来几个好处:
- 对CD过程如何工作有清晰的了解。
- 整洁地使用
@Input()
属性。 - 性能提升。
像其他流行的框架一样,Angular使用下游数据流。 该组件接受使用@Input()
装饰器标记的输入参数。 考虑一个例子:
interface IJoke { author: string; text: string; } @Component({ selector: 'joke', template: './joke.template.html', }) export class JokeComponent { @Input() joke: IJoke; }
假设上面描述的组件可以显示笑话和作者的文字。 编写本文的问题是您可能会意外地或专门地突变所传输的对象。 例如,覆盖文本或作者。
setAuthorNameOnly() { const name = this.joke.author.split(' ')[0]; this.joke.author = name; }
我马上注意到这是一个不好的例子,但它清楚地表明了可能发生的情况。 为了防止此类错误,您需要使输入参数为只读。 因此,您将了解如何正确使用数据并创建CD。 基于此,编写类的最佳方法如下所示:
@Component({ selector: 'joke', template: './joke.template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class JokeComponent { @Input() readonly joke: IJoke; @Output() updateName = new EventEmitter<string>(); setAuthorNameOnly() { const name = this.joke.author.split(' ')[0]; this.updateName.emit(name); } }
所描述的方法不是规则,而只是推荐。 在许多情况下,这种方法会带来不便和无效。 随着时间的流逝,您将学会了解在哪种情况下您可以拒绝提议的处理输入方法。
Rxjs
当然,我可能是错的,但是似乎ReactiveX和反应式编程通常是一种新趋势。 Angular屈服于这种趋势(或者可能是创建了这种趋势),并且默认情况下使用RxJS。 整个框架的基本逻辑都在此库上运行,因此了解反应式编程的原理非常重要。
但是什么是RxJS? 它结合了我将用一种相当简单的语言揭示的三个想法,但有一些遗漏之处:
- “观察者”模式是产生事件的实体,并且有一个侦听器接收有关这些事件的信息。
- 迭代器模式 -允许您顺序访问对象的元素而无需揭示其内部结构。
- 用集合进行函数式编程是一种模式,其中逻辑分解成很小且非常简单的组件,每个组件只能解决一个问题。
结合使用这些模式,我们可以非常简单地描述乍一看很复杂的算法,例如:
private loadUnreadJokes() { this.showLoader(); // fromEvent(document, 'load') .pipe( switchMap( () => this.http .get('/api/v1/jokes') // .pipe(map((jokes: any[]) => jokes.filter(joke => joke.unread))), // ), ) .subscribe( (jokes: any[]) => (this.jokes = jokes), // error => { /* */ }, () => this.hideLoader(), // ); }
所有漂亮的压痕仅18行。 现在尝试在Vanilla或至少jQuery上重写此示例。 几乎100%的空间至少会占用您两倍的空间,而且不会那么富有表现力。 在这里,您可以随心所欲地阅读代码,就像读一本书一样。
可观察的
理解任何数据都可以表示为流并不是立即出现的。 因此,我建议举一个简单的类比。 假设流是按时间排序的数据数组。 例如,在本实施例中:
const observable = []; let counter = 0; const intervalId = setInterval(() => { observable.push(counter++); }, 1000); setTimeout(() => { clearInterval(intervalId); }, 6000);
我们将认为数组中的最后一个值是相关的。 每秒钟将一个数字添加到数组。 我们如何才能在应用程序的其他地方发现已将元素添加到数组中? 在正常情况下,我们将调用某种callback
并更新其上数组的值,然后仅获取最后一个元素。
由于采用了反应式编程,因此不仅无需编写许多新逻辑,也无需考虑更新信息。 可以将其与简单的侦听器进行比较:
document.addEventListener('click', event => {});
您可以在整个应用程序中放置很多EventListener
,并且它们会起作用,除非您当然会故意相反。
反应性编程也可以。 在一个地方,我们仅创建一个数据流并定期在其中放置新值,而在另一个地方,我们订阅该数据流并仅侦听这些值。 也就是说,我们始终了解并可以处理此更新。
现在让我们看一个真实的例子:
export class JokesListComponent implements OnInit { jokes$: Observable<IJoke>; authors$ = new Subject<string[]>(); unread$ = new Subject<number>(); constructor(private jokerService: JokerService) {} ngOnInit() { // , subscribe() this.jokes$ = this.jokerService.getJokes(); this.jokes$.subscribe(jokes => { this.authors$.next(jokes.map(joke => joke.author)); this.unread$.next(jokes.filter(joke => joke.unread).length); }); } }
由于这种逻辑,当更改jokes
数据时,我们会自动更新未读笑话数和作者列表中的数据。 如果您有更多的组件,其中一个组件收集有关一位作者阅读的笑话数量的统计信息,而第二个组件则计算笑话的平均长度,那么好处就显而易见了。
试验台
开发人员迟早会理解,如果项目不是MVP,则需要编写测试。 编写的测试越多,其描述越清晰,越详细,进行更改和实现新功能的过程就越容易,更快,更可靠。
Angular可能预见到了这一点,并为我们提供了强大的测试工具。 首先,许多开发人员尝试从一开始就掌握某种技术,而无需阅读文档。 我做了同样的事情,这就是为什么我很晚才意识到“开箱即用”所有可用测试功能的原因。
您可以在Angular中测试任何内容,但是如果您只需要实例化并开始调用方法来测试常规类或服务,则组件的情况就完全不同了。
正如我们已经发现的,由于DI依赖关系被带到了组件之外。 一方面,这使整个系统有些复杂,另一方面,它为我们提供了建立测试和检查许多案例的巨大机会。 我建议理解一个组件的示例:
@Component({ selector: 'app-joker', template: '<some-dependency></some-dependency>', styleUrls: ['./joker.component.less'], }) export class JokerComponent { constructor( private jokesService: JokesService, @Inject(PARTY_TOKEN) private partyService: PartyService, @Optional() private sleepService: SleepService, ) {} makeNewFriend(): IFriend { if (this.sleepService && this.sleepService.isSleeping) { this.sleepService.wakeUp(); } const joke = this.jokesService.generateNewJoke(); this.partyService.goToParty('Pacha'); this.partyService.toSay(joke.text); const laughingPeople = this.partyService.getPeopleByReaction('laughing'); const girl = laughingPeople.find(human => human.sex === 'female'); const friend = this.partyService.makeFriend(girl); return friend; } }
因此,在当前示例中,存在三种服务。 一个以通常的方式导入,一个通过令牌导入,另一个服务是可选的。 我们如何配置测试模块? 我将立即显示完成的视图:
beforeEach(async(() => { TestBed.configureTestingModule({ imports: [SomeDependencyModule], declarations: [JokerComponent], // , providers: [{provide: PARTY_TOKEN, useClass: PartyService}], }).compileComponents(); fixture = TestBed.createComponent(JokerComponent); component = fixture.componentInstance; fixture.detectChanges(); // , }));
TestBed
允许我们对所需模块进行完整的仿真。 您可以将任何服务连接到其中,替换模块,从组件中获取类的实例,等等。 现在我们已经配置了模块,让我们继续进行可能性。
可以避免不必要的依赖
Angular应用程序由模块组成,这些模块可以包括其他模块,服务,指令等。 实际上,在测试中,我们需要重新创建模块的操作。 如果在我们的示例中,我们在模板中使用<some-dependency></some-dependency>
,则这意味着我们也必须将SomeDependencyModule
导入到测试中。 如果那里有瘾? 因此,它们也需要导入。
如果应用程序很复杂,那么将会有很多这样的依赖关系。 导入所有依赖项将导致以下事实:在每个测试中,将定位整个应用程序,并将调用所有方法。 也许这不适合我们。
至少有一种摆脱必要依赖关系的方法-只需重写模板即可。 假设您具有屏幕快照测试或集成测试,并且无需测试组件的外观。 然后,只需检查方法的操作就足够了。 在这种情况下,您可以编写如下配置:
TestBed.configureTestingModule({ declarations: [JokerComponent], providers: [{provide: PARTY_TOKEN, useClass: PartyService}], }) .overrideTemplate(JokerComponent, '') // , .compileComponents();
是的,这并不适合所有人。在Tinkoff内部,我们同意仅在不需要检查组件显示的情况下使用此方法。例如,仅在处理数据或与参与者通信时。如果需要检查如何将数据传输到子组件,或例如如何处理用户输入,则此选项将不起作用。如果您遇到这种情况,请转到下一段。
您可以从构造函数中弄清所有依赖项
我们已经熟悉了Injection Token,所以我建议立即开始业务。在上面的示例中,我已经在测试中检查了令牌服务。如果您不是在编写集成测试,那么调用真实服务的方法就没有意义,只需进行模拟即可。
ts-mockito
, , . Angular « ».
// export class MockPartyService extends PartyService { meetFriend(): IFriend { return {} as IFriend; } goToParty() {} toSay(some: string) { console.log(some); } } // ... TestBed.configureTestingModule({ declarations: [JokerComponent, MockComponent], providers: [{provide: PARTY_TOKEN, useClass: MockPartyService}], // }).compileComponents();
仅此而已。 .
. , — , — . , :
— . , . — .
总结
Angular, . , , «».
, Angular - . HTTP-, , lazy-loading . Angular .