Angular-秘籍第二版-五-

49 阅读43分钟

Angular 秘籍第二版(五)

原文:zh.annas-archive.org/md5/69fbe45134859c45b2aa58e42abe465f

译者:飞龙

协议:CC BY-NC-SA 4.0

第十一章:使用 Cypress 在 Angular 中进行端到端测试

拥有几个端到端E2E)(E2E)测试的应用程序肯定比没有任何测试的应用程序更可靠,在当今世界,随着新兴企业和复杂应用程序的出现,在某些时候编写 E2E 测试以捕获应用程序的整个流程变得至关重要。Cypress 是目前用于 Web 应用程序 E2E 测试的最佳工具之一。在本章中,您将学习如何使用 Cypress 测试 Angular 应用程序中的 E2E 流程。以下是本章将要涵盖的食谱:

  • 编写您的第一个 Cypress 测试

  • 验证 DOM 元素是否在视图中可见

  • 测试表单输入和提交

  • 等待XMLHttpRequestsXHRs)完成

  • 使用 Cypress 捆绑包

  • 使用 Cypress fixtures 提供模拟数据

技术要求

对于本章中的食谱,请确保您的设置已按照“Angular-Cookbook-2E”GitHub 仓库中的“技术要求”完成。有关设置详细信息,请访问:github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/docs/technical-requirements.md。本章的起始代码位于github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/start/apps/chapter11

编写您的第一个 Cypress 测试

如果您已经编写了 E2E 测试,您可能已经使用 Protractor 这样做过。使用 Cypress 是完全不同的体验。在本食谱中,您将使用现有的 Angular 应用程序设置 Cypress,并使用 Cypress 编写第一个 E2E 测试。

准备工作

我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter11/ng-cypress-starter

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-cypress-starter 
    

    这应该在新的浏览器标签页中打开应用程序,您应该看到以下内容:

    img/B18469_11_01.png

    图 11.1:ng-cypress-starter 应用程序在 localhost:4200 上运行

如何做到这一点…

我们正在工作的应用程序是一个简单的计数器应用程序。它有最小值和最大值,以及一些可以增加、减少和重置计数器值的按钮。我们将首先为我们的应用程序配置 Cypress,然后转向编写测试:

  1. 由于我们使用的是 NX 工作区,这里的设置与常规 Angular 应用程序不同。打开一个新的终端窗口/标签,并确保您位于工作区的根目录中。一旦进入,运行以下命令将Cypress安装到我们的项目中:

    cd start && npm install --save-dev @nx/cypress 
    
  2. 现在,从工作区的根目录运行以下命令为应用程序创建一个cypress项目,如下所示:

    cd start && nx g @nx/cypress:cypress-project ng-cypress-starter-e2e --project=ng-cypress-starter --directory apps/chapter11/ng-cypress-starter-e2e 
    

    当(或如果)被问及时,在安装过程中选择 Cypress 的 Vite 打包器,因为 Vite 是一个更快的打包器,同时也提供了一个更快的开发服务器。并选择“Ap provided”,这样我们就在 start/apps/chapter11 文件夹中创建了 ng-cy-starter-e2e 文件夹。你会看到在 start/apps 文件夹内创建了一个名为 ng-cypress-starter-e2e 的新文件夹。

  3. 让我们运行一个脚本来重命名我们的应用程序,从 chapter11-ng-cypress-starter-e2e 改为 ng-cypress-starter-e2e。这将使我们更容易运行此食谱和下一个食谱的 e2e 测试。请在工作区的根目录中使用以下命令:

    node scripts/rename-app.js chapter11 ng-cypress-starter-e2e start 
    
  4. 现在,你可以从工作区的根目录(在 start 文件夹外部)运行以下命令来启动 Cypress 测试:

    npm run e2e ng-cypress-starter 
    

    你应该能够使用浏览器来启动运行测试。我将使用 Chrome 作为本书 e2e 测试的浏览器。

  5. cypress-chrome 窗口中点击 app.cy.ts(由 步骤 4 打开的浏览器窗口)来运行默认创建的测试。我们将在食谱中修改此文件以编写自己的测试。一旦运行测试,你会看到它们失败。但不要担心,因为我们还没有编写自己的测试。

  6. 让我们现在创建我们的第一个测试。我们将只是检查应用程序标题中的标题是否为 Your first Cypress test in Angular。让我们通过在文件中创建一个 PO页面对象)来替换 src/e2e/support/app.po.ts 文件的全部内容如下:

    export const getHeaderTitle = () =>
      cy.get('.toolbar__title'); 
    
  7. 现在,我们将从 src/e2e/app.cy.ts 文件中导入 getHeaderTitle 并替换第一个测试如下:

    import { getHeaderTitle } from '../support/app.po';
    
    describe('ng-cypress-starter', () => {
      beforeEach(() => cy.visit('/'));
    
      it('should display the correct header title', () => {
        getHeaderTitle().should('contain.text','Your first
    Cypress test in Angular');
      });
    }); 
    
  8. 如果你再次查看 Cypress 窗口,你应该会看到测试通过如下:![img/B18469_11_02.png]

    图 11.2:我们的第一个 Cypress 测试通过

简单,对吧? 现在你已经知道了如何为 Angular 应用程序配置 Cypress(尤其是在 NX 中),请参阅下一节了解它是如何工作的。

它是如何工作的…

Cypress 可以与任何框架和 Web 开发项目集成。一个有趣的事实是,Cypress 在幕后使用 Mocha 作为测试运行器。Cypress 的工具会监视代码更改,这样你就不必反复重新编译测试。Cypress 还在测试的应用程序周围添加了一个外壳,以捕获日志并在测试期间访问 DOM 元素,以及一些用于调试测试的功能。

在我们的 app.cy.ts 文件的最顶部,我们使用 describe 方法,它定义了测试套件,并定义了即将编写的测试的上下文。然后,我们使用 beforeEach 方法来指定在执行每个测试之前应该发生什么。由于每个测试开始时没有数据,我们首先必须确保 Cypress 导航到我们的应用程序的 URL:http://localhost:4200。我们之所以只指定 cy.visit('/') 而它仍然可以工作,是因为 NX 自动使用 @nx/cypress 包进行配置。如果您将 Cypress 添加到标准的 Angular 应用程序(不在 NX 工作区中),您将必须在 Cypress 配置文件(cypress.config.ts)中指定 baseUrl,如下所示:

import { defineConfig } from 'cypress'
export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:4200',
  },
}) 

然而,NX 为这本书的食谱做了这件事。因此,我们只需在我们的测试中提供相对 URL。

对于我们每个测试,我们使用 it 方法来指定它们的标题。您会注意到在 app.cy.ts 文件中,我们正在从 support/app.po 文件导入 getHeaderTitle 方法。如食谱中先前所述,PO 代表 page object。这是一种广泛的做法,使用这些对象来包含返回 Document Object ModuleDOM)元素的函数。这使我们的测试免于与 DOM 交互以检索元素的代码,并且我们有可重用的测试函数。在 app.po 文件中,您可以看到我们使用 cy.get 方法检索一个应用了 toolbar__title 类的单个元素。本书中所有食谱的 Angular 应用程序都有一个标题和一个显示食谱内容的标题。请注意,在 app.cy.ts 文件中,我们使用 getHeaderTitle 方法从我们的 HTML 页面获取目标元素。然后我们使用 should() 方法将标题的文本与预期的值 Your first Cypress test in Angular 进行比较。请注意,我们使用 ‘contain.text’ 而不是 'have.text',因为目标元素中可能有空白字符。以下是一些使用 should 方法的其他示例,其中包含不同的语句:

  • should('be.visible')

  • should('be.empty')

  • should('be.visible')

  • should('have.class''my-class')

  • should('have.id''newUserId')

  • should('be.visible')

  • should('have.focus')

现在您已经了解了食谱的工作原理,请参阅下一节以获取一些有用的链接。

参见

验证 DOM 元素在视图中是否可见

在大多数网络应用程序中,至少有一个元素/视图是基于某种条件显示的。否则,它会被隐藏。当确保最终用户在正确的情况下看到正确的内容时,进行良好的测试变得必要。在这个菜谱中,你将学习如何检查元素是否在 DOM 中可见。

准备工作

我们将要工作的应用程序位于克隆的仓库中的 start/apps/chapter11/ng-cy-element-visibility。然而,e2e 测试在 start/apps/chapter11/ng-cy-element-visibility-e2e 文件夹中。在这个菜谱中,我们将修改这两个文件夹中的文件。让我们按照以下步骤首先运行 e2e 测试:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目的 e2e 测试:

    npm run e2e ng-cy-element-visibility 
    

    这应该会打开 Cypress 窗口。选择 Chrome 进行测试,然后点击 app.cy.ts 文件以运行测试,你应该会看到以下内容:

    图 11.3:ng-cy-element-visibility 应用程序的 Cypress 测试运行

现在我们已经在本地运行了应用程序和 Cypress 测试,让我们看看下一节中菜谱的步骤。

如何操作…

我们有来自上一个菜谱的同一个旧的计数应用程序。然而,有些事情已经改变了。我们现在在顶部有一个按钮,可以切换计数组件 (CounterComponent) 的可见性。此外,我们必须将鼠标悬停在计数卡片上才能看到 IncrementDecrementReset 操作按钮。

  1. 让我们创建必要的页面对象以供测试。我们将在页面对象 app.po.ts 文件中创建返回切换按钮和计数卡片的功能。更新 start/apps/chapter11/ng-cy-element-visibility-e2e 文件夹中的 src/support/app.po.ts 文件,如下所示:

    export const getHeaderTitle = () =>
     cy.get('.toolbar__title');
    **export****const****getToggleCounterButton** **= () =>**
    **cy.****get****(****'[data-test-id="toggleCounterBtn"]'****);**
    **export****const****getCounterCard** **= () => cy.****get****(**
    **'[data-test-id="counterCard"]'****);**
    **export****const****getCounterActions** **= () =>** **getCounterCard****()**
    **.****find****(****'button'****);** 
    
  2. 让我们现在将相关的测试 ID 添加到 HTML 中。我们将为切换计数按钮和计数元素添加 test-id 属性。修改 ng-cy-element-visibility/src/app/app.component.html 文件,如下所示:

    ...
    <main class="content" role="main">
    <div class="...">
    <button **data-test-id****=****"toggleCounterBtn"**
     (click)="toggleCounterVisibility()">Toggle Counter
            Visibility</button>
    <app-counter **data-test-id****=****"counterCard"**
    *ngIf="visibility ===
      visibilityOptions.Visible"></app-counter>
    </div>
    </main> 
    
  3. 现在,我们将编写一个测试来确保当点击切换计数按钮时,我们的计数卡片会显示和隐藏。为此,更新 ng-cy-element-visibility-e2e/src/e2e/app.cy.ts 文件,如下所示:

    import { **getCounterCard**, getHeaderTitle, **getToggleCounterButton** } from '../support/app.po';
    describe('ng-cypress-starter', () => {
      beforeEach(() => cy.visit('/'));
      it('should display the correct header title', () => ...});
      **it****(****'should toggle visibility of counter card when the**
    **toggle button is clicked'****,** **() =>** **{**
    **getCounterCard****().****should****(****'****exist'****);**
    **getToggleCounterButton****().****click****();**
    **getCounterCard****().****should****(****'not.exist'****);**
    **getToggleCounterButton****().****click****();**
    **getCounterCard****().****should****(****'exist'****);**
      }**);**
    }); 
    
  4. 现在,我们将编写另一个测试来检查当我们将鼠标悬停在 Counter 组件上时,我们的操作按钮(IncrementDecrementReset)是否会显示。再次更新 app.cy.ts 文件,如下所示:

    import { **getCounterActions**, getCounterCard, getHeaderTitle, getToggleCounterButton } from '../support/app.po';
    describe('ng-cypress-starter', () => {
      ...
      **it****(****'should show the action buttons when the counter card is hovered'****,**
    **() =>** **{** 
    **getCounterCard****().****trigger****(****'mouseover'****);** 
    **getCounterActions****().****should****(****'have.length'****,** **3****);** 
    **getCounterActions****().****contains****(****'Increment'****)**
    **.****should****(****'be.visible'****);** 
    **getCounterActions****().****contains****(****'Decrement'****)**
    **.****should****(****'be.visible'****);** 
    **getCounterActions****().****contains****(****'Reset'****)**
    **.****should****(****'be.visible'****);** 
    **});**
    }); 
    

    如果你现在查看 Cypress 窗口,你应该会看到测试失败,如下所示:

    图 11.4:无法在悬停时获取操作按钮

    测试失败的原因是 Cypress 目前不提供 CSS 悬停效果。为了解决这个问题,我们将在下一步安装一个包。

  5. 停止运行 e2e 测试,然后从工作区的根目录安装 cypress-real-events 包,如下所示:

    cd start && npm install --save-dev cypress-real-events 
    
  6. 现在,打开ng-cy-element-visibility-e2e项目中的src/support/e2e.ts文件并更新它,如下所示:

    ...
    // Import commands.js using ES2015 syntax:
    **/// <reference types="cypress-real-events" />**
    import './commands';
    **import****'cypress-real-events/support'****;**
    ... 
    
  7. 现在,更新app.cy.ts文件以在counter card元素上使用包中的realHover方法,如下所示:

    ...
    describe('ng-cypress-starter', () => {
      ...
    
      it('should show the action buttons when the counter card is hovered', () =>
        {
        getCounterCard()**.****realHover****();**
        ...
      })
    }); 
    
  8. 现在,再次从工作区根目录运行npm run e2e ng-cy-element-visibility命令(如果尚未运行)。你应该会看到所有测试通过,如图 11.5 所示:

图片

图 11.5:所有测试通过

太棒了!你刚刚学会了如何在不同的场景中检查 DOM 元素的可见性。当然,这些不是识别和与 DOM 元素交互的唯一选项。你可以参考 Cypress 文档以获取更多可能性。现在你已经完成了这个食谱,请查看下一节以了解它是如何工作的。

它是如何工作的…

我们首先通过构建 POs(页面对象)开始了这个食谱。这样做是个好主意,以便在编写测试时准备好函数。如果不存在获取特定页面对象的函数,我们可以在运行时创建它。请注意,我们使用should('exist')语句检查getCounterCard。如果你还不知道,我们也可以使用should('be.visible'),这同样有效。但是,当我们想要确保它不可见时,我们不能使用should('not.be.visible')语句。现在你可能正在想,“什么?!”确实如此!由于 Cypress 中的'visible'是构建得使得元素存在于 DOM 中并且可见,如果我们使用'be.visible''not.be.visible',它无法适应元素不存在于 DOM 中的情况。而且,由于我们使用*ngIf指令来显示或隐藏我们的Counter组件,它最终要么存在于 DOM 中,要么不存在。因此,使用should('exist')should('not.exis')在这里是合适的选择。

对于下一个测试,我们想看看当在计数器卡片上悬停(或进行鼠标悬停)时是否会显示操作按钮。为此,我们可以在计数器卡片上使用带有mouseover事件的trigger方法。然而,这不会起作用。为什么?因为 Cypress 中所有的悬停解决方案最终都会触发 JavaScript 事件,并且不会影响 CSS 伪选择器,而且由于我们的操作按钮(带有'.counter__actions__action'选择器)显示在具有'.counter'选择器的元素的:hover(CSS)上,我们的测试失败了,因为在测试中,我们的操作按钮实际上并没有显示。为了解决这个问题,我们使用了cypress-real-events包,它具有影响伪选择器的realHover方法,最终显示我们的操作按钮。

