开头,本文总结
性能优化往往通过一系列细微且精心测量的改进实现。本文通过一个具体的 Angular 用例——渲染成千上万个实时更新的 Angular 组件——逐步优化。每次调整,我们使用 Lighthouse 和 DevTools 等工具测量每次调整的影响,不仅关注要改变的内容,还探讨为何这些调整有效。
我用 Angular 开发应用程序已有大约 13 年的时间了。其中一些是比较传统的前端应用,大部分复杂性集中在后端,因此需要在那里进行性能优化工作(比如 SQL 优化、后端代码分析、缓存管理、基础设施调整、架构调整等)。而另一些则具有更复杂的前端特性,需要进行深入优化以保持良好用户体验。
这段旅程让我有机会发现很多典型的瓶颈和性能问题,并找出如何避免它们的方法。一路上,我读了不少关于这方面的文章,但常常感觉像是在重复同样的老生常谈,充斥着一些模糊的建议,而不是具体的代码示例,这些示例本可以指出问题所在和解决方案,并提供一些性能指标来衡量解决方案的效果。
这篇文章的目的就是提供一个使用Angular进行代码优化的完整代码示例,特别是在处理大量元素的UI时。
别误解,这里的很多建议听起来可能很熟悉,但我将会尽量提供更多背景信息,并通过性能剖析来帮助理解背后的运作。
我的理由是这样的:如果你能理解你为什么要应用某个修复方法或模式背后的理由(而不是盲目地去应用它,仅仅是因为你看到别人说这样做比较好),你就能更好地在应用程序的早期发现问题并能更好地找到正确的解决方案。
关于这个示例应用这里举的例子中的应用程序展示了我几年前遇到的一种情况,为了清晰起见,已经简化到最基础的程度。该应用程序的一个特点是,它的发展最终需要展示成千上万个独立组件,所有用户通过WebSocket消息实现实时同步。
为了说明这个问题,让我们从一个非常简单的应用程序开始,该应用程序显示一个产品列表,并为每个产品在一定时期内显示每日的数量和状态。为了模拟实际应用程序中应该通过WebSocket消息接收到的实时更新,我们将随机增加每个产品的订单数量,并将这些变化通过一个可观察对象传递出去。
目标是能够处理数百种产品多年的数据(每年的数量有数百列),并能几乎实时显示更新。
架构这个测试应用包含两个模块:
- 一个允许设置数据点数量以及模拟随机修改开关的“设置选项”;
- 一个展示数据点的“表格”(数量、状态),并突出显示最近修改的单元格,以便在大量数据中直观地看到修改。
这是我们开始时的目录结构,
src/
├── app/
│ └── orders/
│ ├── components/
│ │ ├── order-table/ # 订单列表
│ │ └── preference-controls/ # 设置(开始日期,结束日期,行数...)
│ ├── models/ # 模型文件
│ ├── pipes/ # 管道(用于订单模块)
│ └── services/ # 管理订单数据、用户偏好设置、缓存等的服务
├── index.html
├── main.ts
├── styles.scss
└── 其他典型的Angular项目文件等...
显示性能部分
用户画像与问题发现首先要做的是分析页面加载性能。为此,我们将使用 Lighthouse 工具。
- 在隐身模式下打开你的应用,并确保没有激活任何扩展,因为这可能对性能产生重大影响,特别是在DOM元素很多的应用中。
- 打开你的 Chrome调试器 并切换到 Lighthouse标签。
- 这次测试我们只关心性能分析,可以取消其他类别的勾选。
- 选择电脑设备并启动分析。
过了一分钟,我们得到的是如下:
仅仅为了显示我们的数据,没有任何其他交互,首次有效绘制几乎3秒是远远不够好。造成这种情况的原因有很多,其中一些原因也被Lighthouse指了出来。
让我们看看那里提到的主要问题。
🧱 问题1:避免DOM过大
我们演示应用程序的一个核心特点是包含大量元素。我们可以在这一点上稍作改进,但我们留待以后再处理这点。
⌛ 问题 2:缩短 JavaScript 执行时间
我们需要在页面加载时脚本执行方面做更详细的分析,目前除了生成和展示数据外,我们自己写的逻辑不多。我们以后再看。
🧠 问题3:减轻主线程的负担
在这里我们可以做一些事情来快速见效。加载页面时所花费的大部分时间都是在生成这些数据上。在实际应用中,这种情况通常不会出现,因为数据大多数情况下会来自一个 REST API,但这有两点值得关注:
- 它模拟/替代了请求一个重负载 REST API 时可能产生的等待时间
- 它给出了一些示例的重负载计算代码,这些代码会阻塞主线程,我们将解决这个问题。
JavaScript采用了单线程模式。这表示当你需要执行大量计算或同步脚本任务时,它会独占这个线程,从而使界面变得无响应。
然而,有一个解决方案可以在Web Worker中执行这些脚本,从而避免在主线程中执行它们。“但是等等,你刚说JavaScript是单线程的”。
这里有一个重要但微妙的区别需要注意。
JavaScript 单线程运行,但浏览器则不然。
JavaScript引擎(比如Chrome里的V8、Firefox里的SpiderMonkey或Safari里的JavaScriptCore)负责执行你的JavaScript代码。该引擎运行在一个单独的线程上,通常被称为主线程。
不过,这个引擎并不独立存在。它运行在一个更大的环境里,即浏览器。浏览器提供了一系列称为Web API的功能。这些API并不是JavaScript本身的一部分(不属于ECMAScript规范),而是提供给JavaScript使用的。
这一区别至关重要:虽然 JavaScript 是单线程的,但浏览器却不是。借助 Web API 如 setTimeout
、fetch
、WebSocket
和 Web Worker
,JavaScript 可以将任务委托给其它线程并行处理,从而不会阻塞主程序的执行。
比如说:
- fetch() 请求由浏览器的网络层来处理。
- setTimeout() 由定时器系统来安排。
- 而 Web Worker 则在一个完全独立的线程中运行,执行自己的 JavaScript 代码,并通过消息与主线程进行通信。
如果你想通过视觉方式来理解这些概念,我建议你看这段视频,Lydia 对这些概念做了非常棒的视觉解释。强烈推荐你看一下。
可以看一下这个视频:https://www.youtube.com/watch?v=eiC58R16hb8
实现 Web Worker(一种浏览器中的多线程技术)非常简单,我将按照下面的步骤来做:
- 新建一个 ts 文件:src/app/orders/workers/mock-data.worker.ts
- 将负责创建模拟数据的代码提取到该文件中的函数里
- 首先,在文件开头添加以下监听器:
addEventListener('message', ({ data }) => {
// 这里添加你的逻辑
const preferences: OrderPreferences = data;
const mockData = generateMockData(preferences);
// 向主线程发送结果
postMessage(mockData);
});
- 将 OrderService 中的生成的代码替换如下代码:
private 生成模拟数据的函数在工作线程中执行(preferences: OrderPreferences): void {
如果没有 this.worker {
this.worker = new Worker(new URL('../workers/mock-data.worker.ts', import.meta.url), { type: 'module' });
this.worker.onmessage = ({ data }) => {
this.dataSubject.next(data);
};
}
this.worker.postMessage(preferences);
}
如果你想知道这怎么运作,打包工具(例如 Webpack、Vite、esbuild 等)会自动将 TypeScript 编写的 worker 文件编译成 JavaScript 文件,并将 import.meta.url
替换为该文件的相对 URL,从而使这一切都能顺利运行。
现在,当服务启动时,它会创建一个 Web Worker,Web Worker 自身带有独立的执行环境,并同时不会占用主线程,开始生成我们的数据。因此,即便表格还未加载数据,用户界面依然保持响应,最终当 Web Worker 完成任务后,Web Worker 将结果发送给服务,服务再通过一个 Subject(即观察者)将结果发送给其订阅者。
我们再跑一遍 Lighthouse,结果如下:
它还不是完美的,但已经有了显著的改进。在这里,我们可以看到即时的第一有效绘制,然而,不知为何,当我多次运行Lighthouse工具时,它并没有给我完全一致的结果,我看到了从0秒到1.2秒的很多不同数值,但它仍然比最初的3秒要好得多。
第2步:加一层缓存当一个过程很长时,考虑引入缓存的好处很有趣。当然,在考虑缓存之前,你应该先尝试优化所有导致脚本执行时间长的代码,这里假设这段模拟代码已经是优化过的。
目前我们可以通过下面几点来提升用户体验:
- 每当生成了数据时(例如:开始日期 01/01/2025,结束日期 31/12/2025,产品数量 50),如果用户更改了一个参数然后又回到这些值,不应再重新生成数据。我们应该将结果存储到 CacheService 中以供下次使用,并在下次直接使用缓存的数据。
- 为了防止在刷新页面时丢失这些数据,可以将其存储在 localStorage 或 IndexedDB 中,这样在重新加载时仍然可以立即获取到。但是,永久存储所有这些生成的数据可能会占用大量空间,因此在实际应用中,最好只保留当前设置的模拟数据。
让我们实现这个并看看是否能提高加载时间。我不会展示CacheService
代码的细节,因为它相当无聊,特别是保存和从IndexedDB加载数据。但这里是一个大概的想法:OrdersService
现在在生成数据前会请求CacheService
查询给定的一组偏好。如果CacheService
中有数据,数据将被立即返回,从而节省了生成数据所需的时间。
我们现在再运行一次 Lighthouse。
首次有意义的绘制时间为0.3秒,缓存设置完成后,我们就可以继续下一个要点了。然而,Lighthouse仍然提示主线程任务过多,所以我们深入研究一下。
减少 Angular 的体积直到现在,我们使用Lighthouse进行的测量还不能完全代表实际生产环境中的情况,即使是在前端方面,因为我们使用的是本地web服务器。当你运行ng serve
时,Angular会使用JIT(即时编译器),而当你使用ng build
构建生产版本时,AOT(提前编译器)会被启用。
主要的区别是,在JIT模式下,Angular将编译器发送到客户端,然后在客户端编译模板。而在AOT模式下,模板在构建时提前进行编译。AOT模式消除了发送比较大的编译器以及在客户端编译模板的需求,从而减少了脚本在主线程上的运行。你只需在启动ng serve
时添加--configuration production
参数来激活AOT模式。
如果我再做一次Lighthouse分析,我们可以看到减少主线程工作负载的警告已经不见了,并且总阻塞时间减少了25%,从1160毫秒减少到870毫秒。
第2部分 — 更新性能现在我们已经改进了初始加载时间,现在让我们关注一下应用程序在实时数据更新时的表现。这里我们将会给这个示例应用添加一些新功能。
- 添加一个功能,当用户点击单元格时增加一个数值。
- 模拟WebSocket消息以保留需要实时更新的数据的修改。为了简化,我们将只模拟数量更新而不是创建或移除。我还将添加一个设置以根据测试需求开启或关闭该模拟。
这将让我们看到应用程序在需要显示修改时的表现,并了解如何进一步优化它。
我们快速来看一下函数 incrementQuantity
:
/**
* 增加指定产品的数量,参数为产品对象和日期
*/
incrementQuantity(product: 产品, date: 日期): void {
const 日期键 = date.toISOString();
const 当前订单 = product.订单按日期[日期键];
if (当前订单) {
const 更新后的订单 = { ...当前订单, 数量: (当前订单数量 || 0) + 1 };
product.订单按日期[日期键] = 更新后的订单;
const 当前数据 = this.数据源.value;
if (当前数据) {
const 产品索引 = 当前数据.产品.findIndex(p => p.id === product.id);
if (产品索引 !== -1) {
currentData.products[产品索引] = {...product};
this.数据源.next(currentData);
// 更新数据源
}
}
}
}
这段代码挺直观的,不过在性能方面需要注意几点:
- 当前的订单是通过其键来读取的,这样要比遍历整个列表来找它快得多。由于我们通常有很多日期,因此这样访问会更好,并且性能在你有2个元素或100万时都不会改变。
- 为更新后的订单设置了一个新的引用,而不是修改原始对象。这在性能上可能稍微差一点,但我通常更喜欢这样做,因为它会迫使使用OnPush变化检测策略的组件进行更新,并且如果其他代码部分依赖于原始对象,从而限制副作用。
- 通过遍历产品来找到产品索引,这可能是优化的一个地方,尤其是当有很多产品需要处理时。这需要将代码重构,将产品转换成以id为键的映射,考虑到目前产品数量不多,所以我现在暂时不进行这个优化。
现在让我们运行它并进行剖析,看看当我点击一个单元格时会发生什么。测试是在两年的时间内运行的,并涉及100种产品。
顺便说一下,如果你还不知道怎么进行剖析,这里有个办法:
- 打开 Chrome 调试器
- 转到性能(Performance)标签页
- 点击录制按钮,然后进行你想要分析的操作
- 停止录制,然后分析数据。
我们将重点关注页面反应迟钝的1.71秒这段时间。这触发了一个递增操作,从而触发了视图刷新。从用户的角度来看,这会让用户感觉界面卡顿,因为数字大约会在你点击后2秒才增加,所以应该尽量避免这种情况。
首先,让我们看看点击发生的具体瞬间,这本身即占了612毫秒的脚本运行时间。
我们来看看这里发生了什么,
- Angular 正在进行变更检测
-
它执行以下刷新视图的任务:
-
移除被点击的那行 tr
-
创建一个新的 tr 元素来替换这个被点击的
-
为每一列创建新的 td 单元格
- 为每个元素评估对应的模板
在这里,Angular 虽然不会重建整个表格的所有部分,但还是会重新构建每一行。
一个常见的错误是在截图中,使用函数而非管道。每次执行变更检测时,都会重新评估函数。在这种情况下,虽然不会导致大量脚本的执行,但在其他情况下,如果你进行一些计算、日期处理或其他较为复杂的操作,当节点数量很多时,这些操作的累积可能会非常快。
相反,你应该使用管道处理,并且如果提取到管道中的函数计算量较大,那么你可能还想实现一个记忆化缓存机制,以避免对相同的输入重复计算输出。
不过,在这种情况下,我们不需要创建管道,因为我们将会把单元格抽离到它自身的组件中,颜色将根据其属性决定,这样就省去了在模板中调用getStatusColor()的步骤。
OnPush 变更检测当 Angular 进行变更检测时(比如判断是否需要重新渲染元素),默认情况下它会遍历整个应用的组件树,这可能会比较耗时,从而影响性能。为了避免这种情况发生,你可以在组件中启用 OnPush 变更检测 策略,这样 Angular 就只会对输入值发生变化的组件进行变更检测(以及其他特定情况,例如当通过 async 管道订阅的 Observable 推送了新值时)。
问题是,这里我们有一个一个大的OrderTable组件,而它内部的每个子视图(如通过*ngFor
迭代的任何tr
或td
)不能像独立的组件那样配置,这意味着你不能直接将OnPush策略应用到这些部分。这就是为什么我们要这么做,就是将OrderTableComponent分解成3个更小的部分。
- OrderTableComponent 用于表格组件
- ProductOrdersComponent 用于行数据
- OrderCellComponent 用于单元格组件
既然我们顺手,用新的 @for
代替旧的 *ngFor
- 它更易读也更易写
- 它迫使你明确要跟踪变更的字段(这之前你需要手动设置
trackBy
),从而通过减少不必要的重新渲染来提高性能。
新的 for
循环语法可以替代 *ngFor
@for (product of tableData?.products; track product.id) {
<tr productOrders [product]="product" [dates]="tableData.dates"></tr>
}
需要注意的一个重要细节是,我为所有这些组件选择了OnPush变更检测策略,对于这种性能敏感的应用,这应该是你的默认选择。这也是我将其拆分成更小部分的原因,以便可以单独激活表格中的每个部分。
要想启用它,你需要将以下设置添加到你的组件里。
changeDetection: 变更检测策略设置为 OnPush
当我们再次点击数量单元格时,再运行一次性能分析。
1.71秒已经减少到287毫秒,直接与点击相关的脚本时间从612毫秒减少到仅9毫秒!这对用户来说体验更好了。
不过,如果你激活了随机改动设置,该设置被设定为每50毫秒引入一次随机变化,你会发现实际情况与设定频率相差甚远。
当只有一种产品而不是一百种产品时,分析结果会显示呈现出完全不同的图景,这意味着单一产品的市场表现与多种产品有显著差异。
大部分时间都花在了显示这一部分,这部分完全由浏览器负责,也就是说,我们除了减少HTML节点数量并尽可能地简化它们之外,几乎无能为力。
减少DOM减少节点数量的方法有很多:
- 你可以选择添加一些限制,以控制你可以显示的数据,例如强制性过滤器、最大时间范围、分页等。
- 你可以实现一些技术特性来减少DOM中的节点数量,比如虚拟滚动(VirtualScroll)解决方案。
- 你可以重构代码以减少HTML节点的使用,在这种极端情况下,这可能会带来显著的改善。
我们从最后一个开始吧,这样就会让我们更深入地去优化。
既然我们要减少的是DOM节点的数量,让我们试着把产品数量重新设定为100,看看结果如何。除此之外,你可以前往调试器中的性能监视器选项卡。
你可以看到这里我们有整整513000个DOM节点!这对你的浏览器来说绝对是个不小的负担。为了理解我们是如何达到这个数字的,先来看看:让我们来拆解一下:
- 大多数 DOM 节点来自于表格的主体部分
- 在这两年里有 100 行,大约就是 365 2 100 个单元格 = 73000 个单元格
- 每个数量单元格由 7 个 DOM 节点构成:
<td _ngcontent-ng-c3282498127="" class="order-cell">
<app-order-cell _ngcontent-ng-c3282498127="" _nghost-ng-c1143055343="">
<div _ngcontent-ng-c1143055343="" class="cell-content">
<div _ngcontent-ng-c1143055343="" class="quantity">30</div>
<div _ngcontent-ng-c1143055343="" class="status-indicator" style="background-color: rgb(65, 105, 225);"></div>
</div>
<!---->
</app-order-cell>
</td>
<!-- 这是一个订单单元格,显示数量为30,状态指示器为深蓝色背景。 -->
-
td元素自身
-
一个app-order-cell元素(或app-order-cell单元)
-
一个容器div
-
数量div内的文本节点
-
一个状态div(或状态单元)
- 由Angular添加的HTML注释节点(我们无法删除这个)
如果你计算一下,73000 * 7 = 511000,再加上应用程序中的其他几个“DOM节点”的数量,这与你在性能监视器中看到的 513000 个“DOM节点”一致。
如果我们减少每个数量单元的DOM节点数量(DOM节点),DOM节点总数就能减少数十万个。那么我们就这么做:
- 将
td
标签改为OrderCellComponent
,通过将 CSS 选择器改为td[order]
- 移除数量和状态周围的容器,仅使用一个 CSS 类来改变状态单元格的底部边框
- 移除数量
div
,将数量直接放在td
中
我们来看看它如何影响表现水平:
- 我们从 513000 个 DOM 节点减少到了 221000 ,差不多少了 30万 !
- 200毫秒的渲染时间缩短了一半,低于100毫秒。实际上,在生产模式下点击数量时,总耗时为100毫秒,其中与点击相关的脚本运行时间仅为2.67毫秒。
现在基础性能在恶劣条件下看起来相当可靠,我们来添加一个过滤功能,并用一些具体的产品名称试试,看看添加过滤功能后表现如何。
为了保持性能最佳,我在 productFilter$
的定义中加入了节流函数,如下所示:
public productFilter$: Observable<string> = this._filterSubject.pipe(
throttleTime(300, asyncScheduler, {trailing: true}),
distinctUntilChanged(),
tap(filter => this.preferencesService.updateProductFilter(filter))
);
这是一个公共属性 `productFilter# Angular性能优化:一步步教你改善大规模组件更新的应用实例
开头,本文总结
性能优化往往通过一系列细微且精心测量的改进实现。本文通过一个具体的 Angular 用例——渲染成千上万个实时更新的 Angular 组件——逐步优化。每次调整,我们使用 Lighthouse 和 DevTools 等工具测量每次调整的影响,不仅关注要改变的内容,还探讨为何这些调整有效。
我用 Angular 开发应用程序已有大约 13 年的时间了。其中一些是比较传统的前端应用,大部分复杂性集中在后端,因此需要在那里进行性能优化工作(比如 SQL 优化、后端代码分析、缓存管理、基础设施调整、架构调整等)。而另一些则具有更复杂的前端特性,需要进行深入优化以保持良好用户体验。
这段旅程让我有机会发现很多典型的瓶颈和性能问题,并找出如何避免它们的方法。一路上,我读了不少关于这方面的文章,但常常感觉像是在重复同样的老生常谈,充斥着一些模糊的建议,而不是具体的代码示例,这些示例本可以指出问题所在和解决方案,并提供一些性能指标来衡量解决方案的效果。
这篇文章的目的就是提供一个使用Angular进行代码优化的完整代码示例,特别是在处理大量元素的UI时。
别误解,这里的很多建议听起来可能很熟悉,但我将会尽量提供更多背景信息,并通过性能剖析来帮助理解背后的运作。
我的理由是这样的:如果你能理解你为什么要应用某个修复方法或模式背后的理由(而不是盲目地去应用它,仅仅是因为你看到别人说这样做比较好),你就能更好地在应用程序的早期发现问题并能更好地找到正确的解决方案。
关于这个示例应用这里举的例子中的应用程序展示了我几年前遇到的一种情况,为了清晰起见,已经简化到最基础的程度。该应用程序的一个特点是,它的发展最终需要展示成千上万个独立组件,所有用户通过WebSocket消息实现实时同步。
为了说明这个问题,让我们从一个非常简单的应用程序开始,该应用程序显示一个产品列表,并为每个产品在一定时期内显示每日的数量和状态。为了模拟实际应用程序中应该通过WebSocket消息接收到的实时更新,我们将随机增加每个产品的订单数量,并将这些变化通过一个可观察对象传递出去。
目标是能够处理数百种产品多年的数据(每年的数量有数百列),并能几乎实时显示更新。
架构这个测试应用包含两个模块:
- 一个允许设置数据点数量以及模拟随机修改开关的“设置选项”;
- 一个展示数据点的“表格”(数量、状态),并突出显示最近修改的单元格,以便在大量数据中直观地看到修改。
这是我们开始时的目录结构,
src/
├── app/
│ └── orders/
│ ├── components/
│ │ ├── order-table/ # 订单列表
│ │ └── preference-controls/ # 设置(开始日期,结束日期,行数...)
│ ├── models/ # 模型文件
│ ├── pipes/ # 管道(用于订单模块)
│ └── services/ # 管理订单数据、用户偏好设置、缓存等的服务
├── index.html
├── main.ts
├── styles.scss
└── 其他典型的Angular项目文件等...
显示性能部分
用户画像与问题发现首先要做的是分析页面加载性能。为此,我们将使用 Lighthouse 工具。
- 在隐身模式下打开你的应用,并确保没有激活任何扩展,因为这可能对性能产生重大影响,特别是在DOM元素很多的应用中。
- 打开你的 Chrome调试器 并切换到 Lighthouse标签。
- 这次测试我们只关心性能分析,可以取消其他类别的勾选。
- 选择电脑设备并启动分析。
过了一分钟,我们得到的是如下:
仅仅为了显示我们的数据,没有任何其他交互,首次有效绘制几乎3秒是远远不够好。造成这种情况的原因有很多,其中一些原因也被Lighthouse指了出来。
让我们看看那里提到的主要问题。
🧱 问题1:避免DOM过大
我们演示应用程序的一个核心特点是包含大量元素。我们可以在这一点上稍作改进,但我们留待以后再处理这点。
⌛ 问题 2:缩短 JavaScript 执行时间
我们需要在页面加载时脚本执行方面做更详细的分析,目前除了生成和展示数据外,我们自己写的逻辑不多。我们以后再看。
🧠 问题3:减轻主线程的负担
在这里我们可以做一些事情来快速见效。加载页面时所花费的大部分时间都是在生成这些数据上。在实际应用中,这种情况通常不会出现,因为数据大多数情况下会来自一个 REST API,但这有两点值得关注:
- 它模拟/替代了请求一个重负载 REST API 时可能产生的等待时间
- 它给出了一些示例的重负载计算代码,这些代码会阻塞主线程,我们将解决这个问题。
JavaScript采用了单线程模式。这表示当你需要执行大量计算或同步脚本任务时,它会独占这个线程,从而使界面变得无响应。
然而,有一个解决方案可以在Web Worker中执行这些脚本,从而避免在主线程中执行它们。“但是等等,你刚说JavaScript是单线程的”。
这里有一个重要但微妙的区别需要注意。
JavaScript 单线程运行,但浏览器则不然。
JavaScript引擎(比如Chrome里的V8、Firefox里的SpiderMonkey或Safari里的JavaScriptCore)负责执行你的JavaScript代码。该引擎运行在一个单独的线程上,通常被称为主线程。
不过,这个引擎并不独立存在。它运行在一个更大的环境里,即浏览器。浏览器提供了一系列称为Web API的功能。这些API并不是JavaScript本身的一部分(不属于ECMAScript规范),而是提供给JavaScript使用的。
这一区别至关重要:虽然 JavaScript 是单线程的,但浏览器却不是。借助 Web API 如 setTimeout
、fetch
、WebSocket
和 Web Worker
,JavaScript 可以将任务委托给其它线程并行处理,从而不会阻塞主程序的执行。
比如说:
- fetch() 请求由浏览器的网络层来处理。
- setTimeout() 由定时器系统来安排。
- 而 Web Worker 则在一个完全独立的线程中运行,执行自己的 JavaScript 代码,并通过消息与主线程进行通信。
如果你想通过视觉方式来理解这些概念,我建议你看这段视频,Lydia 对这些概念做了非常棒的视觉解释。强烈推荐你看一下。
可以看一下这个视频:https://www.youtube.com/watch?v=eiC58R16hb8
实现 Web Worker(一种浏览器中的多线程技术)非常简单,我将按照下面的步骤来做:
- 新建一个 ts 文件:src/app/orders/workers/mock-data.worker.ts
- 将负责创建模拟数据的代码提取到该文件中的函数里
- 首先,在文件开头添加以下监听器:
addEventListener('message', ({ data }) => {
// 这里添加你的逻辑
const preferences: OrderPreferences = data;
const mockData = generateMockData(preferences);
// 向主线程发送结果
postMessage(mockData);
});
- 将 OrderService 中的生成的代码替换如下代码:
private 生成模拟数据的函数在工作线程中执行(preferences: OrderPreferences): void {
如果没有 this.worker {
this.worker = new Worker(new URL('../workers/mock-data.worker.ts', import.meta.url), { type: 'module' });
this.worker.onmessage = ({ data }) => {
this.dataSubject.next(data);
};
}
this.worker.postMessage(preferences);
}
如果你想知道这怎么运作,打包工具(例如 Webpack、Vite、esbuild 等)会自动将 TypeScript 编写的 worker 文件编译成 JavaScript 文件,并将 import.meta.url
替换为该文件的相对 URL,从而使这一切都能顺利运行。
现在,当服务启动时,它会创建一个 Web Worker,Web Worker 自身带有独立的执行环境,并同时不会占用主线程,开始生成我们的数据。因此,即便表格还未加载数据,用户界面依然保持响应,最终当 Web Worker 完成任务后,Web Worker 将结果发送给服务,服务再通过一个 Subject(即观察者)将结果发送给其订阅者。
我们再跑一遍 Lighthouse,结果如下:
它还不是完美的,但已经有了显著的改进。在这里,我们可以看到即时的第一有效绘制,然而,不知为何,当我多次运行Lighthouse工具时,它并没有给我完全一致的结果,我看到了从0秒到1.2秒的很多不同数值,但它仍然比最初的3秒要好得多。
第2步:加一层缓存当一个过程很长时,考虑引入缓存的好处很有趣。当然,在考虑缓存之前,你应该先尝试优化所有导致脚本执行时间长的代码,这里假设这段模拟代码已经是优化过的。
目前我们可以通过下面几点来提升用户体验:
- 每当生成了数据时(例如:开始日期 01/01/2025,结束日期 31/12/2025,产品数量 50),如果用户更改了一个参数然后又回到这些值,不应再重新生成数据。我们应该将结果存储到 CacheService 中以供下次使用,并在下次直接使用缓存的数据。
- 为了防止在刷新页面时丢失这些数据,可以将其存储在 localStorage 或 IndexedDB 中,这样在重新加载时仍然可以立即获取到。但是,永久存储所有这些生成的数据可能会占用大量空间,因此在实际应用中,最好只保留当前设置的模拟数据。
让我们实现这个并看看是否能提高加载时间。我不会展示CacheService
代码的细节,因为它相当无聊,特别是保存和从IndexedDB加载数据。但这里是一个大概的想法:OrdersService
现在在生成数据前会请求CacheService
查询给定的一组偏好。如果CacheService
中有数据,数据将被立即返回,从而节省了生成数据所需的时间。
我们现在再运行一次 Lighthouse。
首次有意义的绘制时间为0.3秒,缓存设置完成后,我们就可以继续下一个要点了。然而,Lighthouse仍然提示主线程任务过多,所以我们深入研究一下。
减少 Angular 的体积直到现在,我们使用Lighthouse进行的测量还不能完全代表实际生产环境中的情况,即使是在前端方面,因为我们使用的是本地web服务器。当你运行ng serve
时,Angular会使用JIT(即时编译器),而当你使用ng build
构建生产版本时,AOT(提前编译器)会被启用。
主要的区别是,在JIT模式下,Angular将编译器发送到客户端,然后在客户端编译模板。而在AOT模式下,模板在构建时提前进行编译。AOT模式消除了发送比较大的编译器以及在客户端编译模板的需求,从而减少了脚本在主线程上的运行。你只需在启动ng serve
时添加--configuration production
参数来激活AOT模式。
如果我再做一次Lighthouse分析,我们可以看到减少主线程工作负载的警告已经不见了,并且总阻塞时间减少了25%,从1160毫秒减少到870毫秒。
第2部分 — 更新性能现在我们已经改进了初始加载时间,现在让我们关注一下应用程序在实时数据更新时的表现。这里我们将会给这个示例应用添加一些新功能。
- 添加一个功能,当用户点击单元格时增加一个数值。
- 模拟WebSocket消息以保留需要实时更新的数据的修改。为了简化,我们将只模拟数量更新而不是创建或移除。我还将添加一个设置以根据测试需求开启或关闭该模拟。
这将让我们看到应用程序在需要显示修改时的表现,并了解如何进一步优化它。
我们快速来看一下函数 incrementQuantity
:
/**
* 增加指定产品的数量,参数为产品对象和日期
*/
incrementQuantity(product: 产品, date: 日期): void {
const 日期键 = date.toISOString();
const 当前订单 = product.订单按日期[日期键];
if (当前订单) {
const 更新后的订单 = { ...当前订单, 数量: (当前订单数量 || 0) + 1 };
product.订单按日期[日期键] = 更新后的订单;
const 当前数据 = this.数据源.value;
if (当前数据) {
const 产品索引 = 当前数据.产品.findIndex(p => p.id === product.id);
if (产品索引 !== -1) {
currentData.products[产品索引] = {...product};
this.数据源.next(currentData);
// 更新数据源
}
}
}
}
这段代码挺直观的,不过在性能方面需要注意几点:
- 当前的订单是通过其键来读取的,这样要比遍历整个列表来找它快得多。由于我们通常有很多日期,因此这样访问会更好,并且性能在你有2个元素或100万时都不会改变。
- 为更新后的订单设置了一个新的引用,而不是修改原始对象。这在性能上可能稍微差一点,但我通常更喜欢这样做,因为它会迫使使用OnPush变化检测策略的组件进行更新,并且如果其他代码部分依赖于原始对象,从而限制副作用。
- 通过遍历产品来找到产品索引,这可能是优化的一个地方,尤其是当有很多产品需要处理时。这需要将代码重构,将产品转换成以id为键的映射,考虑到目前产品数量不多,所以我现在暂时不进行这个优化。
现在让我们运行它并进行剖析,看看当我点击一个单元格时会发生什么。测试是在两年的时间内运行的,并涉及100种产品。
顺便说一下,如果你还不知道怎么进行剖析,这里有个办法:
- 打开 Chrome 调试器
- 转到性能(Performance)标签页
- 点击录制按钮,然后进行你想要分析的操作
- 停止录制,然后分析数据。
我们将重点关注页面反应迟钝的1.71秒这段时间。这触发了一个递增操作,从而触发了视图刷新。从用户的角度来看,这会让用户感觉界面卡顿,因为数字大约会在你点击后2秒才增加,所以应该尽量避免这种情况。
首先,让我们看看点击发生的具体瞬间,这本身即占了612毫秒的脚本运行时间。
我们来看看这里发生了什么,
- Angular 正在进行变更检测
-
它执行以下刷新视图的任务:
-
移除被点击的那行 tr
-
创建一个新的 tr 元素来替换这个被点击的
-
为每一列创建新的 td 单元格
- 为每个元素评估对应的模板
在这里,Angular 虽然不会重建整个表格的所有部分,但还是会重新构建每一行。
一个常见的错误是在截图中,使用函数而非管道。每次执行变更检测时,都会重新评估函数。在这种情况下,虽然不会导致大量脚本的执行,但在其他情况下,如果你进行一些计算、日期处理或其他较为复杂的操作,当节点数量很多时,这些操作的累积可能会非常快。
相反,你应该使用管道处理,并且如果提取到管道中的函数计算量较大,那么你可能还想实现一个记忆化缓存机制,以避免对相同的输入重复计算输出。
不过,在这种情况下,我们不需要创建管道,因为我们将会把单元格抽离到它自身的组件中,颜色将根据其属性决定,这样就省去了在模板中调用getStatusColor()的步骤。
OnPush 变更检测当 Angular 进行变更检测时(比如判断是否需要重新渲染元素),默认情况下它会遍历整个应用的组件树,这可能会比较耗时,从而影响性能。为了避免这种情况发生,你可以在组件中启用 OnPush 变更检测 策略,这样 Angular 就只会对输入值发生变化的组件进行变更检测(以及其他特定情况,例如当通过 async 管道订阅的 Observable 推送了新值时)。
问题是,这里我们有一个一个大的OrderTable组件,而它内部的每个子视图(如通过*ngFor
迭代的任何tr
或td
)不能像独立的组件那样配置,这意味着你不能直接将OnPush策略应用到这些部分。这就是为什么我们要这么做,就是将OrderTableComponent分解成3个更小的部分。
- OrderTableComponent 用于表格组件
- ProductOrdersComponent 用于行数据
- OrderCellComponent 用于单元格组件
既然我们顺手,用新的 @for
代替旧的 *ngFor
- 它更易读也更易写
- 它迫使你明确要跟踪变更的字段(这之前你需要手动设置
trackBy
),从而通过减少不必要的重新渲染来提高性能。
新的 for
循环语法可以替代 *ngFor
@for (product of tableData?.products; track product.id) {
<tr productOrders [product]="product" [dates]="tableData.dates"></tr>
}
需要注意的一个重要细节是,我为所有这些组件选择了OnPush变更检测策略,对于这种性能敏感的应用,这应该是你的默认选择。这也是我将其拆分成更小部分的原因,以便可以单独激活表格中的每个部分。
要想启用它,你需要将以下设置添加到你的组件里。
changeDetection: 变更检测策略设置为 OnPush
当我们再次点击数量单元格时,再运行一次性能分析。
1.71秒已经减少到287毫秒,直接与点击相关的脚本时间从612毫秒减少到仅9毫秒!这对用户来说体验更好了。
不过,如果你激活了随机改动设置,该设置被设定为每50毫秒引入一次随机变化,你会发现实际情况与设定频率相差甚远。
当只有一种产品而不是一百种产品时,分析结果会显示呈现出完全不同的图景,这意味着单一产品的市场表现与多种产品有显著差异。
大部分时间都花在了显示这一部分,这部分完全由浏览器负责,也就是说,我们除了减少HTML节点数量并尽可能地简化它们之外,几乎无能为力。
减少DOM减少节点数量的方法有很多:
- 你可以选择添加一些限制,以控制你可以显示的数据,例如强制性过滤器、最大时间范围、分页等。
- 你可以实现一些技术特性来减少DOM中的节点数量,比如虚拟滚动(VirtualScroll)解决方案。
- 你可以重构代码以减少HTML节点的使用,在这种极端情况下,这可能会带来显著的改善。
我们从最后一个开始吧,这样就会让我们更深入地去优化。
既然我们要减少的是DOM节点的数量,让我们试着把产品数量重新设定为100,看看结果如何。除此之外,你可以前往调试器中的性能监视器选项卡。
你可以看到这里我们有整整513000个DOM节点!这对你的浏览器来说绝对是个不小的负担。为了理解我们是如何达到这个数字的,先来看看:让我们来拆解一下:
- 大多数 DOM 节点来自于表格的主体部分
- 在这两年里有 100 行,大约就是 365 2 100 个单元格 = 73000 个单元格
- 每个数量单元格由 7 个 DOM 节点构成:
<td _ngcontent-ng-c3282498127="" class="order-cell">
<app-order-cell _ngcontent-ng-c3282498127="" _nghost-ng-c1143055343="">
<div _ngcontent-ng-c1143055343="" class="cell-content">
<div _ngcontent-ng-c1143055343="" class="quantity">30</div>
<div _ngcontent-ng-c1143055343="" class="status-indicator" style="background-color: rgb(65, 105, 225);"></div>
</div>
<!---->
</app-order-cell>
</td>
<!-- 这是一个订单单元格,显示数量为30,状态指示器为深蓝色背景。 -->
-
td元素自身
-
一个app-order-cell元素(或app-order-cell单元)
-
一个容器div
-
数量div内的文本节点
-
一个状态div(或状态单元)
- 由Angular添加的HTML注释节点(我们无法删除这个)
如果你计算一下,73000 * 7 = 511000,再加上应用程序中的其他几个“DOM节点”的数量,这与你在性能监视器中看到的 513000 个“DOM节点”一致。
如果我们减少每个数量单元的DOM节点数量(DOM节点),DOM节点总数就能减少数十万个。那么我们就这么做:
- 将
td
标签改为OrderCellComponent
,通过将 CSS 选择器改为td[order]
- 移除数量和状态周围的容器,仅使用一个 CSS 类来改变状态单元格的底部边框
- 移除数量
div
,将数量直接放在td
中
我们来看看它如何影响表现水平:
- 我们从 513000 个 DOM 节点减少到了 221000 ,差不多少了 30万 !
- 200毫秒的渲染时间缩短了一半,低于100毫秒。实际上,在生产模式下点击数量时,总耗时为100毫秒,其中与点击相关的脚本运行时间仅为2.67毫秒。
现在基础性能在恶劣条件下看起来相当可靠,我们来添加一个过滤功能,并用一些具体的产品名称试试,看看添加过滤功能后表现如何。
为了保持性能最佳,我在 productFilter$
的定义中加入了节流函数,如下所示:
,其类型为 Observable<string>
,它通过管道操作符连接到 _filterSubject
。管道中的 throttleTime
操作符设置了每300毫秒触发一次,并且在最后一次触发时也会执行。distinctUntilChanged
操作符确保了只有当过滤器值发生变化时才会发出新值。tap
操作符用于在过滤器更新时调用 preferencesService.updateProductFilter(filter)
方法。
我们用那个过滤功能,并用以下情况来测试它。
- 从完整的列表开始后,我在过滤框里输入了“ap”
- 我开启了随机修改功能
- 几秒钟后,我又把随机修改功能关掉了
以下是结果。
这里我们可以注意到两件事:
- 列表过滤在40毫秒内完成(这里未展示,但取消过滤后重新构建整个树却需要500毫秒)
- 每50毫秒进行的随机修改几乎是瞬时的:大多数改动涉及已被过滤的行,这不需要DOM操作,其余操作大约需要7毫秒。
这个时候,如果你仍然看到运行速度慢,可能是因为 DOM 中元素还是太多。例如,如果你管理的不是成千上万个元素而是几百万个元素,可能需要考虑其他实现方法来优化性能。
经过多年的使用这类机制,我倾向于避免使用它,或者至少只在万不得已时才使用。为什么?因为大多数情况下,实施它所需的成本和复杂性并不值得其带来的好处。你可以使用现有的库,比如 PrimeNG 虚拟滚动器、Angular CDK 虚拟滚动等,甚至付费库如 ag-grid,但为此你常常需要彻底重构代码结构,使其符合库的要求(如果你对此有疑问,试着在当前示例项目中实现一个二维虚拟滚动功能,使表头和第一列固定,使用这些库之一而无需更改用户界面,然后在评论区告诉我你的经历)。或者你可以从底层实现自己的虚拟滚动机制并自行管理 DOM 结构,祝你好运,正确实现它可能很费时。这虽然是可能的,但可能相当耗时。这两种情况都会带来相当大的成本,这种成本需要通过巨大的性能和用户体验改进来弥补。
话说,如果你需要在一个视图中管理这么多数据,可能值得质疑其背后的需求,以及你最初关于数据展示方式的想法是否仍然有效。很多时候你会发现,与其花费无数小时解决那些复杂的技术问题,不如与用户聊一聊,找到一个最能满足他们需求的解决方案,甚至不需要解决任何技术难题。
避免在调试性能时犯一些常见错误在以私密模式运行时优化,请禁用所有扩展程序,除非你需要用到Angular Dev Tools。我曾经发现要在一小时甚至更久之后,才发现内存或性能问题竟然是某个出乎意料的扩展程序引起的。所以别犯我同样的错误,这样可以省下几个小时的无用功调试时间 😉
预优化不太好。别急于过度优化
如果你是个性能狂热者,每微秒都不放过虽然可能让你感到满足,但这会增加代码复杂性,难以忽视,因此可能让代码更难理解、维护。
如果你的应用程序管理的数据和组件很少,普通人根本不会察觉到优化与否,因为运行速度已经足够快了。这意味着,这样你花在上面的时间和未来维护的额外成本,将只是白白浪费,仅仅是为了你自己开心。
那就是为什么更好的方法是以一种自然的方式编写你的应用程序,遵循你的直觉,感觉自然和简单。只有当应用程序增长到一个点,让你开始注意到用户体验下降的时候,你才应该开始调查瓶颈在哪里以及如何解决它们。但这并不意味着你不应该采取免费优化措施,如果你养成良好的习惯,比如默认使用OnPush变更检测或用*@for
语法代替*ngFor
语法,这样做并不会增加代码的复杂度,并可以帮助避免可能出现的问题。就像一个谚语所说:“先让它运作,再让它正确,最后让它快速”,快速是最后一步,也许在你特意去优化之前,你的代码已经足够快了。
如果你频繁使用 setTimeout
或 setInterval
,你需要知道 Angular 通过 zone.js 对这些方法进行了封装,使得它们会自动触发变化检测。如果你想避免这种情况,你可以在 NgZone.runOutsideAngular 里运行你的 setTimeout
,这样就不会触发变化检测了。
this.ngZone.runOutsideAngular(() => {
setTimeout(() => {
// 无需更新视图的任何代码
}, 1000);
});
这在像我们演示的这类重负载应用中尤为重要,因为每个变化检测周期都可能占用大量CPU资源。
✅ Angular性能的关键要点有哪些- 优先使用OnPush 变更检测,并在必要时拆分大型组件。
- 使用
@for
结合track
以获得更好的渲染性能。 - 避免在模板中调用函数——优先使用纯管道,必要时使用备忘录优化。
- 将大量的计算移到Web Workers中,释放主线程。
- 考虑使用缓存层(内存缓存加IndexedDB)来优化昂贵的运算。
- 减少DOM节点——即使每行多一个div,当有成千上万个div时也会影响性能。
- 不要过早进行过度优化——先分析性能,再进行优化。
- 使用
NgZone.runOutsideAngular()
执行不影响UI的异步逻辑。 - 持续进行性能分析——每次变更前后都测量。
优化 Angular 应用,特别是在我们这种极端情况下,需要知识、观察和务实的结合。虽然并非每个项目都需要如此细致的调整,但知道如何发现问题并加以解决是一项宝贵的能力。
我希望这个逐步指南能帮助你揭开一些性能陷阱的秘密,并能让你明白,大多数性能提升来自清晰地理解幕后的情况。
如果你遇到了类似的问题,或者想要分享其他 Angular 的小技巧,欢迎留下你的评论——我很愿意和大家继续讨论。
共同学习,写下你的评论
评论加载中...
作者其他优质文章