量子角:通过消除区域来最大化性能
实验:以最小的努力从 Angular 中移除 Zone,以提高运行时性能。
本文最初由Giancarlo Buomprisco发表于Bits and Pieces网站。
作为 Angular 开发者,我们非常感谢 Zone:正是由于这个库,我们才能近乎神奇地使用 Angular;事实上,大多数时候我们只需要更改一个属性,它就能正常工作,Angular 会自动重新渲染组件,视图始终保持最新状态。真是太棒了。
在本文中,我想探讨一些方法,说明新的 Angular Ivy 编译器(版本 9 中发布)将如何使应用程序无需 Zone 即可运行,这比过去要简单得多。
因此,我通过使用 Typescript 的装饰器,尽可能少地增加开销,从而大幅提高了应用程序在高负载下的性能。
注意:本文所述方法仅在Angular Ivy 和默认启用的 AOT环境下才能实现。本文仅供学习交流,并非旨在推广文中描述的代码。
提示:使用Bit(GitHub)可以轻松逐步构建 Angular 组件库。跨项目协作开发可重用组件,从而加快开发速度、保持用户界面一致性并编写更具可扩展性的代码。
使用 Angular 而不使用 Zone 的理由
不过,稍等片刻:为了方便我们轻松重新渲染模板,禁用Zone功能值得吗?是的,它非常有用,但正如常言所说,魔法是有代价的。
如果您的应用程序需要特殊的性能目标,禁用Zone可以帮助提高应用程序的性能:性能可以真正改变游戏规则的一个例子是高频更新,这是我在开发实时交易应用程序时遇到的问题,其中 WebSocket 不断地向客户端发送消息。
从 Angular 中移除 Zone
不使用 Zone 运行 Angular 非常简单。第一步是注释掉或删除 polyfills.ts 文件中的 import 语句:
第二步是使用以下选项引导根模块:
platformBrowserDynamic()
.bootstrapModule(AppModule, {
ngZone: 'noop'
})
.catch(err => console.error(err));
Angular Ivy:使用 ɵdetectChanges 和 ɵmarkDirty 手动检测更改
在开始构建 Typescript 装饰器之前,我们需要了解 Ivy 如何允许我们绕过 Zone 和 DI,并通过将组件标记为脏来触发组件的变更检测。
现在我们可以使用从 @angular/core 导出的另外两个函数:ɵdetectChanges和ɵmarkDirty。这两个函数目前仍供私有使用,且不稳定,因此它们都带有前缀字符 ɵ。
让我们来看一个它们如何被使用的例子。
ɵmarkDirty
该函数会将组件标记为脏(例如需要重新渲染),并会在将来某个时间安排一次变更检测,除非它已被标记为脏。
import { ɵmarkDirty as markDirty } from '@angular/core';
@Component({...})
class MyComponent {
setTitle(title: string) {
this.title = title;
markDirty(this);
}
}
ɵ检测变化
出于效率考虑,内部文档不建议使用`ɵdetectChanges`,而建议改用`ɵmarkDirty`。此函数将同步触发组件和子组件的变更检测。
import { ɵdetectChanges as detectChanges } from '@angular/core';
@Component({...})
class MyComponent {
setTitle(title: string) {
this.title = title;
detectChanges(this);
}
}
使用 TypeScript 装饰器自动检测更改
虽然 Angular 提供的功能通过允许我们绕过依赖注入 (DI) 来提升开发者体验,但我们可能仍然会对需要导入并手动调用这些功能才能触发变更检测这一事实感到不满。
为了简化自动变更检测,我们可以编写一个 TypeScript 装饰器来实现这个功能。当然,它也有一些局限性(稍后会看到),但就我的情况而言,它已经足够用了。
介绍 @observed 装饰器
为了以最小的努力检测变化,我们将构建一个可以通过三种方式应用的装饰器:
-
同步方法
-
到可观察的
-
到对象
我们来看两个简单的例子。在下面的图片中,我们将@observed装饰器应用于状态对象和changeName方法。
-
为了检查状态对象的更改,我们在底层使用代理来拦截对象的更改并触发更改检测。
-
我们重写了changeTitle方法,使其包含一个首先调用该方法,然后触发变更检测的函数。
下面我们以BehaviorSubject为例:
对于 Observable 来说,情况稍微复杂一些:我们需要订阅 Observable 并在订阅中将组件标记为已销毁,但还需要清理它。为此,我们需要重写ngOnInit和ngOnDestroy方法来订阅,然后在订阅完成后清理订阅。
让我们一起建造吧!
以下是观察到的装饰器的签名:
export function observed() {
return function(
target: object,
propertyKey: string,
descriptor?: PropertyDescriptor
) {}
}
如上所示,描述符是可选的,因为我们希望装饰器同时应用于方法和属性。如果定义了该参数,则表示装饰器将应用于方法:
-
我们存储原始方法的值
-
我们重写了该方法:我们调用原始函数,然后调用markDirty( this )来触发变更检测。
if (descriptor) {
const original = descriptor.value; // store original
descriptor.value = function(...args: any[]) {
original.apply(this, args); // call original
markDirty(this);
};
} else {
// check property
}
接下来,我们需要检查我们正在处理的属性类型:是 Observable 还是对象。现在我们来介绍 Angular 提供的另一个私有 API,我当然不应该使用它(抱歉!):
- 属性ɵcmp使我们能够访问 Angular 处理的定义后属性,我们可以使用这些属性来重写组件的onInit和onDestroy 方法。
const getCmp = type => (type).ɵcmp;
const cmp = getCmp(target.constructor);
const onInit = cmp.onInit || noop;
const onDestroy = cmp.onDestroy || noop;
为了将属性标记为“待观察”,我们使用ReflectMetadata并将其值设置为 true,以便我们知道在组件初始化时需要观察该属性:
Reflect.set(target, propertyKey, true);
现在需要重写onInit钩子函数,并在实例化时检查其属性:
cmp.onInit = function() {
checkComponentProperties(this);
onInit.call(this);
};
让我们定义函数checkComponentProperties,它将遍历组件的属性,并根据我们之前使用Reflect.set设置的值进行筛选:
const checkComponentProperties = (ctx) => {
const props = Object.getOwnPropertyNames(ctx);
props.map((prop) => {
return Reflect.get(target, prop);
}).filter(Boolean).forEach(() => {
checkProperty.call(ctx, propertyKey);
});
};
`checkProperty`函数负责对各个属性进行修饰。首先,我们要检查该属性是 Observable 类型还是对象。如果是 Observable 类型,则订阅它,并将该订阅添加到组件私有存储的订阅列表中。
const checkProperty = function(name: string) {
const ctx = this;
if (ctx[name] instanceof Observable) {
const subscriptions = getSubscriptions(ctx);
subscriptions.add(ctx[name].subscribe(() => {
markDirty(ctx);
}));
} else {
// check object
}
};
如果该属性是一个对象,则将其转换为代理,并在其处理函数中调用 markDirty。
const handler = {
set(obj, prop, value) {
obj[prop] = value;
ɵmarkDirty(ctx);
return true;
}
};
ctx[name] = new Proxy(ctx, handler);
最后,我们希望在组件销毁时清理订阅:
cmp.onDestroy = function() {
const ctx = this;
if (ctx[subscriptionsSymbol]) {
ctx[subscriptionsSymbol].unsubscribe();
}
onDestroy.call(ctx);
};
这个装饰器并不全面,无法涵盖大型应用程序所需的所有情况(例如,返回 Observable 的模板函数调用,但我正在努力解决这个问题……)。
不过,这足以完成我的小型应用程序的转换。完整的源代码可以在本文末尾找到。
绩效结果及考量
现在我们已经对 Ivy 的内部机制以及如何构建一个利用其 API 的装饰器有了一些了解,是时候在实际应用程序上进行测试了。
我使用我的试验项目Cryptofolio来测试添加和删除 Zone 带来的性能变化。
我已将装饰器应用于所有需要的模板引用,并移除了 Zone。例如,请参见以下组件:
- 模板中使用的两个变量是价格(数值)和趋势(上涨、停滞、下跌),我都用 @observed 标签进行了标记。
@Component({...})
export class AssetPricerComponent {
@observed() price$: Observable<string>;
@observed() trend$: Observable<Trend>;
// ...
}
捆绑包大小
首先,我们来看一下移除 Zone.js 后,打包后的文件大小会减少多少。下图展示了包含 Zone 的构建结果:
以下数据是在未使用 Zone 的情况下拍摄的:
考虑到 ES2015 包,很明显 Zone 占用了近 35kB 的空间,而没有 Zone 的包只有 130 字节。
初始负载
我用 Lighthouse 进行了一些审计,没有进行任何限流:我建议不要太认真对待以下结果:事实上,在我尝试对结果进行平均时,结果波动很大。
不过,软件包大小的差异也可能导致未安装 Zone 的版本得分略高。以下审核结果是在安装了 Zone 的情况下进行的:
以下内容则未使用 Zone 功能:
运行时性能🚀
现在到了最有趣的部分:负载下的运行时性能。我们要测试CPU在每秒多次更新数百个价格时的表现。
为了测试应用程序的负载能力,我创建了大约 100 个价格生成器,每个生成器都会发出模拟数据,价格每 250 毫秒变化一次。价格上涨时显示为绿色,下跌时显示为红色。这会给我的 MacBook Pro 带来相当大的负载。
我在金融领域从事多个高频流媒体应用方面的工作,这种情况我遇到过很多次。
我使用 Chrome 开发者工具分析了每个版本的 CPU 使用率。我们先从 Angular with Zone 开始:
以下内容未包含区域信息:
让我们分析一下以上内容,并看一下 CPU 使用率图表(黄色图表):
-
正如你所见,在区域版本中,CPU 使用率始终保持在 70% 到 100% 之间!持续监控这种负载,它肯定会崩溃。
-
第二个例子中,使用率稳定在 30% 到 40% 之间。太好了!
注意:以上结果是在打开开发者工具的情况下获得的,这会降低性能。
增加负荷
我接着尝试每秒更新每个定价器中的另外 4 个价格:
-
非区域版本在 CPU 使用率达到 50% 的情况下仍然能够轻松应对负载。
-
我通过每 10 毫秒更新一次价格(x 100 个定价器)使 CPU 负载接近 Zone 版本。
使用 Angular Benchpress 进行基准测试
以上并非最科学的基准测试,也不是为了达到这个目的,所以我建议您查看此基准测试,并取消选中除 Angular 和 Zoneless Angular 之外的所有框架。
我从中汲取了一些灵感,并创建了一个执行一些繁重操作的项目,我用Angular Benchpress对其进行了基准测试。
让我们来看看被测组件:
@Component({...})
export class AppComponent {
public data = [];
@observed()
run(length: number) {
this.clear();
this.buildData(length);
}
@observed()
append(length: number) {
this.buildData(length);
}
@observed()
removeAll() {
this.clear();
}
@observed()
remove(item) {
for (let i = 0, l = this.data.length; i < l; i++) {
if (this.data[i].id === item.id) {
this.data.splice(i, 1);
break;
}
}
}
trackById(item) {
return item.id;
}
private clear() {
this.data = [];
}
private buildData(length: number) {
const start = this.data.length;
const end = start + length;
for (let n = start; n <= end; n++) {
this.data.push({
id: n,
label: Math.random()
});
}
}
}
然后我使用 Protractor 和 Benchpress 运行一个小型基准测试套件:它会执行指定的操作次数。
结果
以下是该工具返回的输出示例:
以下是对输出结果中各项指标的解释:
- gcAmount: gc amount in kbytes
- gcTime: gc time in ms
- majorGcTime: time of major gcs in ms
- pureScriptTime: script execution time in ms, without gc nor render
- renderTime: render time in ms
- scriptTime: script execution time in ms, including gc and render
注意:以下图表仅显示渲染时间。完整的输出结果可在以下链接中找到。
测试:创建 1000 行
第一个测试创建了 1000 行:
测试:创建 10000 行
随着负载越来越重,我们可以看到更大的差异:
测试:追加 1000 行
此测试将 1000 行添加到包含 10000 行的列表中:
测试:删除 10000 行
此测试会创建 10000 行数据并将其删除:
结语
虽然我希望您喜欢这篇文章,但我也希望我没有说服您立刻跑到办公室,把 Zone 从您的项目中移除:如果您计划提高 Angular 应用程序的性能,那么这种策略应该是您最不应该做的事情。
诸如 OnPush 变更检测、trackBy、分离组件、在 Zone 之外运行以及将 Zone 事件列入黑名单等技术(以及其他许多技术)都应该优先考虑。这些技术的优缺点权衡很大,而且这可能是你不想付出的代价。
事实上,除非你完全掌控项目(例如,你拥有依赖项,并且有自由和时间来管理开销),否则在没有 Zone 的情况下进行开发仍然会相当令人生畏。
如果其他方法都失败了,并且你认为 Zone 可能确实是一个瓶颈,那么手动检测更改来进一步提升 Angular 的性能可能是一个好主意。
我希望这篇文章能让你对 Angular 的未来发展方向、Ivy 的功能以及如何绕过 Zone 来最大限度地提高应用程序的速度有一个清晰的了解。
源代码
TypeScript 装饰器的源代码可以在其Github 项目页面找到:
资源
-
无区域基准测试项目(zone 分支包含带有 Zone 的代码)
如果您需要任何澄清,或者您认为有什么不清楚或错误的地方,请务必留言!
希望您喜欢这篇文章!如果您喜欢,请在Medium、Twitter或我的网站上关注我,获取更多关于软件开发、前端、RxJS、Typescript 等方面的文章!
文章来源:https://dev.to/gc_psk/quantum-angular-maximizing-performance-by-removing-zone-1nag















