Angular sem zone.js: desempenho máximo

Desenvolvedores angulares devem muito ao zone.js. Ela, por exemplo, ajuda a obter uma facilidade quase mágica ao trabalhar com a Angular. De fato, quase sempre, quando você só precisa alterar alguma propriedade, e nós a alteramos sem pensar em nada, o Angular renderiza novamente os componentes correspondentes. Como resultado, o que o usuário vê sempre contém as informações mais recentes. Isso é ótimo.

Aqui eu gostaria de explorar alguns aspectos de como o uso do novo compilador Ivy (que apareceu no Angular 9) pode facilitar muito a rejeição do uso do zone.js.



Ao abandonar esta biblioteca, pude aumentar significativamente o desempenho do aplicativo Angular em execução sob carga pesada. Ao mesmo tempo, consegui implementar os mecanismos necessários usando decoradores TypeScript, o que levou a muito poucos recursos adicionais do sistema.

Observe que a abordagem para otimizar aplicativos Angular, apresentada neste artigo, é possível apenas porque Angular Ivy e AOT estão ativados por padrão. Este artigo foi escrito para fins educacionais, não tem como objetivo promover a abordagem apresentada ao desenvolvimento de projetos Angular.

Por que você pode precisar usar o Angular sem zone.js?


Antes de continuarmos, vamos fazer uma pergunta importante: “Vale a pena se livrar do zone.js, já que essa biblioteca nos ajuda a renderizar novamente os modelos sem muito esforço?” Obviamente, esta biblioteca é muito útil. Mas, como sempre, você tem que pagar por tudo.

Se seu aplicativo tiver requisitos de desempenho específicos, desativar o zone.js pode ajudar a atender a esses requisitos. Um exemplo de aplicação em que o desempenho é crucial é um projeto cuja interface é atualizada com muita frequência. No meu caso, esse projeto acabou sendo um aplicativo de negociação em tempo real. Sua parte do cliente recebe constantemente mensagens via protocolo WebSocket. Os dados dessas mensagens devem ser exibidos o mais rápido possível.

Remova o zone.js do Angular


O Angular pode ser facilmente executado sem o zone.js. Para fazer isso, você deve primeiro comentar ou excluir o comando de importação correspondente, localizado no arquivo polyfills.ts .


Comando de importação zone.js comentado

Em seguida - você precisa equipar o módulo raiz com as seguintes opções:

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

Hera angular: Detectando alterações com ɵdetectChanges e ɵmarkDirty


Antes de começarmos a criar um decorador TypeScript, precisamos aprender sobre como o Ivy permite que você invoque o processo de detectar alterações de componentes, torná-las sujas e ignorar zone.js e DI.

Duas funções adicionais estão agora disponíveis para nós, exportadas de @angular/core . Esses são ɵdetectChanges e ɵmarkDirty . Essas duas funções ainda se destinam ao uso interno e são instáveis ​​- o símbolo ɵ está localizado no início de seus nomes.

Vamos ver como usar esses recursos.

Função MarkDirty


Esse recurso permite rotular um componente, deixando-o "sujo", ou seja, com necessidade de nova renderização. Ela, se o componente não foi marcado como "sujo" antes de ser chamado, planeja iniciar o processo de detecção de alterações.

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

Função DetectChanges


A documentação interna angular diz que, por razões de desempenho, você não deve usar ɵdetectChanges . Em vez disso, é recomendável usar a função ɵmarkDirty . A função ɵdetectChanges invoca de forma síncrona o processo de detecção de alterações em um componente e seus subcomponentes.

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

Detectar automaticamente alterações usando o decorador TypeScript


Embora as funções fornecidas pela Angular aumentem a usabilidade do desenvolvimento, deixando o DI funcionar, o programador ainda pode ficar frustrado pelo fato de precisar importar e chamar essas funções por conta própria para iniciar o processo de detecção de alterações.

Para simplificar o início automático da detecção de alterações, você pode escrever um decorador TypeScript, que resolverá esse problema independentemente. Obviamente, existem algumas limitações aqui, as quais discutiremos abaixo, mas no meu caso, essa abordagem acabou sendo exatamente o que eu precisava.