参见

测试表单输入和提交

如果你正在构建一个网络应用程序,那么你很可能至少会有一个表单,当涉及到表单时,我们需要确保我们有正确的用户体验UX)和正确的业务逻辑。有什么比为他们编写端到端测试更好的方法来确保一切按预期工作呢?在这个菜谱中,我们将使用 Cypress 测试一个表单,并验证在适当的情况下是否显示了正确的错误。

准备工作

我们将要工作的应用程序位于克隆的仓库中的 start/apps/chapter11/ng-cy-forms 目录下。然而,端到端测试位于 start/apps/chapter11/ng-cy-forms-e2e 文件夹中。在这个菜谱中,我们将修改这两个文件夹中的文件。让我们首先按照以下步骤运行端到端测试:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目的端到端测试:

    npm run e2e ng-cy-forms 
    

    这应该会打开 Cypress 窗口。选择 Chrome 进行测试,并点击 app.cy.ts 文件以运行测试,你应该会看到以下内容:

    图 11.6:运行 ng-cy-forms 应用程序的 Cypress 测试

现在我们有了 Cypress 测试在运行,让我们在下一节中查看菜谱的步骤。

如何做到这一点...

我们必须确保在表单成功提交并带有新版本时,我们看到一条新日志。我们还需要确保在版本输入为空或版本输入的值无效时,我们看到相关的错误。让我们开始吧:

  1. 让我们创建测试所需的页面对象。我们已经在测试中想要使用的元素上有了 data-test-id 属性。因此,我们可以在页面对象文件中引用它们。更新 start/apps/chapter11/ng-cy-forms-e2e/src/support/app.po.ts 文件,如下所示:

    **export****const****getHeaderTitle** **= () => cy.****get****(****'.toolbar__title'****);**
    **export****const****getVersionInput** **= () => cy.****get****(****'[data-test-id="versionInput"]'****);**
    **export****const****getRequiredError** **= () => cy.****get****(****'[data-test-id="versionReqErr"]'****);**
    **export****const****getMismatchError** **= () => cy.****get****(****'[data-test-id="versionMismatchErr"]'****);**
    **export****const****getSubmitButton** **= () => cy.****get****(****'[data-test-id="submitVersionBtn"]'****);**
    **export****const****getLogsListItems** **= () => cy.****get****(****'[data-test-id="logsList"] .logs__item'****);**
    **export****const****getLatestVersion** **= () => cy.****get****(****'[data-test-id="latestVersion"]'****);** 
    
  2. 我们将首先验证我们的表单在没有有效版本的情况下不能提交。为此,让我们确保在输入被清除后或当输入无效时,提交按钮被禁用。在端到端项目的 src/e2e/app.cy.ts 文件中打开并添加一个测试,如下所示:

    import { getHeaderTitle, **getSubmitButton, getVersionInput** } from '../support/app.po';
    describe('ng-cy-forms', () => {
      beforeEach(() => cy.visit('/'));
      it('should display the correct header title', () => {...});
      **it****(****'should have the submit button disabled on invalid input'****,** **() =>** **{**
    **getVersionInput****().****type****(****'invalid input'****);**
    **getSubmitButton****().****should****(****'be.disabled'****);**
    **getVersionInput****().****clear****();**
    **getSubmitButton****().****should****(****'be.disabled'****);**
    **getVersionInput****().****type****(****'0.0.1'****);**
    **getSubmitButton****().****should****(****'be.enabled'****);**
    **});**
    }); 
    

    如果你查看 Cypress 窗口并展开测试,你应该会看到测试通过,如下所示:

    图 11.7:检查当有无效输入时提交按钮是否被禁用

  3. 让我们添加另一个测试,以验证在提交有效版本时,我们看到一个新的版本日志。在 app.cy.ts 文件中添加另一个测试,如下所示:

    ...
    import { getHeaderTitle, getLatestVersion, **getLogsListItems**, getSubmitButton, getVersionInput } from '../support/app.po';
    
    describe('ng-cy-forms', () => {
      ...
      **it****(****'should add a new version log on valid version submission'****,** **() =>** **{**
    **getLogsListItems****().****should****(****'have.length'****,** **1****);**
    **getVersionInput****().****type****(****'0.0.1'****);**
    **getSubmitButton****().****click****();**
    **getLogsListItems****().****should****(****'have.length'****,** **2****);**
    **getLogsListItems****().****eq****(****1****).****then****(****el** **=>** **{**
    **expect****(**
    **el.****text****().****trim****()**
    **).****to****.****eq****(****'version changed to 0.0.1'****)**
    **});**
    **});**
    }); 
    
  4. 我们现在将添加另一个测试,以确保我们可以在版本日志上方看到最新版本。让我们修改 app.cy.ts 文件,如下所示:

    ...
    **describe****(****'ng-cy-forms'****,** **() =>** **{**
    **...**
    **it****(****'should display the latest version'****,** **() =>** **{**
    **getLatestVersion****().****should****(****'have.text'****,** **'Latest Version = 0.0.0'****);**
    **getVersionInput****().****type****(****'0.0.1'****);**
    **getSubmitButton****().****click****();**
    **getLatestVersion****().****should****(****'have.text'****,** **'Latest Version = 0.0.1'****);**
    **});**
    **});** 
    
  5. 我们现在将添加一个测试来验证当版本输入在输入某些内容后清除时(即在提交值之前),用户是否看到错误'版本号是必需的'。在app.cy.ts文件中添加测试,如下所示:

    import { getHeaderTitle, getLatestVersion, getLogsListItems, **getRequiredError**, getSubmitButton, getVersionInput } from '../support/app.po';
    
    describe('ng-cy-forms', () => {
      ...
      **it****(****'should show the version required error when the input ** **gets clered after typing something'****, () => {**
    **getVersionInput****().****type****(****'0.0.1'****);**
    **getVersionInput****().****clear****();**
    **getRequiredError****().****should****(**'exist'**);**
    **getRequiredError****().****should****(**'be.visible'**);**
    **getRequiredError****().****then****(el => {**
    **expect****(**
    **el.****text****().****trim****()**
    **).to.****eq****(**'Version number is required'**)**
    **});**
    **});**
    }); 
    
  6. 最后,让我们编写一个测试来确保在无效输入上显示错误消息。在app.cy.ts文件中添加另一个测试,如下所示:

    ...
    import { getHeaderTitle, getLatestVersion, getLogsListItems, **getMismatchError**, getRequiredError, getSubmitButton, getVersionInput } from '../support/app.po';
    describe('ng-cy-forms', () => {
      ...
      **it****(****'should show the invalid input error when the ** **version input is invalid'****,** **() =>** **{**
    **getVersionInput****().****type****(****'abc123'****);**
    **getMismatchError****().****should****(****'exist'****);**
    **getMismatchError****().****should****(****'be.visible'****);**
    **getMismatchError****().****then****(****el** **=>** **{**
    **expect****(**
    **el.****text****().****trim****()**
    **).****to****.****eq****(****'Version number does not match the pattern (x.x.x)'****)**
    **});**
    **});**
    }); 
    

    如果你现在查看测试窗口,你应该看到所有测试都通过,如下所示:

    图 11.8:应用的所有测试都通过

太棒了!你现在知道如何使用 Cypress 来测试具有一些有趣用例和断言的表单。查看下一节以了解它是如何工作的。

它是如何工作的…

我们首先在我们的app.po.ts文件中实现一些页面对象,因为我们可以在获取元素时重用这些方法。由于我们应用逻辑有一个规则,即提交按钮应该在版本输入中有有效版本之前被禁用,所以我们使用'be.disabled'断言在提交按钮上,如下所示:

getSubmitButton().should('be.disabled'); 

我们随后使用getVersionInput().type('...')函数在版本输入中输入所需的值,并检查按钮是否在版本输入有无效值或完全未输入值时被禁用。

然后我们检查在提交有效版本时是否在日志列表中添加了新的日志。此测试的重要代码块如下:

getLogsListItems().eq(1).then(el => {
  expect(
     el.text().trim()
  ).to.eq('version changed to 0.0.1')
}); 

注意,我们获取日志列表,即日志项。然后我们使用eq(1)从列表中获取第二个元素。然后我们使用then方法获取jQuery<HTMLElement>,这样我们就可以在元素的文本内容上使用trim方法。这是因为当为 Angular 应用程序编写 HTML 模板时,我们可能会在 HTML 标签中格式化内容,导致文本内容中包含空格。因此,在将文本与预期值进行比较之前修剪文本是一个巧妙的主意。或者,您也可以使用.should('contain.text', 'EXPECTED_TEXT')断言而不是.should('have.text', 'EXPECTED_TEXT')断言。

对于我们想要检查是否显示适当错误的情况,我们确保以下内容:

  • 错误元素存在于 DOM 中

  • 错误元素对用户可见

  • 错误元素具有适当的错误信息

注意,我们使用then方法获取错误元素,在测试断言之前修剪文本内容,就像我们对日志项的文本内容验证所做的那样。

参见

等待 XHR 完成

测试 用户界面UI) 转换是端到端测试的核心。虽然立即测试动作的预期结果是重要的,但可能存在结果有依赖性的情况。例如,如果用户填写了 登录 表单,我们只有在收到后端服务器的成功响应后才能显示成功提示,因此我们无法测试成功提示是否立即显示。在这个菜谱中,你将学习如何在执行断言之前等待特定的 XHR 调用完成。

准备工作

我们将要工作的应用程序位于克隆的仓库中的 start/apps/chapter11/ng-cy-http-requests 目录下。然而,端到端测试位于 start/apps/chapter11/ng-cy-http-requests-e2e 文件夹中。在这个菜谱中,我们将仅修改端到端项目的文件。让我们按照以下步骤运行端到端测试:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目的端到端测试:

    npm run e2e ng-cy-http-requests 
    

    这应该打开 Cypress 窗口。选择 Chrome 进行测试,并点击 app.cy.ts 文件以运行测试,你应该看到以下内容:

    图 11.9:ng-cy-http-requests 应用程序的 Cypress 测试运行

现在我们已经运行了 Cypress 测试,让我们在下一节中查看菜谱的步骤。

如何做到这一点...

我们将从一些可以正常工作的测试开始。然而,如果 HTTP 调用的响应有延迟,它们将失败。这是因为 Cypress 有一个 4,000 毫秒ms) (4 秒)的超时时间,在这段时间内,它会不断尝试断言,直到断言通过。如果我们的 XHR 耗时超过 4,000 毫秒怎么办?让我们在菜谱中尝试一下:

  1. 我们将首先编写我们的测试。我们将确保从 HTTP 调用的响应中获取 10 个用户。但在那之前,我们将在 users.po.ts 文件中为这个菜谱创建所需的页面对象,如下所示:

    export const getUsersCards = () => {
      return cy.get('app-users ul li');
    }
    
    export const getSearchInput = () => {
      return cy.get('[data-test-id="searchUsersInput"]');
    } 
    
  2. 更新 users.cy.ts 文件以添加以下测试:

    import { getUsersCards } from '../support/users.po';
    describe('ng-cy-http-requests > users', () => {
      beforeEach(() => cy.visit('/users'));
      it('should get the users list from the server and display', () => {
        getUsersCards().should('have.length', 10);
      });
    }); 
    
  3. 我们将编写另一个测试来检查我们是否根据搜索输入的值获取了搜索到的用户。在 users.cy.ts 文件中添加另一个测试,如下所示:

    import { **getSearchInput**, getUsersCards } from '../support/users.po';
    
    describe('ng-cy-http-requests > users', () => { 
      ...
      **it****(****'****should get the users list on searching'****,** **() =>** **{**
    **getSearchInput****().****type****(****'rube'****);**
    **getUsersCards****().****should****(****'have.length'****,** **1****);**
    **getUsersCards****().****find****(****'h4'****).****should****(**
    **el** **=>** **{**
    **expect****(**
    **el.****text****().****trim****()**
    **).****to****.****eq****(**
    **'Ruben Wheeler'**
    **)**
    **}**
    **);**
    **});**
    }); 
    

    你应该看到两个测试都通过,如图 11.10 所示。然而,这不是编写 UI 测试的最佳方式,因为它们应该与来自实际 API 服务器的数据 独立。在实践中,我们通常模拟 API 调用,你将在本章后面的 使用 Cypress 固定值提供模拟数据 菜谱中了解到这一点。

    图 11.10:用户页面测试通过

  4. 首先,我们需要模拟一个场景,即在 4,000 毫秒后出现期望的结果。我们将使用rxjs中的delay操作符来实现这一点,延迟时间为 5,000 毫秒。让我们在项目的user.service.ts文件中应用它,如下所示:

    …
    **import** **{** **EMPTY****,** **Observable****, delay, map, mergeMap,** **of** **}** **from****'rxjs'****;**
    @Injectable({
      providedIn: 'root'
    })
    export class UserService {
      http = inject(HttpClient);
      getAll(): Observable<User[]> {
        return **of****(****EMPTY****)**
    **.****pipe****(**
    **delay****(****5000****),**
    **mergeMap****(****() =>** **{**
    **return****this****.****http****.****get****<****User****[]>(**
    **'/assets/users.json'****)**
    **})**
    **);**
      }
      ...} 
    

    如果你现在检查 Cypress 测试,你应该会看到一个失败的测试,如图 11.11 所示:

    图 11.11:特定用户搜索测试的断言失败

  5. 现在,我们可以尝试修复这个问题,这样它就不会在乎 XHR 花费了多长时间——我们总是在进行断言之前等待它完成。让我们拦截 XHR 调用并为其创建一个别名,这样我们就可以稍后使用它来等待 XHR 调用。更新users.cy.ts文件,如下所示:

    ...
    describe('ng-cy-http-requests > users', () => {
      **beforeEach****(****() =>** **{**
    **cy.****intercept****(****'/assets/users.json'****).****as****(****'searchUsers'****);**
    **cy.****visit****(****'/users'****);**
    **});**
      ...
    }); 
    
  6. 现在,让我们使用别名在断言之前等待 XHR 调用完成。更新users.cy.ts文件,如下所示:

    ...
    describe('ng-cy-http-requests > users', () => {
      ...
      it('should get the users list from the server and display', () => {
        **cy.****wait****(****'@searchUsers'****, {**
    **timeout****:** **10000**
    **});**
    getUsersCards().should('have.length', 10);
      });
      it('should get the users list on searching', () => {
        getSearchInput().type('rube');
        **cy.****wait****(****'@searchUsers'****, {**
    **timeout****:** **10000**
    **});**
    getUsersCards().should('have.length', 1);
        getUsersCards().find('h4').should(...);
      });
    }); 
    

    如果你现在检查users.cy.ts的 Cypress 测试,你应该会看到所有测试都通过,如下所示:

    图 11.12:在断言之前等待 XHR 调用完成的测试

太好了!你现在知道如何使用 Cypress 实现包含在断言之前等待特定 XHR 调用完成的端到端测试。要了解配方背后的所有魔法,请参阅下一节。

