没有zone.js的Angular:最高性能

Angular开发人员应归功于zone.js。 例如,她在与Angular合作时帮助实现了几乎神奇的轻松。 实际上,几乎总是,当您只需要更改某些属性,而我们在不考虑任何事情的情况下更改它时,Angular会重新渲染相应的组件。 结果,用户看到的总是包含最新信息。 太好了

在这里,我想探讨使用新Ivy编译器(出现在Angular 9中)如何极大地促进拒绝使用zone.js的某些方面。



通过放弃该库,我能够显着提高在高负载下运行的Angular应用程序的性能。 同时,我设法使用TypeScript装饰器实现了所需的机制,这几乎不需要额外的系统资源。

请注意,本文介绍的优化Angular应用程序的方法仅因为默认情况下启用了Angular Ivy和AOT是可行的。 本文是出于教育目的而写的,其目的不是为了促进其中介绍的方法用于Angular项目的开发。

为什么可能需要在没有zone.js的情况下使用Angular?


在继续之前,让我们问一个重要的问题:“鉴于这个库可以帮助我们轻松地重新渲染模板,所以值得摆脱zone.js吗?” 当然,这个库非常有用。 但是,像往常一样,您必须支付所有费用。

如果您的应用程序有特定的性能要求,则禁用zone.js可以帮助满足这些要求。 一个性能至关重要的应用程序示例是一个界面经常更新的项目。 以我为例,这样的项目原来是实时交易应用程序。 它的客户端不断通过WebSocket协议接收消息。 这些消息中的数据应尽快显示。

从Angular中删除zone.js


没有zone.js,可以很容易地使Angular正常工作。 为此,必须首先注释掉或删除相应的导入命令,该命令位于polyfills.ts文件中。


注释掉zone.js导入命令

下一步-您需要为根模块配备以下选项:

 platformBrowserDynamic()  .bootstrapModule(AppModule, {    ngZone: 'noop'  })  .catch(err => console.error(err)); 

Angular Ivy:通过ɵdetectChanges和ɵmarkDirty进行自我检测的更改


在开始创建TypeScript装饰器之前,我们需要了解Ivy如何允许您调用检测组件更改,使其变脏并绕过zone.js和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功能


Angular内部文档说,出于性能原因,您不应使用ɵdetectChanges 。 相反,建议使用ɵmarkDirty函数。 ɵdetectChanges函数同步调用检测组件及其子组件中的更改的过程。

 import { ɵdetectChanges as detectChanges } from '@angular/core'; @Component({...}) class MyComponent {  setTitle(title: string) {    this.title = title;    detectChanges(this);  } } 

使用TypeScript装饰器自动检测更改


尽管Angular提供的功能可以通过允许DI来增加开发的可用性,但是程序员仍然感到沮丧,因为他需要自己导入并调用这些函数来启动变更检测过程。

为了简化更改检测的自动开始,您可以编写TypeScript装饰器,它将独立解决此问题。 当然,这里有一些限制,我们将在下面讨论,但是在我看来,这种方法正是我所需要的。

ob介绍@observed装饰器


为了检测到更改,以最小的努力,我们将创建可以以三种方式应用的装饰器。 即,它适用于以下实体:

  • 要同步的方法。
  • 可观察对象。
  • 到普通物体。

考虑几个小例子。 在以下代码片段中,我们将@observed装饰器应用于state对象和changeTitle方法:

 export class Component {    title = '';    @observed() state = {        name: ''    };    @observed()    changeTitle(title: string) {        this.title = title;    }    changeName(name: string) {        this.state.name = name;    } } 

  • 要检查state对象的更改,我们使用一个代理对象,该对象拦截对对象的更改并调用检测更改的过程。
  • 我们通过应用一个函数来覆盖changeTitle方法,该函数首先调用此方法,然后启动更改检测过程。

这是BehaviorSubject的示例:

 export class AppComponent {    @observed() show$ = new BehaviorSubject(true);    toggle() {        this.show$.next(!this.show$.value);    } } 

对于Observable对象,使用装饰器看起来更加复杂。 即,您需要订阅观察到的对象,并在订阅中将组件标记为“脏”,但是还需要清除订阅。 为了做到这一点,我们重新分配了ngOnInitngOnDestroy以进行订阅并稍后ngOnDestroy进行清理。

▍创建装饰


这是observed装饰器签名:

 export function observed() {  return function(    target: object,    propertyKey: string,    descriptor?: PropertyDescriptor  ) {} } 

