原文:Making your Angular apps fast Using Zones in Angular for better performance作者:Pascal PrechtAngular声称默认情况下速度非常快。“快”到底是什么意思?当然,这总是取决于上下文。我们的应用程序做什么?它在某一时刻做了多少不同的事情?应用程序的组件树是如何构造的?它引入了多少个绑定?当我们试图为应用程序提速时,就要考虑这些问题。本文将围绕一个demo来展开,我们会通过一些方法和技巧来优化它,实现提速。
渲染10000个可拖拽的SVG盒子
我们从demo本身开始。我们想要设计一个极限场景,它可以触及到Angular框架的边界,使得性能改进更容易可视化。它不一定是一个真实世界的场景,而是有足够的挑战性来展示我们可以做什么。所以在这个demo里,我们决定渲染10000个可拖动的SVG盒子。渲染10000个SVG盒子并不一定是一项艰巨而现实的任务,但是,当这些盒子中的每一个都需要是可拖动的时,它会变得非常有趣,因为每当有mousemove事件被触发时,Angular必须执行变化检测并重新渲染需要重新呈现的内容。有10000个盒子,这可能是一个相当大的工作。demo应用程序由两个组件组成:AppComponent 和 BoxComponent以下是AppComponent:
@Component({
selector: 'demo-app',
template: `
="mouseDown($event)"
(mouseup)="mouseUp($event)"
(mousemove)="mouseMove($event)">
"let box of boxes" [box]="box">
`
})
export class AppComponent implements OnInit {
currentId = null;
boxes = [];
offsetX;
offsetY;
ngOnInit() {
this.boxes = ... // generate 10000 random box coordinates
}
mouseDown(event) {
const id = Number(event.target.getAttribute("dataId"));
const box = this.boxes[id];
this.offsetX = box.x - event.clientX;
this.offsetY = box.y - event.clientY;
this.currentId = id;
}
mouseMove(event) {
event.preventDefault();
if (this.currentId !== null) {
this.updateBox(this.currentId, event.clientX + this.offsetX, event.clientY + this.offsetY);
}
}
mouseUp() {
this.currentId = null;
}
updateBox(id, x, y) {
const box = this.boxes[id];
box.x = x;
box.y = y;
}
}
先不要被代码搞晕,这段代码的重点就是3个:
我们有一个SVG元素,其中包含mousedown、mousemove和mouseup的事件处理
我们为盒子生成10000个随机坐标,并使用ngFor渲染
我们在mouseMove()中更新被拖动的盒子
@Component({
selector: '[box]',
template: `
[attr.dataId]="box.id"
[attr.x]="box.x"
[attr.y]="box.y"
width="20"
height="20"
stroke="black"
[attr.fill]="selected ? 'red' : 'transparent'"
strokeWidth="1">
`
})
export class BoxComponent {
@Input() box;
@Input() selected;
}
它实际上只是渲染一个SVG rect元素,并使用几个属性绑定来设置给定box对象的坐标。通过点击并拖动一个盒子,我们可以看到并感觉到这个demo相当简陋。我们先来测一下它到底有多快。
衡量应用性能
我们感兴趣的是运行时性能,以及当mousemove事件被触发时Angular执行任务所需的时间——因为这是Angular在拖放一个盒子时必须做的工作。要衡量这些东西是相当容易的。例如,Chrome的devtools附带了一个优秀的时间轴工具,它可以让我们在浏览器中很方便地评测JavaScript的执行情况和每帧的绘制时间。步骤很简单:打开devtools(ALT+CMD+I)
选择时间线(Timeline)
按右上角的“录制”(record)按钮或(CMD+E)
单击并拖动一个盒子
再次单击红色录制按钮停止录制
第1个 Profile, Event (mousemove): ~40ms, ~52ms (最快, 最慢)
第2个 Profile, Event (mousemove): ~45ms, ~61ms (最快, 最慢)
第3个 Profile, Event (mousemove): ~41ms, ~52ms (最快, 最慢)
如何提速
Angular是非常快的。然而,事实证明我们可以做很多事情来让我们的代码更快。以下是我们与Angular的核心团队成员Tobias合作提出的3个性能改进建议,Tobias主要从事编译器的工作,并且知道如何试项目变得更快。此外,Jordi Collell 也热心地贡献了一个很棒的方案,我们也补充到后面。提速方案:使用ChangeDetectionStrategy.OnPush:使用Angular的OnPush变化检测策略来节省每个变化检测任务的视图绑定时间
实现“简单”NgFor:Angular的NgFor指令有时有点太“智能”了,使用更简单的版本可能会更快
移除变化检测器:另一个选择是从组件树中移除所有变化检测器,只对实际发生更改的组件执行变化检测
使用Angular的NgZone API:在Angular Zone之外执行任务,避免Angular执行变化检测任务
使用OnPush变化检测策略
这可能是最显而易见的方法了。Angular的OnPush变化检测策略使我们能够减少Angular在应用程序发生更改时必须进行的检查数量。这样做的目的是使Angular仅在组件的某个输入发生更改时检查其视图绑定。在我们的demo中,我们只有两个组件——AppComponent和BoxComponent,并且只有BoxComponent接收输入。如果我们将BoxComponent的更改检测策略设置为OnPush,我们应该能够为每个盒子节省4个绑定的变化检测(4个绑定是因为BoxComponent视图中有4个属性绑定),10000个盒子那就是总共节省了39996个绑定的检测(只有一个盒子是正在更改的)。为了正确地使用OnPush,我们需要在代码中做两个微小的更改:设置变化检测策略,并确保BoxComponent的输入值是不可变(immutable)的。下面看看我们如何将变化检测策略设置为OnPush:
import {
Component,
Input,
ChangeDetectionStrategy
} from '@angular/core';
@Component({
selector: '[box]',
changeDetection: ChangeDetectionStrategy.OnPush, // set to OnPush
...
})
export class BoxComponent {
@Input() box;
@Input() selected;
}
为了使所有输入都不可变,我们只需在每次更新盒子时创建新的引用:
@Component(...)
class AppComponent {
...
updateBox(id, x, y) {
this.boxes[id] = { id, x, y }; // new references instead of mutation
}
}
目前还很难注意到实际的区别。这是因为之前未优化的demo已经相当快了。让我们再次测量一下,看看我们的应用程序是否更快:第1个 Profile, Event (mousemove): ~25ms, ~35ms (最快, 最慢)第2个 Profile, Event (mousemove): ~21ms, ~44ms (最快, 最慢)第3个 Profile, Event (mousemove): ~23ms, ~37ms (最快, 最慢)如我们所见,使用OnPush确实提高了我们的运行时性能。我们可能没有感觉到一个巨大的视觉差异,但通过数字可以看出,我们的应用程序现在大约是两倍的速度。考虑到我们只需要做的小改动,这是一个伟大的提速结果!事实证明,我们可以进一步改进。现在,所有10000个盒子都被检查过了,跳过的只是视图检查,这意味着还是有多余的39996个绑定检查。这是因为Angular需要检查组件才能找出输入是否已更改。我们还可以考虑引入某种分割模型,把10000个盒子分成10个,每个盒子有1000个盒子。这将使要检查的组件数量减少到只有999个。然而这不仅会使我们的代码更加复杂,还会改变应用程序的算法,而这正是我们要避免的。所以不考虑这种方案。
使用“简单”NgFor
另一件在我们的demo中并不明显的事情是:NgFor可能会占用更多的时间。这是什么意思?如果我们看一下NgFor的源代码,我们可以看到它不仅创建它迭代的DOM项,它还跟踪每个项的位置,以处理它被移动的情况。这对于动画效果来说是很棒的,可以使得进出的动画效果非常自然。然而,在我们的demo中,我们并没有真正移动集合中的元素。事实上,我们根本没有碰到它。那么,如果我们可以使用一个更简单的NgFor,它不关心集合中的元素位置呢?这就是我们的另一个方案:实现一个“简单”的NgFor。然而创建这样一个指令需要更多的工作,它不适合在本文展开,所以我们将在另一篇文章中讨论SimpleNgFor的实现。我们直接看效果: SimpleNgFor (不用 OnPush)第1个 Profile, Event (mousemove): ~45ms, ~50ms (最快, 最慢)
第2个 Profile, Event (mousemove): ~43ms, ~53ms (最快, 最慢)
第3个 Profile, Event (mousemove): ~42ms, ~50ms (最快, 最慢)
第1个 Profile, Event (mousemove): ~22ms, ~32ms (最快, 最慢)
第2个 Profile, Event (mousemove): ~22ms, ~39ms (最快, 最慢)
第3个 Profile, Event (mousemove): ~21ms, ~30ms (最快, 最慢)
从变化检测器树上移除变化检测器
Angular在处理过程中交给开发人员的控制是非常强大的。OnPush使我们能够决定在发生更改时跳过组件树更改检测的时间和位置。虽然这已经是超级强大,但事实证明,在某些情况下,这样做还是不够。这就是为什么Angular允许我们访问每个组件的ChangeDetectorRef,通过它我们可以完全启用或禁用变化检测。我们要处理10000个可拖动的SVG盒子,并且在每次更改(每个mousemove事件)时检查所有这些盒子。如前所述,OnPush并没有阻止Angular检查盒子本身,只阻止它们的视图变更检查。但是,如果我们可以完全关闭所有组件的变化检测,而只对实际移动的box组件执行变化检测呢?这显然会减少每个任务的工作量,因为我们不再检查10000个盒子,而只检查一个。怎么实现呢?我们要做的第一件事是,从变化检测树中移除组件的变化检测器。我们可以使用DI注入组件的ChangeDetectorRef,并使用其detach()方法。唯一要注意的的是,我们只想在第一次执行变化检测之后移除变化检测器,否则我们将看不到任何盒子。为了在适当的时候调用detach(),我们可以利用Angular的AfterViewInit生命周期钩子。先看AppComponent:
import { AfterViewInit, ChangeDetectorRef } from '@angular/core';
@Component(...)
class AppComponent implements AfterViewInit {
...
constructor(private cdr: ChangeDetectorRef) {}
ngAfterViewInit() {
this.cdr.detach();
}
}
我们对所有的盒子组件也做同样的处理:
@Component(...)
class BoxComponent implements AfterViewInit {
...
constructor(private cdr: ChangeDetectorRef) {}
ngAfterViewInit() {
this.cdr.detach();
}
}
酷,现在我们能看到所有的盒子,但是拖放不再起作用了。这是因为变化检测被完全关闭,不再执行任何事件的处理程序。下一步就是要确保对正在拖动的盒子执行变化检测。我们可以扩展BoxComponent,给它加一个update()方法,该方法执行变化检测,如下所示:
@Component(...)
class BoxComponent implements AfterViewInit {
...
update() {
this.cdr.detectChanges();
}
}
很酷,现在我们可以在需要时随时调用这个方法,基本上就是在我们的mouseDown()、mouseMove()和mouseUp()处理方法中调用。但是,我们如何访问被拖动的一个特定的box组件实例呢?仅仅依靠 this.boxes[id] 不再起作用了,因为它不是BoxComponent的实例。我们需要用这样一个实例来扩展事件对象,以便相应地访问它。这里有点小麻烦。我们希望从mousedown事件得到的DOM事件对象上可以得到BoxComponent实例,如下所示:
@Component(...)
export class AppComponent implements AfterViewInit {
...
mouseDown(event) {
const boxComponent = event.target['BoxComponent'];
const box = boxComponent.box;
const mouseX = event.clientX;
const mouseY = event.clientY;
this.offsetX = box.x - mouseX;
this.offsetY = box.y - mouseY;
this.currentBox = boxComponent;
boxComponent.selected = true;
boxComponent.update();
}
}
为了能在event对象(其中target是底层的SVG rect元素)中得到盒子组件实例,我们可以将其作为BoxComponent的视图子元素(view child)进行访问,并使用具有BoxComponent实例的新属性对其进行扩展。我们还向SVG元素添加一个局部模板变量#rect,以便ViewChild('rect')可以相应地查询它。
@Component({
selector: '[box]',
template: `
#rect ...>
`
})
export class BoxComponent implements AfterViewInit {
@ViewChild('rect')
set rect(value: ElementRef) {
if (value) {
value.nativeElement['BoxComponent'] = this;
}
}
...
}
太棒了!我们现在可以继续在所有需要的方法中使用BoxComponent.update()。
JavaScript的执行时间现在都是低于1ms,这是因为Angular只需检查用户拖动的box组件。这很好地展示了Angular为开发人员提供了多大的控制权,以及我们如何利用它满足我们的特定需求。
使用Angular的NgZone API
这是 Jordi Collell 提供的一个方案,再次感谢他的贡献。在我们使用Zone API 和 Angular的NgZone中的API之前,我们需要了解Zone什么,以及它们在Angular中的用途:Zone包装异步浏览器API,并在异步任务开始或结束时通知使用者。Angular利用这些API在完成任何异步任务时获得通知,运行变化检测。异步任务包括XHR调用、setTimeout()和几乎所有的用户事件,如click、submit、mousedown等等。一旦得到通知,Angular知道它必须执行变化检测,因为任何异步操作都可能更改了应用程序状态(例如当我们使用Angular的Http服务从远程服务器获取数据时)。以下代码片段显示了这样的调用如何更改应用程序状态:
@Component(...)
export class AppComponent {
data: any; // initial application state
constructor(private dataService: DataService) {}
ngOnInit() {
this.dataService.fetchDataFromRemoteService().subscribe(data => {
this.data = data // application state has changed, change detection needs to run now
});
}
}
好了,现在我们已经了解Zone的概念了,让我们看看如何使用Zone来加快我们的demo应用程序。我们知道,每当发生异步事件并且它有绑定的事件处理方法时,都会执行变化检测。这正是为什么我们最初的demo表现得相当简陋。让我们看看AppComponent的模板:
@Component({
...
template: `
="mouseDown($event)"
(mouseup)="mouseUp($event)"
(mousemove)="mouseMove($event)">
"let box of boxes" [box]="box">
`
})
class AppComponent {
...
}
有3个事件处理方法绑定到外部SVG元素。当这些事件中的任何一个被触发并且其处理方法被执行时,就会执行变化检测。事实上,这意味着即使我们只是将鼠标移动到这些盒子上而没有实际拖动一个盒子,Angular也会运行变化检测!这里就有NgZone APIs的用武之地了。NgZone使我们能够显式地在Angular的Zone之外运行某些代码,防止Angular运行任何变化检测。所以事件处理方法仍然会被执行,但是由于它们不在Angular的Zone内运行,Angular不会收到任务完成的通知,因此不会执行任何变化检测。而我们只想在释放拖动的盒子之后运行变化检测。那我们怎么做到这一点?我们要做的就是让mouseMove()事件处理方法在Angular的区域之外绑定和执行。除此之外,我们知道只有在选择要拖动的盒子时才要绑定该事件处理方法。换句话说,我们需要更改mouseDown()事件处理方法,以便强制地将该事件监听器添加到文档中。如下所示:
import { Component, NgZone } from '@angular/core';
@Component(...)
export class AppComponent {
...
element: HTMLElement;
constructor(private zone: NgZone) {}
mouseDown(event) {
...
this.element = event.target;
this.zone.runOutsideAngular(() => {
window.document.addEventListener('mousemove', this.mouseMove.bind(this));
});
}
mouseMove(event) {
event.preventDefault();
this.element.setAttribute('x', event.clientX + this.clientX + 'px');
this.element.setAttribute('y', event.clientX + this.clientY + 'px');
}
}
我们注入NgZone并在mouseDown()事件处理方法中调用runOutsideAngular(),在该事件处理方法中,我们为mousemove事件附加一个事件处理方法。这样可以确保mousemove事件处理方法实际上只在选择盒子时附加到文档。此外,我们保存了对单击盒子的底层DOM元素的引用,以便在mouseMove()方法中更新其x和y属性。我们使用的是DOM元素,而不是具有x和y绑定的box对象,因为绑定不会被检测到,因为我们在Angular的Zone之外运行代码。换句话说,我们确实更新了DOM,所以我们可以看到盒子在移动,但实际上我们并没有更新盒子的模型(model)。另外,请注意,我们从组件的模板中移除了mouseMove()绑定。我们也可以删除mouseUp()处理方法并强制附加它,就像我们对mouseMove()处理方法所做的那样。但是在性能方面,它不会增加任何价值,因此为了简单起见,我们决定将其保留在模板中:
<svg (mousedown)="mouseDown($event)" (mouseup)="mouseUp($event)"> <svg:g box *ngFor="let box of boxes" [box]="box"> </svg:g></svg>
在下一步中,我们要确保,每当我们释放一个盒子(mouseUp)时,我们都会更新盒子的模型(model),另外,我们还希望执行变化检测,以便模型(model)再次与视图(view)同步。NgZone最酷的地方不仅是它允许我们在Angular的Zone之外运行代码,它还提供了在Angular Zone内运行代码的API,这最终将导致Angular再次执行变化检测。我们要做的就是调用NgZone.run()并给出应该执行的代码。以下是优化后的mouseUp()事件处理方法:
@Component(...)
export class AppComponent {
...
mouseUp(event) {
// Run this code inside Angular's Zone and perform change detection
this.zone.run(() => {
this.updateBox(this.currentId, event.clientX + this.offsetX, event.clientY + this.offsetY);
this.currentId = null;
});
window.document.removeEventListener('mousemove', this.mouseMove);
}
}
还请注意,我们在每个mouseUp上删除了mousemove事件的事件监听器。否则,事件处理方法仍将在每次鼠标移动时执行,这会导致在手指被提起后,盒子也会继续移动;而且还会导致事件处理方法堆积,这不仅会造成奇怪的副作用,而且会耗尽我们的运行时内存。下面看看性能测量:
第1个 Profile, Event (mousemove): ~0.45ms, ~0.50ms (最快,最慢)
第2个 Profile, Event (mousemove): ~0.39ms, ~0.52ms (最快,最慢)
第3个 Profile, Event (mousemove): ~0.38ms, ~0.45ms (最快,最慢)
总结
Angular在默认情况下是非常快的,但它仍然为我们提供了可以提升应用程序性能的工具,它提供了很多控制如何执行变化检测的工具。我们还可以做点什么来提升性能吗?以上结果证明是可以的。关于demo演示:以上所有的demo演示都是在Angular的开发模式下执行的。我们可以打开生产模式,这样可以进一步提高性能,因为它确保只运行一次变化检测,而不是在开发模式下运行两次。但是要注意,以上所展示的技巧并不是银弹。它们可能对您的特定用例起作用,也可能不起作用。希望你能从中得到启发,学会如何让你的应用程序更快!欢迎大家踊跃投稿,提出建议帮助前端周刊做得更好。
投稿方式:直接分享文章的链接给周刊组成员。
关于我们:我们是晓教育集团大教学前端团队,是一个年轻的团队。我们支持了集团几乎所有的教学业务。现伴随着事业群的高速发展,团队也在迅速扩张,欢迎各位前端高手加入我们~
我们希望你是:技术上基础扎实、某领域深入;学习上善于沉淀、持续学习;性格上乐观开朗、活泼外向。
如有兴趣加入我们,欢迎发送简历至邮箱:liushan@xiao100.com