它是如何工作的...

在配方中,我们使用一种称为变量别名的东西。我们首先使用cy.intercept方法,以便 Cypress 可以监听网络调用。请注意,我们使用特定的 URL /assets/users.json 作为参数,然后使用.as('searchUsers')语句为此拦截提供一个别名。请注意,我们修改了user.service.ts,这导致在 API 调用之前有 5,000 毫秒的延迟。

Cypress 有一个默认的超时时间为 4,000 毫秒,我们不想限制我们的 API 调用在测试中在 4,000 毫秒内处理。因此,我们使用cy.wait('@searchUsers');语句,使用searchUsers别名通知 Cypress 它必须等待别名拦截发生——也就是说,直到网络调用完成,不管它需要多长时间才能达到 Cypress 的第二超时(网络调用为 30,000 毫秒)。这使得我们的当前测试通过,尽管默认的 4,000 毫秒 Cypress 超时和 Cypress 中 HTTP 调用的 5,000 毫秒(大约 5 秒)超时在实际上进行网络调用之前已经过去了。魔法,不是吗?

注意,Cypress 对断言有默认的超时时间,例如检查元素是否可见或具有特定的文本,默认为 4,000 毫秒。对于 HTTP 调用初始化,默认超时时间为 5,000 毫秒。这使得我们的测试有点棘手,因为我们试图模拟服务器响应延迟的同时延迟 HTTP 调用的初始化。因此,我们不得不为cy.waitoptions参数设置超时时间为 10,000 毫秒。这允许 Cypress 在 5,000 毫秒(我们添加到用户服务中)等待调用被初始化。在实际场景中,你的 HTTP 调用将立即启动,响应可能会延迟。Cypress 等待 5,000 毫秒以等待调用被初始化,所以你应该没问题。一旦调用被启动,Cypress 默认将超时时间设置为 30,000 毫秒以等待响应。

好吧,我希望你喜欢这个食谱——查看下一节以获取进一步阅读的链接。

相关内容

使用 Cypress 捆绑包

Cypress 提供了一系列捆绑的工具和包,我们可以在测试中使用它们来简化工作,这并不是因为使用 Cypress 编写测试本身很困难,而是因为这些库已经被许多开发者使用,因此他们已经熟悉它们。在本食谱中,我们将查看捆绑的 jQuery、Lodash 和 Minimatch 库,以测试一些我们的用例。

准备工作

我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter11/ng-cy-bun-pack目录下。然而,端到端测试在start/apps/chapter11/ng-cy-bun-pack-e2e文件夹中。在本食谱中,我们只将修改端到端项目的文件。让我们按照以下步骤运行端到端测试:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目的端到端测试:

    npm run e2e ng-cy-bun-pack 
    

    这应该会打开 Cypress 窗口。选择Chrome进行测试,并点击users.cy.ts文件以运行测试,你应该会看到以下内容:

    图 11.13:使用 Cypress 运行的 ng-cy-bun-pack 应用程序测试

现在我们已经运行了 Cypress 测试,让我们在下一节中查看食谱的步骤。

如何做到这一点...

对于这个食谱,我们有users列表和一个搜索应用程序,该应用程序使用 HTTP 请求从一个 JSON 文件中获取一些用户。我们将对 DOM 进行一些断言,验证 API 的响应,并断言 URL 的变化。让我们开始:

  1. 首先,我们将尝试使用捆绑的jQuery库和 Cypress 一起。我们可以使用Cypress.$来访问它。让我们添加另一个测试并记录一些 DOM 元素。更新users.cy.ts文件,如下所示:

    ...
    describe('Users List Page', () => {
      ...
      **it****(****'should show the no results found message on search'****,** **() =>** **{**
    **const** **{ $ } =** **Cypress****;**
    **cy.****wait****(****'@searchUsers'****);**
    **const** **searchInput = $(****'[data-test- id="searchUsersInput"]'****);**
    **console****.****log****(searchInput);**
    **})**
    }); 
    

    如果你现在查看测试(Cypress 窗口),特别是控制台,你应该会看到以下日志:

    图 11.14:使用 jQuery 通过 Cypress 记录的搜索输入

  2. 现在,让我们尝试更改搜索输入的值,以便我们可以看到 'No results' 消息。进一步更新测试,如下所示:

    it('should show the no results found message on search', () => {
        const { $ } = Cypress;
        cy.wait('@searchUsers');
        const searchInput = $('[data-test-id="searchUsersInput"]');
        **cy.****wrap****(searchInput).****type****(****'abc123'****);**
    }); 
    
  3. 让我们在 users.po.ts 文件中添加一个新的页面对象元素,以便我们可以获取 noResults 消息。按照以下方式更新文件:

    ...
    export const getNoResultsMessage = () => {
      return cy.get('[data-test-id="noResultsFoundMessage"]');
    } 
    
  4. 让我们使用页面对象和 then 方法通过记录 'no results' 消息来使用 jQuery 元素。按照以下方式更新 users.cy.ts 文件中的测试:

    it('should show the no results found message on search', () => {
        const { $ } = Cypress;
        cy.wait('@searchUsers');
        const searchInput = $('[data-test-id="searchUsersInput"]');
        cy.wrap(searchInput).type('abc123');
        getNoResultsMessage().then((el) => {
          console.log(el);
        });
      }); 
    

    你应该在 Cypress 窗口的控制台中看到 no results 消息,如下所示:

    图 11.15:使用 Cypress.$ 通过 jQuery 记录的 noResults 消息

    正如你所见,jQuery 元素已在控制台中记录。现在我们将使用 Chai 断言来验证它是否存在并且有一个消息。

  5. 进一步更新测试以检查元素是否存在并且具有以下文本:

    it('should show the no results found message on search', () => {
        const { $ } = Cypress;
        cy.wait('@searchUsers');
        const searchInput = $('[data-test-
     id="searchUsersInput"]');
        cy.wrap(searchInput).type('abc123');
        getNoResultsMessage().then((el) => {
          **expect****(el).****to****.****exist****;**
    **expect****(el.****text****().****trim****()).****to****.****eq****(****'Nothing returned for ** **the following search'****);**
        });
      }); 
    

    如果你现在在 Cypress 中看到这个测试,它应该会通过,如下所示:

    图 11.16:使用 jQuery 通过 Cypress 通过的测试

  6. 我们现在将使用与 Cypress 一起捆绑的 lodash.js 包来遍历每张卡片并确保出生日期格式正确。在 users.cy.ts 文件中编写另一个测试,如下所示:

    ...
    describe('Users List Page', () => {
      ...
      **it****(****'should show the dob on each user in the correct format'****,** **() =>** **{**
    **const** **{ $, _ } =** **Cypress****;**
    **cy.****wait****(****'@searchUsers'****);**
    **getUsersCards****().****then****(****(****cards****) =>** **{**
    **_.****forEach****(cards,** **(****card****) =>** **{**
    **const** **cardItem = $(card);**
    **const** **dobText = cardItem.****find****(****'.dob'****).****text****();**
    **const** **dob = dobText.****split****(****'Birthday:'****)[****1****].****trim****();**
    **expect****(dobRegex.****test****(**
    **dob**
    **)).****to****.****be****.****true****;**
    **})**
    **});**
    **});**
    }); 
    
  7. 让我们再添加一个测试来再次使用 lodash。我们将确保用户在视图中的所有名称都是唯一的,也就是说,没有重复的用户卡片。在 users.cy.ts 文件中添加另一个测试,如下所示:

    ...
    describe('Users List Page', () => {
      ...
      **it****(****'should have unique names for all the users'****,** **() =>** **{**
    **const** **{ $, _ } =** **Cypress****;**
    **cy.****wait****(****'@searchUsers'****);**
    **getUsersCards****().****then****(****(****cards****) =>** **{**
    **const** **names = _.****map****(cards,** **(****card****) =>** **{**
    **const** **cardItem = $(card);**
    **return** **cardItem.****find****(****'h4'****).****text****();**
    **});**
    **const** **uniqueNames = _.****uniq****(names);**
    **expect****(names.****length****).****to****.****equal****(uniqueNames.****length****);**
    **});**
    **});**
    }); 
    
  8. 我们接下来要探索的下一个包是 minimatch 包。当我们点击用户卡片时,它会打开用户详情。由于我们将时间戳附加到 URL 作为查询参数,我们无法使用断言将 URL 作为精确匹配进行比较。让我们使用 minimatch 包来使用模式进行断言。添加一个新的测试,如下所示:

    ...
    describe('Users List Page', () => {
      ...
      **it****(****'should go to the user details page with the user uuid'****,** **() =>** **{**
    **const** **{ minimatch, $ } =** **Cypress****;**
    **getUsersCards****().****then****(****(****cards****) =>** **{**
    **const** **userCard = cards[****0****];**
    **const** **uuid = $(userCard).****attr****(****'ng-reflect-router-link'****)**
    **.****split****(****'/users/'****)[****1****];**
    **cy.****wrap****(userCard).****click****();**
    **cy.****url****().****should****(****(****url****) =>** **{**
    **const** **urlMatches =** **minimatch****(url,**
    **`****${location.origin}****/users/****${uuid}*****`****,**
    **{** **debug****:** **true** **});**
    **expect****(urlMatches).****to****.****equal****(****true****);**
    **});**
    **});**
    **});**
    }); 
    

现在所有测试都已通过使用 Cypress 捆绑的包完成。现在我们已经完成了配方,让我们看看下一节中它是如何工作的。

工作原理...

Cypress 将 jQuery 捆绑在一起,我们通过 Cypress.$ 属性使用它。这允许我们执行 jQuery 函数允许我们执行的所有操作。例如,你可以使用以下捆绑的 jQuery 函数:

  • each:

    • 用法:$(elements).each(function(index, element) {})

    • 描述:遍历 jQuery 对象,为每个匹配元素执行一个函数

  • text:

    • 用法:$(selector).text()

    • 描述:获取匹配元素集中每个元素的合并文本内容,包括其子元素

  • val:

    • 用法:$(selector).val()

    • 描述:获取匹配元素集中第一个元素的当前值

  • hasClass:

    • 用法:$(selector).hasClass(className)

    • 描述:确定是否有任何匹配元素被分配给给定的类

  • addClass:

    • 用法:$(selector).addClass(className)

    • 描述:将指定的类(或类集)添加到匹配元素集中的每个元素

  • removeClass:

    • 用法:$(selector).removeClass(className)

    • 描述:从匹配元素集中的每个元素中删除单个类、多个类或所有类

重要提示

Cypress.$只能从 DOM 中立即可用的文档元素中获取数据。这对于在 Cypress 测试窗口中使用 Chrome DevTools 调试 DOM 来说很棒。然而,重要的是要理解它没有关于 Angular 变更检测的任何上下文。此外,你不能查询页面一开始就不可见的任何元素,正如我们在菜谱中所经历的那样——也就是说,它不会等待 XHR 调用以使元素可见。

Cypress 还捆绑了lodash并通过Cypress._对象公开它。在菜谱中,我们使用_.each()方法遍历卡片项以执行多个任务。我们还使用了_.uniq方法,它接受一个数组并返回一个包含唯一项的数组。然后我们比较原始数组和唯一数组的长度,以确保我们的原始数组包含所有唯一的名称。请注意,我们可以在 Cypress 测试中使用任何lodash方法,而不仅仅是提到的那些方法。