如您所见, descriptor是一个可选参数。 这是因为我们需要将装饰器应用于方法和属性。 如果参数存在,则意味着将装饰器应用于该方法。 在这种情况下,我们这样做:

  • 保存descriptor.属性descriptor. value
  • 我们重新定义该方法,如下所示:调用原始函数,然后调用markDirty(this)以启动更改检测过程。 看起来是这样的:

     if (descriptor) {  const original = descriptor.value; //     descriptor.value = function(...args: any[]) {    original.apply(this, args); //       markDirty(this);  }; } else {  //   } 

接下来,您需要检查我们正在处理的属性类型。 它可以是一个Observable对象,也可以是一个普通对象。 在这里,我们将使用另一个内部Angular API。 我相信,它不适用于常规应用程序(抱歉!)。

我们谈论的是ɵcmp属性,在ɵcmp属性后,它可以访问Angular处理的属性。 我们可以使用它们来覆盖onInitonDestroy

 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设置的值对它们进行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 {    //    } }; 

如果该属性是普通对象,则我们将其转换为Proxy对象,并在其handler函数中调用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的内部机制,以及如何使用这些机制创建装饰器,那么现在该测试一下我们在实际应用程序中的功能了。

为了找出摆脱zone.js对Angular应用程序性能的影响,我使用了我的Cryptofolio业余项目。

我将装饰器应用于模板和禁用的zone.js中使用的所有必要链接。 例如,考虑以下组件:

 @Component({...}) export class AssetPricerComponent {  @observed() price$: Observable<string>;  @observed() trend$: Observable<Trend>;   // ... } 

模板中使用了两个变量: price (资产价格将位于此处)和trend (此变量可以采用upstaledown值,指示价格变化的方向)。 我用@observed装饰它们。

▍项目包大小


首先,让我们看一下在摆脱zone.js的同时,项目包的大小已减少了多少。 以下是使用zone.js构建项目的结果。


使用zone.js构建项目的结果

这是没有zone.js的程序集。


没有zone.js的项目构建的结果

请注意polyfills-es2015.xxx.js 。 如果项目使用zone.js,则其大小约为35 Kb。 但是没有zone.js,只有130个字节。

▍启动


我使用Lighthouse研究了两个应用程序选项。 这项研究的结果如下。 应该指出的是,我不会太重视它们。 事实是,在尝试寻找平均值时,通过对同一应用程序版本执行多次测量,我得到了明显不同的结果。

评估两个应用程序选项的差异可能仅取决于捆绑包的大小。

因此,这是使用zone.js的应用程序获得的结果。


使用zone.js的应用程序的分析结果

这是在分析未使用zone.js的应用程序之后发生的事情。


不使用zone.js的应用程序的分析结果

▍表现


现在我们进入了最有趣的阶段。 这是在负载下运行的应用程序的性能。 我们想了解当应用程序每秒显示数百种资产的价格更新时处理器的感觉。

为了加载该应用程序,我创建了100个实体,这些实体以每250毫秒更改一次的价格提供条件数据。 如果价格上涨,则以绿色显示。 如果减少-红色。 所有这些可能会严重加载我的MacBook Pro。

应当指出,在金融部门从事旨在为数据片段的高频传输设计的几种应用程序时,我多次遇到类似的情况。

为了分析不同版本的应用程序如何使用处理器资源,我使用了Chrome开发人员工具。

这是使用zone.js的应用程序的外观


由使用zone.js的应用程序创建的系统负载

这是不使用zone.js的应用程序的工作方式。


由不使用zone.js的应用程序创建的系统负载

我们分析这些结果,并注意处理器负载图(黄色):

  • 如您所见,使用zone.js的应用程序会不断加载70-100%的处理器! 如果您长时间保持浏览器选项卡处于打开状态,从而在系统上造成了这样的负载,则其中运行的应用程序很可能会失败。
  • 不使用zone.js的应用程序版本会在处理器上产生30%到40%的稳定负载。 太好了!

请注意,这些结果是在打开“ Chrome开发者工具”窗口的情况下获得的,这也给系统增加了压力并降低了应用程序的速度。

▍负荷增加


我试图确保每个负责更新价格的实体除已产生的价格外,每秒还会发布4次更新。

这是我们设法找出不使用zone.js的应用程序的内容:

  • 该应用程序通常使用大约50%的处理器资源来应对负载。
  • 仅当价格每10毫秒更新一次(与以前一样,新数据来自100个实体)时,他设法通过zone.js尽可能多地为处理器加载了应用程序。

with Angular Benchpress的性能分析


我上面进行的性能分析不能称为特别科学。 为了更认真地研究各种框架的性能,我建议使用此基准 。 为了进行研究,Angular应该选择该框架的常规版本以及不带zone.js的版本。

受此基准测试的一些想法启发,我创建了一个执行大量计算的项目 。 我用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()      });    }  } } 