▍Introdução do decorador @observed


Para detectar mudanças, fazendo o mínimo de esforço possível, criaremos um decorador que pode ser aplicado de três maneiras. Ou seja, é aplicável às seguintes entidades:

  • Para métodos síncronos.
  • Objetos observáveis.
  • Para objetos comuns.

Considere alguns exemplos pequenos. No fragmento de código a seguir, aplicamos o decorador @observed ao objeto state e ao método changeTitle :

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

  • Para verificar se há alterações no objeto de state , usamos um objeto proxy que intercepta as alterações no objeto e chama o procedimento para detectar alterações.
  • Substituímos o método changeTitle aplicando uma função que primeiro chama esse método e, em seguida, inicia o processo de detecção de alterações.

E aqui está um exemplo com BehaviorSubject :

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

No caso de objetos observáveis, usar um decorador parece um pouco mais complicado. Ou seja, você precisa assinar o objeto observado e marcar o componente como "sujo" na assinatura, mas também precisa limpar a assinatura. Para fazer isso, reatribuímos o ngOnInit e o ngOnDestroy para assinar e limpá-lo mais tarde.

▍Criar um decorador


Aqui está a assinatura do decorador observed :

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

Como você pode ver, o descriptor é um parâmetro opcional. Isso ocorre porque precisamos que o decorador seja aplicado a métodos e propriedades. Se o parâmetro existir, isso significa que o decorador é aplicado ao método. Nesse caso, fazemos o seguinte:

  • Salve a propriedade do descriptor. value .
  • Redefinimos o método da seguinte maneira: chame a função original e chame markDirty(this) para iniciar o processo de detecção de alterações. Aqui está o que parece:

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

Em seguida, você precisa verificar com que tipo de propriedade estamos lidando. Pode ser um objeto observável ou um objeto comum. Aqui vamos usar outra API Angular interna. Acredito que ele não se destina ao uso em aplicativos regulares (desculpe!).

Estamos falando da propriedade ɵcmp , que dá acesso às propriedades processadas pelo Angular após serem definidas. Podemos usá-los para substituir os métodos dos onDestroy e onDestroy .

 const getCmp = type => (type).ɵcmp; const cmp = getCmp(target.constructor); const onInit = cmp.onInit || noop; const onDestroy = cmp.onDestroy || noop; 

Para marcar uma propriedade como uma a ser monitorada, usamos ReflectMetadata e configuramos seu valor como true . Como resultado, saberemos que precisamos observar a propriedade quando o componente for inicializado:

 Reflect.set(target, propertyKey, true); 

Agora é hora de substituir o gancho onInit e verificar as propriedades ao criar a instância do componente:

 cmp.onInit = function() {  checkComponentProperties(this);  onInit.call(this); }; 

Definimos a função checkComponentProperties , que checkComponentProperties as propriedades do componente, filtrando-as de acordo com o valor definido anteriormente usando 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);  }); }; 