我们还使用了minimatch包,Cypress 通过Cypress.minimatch对象公开了这个包。minimatch包非常适合匹配和测试字符串与全局模式。我们用它来测试在通过模式导航到用户的详细页面后,测试 URL。在使用minimatch时,有一个重要的事情要知道,它比较的是全局模式,应该包含整个 URL,而不是像正则表达式一样的字符串。这就是为什么我们使用 {location.origin}/users/${uuid}* ``语句来包含location.origin`。

简单易行。现在你了解了这个菜谱的工作原理,请查看下一节以获取一些有用的链接。

参见

使用 Cypress 固定数据提供模拟数据

当涉及到编写端到端测试时,固定数据在确保测试不会出现不一致(在不同测试运行中结果不同)方面发挥着重要作用。考虑一下,你的测试依赖于从你的 API 服务器获取数据,或者你的测试包括快照测试,这包括从内容分发网络CDN)或第三方 API 获取图像。尽管它们在技术上对于测试成功运行是必需的,但服务器数据和图像是否从原始来源获取并不重要;因此,我们可以为它们创建固定数据。在这个菜谱中,我们将为存储桶数据创建固定数据,以避免在执行端到端测试时需要运行服务器。

准备工作

我们将要与之合作的应用程序位于克隆的仓库中的 start/apps/chapter11/ng-cy-mock-data。然而,端到端测试在 start/apps/chapter11/ng-cy-mock-data-e2e 文件夹中。在这个菜谱中,我们将仅修改端到端项目的文件。让我们按照以下步骤运行端到端测试:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行具有 API 服务器的项目的端到端测试:

    npm run e2e ng-cy-mock-data with-server 
    

    这应该会打开 Cypress 窗口以及服务器。选择 Chrome 进行测试,并点击 app.cy.ts 文件以运行测试,你应该会看到以下内容:

    图 11.17:使用 Cypress 运行的 ng-cy-mock-data 测试

现在我们有了 Cypress 测试在运行,让我们看看下一节中菜谱的步骤。

如何做到这一点...

我们有桶应用程序,我们在本书的许多菜谱中都使用了它。然而,我们将在这个菜谱中使用 Cypress 编写一些端到端测试。有趣的部分是应用程序与后端服务器通信以管理桶项目。当使用真实 API 向桶中添加或删除项目时,我们的测试将会失败。让我们开始吧:

  1. 我们的后端(fake-be)默认返回四个桶项目。参见*如何工作...*部分了解详情。我们将在稍后向 app.cy.ts 文件添加一个新测试,以确保我们能够向桶中添加另一个项目。但在那之前,让我们在 app.po.ts 文件中添加一些页面对象,如下所示:

    export const getHeaderTitle = () =>
     cy.get('.toolbar__title');
    export const getFruits = () => cy.get('.fruits__item');
    **export****const****getFruitSelector** **= () => cy.****get****(****'data-test-id="fruitSelector"'****);**
    **export****const****getAddItemSubmitButton** **= () =>**
    **cy.****get****(****'[data-test-id="addItemSubmitBtn"'****);**
    **export****const****getSuccessToast** **= () => cy.****get****(****'#toast-container .toast-success'****);**
    **export****const****getErrorToast** **= () => cy.****get****(****'#toast-****container .toast-error'****);** 
    
  2. 现在,我们可以添加我们的测试以确保我们能够向桶中添加一个项目。将以下测试添加到 app.cy.ts 文件中:

    import { **getAddItemSubmitButton**, **getFruitSelector**, getFruits, getHeaderTitle, **getSuccessToast** } from '../support/app.po';
    
    describe('ng-cy-mock-data', () => {
      beforeEach(() => {
        cy.visit('/')
      });
    
      …
    
      **it****(****'should add a bucket item to the list'****,** **() =>** **{**
    **getFruitSelector****().****select****(****'Apple** **![****'****);**    **getAddItemSubmitButton****().****click****();**    **getSuccessToast****().****should****(****'be.visible'****);**    **getSuccessToast****().****then****(****el => {**    **expect****(el.****text****().****trim****()).to.****eq****(****'Bucket item added'****);**    **});**    **getFruits****().****should****(****'have.length'****,** **5****);**    **});**     });     ```    这是我们测试开始出错的地方,如*图 11.18*所示。由于我们的测试每次运行都会向实际服务器添加一个项目,所以我们不能期望服务器返回与我们的测试相同数量的项目(四个项目),除非我们重新启动服务器。    ![](https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/f752d28c6cd0469bb34bb855ac3b4fed~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1771398144&x-signature=%2Ftkb4giDEFxrFyvXIN2pVcxO0b8%3D)
    
    图 11.18:由于向真实服务器添加数据而失败的测试
    
    
  3. 我们首先将为我们的 HTTP 调用到 fake-be 后端创建一个固定装置。在 src/fixtures 文件夹下创建一个新文件,命名为 get-bucket.json。然后向其中添加以下 JSON 数据:

    {
    "bucket": 
    { "id": 1, "name": "Apple ![" },    { "id": 2, "name": "Banana ![](https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/7570aa2b407c41fa8def88984bb1ad92~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1771398144&x-signature=7E4lzAQUHE2S9Lxk5epAYbeQIFI%3D)" },
    { "id": 3, "name": "Grapes ![](https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/88fecf28149e4417a2fbf8d5d432d374~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1771398144&x-signature=Z4VJtOX8f4CLTNKLwticHRNGG2Q%3D)" },
    { "id": 4, "name": "Cherry ![](https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/1046315518e14b38b413636aab7b1fd6~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1771398144&x-signature=2lEQt%2Fw9r8LXDmUtbTJ7WhckXaQ%3D)" }
    ]
    } 
    
  4. 现在,让我们在我们的 app.cy.ts 文件中使用固定装置。我们将在 beforeEach 生命周期钩子中使用它,因为我们希望为文件中的所有测试使用固定装置。更新 app.cy.ts 文件,如下所示:

    ...
    describe('ng-cy-mock-data', () => {
      **beforeEach****(****() =>** **{**
    **cy.****fixture****(****"get-bucket"****)**
    **.****then****(****(****response****) =>** **{**
    **cy.****intercept****(****'GET'****,** **'http://localhost:3333/api/bucket'****,**
    **response)**
    **return** **cy.****fixture****(****"-bucket"****);**
    **})**
    **.****visit****(****'/'****)**
    **})**;
         ...
    }); 
    

    这并没有解决这个问题,因为向桶中添加项目的调用仍然发送到真实 API。我们还需要为它创建一个固定装置。

  5. src/fixtures 文件夹内创建一个新文件。命名为 add-bucket-item.json 并向其中添加以下代码:

    {
    "fruit": { "id": 5, "name": "Apple ![](https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/e24f141a4aff40fdb441a4233a15753e~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1771398144&x-signature=m7vUi4mYz0xy8P2U6I%2BZtpM16iY%3D)" }
    } 
    
  6. 我们现在将在我们的测试文件中使用 add-bucket-item 固定装置。更新 app.cy.ts 文件,如下所示以使用固定装置:

    ...
    
    describe('ng-cy-mock-data', () => {
      beforeEach(() => {
        cy.fixture("get-bucket")
          .then((getBucketResp) => {
            cy.intercept('GET', 'http://localhost:3333/api/bucket',
              getBucketResp)
            **return** **cy.****fixture****(****"add-bucket-item"****);**
          })
          **.****then****(****(****addItemResp****) =>** **{**
    **cy.****intercept****(****'POST'****,**
    **'http://localhost:3333/api/bucket'****, addItemResp)**
    **})**
    .visit('/')
      });
      ...
    }); 
    

    现在,如果你运行端到端测试,你应该会看到所有测试都通过了。无论你刷新 Cypress 窗口多少次;它们总是会通过,因为每次的响应都是相同的:

    图片

    图 11.19:使用固定值测试 add-bucket-item 通过

  7. 我们现在将创建一个测试来删除一个项目并确保我们看到通知,并且视图中的一个项目被移除。让我们在 app.cy.ts 文件中添加另一个测试,如下所示:

    ...
    describe('ng-cy-mock-data', () => {
      ...
      **it****(****'should delete a bucket item from the list'****,** **() =>** **{**
    **getFruits****().****should****(****'have.length'****,** **4****);**
    **getFruits****().****eq****(****0****).****find****(****'.fruits__item__delete-icon'****).****click****();**
    **getSuccessToast****().****should****(****'be.visible'****);**
    **getSuccessToast****().****then****(****el** **=>** **{**
    **expect****(el.****text****().****trim****()).****to****.****eq****(****'Bucket item deleted'****);**
    **});**
    **getFruits****().****should****(****'have.length'****,** **3****);**
    **});**
    }); 
    

    如果你现在运行测试,你会看到 delete item 测试失败,因为它找不到项目。那是因为这个 DELETE 调用仍然被发送到实际服务器。如果你有服务器运行,你会看到一个错误,如图 11.20 所示:

    图片

    图 11.20:找不到项目错误

  8. 我们将在 src/fixtures 文件夹中创建一个新的固定值,命名为 delete-bucket-item.json。向其中添加以下代码:

    { "success": true } 
    
  9. 现在,让我们在 app.cy.ts 文件中的 beforeEach() 钩子中使用固定值,如下所示:

    ...
    describe('ng-cy-mock-data', () => {
      beforeEach(() => {
        cy.fixture("get-bucket")
          .then((response) => {
            cy.intercept('GET', 'http://localhost:3333/api/bucket',
              response)
            return cy.fixture("add-bucket-item");
          })
          .then((response) => {
            cy.intercept('POST', 'http://localhost:3333/api/bucket',
              response)
            **return** **cy.****fixture****(****"delete-bucket-item"****);**
          })
          **.****then****(****(****deleteItemResp****) =>** **{** 
    **cy.****intercept****(****'DELETE'****,**
    **'****http://localhost:3333/api/bucket/*'****,**
    **deleteItemResp)** 
    **})**
          .visit('/')
      });
      ...
    }); 
    

    如果你现在查看测试,所有测试都应该通过,如下所示:

    图片

    图 11.21:使用固定值后所有测试通过

太好了!你现在知道如何在 Cypress E2E 测试中使用固定值。现在你已经完成了这个食谱,请看下一节了解它是如何工作的。

它是如何工作的…

我们在 app.cy.ts 文件中有一个初始测试,以确保当应用程序加载时,我们从服务器获取四个桶项目。你可以看到发送默认桶数据的后端文件,它位于 <workspace-root>/codewithahsan/e2e/fake-be/src/app/bucket/bucket.service.ts。然而,当我们开始通过测试向桶中添加项目时,我们的测试就会中断,因为我们正在处理真实数据。我们很少这样做,以确保我们可以准确地做出断言。这就是为什么大型团队有测试环境,如果他们真的想处理真实数据,就会在数据库中播种数据。由于我们的桶应用程序非常小,我们实际上不需要处理真实数据,所以我们在这个食谱中添加了固定值。在 Cypress 测试中,固定值通过 cy.fixture 方法注册,这允许我们使用文件中的数据。在这个食谱中,我们使用固定值来处理应用程序对 fake-be 服务器进行的所有 HTTP 调用,即以下内容:

  • 获取所有桶数据 – GET http://localhost:3333/api/bucket

  • 向桶中添加项目 – POST http://localhost:3333/api/bucket

  • 从桶中删除项目 – DELETE http://localhost:3333/api/bucket/ITEM_ID

注意,对于每个 HTTP 请求,我们使用 cy.fixture('FIXTURE_NAME') 而不带 .json 扩展名,这实际上指向 cypress/fixture/FIXTURE_NAME.json 文件。

首先,我们使用 cy.fixture 方法注册固定值(或获取它)。然后我们使用 then 方法获取固定值(JSON)文件的正文。然后我们使用 cy.intercept 方法使用 GET/POST/DELETE 方法以及 URL 模式作为 Minimatch glob 模式来拦截 HTTP 调用以获取固定值响应,并将其作为 HTTP 调用的响应提供。因此,所有匹配 glob 模式的拦截调用都使用我们的固定值。

现在你已经了解了这个食谱的工作原理,请查看下一节以获取一些资源。

参见

在 Discord 上了解更多

要加入这本书的 Discord 社区——在那里你可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:

packt.link/AngularCookbook2e

二维码

第十二章:Angular 中的性能优化

性能在您为最终用户构建的任何产品中始终是一个关注点。它是增加某人首次使用您的应用成为客户机会的关键元素。现在,我们真的无法提高应用性能,除非我们确定潜在的改进可能性以及实现这些方法。在本章中,您将学习一些在提高 Angular 应用程序时可以部署的方法。您将学习如何使用几种技术来分析、优化和改进您的 Angular 应用性能。以下是本章将要涵盖的食谱:

  • 使用 OnPush 变更检测修剪组件子树

  • 从组件中分离变更检测器

  • 使用 runOutsideAngular 在 Angular 外部运行 async 事件

  • 使用 trackBy*ngFor 列表

  • 将繁重计算移动到纯管道

  • 使用 Web Workers 进行繁重计算

  • 使用性能预算进行审计

  • 使用 webpack-bundle-analyzer 分析包

技术要求

对于本章的食谱,请确保您的设置已按照 'Angular-Cookbook-2E' GitHub 仓库中的 '技术要求' 完成。有关设置详细信息,请访问:github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/docs/technical-requirements.md。本章的起始代码位于 github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/start/apps/chapter12

使用 OnPush 变更检测修剪组件子树

在当今现代网络应用的世界中,性能是优秀 用户体验UX) 和最终企业转化率的关键因素之一。在本章的第一个食谱中,我们将讨论您可以在组件的任何适当位置进行的根本性或最基础的优化,即通过使用 OnPush 变更检测策略。我们正在工作的应用有一些性能问题,特别是 UserCardComponent 类。这是因为它使用一个获取器函数 randomColor 来为其背景生成随机颜色。在幕后,该函数使用 factorial 函数来增加更多处理时间。但这只是为了演示一个组件,如果发生一些复杂计算,并且触发了多个变更检测,它可能会导致 UI 挂起。

准备工作

我们将要工作的应用位于克隆的仓库中的 start/apps/chapter12/ng-on-push-strategy

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-on-push-strategy 
    

    这应该在新的浏览器标签页中打开应用,您应该看到以下内容:

    图片

    图 12.1:运行在 http://localhost:4200 的 ng-on-push-strategy 应用程序

点击标有点击我的按钮或尝试搜索某个用户。你会看到应用程序运行得太慢,并且经常挂起。现在我们已经将项目在浏览器上运行,让我们看看下一节中的食谱步骤。

如何做到这一点...