我使用量角器和Benchpress启动了一小套基准测试。 进行了指定次数的操作。


卧推动作

结果


这是使用Benchpress获得的结果的示例。


基准测试结果

这是此表中显示的指标的说明:

  • gcAmount :gc操作量(垃圾收集),Kb。
  • gcTime :gc操作时间,毫秒。
  • majorGcTime :主要操作时间gc,毫秒。
  • pureScriptTime :脚本执行时间(以毫秒为单位),不包括gc操作和渲染。
  • renderTime :渲染时间,毫秒。
  • scriptTime :考虑gc操作和渲染的脚本执行时间。

现在,我们将分析各种应用程序变体中某些操作的性能。 绿色显示使用zone.js的应用程序的结果,橙色显示不使用zone.js的应用程序的结果。 请注意,此处仅分析渲染时间。 如果您对所有测试结果都感兴趣,请点击此处

测试:创建1000行


在第一个测试中,创建了1000行。


测试结果

测试:创建10,000行


随着应用程序负载的增长,其性能差异也随之增加。


测试结果

测试:加入1000行


在此测试中,将1000行附加到10,000行。


测试结果

测试:删除10,000行


在这里,创建了10,000行,然后将其删除。


测试结果

TypeScript装饰器源代码


以下是此处讨论的TypeScript装饰器的源代码。 此代码也可以在这里找到。

 // tslint:disable import { Observable, Subscription } from 'rxjs'; import { Type, ɵComponentType as ComponentType, ɵmarkDirty as markDirty } from '@angular/core'; interface ComponentDefinition {  onInit(): void;  onDestroy(): void; } const noop = () => { }; const getCmp = <T>(type: Function) => (type as any).ɵcmp as ComponentDefinition; const subscriptionsSymbol = Symbol('__ng__subscriptions'); export function observed() {  return function(    target: object,    propertyKey: string,    descriptor?: PropertyDescriptor  ) {    if (descriptor) {      const original = descriptor.value;      descriptor.value = function(...args: any[]) {        original.apply(this, args);        markDirty(this);      };    } else {      const cmp = getCmp(target.constructor);      if (!cmp) {        throw new Error(`Property ɵcmp is undefined`);      }      const onInit = cmp.onInit || noop;      const onDestroy = cmp.onDestroy || noop;      const getSubscriptions = (ctx) => {        if (ctx[subscriptionsSymbol]) {          return ctx[subscriptionsSymbol];        }        ctx[subscriptionsSymbol] = new Subscription();        return ctx[subscriptionsSymbol];      };      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 {          const handler = {            set(obj: object, prop: string, value: unknown) {              obj[prop] = value;              markDirty(ctx);              return true;            }          };          ctx[name] = new Proxy(ctx, handler);        }      };      const checkComponentProperties = (ctx) => {        const props = Object.getOwnPropertyNames(ctx);        props.map((prop) => {          return Reflect.get(target, prop);        }).filter(Boolean).forEach(() => {          checkProperty.call(ctx, propertyKey);        });      };      cmp.onInit = function() {        const ctx = this;        onInit.call(ctx);        checkComponentProperties(ctx);      };      cmp.onDestroy = function() {        const ctx = this;        onDestroy.call(ctx);        if (ctx[subscriptionsSymbol]) {          ctx[subscriptionsSymbol].unsubscribe();        }      };      Reflect.set(target, propertyKey, true);    }  }; } 

总结


尽管我希望您喜欢我关于优化Angular项目性能的故事,但我也希望我不会希望您急于从项目中删除zone.js。 为了提高Angular应用程序的性能,此处描述的策略应该是您可以采取的最后手段。

首先,您需要尝试以下方法,例如使用OnPush更改检测策略,应用trackBy ,禁用组件,在zone.js外部执行代码,将zone.js事件列入黑名单(此优化方法列表可以继续)。 此处显示的方法非常昂贵,我不确定每个人是否愿意为性能付出如此高的代价。

实际上,没有zone.js的开发可能不是最吸引人的东西。 也许这不仅适用于参与项目的人员,而且完全由他控制。 也就是说-它是依赖项的所有者,并且有能力和时间将所有内容恢复为正确的形式。

如果事实证明您尝试了所有事情并认为项目的瓶颈恰恰是zone.js,那么也许您应该尝试通过独立检测更改来加快Angular的速度。

我希望本文能使您了解Angular未来的期望,Ivy的能力以及zone.js可以做什么以最大程度地提高应用程序速度。

亲爱的读者们! 如何优化需要最大性能的Angular项目?


Source: https://habr.com/ru/post/zh-CN476956/


All Articles