A função checkProperty será responsável por decorar propriedades individuais. Primeiro, verificamos se a propriedade é um objeto observável ou regular. Se este é um objeto Observável, nós o assinamos e adicionamos a assinatura à lista de assinaturas armazenadas no componente para suas necessidades internas.

 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 {    //    } }; 

Se a propriedade for um objeto comum, então a converteremos em um objeto Proxy e chamaremos markDirty em sua função de handler :

 const handler = {  set(obj, prop, value) {    obj[prop] = value;    ɵmarkDirty(ctx);    return true;  } }; ctx[name] = new Proxy(ctx, handler); 

Por fim, você precisa limpar a assinatura depois de destruir o componente:

 cmp.onDestroy = function() {  const ctx = this;  if (ctx[subscriptionsSymbol]) {    ctx[subscriptionsSymbol].unsubscribe();  }  onDestroy.call(ctx); }; 

As possibilidades deste decorador não podem ser consideradas abrangentes. Eles não cobrem todos os usos possíveis que podem aparecer em um aplicativo grande. Por exemplo, são chamadas para funções de modelo que retornam objetos Observáveis. Mas estou trabalhando nisso.

Apesar disso, o decorador acima é suficiente para o meu pequeno projeto. Você encontrará o código completo no final do material.

Análise dos resultados da aceleração de aplicativos


Agora que conversamos um pouco sobre os mecanismos internos de Ivy e como criar um decorador usando esses mecanismos, é hora de testar o que temos em um aplicativo real.

Para descobrir o efeito de se livrar do zone.js no desempenho de aplicativos Angular, usei meu projeto de hobby do Cryptofolio .

Apliquei o decorador em todos os links necessários usados ​​nos modelos e em zone.js. desativado Por exemplo, considere o seguinte componente:

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

Duas variáveis ​​são usadas no modelo: price (o price do ativo será localizado aqui) e trend (essa variável pode levar os valores para up , down e down , indicando a direção da mudança de preço). @observed os com @observed .

▍ Tamanho do pacote do projeto


Para começar, vamos dar uma olhada em quanto o tamanho do pacote do projeto diminuiu enquanto se livrava do zone.js. Abaixo está o resultado da criação do projeto com zone.js.


Resultado da criação de um projeto com zone.js

E aqui está a montagem sem zone.js.


O resultado da criação de um projeto sem zone.js

Preste atenção ao polyfills-es2015.xxx.js . Se o projeto usar zone.js, seu tamanho será de aproximadamente 35 Kb. Mas sem zone.js - apenas 130 bytes.

▍Inicialização


Eu pesquisei duas opções de aplicação usando o Lighthouse. Os resultados deste estudo são apresentados abaixo. Note-se que eu não os levaria muito a sério. O fato é que, ao tentar encontrar os valores médios, obtive resultados significativamente diferentes executando várias medidas para a mesma versão do aplicativo.

Talvez a diferença na avaliação das duas opções de aplicativo dependa apenas do tamanho dos pacotes configuráveis.

Então, aqui está o resultado obtido para um aplicativo que usa zone.js.


Resultados da análise para um aplicativo que usa zone.js

E aqui está o que aconteceu depois de analisar o aplicativo em que o zone.js não é usado.


Resultados da análise para um aplicativo que não usa zone.js

▍ Desempenho


E agora chegamos ao mais interessante. Esse é o desempenho de um aplicativo em execução sob carga. Queremos saber como o processador se sente quando o aplicativo exibe atualizações de preços para centenas de ativos várias vezes por segundo.

Para carregar o aplicativo, criei 100 entidades que fornecem dados condicionais a preços que mudam a cada 250 ms. Se o preço subir, ele será exibido em verde. Se reduzido - vermelho. Tudo isso poderia carregar seriamente o meu MacBook Pro.

Deve-se notar que, enquanto trabalhava no setor financeiro em vários aplicativos projetados para a transmissão de alta frequência de fragmentos de dados, me deparei com uma situação semelhante muitas vezes.

Para analisar como diferentes versões do aplicativo usam os recursos do processador, usei as ferramentas de desenvolvedor do Chrome.

Veja como é o aplicativo que usa o zone.js.


Carregamento do sistema criado por um aplicativo que usa zone.js

E aqui está como um aplicativo funciona em que zone.js não é usado.


Carregamento do sistema criado por um aplicativo que não usa zone.js

Analisamos esses resultados, prestando atenção ao gráfico de carga do processador (amarelo):

  • Como você pode ver, um aplicativo que usa o zone.js carrega constantemente o processador em 70-100%! Se você mantiver a guia do navegador aberta por um longo tempo, criando uma carga no sistema, o aplicativo em execução nela poderá falhar.
  • E a versão do aplicativo em que o zone.js não é usado cria uma carga estável no processador na faixa de 30 a 40%. Ótimo!

Observe que esses resultados foram obtidos com a janela Ferramentas do desenvolvedor do Chrome aberta, o que também sobrecarrega o sistema e diminui a velocidade do aplicativo.

▍ aumento de carga


Tentei garantir que todas as entidades responsáveis ​​pela atualização do preço emitissem mais 4 atualizações a cada segundo, além do que já produz.

Aqui está o que conseguimos descobrir sobre o aplicativo em que o zone.js não é usado:

  • Esse aplicativo normalmente lida com a carga, agora usando cerca de 50% dos recursos do processador.
  • Ele conseguiu carregar o processador tanto quanto o aplicativo com zone.js, apenas quando os preços eram atualizados a cada 10 ms (novos dados, como antes, vinham de 100 entidades).

Analysis Análise de desempenho com o Angular Benchpress


A análise de desempenho que conduzi acima não pode ser chamada de particularmente científica. Para um estudo mais sério do desempenho de várias estruturas, eu recomendaria o uso dessa referência . Para pesquisa, o Angular deve escolher a versão usual dessa estrutura e sua versão sem zone.js.

Eu, inspirado por algumas idéias desse benchmark, criei um projeto que realiza cálculos pesados. Testei seu desempenho com o Angular Benchpress .

Aqui está o código do componente testado:

 @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()      });    }  } } 