我们将添加一些代码来监控randomColor获取器被调用的次数。这将显示 Angular 默认情况下触发的更改检测的次数。我们还将修复这个问题,并使用OnPush更改检测策略使其更高效(尽可能多)。让我们开始吧:

  1. 首先,让我们确保应用程序不会因为你的机器而运行得太慢,以至于让你的笔记本电脑/PC 挂起。打开src/app/app.config.ts文件,并将RANDOMIZATION_COUNT令牌的值从9调整为最适合你的值。

  2. 然后,尝试通过在搜索框中输入他们的名字来搜索名为Irineu的用户。你会注意到应用程序仍然挂起,并且需要几秒钟才能显示用户。你还会注意到,当你输入时,你甚至看不到搜索框中的输入字母。也就是说,渲染有延迟。

    让我们在代码中添加一些逻辑。我们将检查页面加载时 Angular 调用idUsingFactorial方法的次数。

  3. 让我们创建一个服务,我们将使用它来跟踪特定用户的特定卡片被调用的次数。从工作区根目录运行以下命令以创建服务:

    cd start && nx g s services/logs --project ng-on-push-strategy 
    

    当被询问时,选择@schematics/angular:service

  4. 按照以下方式更新src/app/services/logs.service.ts文件的内容:

    import { Injectable } from '@angular/core';
    
    @Injectable({
      providedIn: 'root'
    })
    export class LogsService {
      logs: Record<string, number> = {}
    
      updateLogEntry(email: string) {
        if (this.logs[email] === undefined) {
          this.logs = {
            ...this.logs,
            [email]: 1
          }
        } else {
          this.logs = {
            ...this.logs,
            [email]: this.logs[email] + 1
          }
        }
      }
    } 
    
  5. 现在,在src/app/component/user-card/user-card.component.ts文件中注入LogService。我们还将创建一个获取器(log)函数来获取用户的计数,并且每当调用randomColor获取器时,我们将更新计数。按照以下方式更新提到的文件:

    ...
    **import** **{** **LogsService** **}** **from****'../../services/logs.service'****;**
    @Component({...})
    export class UserCardComponent {
      ...
      randomizationCount = inject(RANDOMIZATION_COUNT);
      **logsService =** **inject****(****LogsService****);**
    **get****log****() {**
    **return****this****.****logsService****.****logs****[****this****.****user****.****email****] ??** **0****;**
    **}**
    get randomColor() {
        **this****.****logsService****.****updateLogEntry****(****this****.****user****.****email****);**
        ...
      }
    } 
    
  6. 现在,我们将使用用户卡片组件模板中的日志来显示计数。按照以下方式更新src/app/component/user-card/user-card.component.html文件:

    <div [style.backgroundColor]="randomColor"...>
    <img ...>
    <div class="card-body flex-1">...</div>
    **<****div****class****=****"p-4 bg-slate-900 text-green-300 rounded-md h-fit"****>**
    **<****div****>**
    **Color Generation Count:**
    **</****div****>**
    **<****pre****>****{{log}}****</****pre****>**
    **</****div****>**
    </div> 
    

    如果你现在查看应用程序,你应该会看到以下颜色生成计数

    图片

    图 12.2:页面加载时显示的颜色生成计数

  7. 现在,点击点击我按钮。然后,聚焦(点击)快速搜索输入框,然后点击外部。重复几次,你应该会看到即使卡片不应该重新渲染,颜色也会被重新生成。图 12.3显示了它应该看起来是什么样子:图片

    图 12.3:未搜索任何内容与应用程序交互后的日志

    注意,如果你开始搜索某些内容,你会得到更多的重新渲染。这是因为每个 keyup 和/或 keydown 事件都会触发更多的重新渲染。

  8. 为了解决这个问题,我们将使用OnPush策略,并观察它如何改变用户卡片组件相对于 Angular 的变更检测策略的行为。按照以下方式更新user-card.component.ts文件:

    import { **ChangeDetectionStrategy**, Component, Input, inject } from '@angular/core';
    ...
    @Component({
      ...,
      styleUrls: ['./user-card.component.scss'],
      **changeDetection****:** **ChangeDetectionStrategy****.****OnPush**
    })
    ... 
    

    现在,如果你尝试点击点击我按钮,在搜索输入框中聚焦并点击外部,或者做任何其他的事情(除了搜索用户),你将不会在卡片上的颜色生成计数中看到任何变化,如图图 12.4所示:

    图片

    图 12.4:OnPush 策略防止不必要的渲染

太好了!通过使用OnPush策略,我们能够提高UserCardComponent的整体性能。其余的只是为了好玩。现在你知道如何使用这个策略了,请看下一节了解它是如何工作的。

它是如何工作的...

默认情况下,Angular 使用Default变更检测策略——或者技术上讲,它是来自@angular/core包的ChangeDetectionStrategy.Default枚举。由于 Angular 不知道我们创建的每个组件,它使用默认策略以避免任何意外。这意味着当可能发生变化时,框架将检查整个组件树中的更改。这可能包括用户事件、定时器、XHRs、promises 等。在一个具有大量绑定的复杂 UI 中,这可能导致性能下降,尤其是在大多数这些组件不经常更改或仅依赖于特定输入的情况下。

但是,作为开发者,如果我们知道一个组件除非其@Input()变量之一发生变化,否则不会改变,我们就可以——并且应该——为该组件使用OnPush变更检测策略。为什么?因为这个策略告诉 Angular 只有在组件的@Input()变量发生变化时才运行变更检测。这种策略对于表示组件(有时称为“哑”组件)来说是一个绝对的赢家,这些组件只是应该使用@Input()变量/属性显示数据,并在交互时发出@Output()事件。这些表示组件通常不包含任何业务逻辑,如复杂的计算、使用服务进行HTTP调用等。因此,我们更容易在这些组件中使用OnPush策略,因为它们只有在父组件的@Input()属性之一发生变化时才会显示不同的数据,即它们的引用应该发生变化。例如,如果我们有一个用户数组,现在应该有一个全新的数组来运行变更检测。如果它是同一个数组,但我们只是添加或删除了一个项目,使用OnPush的变更检测将不会被触发。

由于我们现在在UserCardComponent上使用OnPush策略,它只有在搜索时替换整个users数组时才会触发变更检测。这发生在500ms的防抖之后(users.component.ts文件中的第 31 行),所以只有在用户停止输入时才执行。因此,在优化之前,默认的变更检测会在每个按键敲击(浏览器事件)时触发,而现在则不会。

重要提示

如你所知,OnPush策略仅在@Input()绑定中的一个或多个发生变化时触发 Angular 变更检测机制。这意味着如果我们更改组件(UserCardComponent)内的属性,它将不会在视图中反映出来,因为在这种情况下变更检测机制不会运行,因为这个属性不是@Input()绑定。你必须将组件标记为脏的,这样 Angular 才能检查组件并运行变更检测。你将使用ChangeDetectorRef服务来完成此操作——具体来说,使用markForCheck方法。

参见

从组件中移除变更检测器

在前面的食谱中,我们学习了如何在组件中使用OnPush策略来避免 Angular 变更检测在没有@Input()绑定发生变化的情况下运行。然而,还有一种方法可以告诉 Angular 不要为特定的组件及其子树运行变更检测。这将完全从变更检测周期中移除组件及其子树,如图 12.5 所示,这可能会导致整体性能的提升。当你想要完全控制何时运行变更检测时,这也很有用。在本食谱中,你将学习如何完全从 Angular 组件中移除变更检测器以获得性能提升。

图 12.5:变更检测器从组件树中移除

准备工作

我们将要工作的应用位于克隆的仓库中的start/apps/chapter12/ng-cd-ref

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-cd-ref 
    

    这应该在新的浏览器标签页中打开应用,你应该看到以下内容:

    图 12.6:ng-cd-ref 应用在 http://localhost:4200 运行

点击写着点击我的按钮,或者尝试搜索某个用户。你会看到应用运行得太慢,经常卡住。现在我们已经将项目在浏览器上启动,让我们看看下一节中食谱的步骤。

如何做到这一点...

我们正在工作的应用程序有一些性能问题,特别是与 UserCardComponent 类有关。这是因为它使用一个获取器函数 randomColor 来为其背景生成随机颜色。在幕后,该函数使用 factorial 函数来增加处理时间。但这只是为了演示一个组件,如果同时发生一些复杂计算和多个变更检测被触发,它可能会导致 UI 挂起。我们将添加一些代码来监控 randomColor 获取器被调用的次数。这将显示 Angular 默认触发的变更检测次数。我们还将修复问题,并使其更高效(尽可能做到),通过完全从特定组件中分离变更检测器。让我们开始吧:

  1. 首先,让我们确保应用程序对于您的机器来说不会太慢,这样它就不会使您的笔记本电脑/PC 挂起。打开 src/app/app.config.ts 文件,并将 RANDOMIZATION_COUNT 令牌的值从 9 调整为您认为最合适的值。

  2. 然后,尝试通过在搜索框中输入他们的名字来搜索一个名为 Irineu 的用户。你会注意到应用程序仍然处于挂起状态,并且显示用户需要几秒钟。你还会注意到,当你输入字母时,甚至看不到搜索框中的字母。也就是说,渲染存在延迟。

    让我们在代码中添加一些逻辑。我们将检查当页面加载时,Angular 调用 idUsingFactorial 方法的次数。

  3. 让我们创建一个我们将用于跟踪特定用户的特定用户卡片被调用的次数的服务。从工作区根目录运行以下命令以创建服务:

    cd start && nx g s services/logs --project ng-cd-ref 
    

    当被询问时,请选择 @schematics/angular:service

  4. 按照以下方式更新 src/app/services/logs.service.ts 文件的内容:

    import { Injectable } from '@angular/core';
    @Injectable({
      providedIn: 'root'
    })
    export class LogsService {
      logs: Record<string, number> = {}
      updateLogEntry(email: string) {
        if (this.logs[email] === undefined) {
          this.logs = {
            ...this.logs,
            [email]: 1
          }
        } else {
          this.logs = {
            ...this.logs,
            [email]: this.logs[email] + 1
          }
        }
      }
    } 
    
  5. 现在,在 src/app/component/user-card/user-card.component.ts 文件中注入 LogService。我们还将创建一个获取器(log)函数来获取用户的计数,并且每次调用 randomColor 获取器时,我们将更新计数。按照以下方式更新提到的文件:

    ...
    **import** **{** **LogsService** **}** **from****'../../services/logs.service'****;**
    @Component({...})
    export class UserCardComponent {
      ...
      randomizationCount = inject(RANDOMIZATION_COUNT);
      **logsService =** **inject****(****LogsService****);**
    **get****log****() {**
    **return****this****.****logsService****.****logs****[****this****.****user****.****email****] ??** **0****;**
    **}**
    get randomColor() {
        **this****.****logsService****.****updateLogEntry****(****this****.****user****.****email****);**
        ...
      }
    } 
    
  6. 现在,我们将使用用户卡片组件的模板中的日志来显示计数。按照以下方式更新 src/app/component/user-card/user-card.component.html 文件:

    <div [style.backgroundColor]="randomColor"...>
    <img ...>
    <div class="card-body flex-1">...</div>
    **<****div****class****=****"p-4 bg-slate-900 text-green-300 rounded-md h-fit"****>**
    **<****div****>**
    **Color Generation Count:**
    **</****div****>**
    **<****pre****>****{{log}}****</****pre****>**
    **</****div****>**
    </div> 
    

    如果你现在查看应用程序,你应该会看到以下 颜色生成计数

    图片 B18469_12_07

    图 12.7:页面加载时显示的颜色生成计数

  7. 现在,点击 Click Me 按钮。然后(点击)将焦点放在 Quick Search 输入上,然后点击外部。重复几次,你应该会看到即使卡片不应该重新渲染,颜色也会被重新生成。图 12.8 显示了它应该看起来是什么样子!:图片 B18469_12_08

    图 12.8:与应用程序交互后(未搜索任何内容)的日志

    注意,如果你开始搜索某些内容,你会得到更多的重新渲染。那是因为每个 keyup 和/或 keydown 事件都会触发更多的重新渲染。

  8. 为了解决这个问题,当组件加载时,我们将从user-card组件中分离出更改检测器的引用,这样就不会在之后重新渲染。这是假设卡片的内容永远不会改变。按照以下方式更新user-card.component.ts文件:

    import { **AfterViewInit**, **ChangeDetectorRef**, Component, Input, inject } from '@angular/core';
          ...
    
    export class UserCardComponent**implements****AfterViewInit** {
      ...
      logsService = inject(LogsService);
      **cdRef =** **inject****(****ChangeDetectorRef****);**
    **ngAfterViewInit****():** **void** **{**
    **this****.****cdRef****.****detach****();**
    **}**
      ...
    } 
    

    现在,如果你尝试点击点击我按钮,关注搜索输入并点击外部,或者做任何其他(除了搜索用户)的事情,你将看不到卡片上的颜色生成计数发生变化,如图图 12.9所示:

    图片

    图 12.9:分离的更改检测器防止不必要的渲染

    但如果组件后来需要更改怎么办?我们如何完全控制更改检测?

  9. 假设应用程序有一个更改用户名字的功能。我们将硬编码逻辑来更改第一个用户的姓氏。在这种情况下,我们必须告诉 Angular 运行更改检测。让我们更新users.component.html文件,以更新src/app/users/users.component.html文件中的点击我按钮,如下所示:

    <div class="home">
    <section class="flex flex-col gap-4 w-full">
    <h2 class="text-center text-2xl">Users</h2>
    <form class="input-container flex gap-4 w-full items
          -center mb-4" [formGroup]="searchForm">
    <div class="relative flex-1">...</div>
    **<****button** **(****click****)=****"updateName(users[0])"****>****Update**
    **Irineu's Name****</****button****>**
    </form>
    <div class="secondary-container flex justify-center">...</div>
    </section>
    </div>
    <ng-template #loader>
    <app-loader></app-loader>
    </ng-template> 
    
  10. 让我们更新 TypeScript 文件,添加更新用户名字的功能。按照以下方式更新src/app/users/users.component.ts文件:

     ...
    export class UsersComponent implements OnInit {
      ...
    
      usersTrackBy(_index: number, user: IUser) {
        return user.uuid;
      }
    
      **updateName****(****user****:** **IUser****) {**
    **this****.****users** **=** **this****.****users****.****map****(****(****userItem****) =>** **{**
    **if** **(userItem.****uuid** **=== user.****uuid****) {**
    **return** **{**
    **...userItem,**
    **name****: {**
    **...userItem.****name****,**
    **last****:** **'Test 123'**
    **}**
    **}**
    **}**
    **return** **userItem;**
    **})**
    **}**
    } 
    

    如果你点击更新 Irineu 的名字按钮,你将看不到 UI 上的任何变化。那是因为更改检测器仍然与每个用户卡片组件分离。所以名字改变了,但你无法在 UI 上看到变化。参见图 12.10,其中 Angular (Chrome) DevTools 显示了组件中正在更新的值,但 UI 没有反映出来。

    图片

    图 12.10:由于更改检测器分离,用户卡片未重新渲染

  11. 为了解决这个问题,我们将查询用户页面上的UserCard组件,并将手动在所需组件上运行ChangeDetectorRef.detectChanges方法。按照以下方式更新src/app/users/users.component.ts文件:

    import { Component, inject, OnInit, **QueryList**, **ViewChildren**} from '@angular/core';
    ...
    export class UsersComponent implements OnInit {
      **@ViewChildren****(****UserCardComponent****) userCards!:**
    **QueryList****<****UserCardComponent****>;**
      users!: IUser[];
      ...
    
      updateName(user: IUser) {
        this.users = this.users.map((userItem) => {
          ...
        })
        **const** **matchingComponent =** **this****.****userCards****.****find****(****comp** **=>** **{**
    **return** **comp.****user****.****uuid** **=== user.****uuid****;**
    **})**
    **if** **(matchingComponent) {**
    **setTimeout****(****() =>** **{**
    **matchingComponent.****cdRef****.****detectChanges****();**
    **},** **0****);**
    **}**
      }
    } 
    

    现在,如果你点击更新 Irineu 的名字按钮四次,你应该会看到第一个用户卡的计数为5,而其余的卡片仍然渲染1次,如图图 12.11所示。

    图片

    图 12.11:完全控制的更改检测

太好了!通过几个步骤,我们使用 Angular 的ChangeDetectorRef服务提高了UserCardComponent的整体性能。我们不仅提高了性能,而且还根据我们的用例从父组件(UsersComponent)完全控制了它。现在你知道如何使用ChangeDetectorRef服务,请参见下一节了解它是如何工作的。

它是如何工作的…

ChangeDetectorRef 服务提供了一系列重要的方法来控制 Angular 中的变更检测。在配方中,我们使用组件的 ngAfterViewInit 方法中的 detach 方法来在组件首次渲染后立即将 Angular 变更检测机制从组件中断开。结果,UserCardComponent 类上不会触发任何变更检测。这是因为 Angular 有一个变更检测树,其中每个组件都是一个节点。当我们从变更检测树中断开一个组件时,该组件(作为一个树节点)被断开,其子组件(或节点)也是如此。通过这样做,我们最终确保 UserCardComponent 类上不会发生任何变更检测。如果 UserCardComponent 中使用了其他组件,它们也不会为它们运行变更检测。结果,当我们点击按钮或聚焦和失去焦点在用户页面的输入上时,即使我们像配方中那样更新了第一个用户的名称,也不会有任何渲染。

此外,当我们需要在视图中显示第一个用户名称的更改时,这需要触发 Angular 的变更检测机制,我们使用来自 ChangeDetectorRef 实例的 detectChanges 方法,在我们将更新后的用户数组分配给 UsersComponent 类中的 users 属性之后立即使用。结果,Angular 运行变更检测机制,我们在第一个用户卡片上看到更新的名称。这赋予我们完全决定是否完全断开变更检测、重新连接它或仅对需要变更检测的特定情况手动运行它的权力。

现在你已经了解了配方的工作原理,请参阅下一节以获取一些有用的链接。

参见

使用 runOutsideAngular 在 Angular 外部运行异步事件

Angular 在其几个方面运行变更检测机制,包括但不限于所有浏览器事件,如keyupkeydownclick等。它还在setTimeoutsetInterval和 Ajax HTTP 调用上运行变更检测。如果我们必须避免在这些事件上运行变更检测,我们必须告诉 Angular 不要在这些事件上触发变更检测——例如,如果您在 Angular 组件中使用setInterval方法,每次其回调方法被调用时,它将触发一个 Angular 变更检测周期。这可能导致大量的变更检测周期,甚至可能导致您的应用挂起。理想的情况是能够继续使用setInterval方法等,而不触发变更检测。在这个菜谱中,您将学习如何做到这一点。您将学习如何使用NgZone服务在zone.js之外执行代码块,特别是使用runOutsideAngular方法。参见图 12.12以了解应用程序的结构:

图 12.12:ng-run-outside-angular 应用的组件层次结构

准备中

我们将要工作的应用位于克隆的仓库中的start/apps/chapter12/ng-run-outside-angular

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-run-outside-angular 
    

    这应该在新的浏览器标签页中打开应用,您应该看到以下内容:

    图 12.13:在 http://localhost:4200 上运行的 ng-run-outside-angular 应用

现在我们已经运行了应用,让我们在下一节中查看菜谱的步骤。

如何做到这一点...

我们有一个显示手表的应用。然而,目前应用中的变更检测并不优化,我们有很大的改进空间。我们将尝试使用ngZone中的runOutsideAngular方法移除任何不必要的变更检测。让我们开始吧:

  1. 时钟值正在不断更新。因此,我们为每个更新周期运行变更检测。打开 Chrome 开发者工具并切换到控制台标签。输入appLogs并按Enter键,以查看变更检测为WatchComponent以及渲染小时、分钟、秒和毫秒的组件运行了多少次。它应该看起来像这样:

    图 12.14:反映变更检测运行次数的 appLogs 对象

  2. 为了衡量性能,让我们在时间上减少我们的观察范围。让我们添加一些代码,在应用启动 4 秒后关闭时钟的间隔计时器。修改watch-box.component.ts文件,如下所示:

    ...
    @Component({...})
    export class WatchBoxComponent implements OnInit {
      ...
      ngOnInit(): void {
        this.intervalTimer = setInterval(() => {
          this.timer();
      }, 1);
        **setTimeout****(****() =>** **{**
    **clearInterval****(****this****.****intervalTimer****);**
    **},** **4000****);**
      }
      ...
    } 
    
  3. 刷新应用并等待 4 秒,直到时钟停止。然后,在控制台标签页中多次输入appLogs,按Enter键,查看结果。时钟停止了,但动画仍在运行。你应该会看到对watchComponentRender键的变更检测仍然增加,如下所示:

    图 12.15:监视组件的变更检测仍在运行

  4. 让我们在 4 秒后也在监视内部停止动画。更新watch.component.ts文件,如下所示:

    ...
    export class WatchComponent implements OnInit {
      ...
      ngOnInit(): void {
        this.intervalTimer = setInterval(...}, 30);
        **setTimeout****(****() =>** **{**
    **clearInterval****(****this****.****intervalTimer****);**
    **},** **4000****);**
      }
      ...
    } 
    

    刷新应用并等待动画停止。查看 Chrome DevTools 中的appLogs对象。你应该会看到变更检测对watch键停止,如下所示:

    图 12.16:停止动画间隔后,变更检测停止

  5. 我们希望的结果是继续运行动画和时钟,并且没有额外的变更检测周期运行。为此,现在我们只需停止监视即可。要做到这一点,更新watch-box.component.ts文件,如下所示:

    ...
    @Component({...})
    export class WatchBoxComponent implements OnInit {
      ...
      ngOnInit(): void {
        **// this.intervalTimer = setInterval(() => {**
    **//   this.timer();**
    **// }, 1);**
    **// setTimeout(() => {**
    **//   clearInterval(this.intervalTimer);**
    **// }, 4000);**
      }
    } 
    

    由于我们现在已经停止了时钟,appLogswatchComponentRender键的值现在仅基于这 4 秒的动画。刷新应用并等待动画停止。在 Chrome DevTools 中输入appLogs(在控制台标签页)。你现在应该会看到watchComponentRender键的值在250270之间。

  6. 让我们通过在ngZone服务外部运行间隔来避免在动画上运行变更检测。我们将为此使用runOutsideAngular方法。更新watch.component.ts文件,如下所示:

    import {
      ...
      ViewChild,
      **inject****,**
    **NgZone****,**
    } from '@angular/core';
    @Component({...})
    export class WatchComponent implements OnInit {zone = inject(NgZone);
      ...
      ngOnInit(): void {
      if (!window['appLogs']) {
        window['appLogs'] = {};
      }
      window['appLogs'][ watchComponentRender] = 0;
        **this****.****zone****.****runOutsideAngular****(****() =>** **{**
       ...
          setTimeout(() => {
            clearInterval(this.intervalTimer);
          }, 4000);
        **});**
      }
      ...
    } 
    

    刷新应用并等待大约 5 秒。如果你现在检查appLogs对象,你应该会看到每个属性的变更检测运行总数有所减少,如下所示:

    图 12.17:使用runOutsideAngular()后的WatchComponent中的appLogs对象

    哈哈!注意,appLogs对象中watch键的值已经从大约260下降到4。这意味着我们的动画现在根本不会对变更检测做出贡献,并且watch组件在 4 秒内只渲染 4 次。

  7. WatchComponent类的动画中移除clearInterval的使用。因此,背景圆圈(蓝色圆圈)动画应该再次开始。修改watch.component.ts文件,如下所示:

    ...
    @Component({...})
    export class WatchComponent implements OnInit {
      ...
      ngOnInit(): void {
        ...
        this.ngZone.runOutsideAngular(() => {
          this.intervalTimer = setInterval(() => {
            this.animate();
          }, 30);
          setTimeout(() => { <-- remove this
            clearInterval(this.intervalTimer);
          }, 4000);
        });
      }
      ...
    } 
    
  8. 最后,从WatchBoxComponent类中移除clearInterval的使用,并取消注释setInterval以运行时钟。更新watch-box.component.ts文件,如下所示:

    import { Component, OnInit } from '@angular/core';
    @Component({
      selector: 'app-watch-box',
      templateUrl: './watch-box.component.html',
      styleUrls: ['./watch-box.component.scss'],
    })
    export class WatchBoxComponent implements OnInit {
      name = '';
      time = {
        hours: 0,
        minutes: 0,
        seconds: 0,
        milliseconds: 0,
      };
      intervalTimer;
      constructor() {}
      ngOnInit(): void {
        this.intervalTimer = setInterval(() => {
          this.timer();
        }, 1);
        setTimeout(() => { //<-- Remove this
          clearInterval(this.intervalTimer);
        }, 4000);
      }
      ...
    } 
    

    刷新应用并在几秒钟后多次检查appLogs对象的价值。你应该会看到类似以下的内容:

    图 12.18:使用runOutsideAngular()进行性能优化后的appLogs对象

    观察前面的截图,你可能会问,“Ahsan!这是什么?与经过的毫秒数相比,watchComponentRender 键的变化检测运行次数仍然非常巨大。这到底是如何提高性能的呢?” 很高兴你问了!我将在 它是如何工作的… 部分告诉你原因。

  9. 作为最后一步,停止 Angular 服务器并运行以下命令以在生产模式下启动服务器:

    npm run serve:prod ng-run-outside-angular 
    
  10. 再次导航到 https://localhost:4200。等待几秒钟,然后在 控制台 选项卡中多次检查 appLogs 对象。你应该会看到如下对象:

图 12.19:使用生产构建的 appLogs 对象

嘣!如果你看前面的截图,你应该会看到 watchComponentRender 键的变化检测运行次数总是比 milliseconds 键多几个周期。这意味着 WatchComponent 类只有在 @Input()milliseconds 绑定值更新时才会(几乎)重新渲染。但为什么多几个周期呢?请看下一节了解它是如何工作的!

它是如何工作的…

在这个菜谱中,我们首先查看包含一些键值对的 appLogs 对象。每个键值对的值表示 Angular 为特定组件运行变化检测的次数。hoursmillisecondsminutesseconds 键代表时钟上显示的每个值的 WatchTimeComponent 实例。watchComponentRender 键代表 WatchComponent 实例的变化检测周期。

在菜谱的开始部分,我们看到 watch 键的值是 milliseconds 键值的两倍多。我们为什么要在乎 milliseconds 键呢?因为我们的应用程序中 @Input() 属性绑定 milliseconds 的变化最为频繁——也就是说,它每 1 毫秒ms)就会变化一次。其次是 WatchComponent 类中的 xCoordinateyCoordinate 属性,它们每 30 毫秒变化一次。xCoordinateyCoordinate 值并没有直接绑定到模板(HTML)上,因为它们会改变 stopWatch 视图子组件的 CSS 变量。这发生在 animate 方法内部,如下所示:

el.style.setProperty('--x', `${this.xCoordinate}px`);
el.style.setProperty('--y', `${this.yCoordinate}px`); 

因此,更改这些值不应触发变更检测。我们首先通过将测试时间限制在运行时钟,使用WatchBoxComponent类中的clearInterval方法来停止时钟,使其在 4 秒内停止,以便我们可以评估这些数值。在图 12.15中,我们看到即使时钟停止后,变更检测机制仍然会为WatchComponent类触发。随着时间的推移,这会增加appLogs对象中watch键的计数。然后我们通过在WatchComponent类中使用clearInterval来停止动画。这也使得背景(蓝色圆形)动画在 4 秒后停止。在图 12.16中,我们看到动画停止后,watch键的计数不再增加。

然后,我们尝试仅基于动画来查看变更检测的计数。在步骤 6中,我们停止了时钟。因此,我们只得到了appLogs对象中watch键基于动画的计数,这个值在250270之间。

然后,我们将神奇的runOutsideAngular方法引入到我们的代码中。这个方法是NgZone服务的一部分。NgZone服务包含在@angular/core包中。runOutsideAngular方法接受一个方法作为参数。这个方法在 Angular 区域外执行。这意味着在runOutsideAngular方法内部使用的setTimeoutsetInterval方法不会触发 Angular 变更检测周期。但技术上,我们为什么在区域外运行这个setInterval呢?因为我们的间隔调用animate方法,它更新 CSS 变量--x--y。由于它们会自动触发动画,并且WatchComponent类的其他属性不需要在 UI 中显示(需要重新渲染),我们可以将此代码移动到runOutsideAngular方法中。您可以在图 12.17中看到,使用runOutsideAngular方法后,计数降至4

然后,我们从WatchBoxComponentWatchComponent类中移除了clearInterval的使用——也就是说,再次运行时钟和背景(蓝色圆形)动画,就像我们一开始做的那样。在图 12.18中,我们看到watch键的计数大约是milliseconds键的两倍。那么,为什么它大约是两倍呢?这是因为,在开发模式下,Angular 运行变更检测机制两次以确保没有副作用。例如,一个state属性的更新可能在一个(子)组件中引发变更检测,等等。因此,在步骤 9步骤 10中,我们在生产模式下运行应用程序,在图 12.19中,我们看到watch键的值仅比milliseconds键的值多几个周期,这意味着动画不再触发我们应用程序的任何变更检测。

但为什么watchComponentRender键与milliseconds键相比有更多的周期?这是因为WatchComponent是显示毫秒的组件(WatchTimeComponent)的父组件。可能会有一些基于浏览器交互的变更检测周期,但如果你在刷新应用且与应用没有任何交互时,watchComponentRender与毫秒计数之间的差异在生产模式下可能低至一个变更检测周期。

太棒了,不是吗?如果你觉得这个食谱很有用,请通过我的社交媒体告诉我。

现在你已经了解了它是如何工作的,请查看下一节以获取更多阅读材料。

另请参阅

使用trackBy*ngFor列表

列表是我们今天构建的大多数应用的一个基本部分。如果你正在构建一个 Angular 应用,有很大可能性你会在某个时候使用*ngFor指令来渲染列表。*ngFor指令允许我们遍历数组或对象,为每个项目生成 HTML。然而,如果我们正在渲染大型列表,不谨慎地使用*ngFor可能会导致性能问题,尤其是在*ngFor的源被完全更改(整个数组被替换)时。在这个示例中,我们将学习如何使用带有trackBy函数的*ngFor指令来提高列表的性能。让我们开始吧。

准备工作

我们将要工作的应用位于克隆的仓库中的start/apps/chapter12/ng-for-trackby

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目:

    npm run serve ng-for-trackby 
    

    这应该在新的浏览器标签页中打开应用,你应该看到以下内容:

    图 12.20:ng-for-trackby 应用在 http://localhost:4200 上运行

现在我们已经运行了应用,让我们在下一节中查看食谱的步骤。

如何做到这一点…

我们有一个应用,在视图中显示了 10,000 个用户的列表。由于我们没有使用虚拟滚动(例如来自@angular/material),而是使用标准的*ngFor列表,所以我们现在确实面临一些性能问题。请注意,当你刷新应用时,即使在加载器隐藏后,你也会在列表出现前看到大约 2-3 秒的空白白色框。如果你的机器卡住了很长时间,你可以在data.service.ts文件中修改USERS_LIMIT变量的值。让我们开始重现性能问题的步骤,之后我们将修复这些问题:

  1. 首先,打开 Chrome 开发者工具并查看 控制台 选项卡。你应该会看到“用户卡片创建”消息记录了 10,000 次。每次创建/初始化用户卡片组件(UserCardComponent类的实例)时,都会记录此消息。

  2. 现在,通过卡片上的删除按钮删除第一个项目。现在你应该会看到相同的消息(用户卡片创建)再次记录了 9,999 次,如以下截图所示。这意味着我们为剩余的 9,999 个项目重新创建了list-item组件!图片 B18469_12_21

    图 12.21:删除项目后再次显示的日志

  3. 现在,点击第一个项目(这会根据现有代码更新第一个项目)。你应该再次看到用户卡片创建日志,如图 12.22所示。这意味着我们在更新列表中的任何项目时都会重新创建所有 9,999 个列表项。你会注意到用户界面UI)中第一个项目的名称更新大约在 2-3 秒内反映出来:图片 B18469_12_22

    图 12.22:更新项目后再次显示的日志

  4. 现在,让我们通过使用trackBy函数来修复性能问题。打开users-list.component.ts文件并按照以下方式更新它:

    ...
    export class UsersListComponent {
      @Input() listItems: AppUserCard[] = [];
      @Output() itemClicked = new EventEmitter<AppUserCard>();
      @Output() itemDeleted = new EventEmitter<AppUserCard>();
      **trackByFn****(****_index****:** **number****,** **item****:** **AppUserCard****) {**
    **return** **item.****id****;**
    **}**
    } 
    
  5. 现在,更新users-list.component.html文件以使用我们刚刚创建的trackByFn方法,如下所示:

    <h4 class="heading">Our trusted customers</h4>
    <ul class="list list-group p-2">
    <li class="list__item list-group-item" *ngFor="let item of
        listItems; **trackBy: trackByFn**">
        ...
      </li>
    </ul> 
    
  6. 现在,刷新应用程序,删除第一个项目,然后点击(新的)第一个列表项来更新它。你会注意到项目立即更新,并且不再记录用户卡片创建消息,如图 12.23所示:图片 B18469_12_23

    图 12.23:使用 trackBy 函数更新项目后不再有进一步的日志记录

太好了!你现在知道如何使用trackBy函数与*ngFor指令来优化 Angular 中列表的性能。要了解食谱背后的所有魔法,请参阅下一节。

它是如何工作的...

*ngFor指令允许我们遍历可迭代对象并渲染多个 HTML 元素或组件。当处理原始数组(布尔值、字符串或数字值)时,Angular 通过其值跟踪每个项目以识别元素。然而,当处理对象时,Angular 通过内存位置跟踪它们,就像 JavaScript 处理对象相等性检查一样。这意味着如果你只是更改数组中对象的属性,它不会重新渲染该对象的模板。但是,如果你提供一个新对象来替换它(内存中的不同引用),则将重新渲染该项的内容。这就是我们在本食谱中重现性能问题的方法。由于我们在更新或删除项目时替换整个数组,Angular 将数组视为一个新资源来迭代。在data.service.ts文件中,我们在名为DataService的服务中为updateUser方法有以下代码:

updateUser(updatedUser: AppUserCard) {
  this.users = this.users.map((user) => {
    if (user.id === updatedUser.id) {
      return {
        ...updatedUser,
      };
    }
    return { ...user };
  });
} 