Lancei um pequeno conjunto de benchmarks usando o Transferidor e o Benchpress. As operações foram executadas um número especificado de vezes.


Benchpress em ação

Resultados


Aqui está uma amostra dos resultados obtidos usando o Benchpress.


Resultados de Benchpress

Aqui está uma explicação dos indicadores apresentados nesta tabela:

  • gcAmount : volume de operações gc (coleta de lixo), Kb.
  • gcTime : tempo de operação do gc, ms.
  • majorGcTime : hora das principais operações gc, ms.
  • pureScriptTime : tempo de execução do script em ms, excluindo operações e renderização do gc.
  • renderTime : tempo de renderização, ms.
  • scriptTime : tempo de execução do script, levando em consideração as operações e a renderização do gc.

Agora vamos considerar a análise da implementação de algumas operações em várias versões do aplicativo. Verde mostra os resultados de um aplicativo que usa zone.js, laranja mostra os resultados de um aplicativo sem zone.js. Observe que apenas o tempo de renderização é analisado aqui. Se você estiver interessado em todos os resultados dos testes, verifique aqui .

Teste: criando 1000 linhas


No primeiro teste, 1000 linhas são criadas.


Resultados do teste

Teste: criando 10.000 linhas


À medida que a carga nos aplicativos aumenta, o mesmo ocorre com a diferença no desempenho.


Resultados do teste

Teste: junte 1000 linhas


Neste teste, 1000 linhas são anexadas a 10.000 linhas.


Resultados do teste

Teste: removendo 10.000 linhas


Aqui, 10.000 linhas são criadas, que são excluídas.


Resultados do teste

Código-fonte do Decorator TypeScript


Abaixo está o código-fonte do decorador TypeScript discutido aqui. Este código também pode ser encontrado aqui .

 // 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);    }  }; } 

Sumário


Embora eu espere que você tenha gostado da minha história sobre como otimizar o desempenho de projetos Angular, também espero que não incline você a se apressar para remover o zone.js do seu projeto. A estratégia descrita aqui deve ser o último recurso para o qual você pode recorrer, a fim de aumentar o desempenho do seu aplicativo Angular.

Primeiro, você precisa tentar abordagens como usar a estratégia de detecção de alterações OnPush, aplicar trackBy , desativar componentes, executar código fora dos eventos zone.js, blacklisting zone.js (esta lista de métodos de otimização pode ser continuada). A abordagem mostrada aqui é bastante cara e não tenho certeza de que todos estejam dispostos a pagar um preço tão alto pelo desempenho.

De fato, o desenvolvimento sem o zone.js pode não ser a coisa mais atraente. Talvez isso não seja apenas para a pessoa que está envolvida no projeto, que está sob seu controle total. Ou seja - é o proprietário das dependências e tem a capacidade e o tempo para trazer tudo à sua forma correta.

Se você tentou de tudo e acredita que o gargalo do seu projeto é precisamente zone.js, talvez tente acelerar o Angular detectando independentemente as alterações.

Espero que este artigo tenha permitido que você veja o que o Angular espera no futuro, o que o Ivy é capaz e o que o zone.js pode fazer para maximizar a velocidade do aplicativo.

Caros leitores! Como você otimiza seus projetos Angular que precisam de desempenho máximo?


Source: https://habr.com/ru/post/pt476956/


All Articles