注意,我们使用对象展开运算符({ … })为数组中的每个项目返回一个新的对象。这最终告诉*ngFor指令重新渲染UserListComponent类中listItems数组中的每个项目的 UI。假设你已经渲染了 1,000 个用户。如果你搜索一个返回 100 个用户的术语,理想情况下,Angular 不应该重新渲染这 100 个用户,因为它们已经在视图中渲染过了。然而,Angular 会重新渲染所有列表项的 UI,原因如下(但不仅限于这些):

  • 用户的排序/放置可能已更改。

  • 用户的长度可能已更改。

现在,我们想要避免使用对象引用作为每个列表项的唯一标识符。对于我们的用例,我们知道每个用户的 ID 是唯一的;因此,我们使用trackBy函数告诉 Angular 使用用户的 ID 作为唯一标识符。现在,即使我们在updateUser方法(如前所述)更新用户后为每个用户返回一个新的对象,Angular 也不会重新渲染所有列表项。这是因为新的对象(用户)具有相同的 ID,Angular 使用它来跟踪它们。很酷,对吧?

现在你已经了解了食谱的工作原理,请查看下一节以查看进一步阅读的链接。

相关内容

将重计算移动到纯管道

在 Angular 中,我们有一种特定的编写组件的方式。由于 Angular 具有强烈的意见导向,我们已经有来自社区和 Angular 团队的大量指南,关于编写组件时需要考虑的事项——例如,直接从组件中发起 HTTP 调用被认为是一种不太好的做法。同样,如果组件中有重计算且每次变更检测周期都会触发,这也不被认为是一种好的做法。想象一下,视图依赖于使用不断计算的数据的转换版本。这将在每个渲染周期中引起大量的计算和处理。一个很好的技术是将重计算移动到 Angular(纯)管道中(特别是如果计算发生在每次变更检测周期时)。

准备中

我们将要工作的应用位于克隆的仓库中的start/apps/chapter12/ng-pipes-perf

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-pipes-perf 
    

    这应该在新的浏览器标签页中打开应用,你应该会看到以下内容:

    图片

    图 12.24:ng-pipes-perf 应用在 http://localhost:4200 运行

点击标有点击我的按钮,或者尝试搜索一些用户。你会看到应用太慢,经常卡住。现在,我们已经将项目在浏览器上启动,让我们在下一节中查看食谱的步骤。

如何做到这一点...

我们正在工作的应用程序有一些性能问题,特别是与UserCardComponent类有关。这是因为它使用一个 getter 函数randomColor为其背景生成随机颜色。在幕后,该函数使用factorial函数来增加处理时间。但这只是为了演示一个组件,如果同时发生一些复杂计算和多个变更检测被触发,它可能会导致 UI 卡住。我们将添加一些代码来监控randomColor getter 被调用的次数。这将显示 Angular 默认触发的变更检测次数。我们还将通过完全从特定组件中分离变更检测来修复问题,并使其更高效(尽可能多)。让我们开始吧:

  1. 首先,让我们确保应用程序对您的机器来说足够慢,以至于它使您的笔记本电脑/PC 卡住。打开src/app/app.config.ts文件,并将RANDOMIZATION_COUNT令牌的值从9调整为最适合您的值。

  2. 然后,尝试通过在搜索框中输入他们的名字来搜索名为Irineu的用户。你会注意到应用程序仍然卡住,并且显示用户需要几秒钟。你还会注意到,当你输入字母时甚至看不到搜索框中的字母。也就是说,渲染存在延迟。

    让我们在代码中添加一些逻辑。我们将检查 Angular 在页面加载时调用idUsingFactorial方法的次数。

  3. 让我们创建一个服务,我们将使用它来跟踪特定用户的特定用户卡片被调用的次数。从工作区的根目录运行以下命令来创建一个服务:

    cd start && nx g s services/logs --project ng-pipes-perf 
    

    当被询问时,请选择@schematics/angular:service

  4. 按照以下方式更新src/app/services/logs.service.ts文件的内容:

    import { Injectable } from '@angular/core';
    @Injectable({
      providedIn: 'root'
    })
    export class LogsService {
      logs: Record<string, number> = {}
      updateLogEntry(email: string) {
        if (this.logs[email] === undefined) {
          this.logs = {
            ...this.logs,
            [email]: 1
          }
        } else {
          this.logs = {
            ...this.logs,
            [email]: this.logs[email] + 1
          }
        }
      }
    } 
    
  5. 现在,在src/app/component/user-card/user-card.component.ts文件中注入LogService。我们还将创建一个 getter(log)函数来获取用户的计数,并且每当randomColor getter 被调用时,我们将更新计数。按照以下方式更新提到的文件:

    ...
    **import** **{** **LogsService** **}** **from****'../../services/logs.service'****;**
    @Component({...})
    export class UserCardComponent {
      ...
      randomizationCount = inject(RANDOMIZATION_COUNT);
      **logsService =** **inject****(****LogsService****);**
    **get****log****() {**
    **return****this****.****logsService****.****logs****[****this****.****user****.****email****] ??** **0****;**
    **}**
    get randomColor() {
        **this****.****logsService****.****updateLogEntry****(****this****.****user****.****email****);**
        ...
      }
    } 
    
  6. 现在,我们将使用用户卡片组件的模板中的日志来显示计数。按照以下方式更新src/app/component/user-card/user-card.component.html文件:

    <div [style.backgroundColor]="randomColor"...>
    <img ...>
    <div class="card-body flex-1">...</div>
    **<****div****class****=****"p-4 bg-slate-900 text-green-300 rounded-md** **h-fit"****>**
    **<****div****>**
    **Color Generation Count:**
    **</****div****>**
    **<****pre****>****{{log}}****</****pre****>**
    **</****div****>**
    </div> 
    

    如果你现在查看应用程序,你应该会看到以下颜色生成计数

    图 12.25

    图 12.25:页面加载时显示的颜色生成计数

  7. 现在,点击更新 Irineu 的名字按钮。然后(点击)聚焦于快速搜索输入框,然后点击外部。重复几次,你应该会看到颜色被重新生成,尽管卡片不应该被重新渲染。图 12.26显示了它应该看起来是什么样子!图 12.26

    图 12.26:与应用程序交互后未搜索任何内容的日志

    注意,如果你开始搜索某些内容,你会得到更多的重新渲染。这是因为每个 keyup 和/或 keydown 事件都会触发更多的重新渲染。

  8. 为了解决这个问题,我们将创建一个 Angular 管道。我们将把生成随机颜色的计算移动到这个 Angular 管道中。在项目根目录中,在终端中运行以下命令:

    cd start && nx g pipe random-color --directory apps/chapter12/ng-pipes-perf/src/app/pipes 
    

    当被要求时,使用@schematics/angular:pipe脚图。

  9. 现在,将randomColor获取器函数和factorial函数从user-card.component.ts文件移动到 Angular 管道的文件pipes/random-color.pipe.ts中,如下所示:

    import { Pipe, PipeTransform, inject } from '@angular/core';
    **import** **{** **LogsService** **}** **from****'../services/logs.service'****;**
    **import** **{ randColor }** **from****'@ngneat/falso'****;**
    **import** **{** **IUser** **}** **from****'../interfaces/user.interface'****;**
    ...
    export class RandomColorPipe implements PipeTransform {
      **logsService =** **inject****(****LogsService****);**
    **factorial****(****n****:** **number****):** **number** **{**
    **if** **(n ==** **0** **|| n ==** **1****) {**
    **return****1****;**
    **}** **else** **{**
    **return** **n *** **this****.****factorial****(n -** **1****);**
    **}**
    **}**
    **randomColor****(****email****:** **string****,** **randomizationCount****:** **number****) {**
    **this****.****logsService****.****updateLogEntry****(email);**
    **let** **color;**
    **for** **(****let** **i =** **0****; i <** **this****.****factorial****(randomizationCount); i++) {**
    **color =** **randColor****();**
    **}**
    **return** **color;**
    **}**
    transform(r**andomizationCount****:** **number****,** **user****:** **IUser**):
        **string** **|** **undefined** {
        return **this****.****randomColor****(user.****email****, randomizationCount)**;
      }
    } 
    
  10. 确保从src/app/component/user-card/user-card.component.ts文件中删除那些函数(randomColor获取器和factorial)。同时删除任何未使用的导入。

  11. 现在,将randomColor管道添加到user-card.component.ts文件中的用户卡片组件中,如下所示:

    ...
    **import** **{** **RandomColorPipe** **}** **from****'../../pipes/random-color.pipe'****;**
    @Component({
      selector: 'app-user-card',
      standalone: true,
      imports: [CommonModule, RouterModule, **RandomColorPipe**],
      ...
    }) 
    
  12. 现在,更新user-card.component.html文件以使用randomColor管道代替我们之前使用的获取器。代码应该看起来像这样:

    <div [style.backgroundColor]="**randomizationCount | randomColor : user**" class="card flex flex-col max-w-sm mx-auto h-full duration-200 cursor-pointer hover:border-purple-500 hover:shadow-md p-4 border border-slate-300 rounded-md text-center" *ngIf="user" routerLink="/user/{{user.uuid}}">
      ...
    </div> 
    
  13. 现在,刷新应用并重复步骤 7。你会看到颜色只为第一张卡片生成,其他卡片不会重新渲染,如图12.27所示:

    图 12.27:只有第一张卡片重新生成颜色

嘣!现在你知道了如何通过将重计算移动到纯 Angular 管道来优化性能,请看下一节了解它是如何工作的。

它是如何工作的…

如我们所知,Angular 默认在应用中每个由浏览器事件触发的变化检测上运行。由于我们在组件模板(UI)中使用了一个randomColor获取器,这个函数每次 Angular 运行变化检测周期时都会运行。这会导致更多的计算和性能问题。如果我们使用函数调用而不是获取器,这也会成立。

我们可以退一步,从最初的实现中思考一下randomColor获取器的作用。它是基于randomizationCount属性的阶乘使用for循环工作的。这只是为了给这个例子增加很多处理时间,但你可以想象任何在变化检测周期中涉及的重计算都会导致性能问题。在这些情况下,我们可能会使用纯函数或者可能使用记忆化。

纯函数是一个函数,给定相同的输入,总是返回相同的输出。记忆化是一种技术,其中,如果输入没有改变(即,函数使用与上次相同的输入调用),则返回缓存的输出,并跳过重计算。幸运的是,Angular 纯管道是纯函数和记忆化的,因为它们只有在输入改变时才会被调用。如果不是这种情况,管道的转换函数将不会被调用,组件也不会重新渲染。

在这个菜谱中,我们将计算移动到一个新创建的 Angular 管道。管道的 transform 方法接收 randomizationCount 作为第一个值,以及 user(类型为 IUser)作为第二个输入。管道随后使用 randomColor 方法,最终使用 factorial 方法来计算一个随机颜色。当我们开始在搜索框中输入时,用户卡片的值不会改变。这导致管道直到我们根据搜索查询得到一组新的用户时才会被触发。一旦我们得到结果,用户卡片将被重新渲染,因此我们为它们得到新的颜色。结果,由于浏览器事件,没有不必要的计算运行,从而优化性能并解除 UI 线程的阻塞。

相关内容

使用 Web workers 进行重量级计算

如果你的 Angular 应用在执行动作时进行大量计算,那么它有很大可能会阻塞 UI 线程。这会导致渲染 UI 时出现延迟,因为它阻塞了主 JavaScript 线程。Web workers 允许我们在后台线程中运行重量级计算,从而释放 UI 线程,使其不被阻塞。在这个菜谱中,我们将使用一个在 UserService 类中进行重量级计算的应用程序。它为每个用户卡片创建一个唯一的 ID 并将其保存到 localStorage 中。然而,在这样做之前,它会循环几千次,这会导致我们的应用程序挂起一段时间。在这个菜谱中,我们将把重量级计算从组件移动到 Web worker,并且还会添加一个回退方案,以防 Web workers 不可用。

准备工作

我们将要工作的应用程序位于克隆的仓库中的 start/apps/chapter12/ng-ww-perf 目录内:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-ww-perf 
    

    这应该在新的浏览器标签页中打开应用程序,你应该会看到以下内容:

    图片

    图 12.28:ng-ww-perf 应用程序在 http://localhost:4200 运行

现在我们已经启动了应用程序,让我们在下一节中查看菜谱的步骤。

如何操作…

一旦你打开应用,你会注意到用户卡片渲染需要一些时间。我们还可以看到用户卡片背景随机颜色生成的次数。如果你输入某些内容,或者点击更新 Irineu 的名字按钮,你会看到 UI 线程在计算完成之前被阻塞。罪魁祸首是UserCardComponent类中的randomColor获取器方法。这最终在渲染颜色之前,基于RANDOMIZATION_COUNT令牌的值运行一个for循环来生成一个随机颜色。这发生在src/app/utils.ts文件中的generateRandomColor方法内部。让我们开始配方来提高应用性能。我们将从实现一个 web worker 开始:

  1. 我们首先创建一个 web worker。在工作区根目录中运行以下命令:

    cd start && nx generate web-worker workers/randomColor --project ng-ww-perf 
    

    当被询问时,选择@nx/angular:web-worker

  2. 现在,更新workers/random-color.worker.ts文件中的代码如下:

    /// <reference lib="webworker" />
    import { generateRandomColor } from "../utils";
    type RandomColorIncomingEvent = {
      data: {
        randomizationCount: number
      }
    }
    export type RandomColorOutgoingEvent = { data: { color: string } };
    addEventListener('message', ({ data }:
      RandomColorIncomingEvent) => {
      const {
        randomizationCount
      } = data;
      console.log('inside the worker', data)
      if (!randomizationCount) {
        return;
      }
      const color = generateRandomColor(randomizationCount);
      postMessage({
        color
      });
    });
    export const getRandomColorWorker = () => {
      if (typeof Worker !== undefined) {
        return new Worker(new URL('./random-color.worker', import.meta.url), {
          type: 'module'
        })
      }
      return null;
    } 
    
  3. 让我们将UserCardComponent类中的randomColor获取器替换为一个普通属性。按照以下方式更新user-card.component.ts文件:

    ...
    export class UserCardComponent implements OnInit, OnChanges {
      ...
      randomizationCount = inject(RANDOMIZATION_COUNT);
      **randomColor =** **''****;**
      ...
    } 
    

    确保删除randomColor获取器函数。否则,TypeScript 会抛出错误,因为我们不能有一个属性和一个具有相同名称的获取器方法。

  4. 现在,我们将使用user-card.component.ts文件中的 worker。按照以下方式更新它:

    import { Component, Input, **OnInit**, inject } from '@angular/core';
    ...
    import { RANDOMIZATION_COUNT } from '../../tokens';
    **import** **{** **RandomColorOutgoingEvent** **, getRandomColorWorker }** **from****'../../workers/random-color.worker'****;**
    @Component({...})
    export class UserCardComponent **implements****OnInit** {
      @Input() user!: IUser;
      @Input() index = 0;
      logsService = inject(LogsService);
      randomizationCount = inject(RANDOMIZATION_COUNT);
      randomColor = '';
      **worker****:** **Worker** **|** **null** **=** **getRandomColorWorker****();**
    **ngOnInit****():** **void** **{**
    **if** **(!****this****.****worker****) {**
    **return****;**
    **}**
    **this****.****worker****.****onmessage** **=** **(****{ data: { color } }:**
    **RandomColorOutgoingEvent****) =>** **{**
    **console****.****log****(**
    **`received color** **${color}** **from worker for user** **${****this****.user.email}****`**
    **);**
    **this****.****logsService****.****updateLogEntry****(****this****.****user****.****email****);**
    **this****.****randomColor** **= color;**
    **};**
    **}**
     ...
    } 
    
  5. 到目前为止,我们只是添加了一个监听器,用于接收从 worker 生成的颜色。但首先,我们必须从组件向 worker 发送一条消息,以便它生成一个随机颜色。将user-card.component.ts文件更新为使用OnChanges生命周期钩子。我们将使用它向 worker 发送消息:

    import { Component, Input, **OnChanges**, OnInit, **SimpleChanges**, inject } from '@angular/core';
    ...
    export class UserCardComponent implements OnInit, **OnChanges**{
      ...
      ngOnInit(): void {...}
    
      **ngOnChanges****(****changes****:** **SimpleChanges****) {**
    **if** **(changes[****'user'****].****currentValue** **!==**
    **changes[****'user'****].****previousValue****) {**
    **if** **(!****this****.****worker****) {**
    **this****.****randomColor** **=** **generateRandomColor****(**
    **this****.****randomizationCount****);**
    **return****;**
    **}**
    **this****.****worker****.****postMessage****({** **randomizationCount****:**
    **this****.****randomizationCount** **});**
    **}**
    **}**
      ...
    } 
    
  6. 最后,让我们确保在相应的用户卡片被销毁(终止)时,worker 也被销毁。按照以下方式更新user-card.component.ts文件:

    ...
    @ import { Component, Input, OnChanges, **OnDestroy**, OnInit, SimpleChanges, inject } from '@angular/core';
    export class UserCardComponent implements OnInit, OnChanges,
      **OnDestroy**{
      ...
      **ngOnDestroy****():** **void** **{**
    **this****.****worker****?.****terminate****();**
    **}**
    get log() {...}
    } 
    
  7. 刷新应用并注意用户卡片渲染所需的时间。它们应该比之前快得多。此外,你应该能够看到以下日志反映了从应用到 web worker 以及相反的通信:img/B18469_12_29.png

    图 12.29:显示从应用向 web worker 发送消息的日志

哇哦!web worker 的力量!现在你知道如何在 Angular 应用中使用 web worker 将繁重的计算移到它们那里。由于你已经完成了配方,请查看下一节了解它是如何工作的。

它是如何工作的…

正如我们在食谱描述中讨论的那样,Web 工作者允许我们在主 JavaScript(或 UI 线程)之外的一个单独的线程中运行和执行代码。在食谱的开始部分,无论何时我们刷新应用程序或搜索用户,它都会阻塞 UI 线程。我们有时会看到加载器被挂起,或者出现一个空白屏幕。直到为每个卡片生成一个随机颜色。我们通过使用 NX 命令行界面CLI)开始食谱,创建一个 Web 工作者。这会创建一个 random-color.worker.ts 文件,其中包含一些模板代码,用于接收来自 UI 线程的消息并将其作为响应发送回它。

CLI 命令还会通过添加 webWorkerTsConfig 属性来更新 project.json 文件。webWorkerTsConfig 属性的值是对 tsconfig.worker.json 文件的路径,CLI 命令还会创建这个 tsconfig.worker.json 文件。如果您打开 tsconfig.worker.json 文件,您应该看到以下代码:

/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "../../../out-tsc/worker",
"lib": [
"es2018",
"webworker"
],
"types": []
},
"include": [
"src/**/*.worker.ts"
]
} 

然而,在我们的 NX 工作区中,我们有一个 tsconfig.base.json 文件而不是 tsconfig.json。因此,我们进行了修复。

在 Web 工作者文件中,我们有 addEventListener 方法的调用,它从 UI 线程接收消息到工作者。请注意,我们期望从 UI 线程接收 randomizationCount 属性,这样我们就可以在 utils.ts 文件中的 generateRandomColor 方法中使用它。

工作者文件还有一个名为 getRandomColorWorker 的方法。每次调用它时,都会返回一个新的工作者实例。由于我们从用户卡片组件中调用它,每个卡片都会得到一个新的工作者实例。

然后,在我们的 UserCardComponent 类中,我们为每个组件获取一个新的工作者实例,并使用 ngOnInit 生命周期钩子向工作者添加一个事件监听器。这样,每当工作者发送消息时,用户卡片组件都可以读取它——也就是说,它会获取生成的颜色并将其分配给 randomColor 属性。这反过来会设置用户卡片的 backgroundColor。请注意,ngOnInit 生命周期钩子只为工作者发送的消息注册监听器。但首先,我们必须告诉工作者生成随机颜色。为此,我们使用 ngOnChanges 生命周期钩子。在钩子中,我们观察用户输入的值。如果它发生变化,我们就向工作者发送消息,为特定的用户卡片生成一个随机颜色。如果您点击 更新 Irineu 的姓名 按钮,您将看到从用户卡片发送和接收的连续日志。请注意,以这种方式将颜色生成移动到工作者,也会导致在浏览器事件(点击、按键等)触发时,其他组件不再重新渲染。最后,我们使用 ngOnDestroy 生命周期钩子来终止工作者,以避免内存泄漏。

注意,在 ngOnChanges 钩子中,如果浏览器不支持工作者,我们也会回退到 utils.ts 文件中方法的常规使用。

参见

使用性能预算进行审计

在今天的世界里,大多数人口都有良好的互联网连接来使用日常应用程序,无论是移动应用程序还是网页应用程序,我们向最终用户发送的数据量是多么令人着迷。现在发送给用户的 JavaScript 数量呈不断增长的趋势,如果您正在开发一个网页应用程序,您可能希望使用性能预算来确保包大小不超过某个限制。对于 Angular 应用程序,设置预算大小非常简单。在这个配方中,您将学习如何使用性能预算来确保我们的 Angular 应用程序的包大小保持较小。

准备工作

我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter12/ng-perf-budgets内:

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以构建项目:

    npm run build ng-perf-budgets 
    

    这应该会构建应用程序,您应该在终端中看到以下内容:

    图 12.30:生产模式下的构建输出,没有性能预算

注意现在main.*.js文件的包大小约为 294 千字节KB)。既然我们已经构建了应用程序,接下来让我们看看下一节中的构建步骤。

如何做到这一点…

目前我们的应用程序在包大小方面很小。然而,随着未来业务需求的发展,这可能会变成一个巨大的应用程序。为了这个配方的目的,我们将故意增加包大小,然后使用性能预算来阻止 Angular 构建在包大小超过预算时生成。让我们开始配方:

  1. 打开app.component.ts文件并按照以下方式更新它:

    ...
    **import** ***** **as** **moment** **from****'../lib/moment'****;**
    **import** ***** **as****THREE****from****'three'****;**
    @Component({...})
    export class AppComponent {
      ...
      constructor() {
       const scene = new THREE.Scene(); 
       console.log(moment().format('MMM Do YYYY'));
       console.log(scene);
     }
      ...
    } 
    
  2. 现在,通过从工作区根目录运行以下命令再次构建应用程序:

    npm run build ng-perf-budgets 
    

    您应该看到main.*.js文件的包大小现在大约为 1.10 兆字节MB)。与原始的约 294 KB 相比,这是一个巨大的尺寸增加,如以下截图所示:

    图 12.31:main.*.js 的包大小增加到 1.10 MB

    由于我们使用 NX 作为我们的配方,我们已经有 NX 设置的预算。然而,如果您有一个常规的 Angular 应用程序(没有 NX),您需要更新angular.json文件以添加预算。

  3. start/apps/chapter12/ng-perf-budgets文件夹内打开project.json文件并更新它。我们要针对的属性是targets.build.configurations.production.budgets。更新的代码应如下所示:

    ...
    {
    "budgets": [
    {
    "type": "initial",
    "maximumWarning": "**800kb**",
    "maximumError": "**1mb**"
    },
    {
    "type": "anyComponentStyle",
    "maximumWarning": "2kb",
    "maximumError": "4kb"
    }
    ]
    }
    ... 
    

    注意,我们只为initial包将maximumWarning500kb更改为800kb

  4. 让我们通过不在app.component.ts文件中导入整个库,而是使用date-fns包来改进我们的应用程序,代替moment.js。从工作区的根目录运行以下命令来安装date-fns包:

    cd start && npm install --save date-fns 
    
  5. 现在,更新app.component.ts文件,如下所示:

    import { Component } from '@angular/core';
    import { Router } from '@angular/router';
    import { AuthService } from './services/auth.service';
    **import** **{ format }** **from****'date-fns'****;**
    **import** **{** **Scene** **}** **from****'three'****;**
    @Component({...})
    export class AppComponent {
      ...
      constructor() {
        **const** **scene =** **new****Scene****();**
    **console****.****log****(****format****(****new****Date****(),** **'LLL do yyyy'****));** 
    **console****.****log****(scene);**
      }
      ...
    } 
    
  6. 再次运行npm run build ng-perf-budgets命令。你应该会看到包大小减少,并且构建成功生成,如下所示:

    图 12.32:使用 date-fns 和优化导入后的减少的包大小

嘣!你刚刚学会了如何在 Angular 中使用性能预算。这些预算可以根据你的配置抛出警告和错误。请注意,预算可以根据不断变化的企业需求进行修改。然而,作为工程师,我们必须谨慎地设置性能预算,以避免向最终用户发送超过一定限制的 JavaScript。

既然你已经完成了这个食谱,请查看下一节,了解它是如何工作的。

它是如何工作的…

Angular 性能预算是为 Angular 应用程序中各种性能指标的可接受限制设定的指南。这些预算有助于确保应用程序的性能保持在可接受的范围内,并且随着代码库的增长或变化,性能不会显著下降。你可以与之工作的最重要的性能指标是初始包大小。这是用户设备首先加载的主要 JavaScript 文件集,并且它们会被急切地加载。在这个食谱中,我们有两个问题。首先,我们使用了moment.js,这是一个不可摇树的库。这意味着如果我们导入这个库,整个库都会包含在最终的包中,而可摇树库在构建时,由构建工具移除应用中未使用的代码。我们引入的第二个问题是,我们在组件中包含了整个库three,它是可摇树的,但我们的导入是不准确的。我们引入这些问题是为了看到包大小增加。但正如我们所看到的,在 NX 中,我们可以使用任何 Angular 应用的project.json文件来管理性能预算。如果你正在使用基于 Angular CLI 的应用程序,你会在angular.json文件中做同样的事情。在实践中,你会使用警告阈值和错误阈值,这确保了你不会向最终用户发送巨大的 JavaScript 包。

参见

使用 webpack-bundle-analyzer 分析包

在上一个菜谱中,我们查看为我们的 Angular 应用程序配置预算,这很有用,因为你可以知道整体包大小何时超过某个阈值,尽管你不知道代码的每一部分对最终包的贡献有多大。这就是我们所说的分析包,在本菜谱中,你将学习如何使用webpack-bundle-analyzer来审计包大小及其影响因素。

准备工作

我们将要工作的应用程序位于克隆仓库的start/apps/chapter12/ng-perf-wba目录中:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令来构建项目:

    npm run build ng-perf-wba 
    

    这应该会构建应用程序,你应该在终端中看到以下内容:

    图 12.33:ng-perf-wba 应用程序在 http://localhost:4200 上运行

现在我们已经构建了应用程序,让我们在下一节中查看菜谱的步骤。

如何做到这一点...

目前,我们的应用程序在包大小方面相对较小。然而,随着未来业务需求的发展,这可能会变成一个巨大的应用程序。为了本菜谱的目的,我们将故意增加包大小,然后使用webpack-bundle-analyzer来观察导致大包大小的包。让我们开始这个菜谱:

  1. 打开app.component.ts文件并更新它,如下所示:

    ...
    **import** ***** **as** **moment** **from****'../lib/moment'****;**
    **import** ***** **as****THREE****from****'three'****;**
    @Component({...})
    export class AppComponent {
      ...
      **constructor****() {**
    **const** **scene =** **new****THREE****.****Scene****();** 
    **console****.****log****(****moment****().****format****(****'MMM Do YYYY'****));**
    **console****.****log****(scene);**
    **}**
      ...
    } 
    
  2. 现在,再次从工作区根目录运行以下命令来构建应用程序:

    npm run build ng-perf-wba 
    

    你应该看到main.*.js文件的包大小现在大约是 1.10 MB。与原始的约 294 KB 相比,这是一个巨大的尺寸增加。因此,初始总大小变为 1.15 MB,构建失败,正如你在以下屏幕截图中所看到的:

    图 12.34:由于包大小增加导致的构建失败

  3. 我们首先创建一个包含 JSON 格式包信息的stats.json文件的构建。为此,从工作区根目录运行以下命令:

    npm run build ng-perf-wba with-stats 
    
  4. 现在,从工作区根目录运行以下命令,让webpack-bundle-analyzer读取stats.json文件,如下所示:

    npx webpack-bundle-analyzer ./start/dist/apps/chapter12/ng-perf-wba/stats.json 
    

    这将启动一个带有包分析的服务器。你应该在你的默认浏览器中看到一个新标签页被打开,并且它看起来应该是这样的:

    图 12.35:使用 webpack-bundle-analyzer 进行包分析

  5. 注意到lib文件夹占据了包大小的大部分——大约 562 KB,你可以通过在lib框上悬停鼠标来检查。整体包大小是 1.16 MB。让我们尝试优化包大小。让我们安装date-fns包,这样我们就可以用它来代替moment.js。从你的项目根目录运行以下命令:

    cd start && npm install --save date-fns 
    
  6. 现在,更新app.component.ts文件以使用date-fns包的format方法,而不是使用moment().format方法。我们还将只从Three.js包中导入Scene类,而不是导入整个库。代码应该看起来像这样:

    ...
    import { AuthService } from './services/auth.service';
    **import** **{ format }** **from****'date-fns'****;**
    **import** **{** **Scene** **}** **from****'three'****;**
    @Component({...})
    export class AppComponent {
      ...
      constructor() {
        **const** **scene =** **new****Scene****();**
    **console****.****log****(****format****(****new****Date****(),** **'LLL do yyyy'****));** 
    **console****.****log****(scene);**
      }
      ...
    } 
    
  7. 重复步骤 3步骤 4以重新构建应用程序并通过webpack-bundle-analyzer进行分析。

    一旦webpack-bundle-analyzer运行,你应该会看到分析结果,如下面的屏幕截图所示。注意,我们不再有moment.js文件或lib块,整体包大小已从 1.16 MB 减少到大约 835 KB:

    图片

    图 12.36:优化后的包分析

哇哦!你现在已经知道如何使用webpack-bundle-analyzer包来审计 Angular 应用程序的包大小了。这是一个提高整体性能的好方法,因为你可以识别出导致包大小增加的块,然后优化这些包。如果你在优化前后从工作区根目录运行npm run serve ng-perf-wba,你会看到相同的控制台日志,这表明我们保留了现有功能并优化了包。

相关链接

在 Discord 上了解更多

要加入这本书的 Discord 社区——在那里你可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:

packt.link/AngularCookbook2e

二维码