Angular 的性能优化

目录

  • 序言
  • 变更检查机制
  • 性能优化原理
  • 性能优化方案
  • 小结
  • 参考

序言

本文将谈一谈 Angular 的性能优化,并且主要介绍与运行时相关的优化。在谈如何优化之前,首先我们需要明确什么样的页面是存在性能问题?好的性能的衡量指标是什么?性能优化背后的原理又是如何的?如果你对这些问题感兴趣,那么就请继续读下去。

speed and performance


变更检测机制

不同于网络传输优化,运行时优化更加关注于 Angular 的运行机制以及如何编码才能有效地避免性能问题(最佳实践)。而要弄明白 Angular 的运行机制,首先需要理解它的变更检测机制(也被称为脏检查)——如何将状态的变更重新渲染到视图之中。而如何将组件状态的变化反应到视图中,也是前端三大框架都需要解决的一个问题。不同框架的解决方案既有类似的思路也有各自的特色。


首先,Vue 和 React 都是采用虚拟 DOM 来实现视图更新,不过具体实现上还是有所区别:


对于 React:

  1. 通过使用 setState forceUpdate 来触发 render 方法更新视图
  2. 父组件更新视图时,也会判断是否需要 re-render 子组件

对于 Vue:

  1. Vue 会遍历 data 对象的所有属性,并使用 Object.defineProperty 把这些属性全部转为经过包装的 gettersetter
  2. 每个组件实例都有相应的 watcher 实例对象,它会在组件渲染的过程中把属性记录为依赖
  3. 当依赖项的 setter 被调用时,会通知 watcher 重新计算,从而使它关联的组件得以更新

而 Angular 则是通过引入 Zone.js 对异步操作的 API 打补丁,监听其触发来进行变更检测。关于 Zone.js 的原理在之前的一篇文章中有详细的介绍。简单来说,Zone.js 通过 Monkey patch (猴补丁)的方式,暴力地将浏览器或 Node 中的所有异步 API 进行了封装替换。


比如浏览器中的 setTimeout

let originalSetTimeout = window.setTimeout;

window.setTimeout = function(callback, delay) {
  return originalSetTimeout(Zone.current.wrap(callback),  delay);
}

Zone.prototype.wrap = function(callback) {
  // 获取当前的 Zone
  let capturedZone = this;

  return function() {
    return capturedZone.runGuarded(callback, this, arguments);
  };
};

或者 Promise.then方法:

let originalPromiseThen = Promise.prototype.then;

// NOTE: 这里做了简化,实际上 then 可以接受更多参数
Promise.prototype.then = function(callback) {
  // 获取当前的 Zone
  let capturedZone = Zone.current;
  
  function wrappedCallback() {
    return capturedZone.run(callback, this, arguments);
  };
  
  // 触发原来的回调在 capturedZone 中
  return originalPromiseThen.call(this, [wrappedCallback]);
};

Zone.js 在加载时,对所有异步接口进行了封装。因此所有在 Zone.js 中执行的异步方法都会被当做为一个 Task 被其统一监管,并且提供了相应的钩子函数(hooks),用来在异步任务执行前后或某个阶段做一些额外的操作。因此通过 Zone.js 可以很方便地实现记录日志、监控性能、控制异步回调执行的时机等功能。


而这些钩子函数(hooks),可以通过Zone.fork()方法来进行设置,具体可以参考如下配置:

Zone.current.fork(zoneSpec) // zoneSpec 的类型是 ZoneSpec

// 只有 name 是必选项,其他可选
interface ZoneSpec {
  name: string; // zone 的名称,一般用于调试 Zones 时使用
  properties?: { [key: string]: any; } ; // zone 可以附加的一些数据,通过 Zone.get('key') 可以获取 
  onFork: Function; // 当 zone 被 forked,触发该函数
  onIntercept?: Function; // 对所有回调进行拦截
  onInvoke?: Function; // 当回调被调用时,触发该函数
  onHandleError?: Function; // 对异常进行统一处理
  onScheduleTask?: Function; // 当任务进行调度时,触发该函数
  onInvokeTask?: Function; // 当触发任务执行时,触发该函数
  onCancelTask?: Function; // 当任务被取消时,触发该函数
  onHasTask?: Function; // 通知任务队列的状态改变
}

举一个onInvoke的简单列子:

let logZone = Zone.current.fork({ 
  name: 'logZone',
  onInvoke: function(parentZoneDelegate, currentZone, targetZone, delegate, applyThis, applyArgs, source) {
    console.log(targetZone.name, 'enter');
    parentZoneDelegate.invoke(targetZone, delegate, applyThis, applyArgs, source)
    console.log(targetZone.name, 'leave'); }
});

logZone.run(function myApp() {
    console.log(Zone.current.name, 'queue promise');
    Promise.resolve('OK').then((value) => {console.log(Zone.current.name, 'Promise', value)
  });
});

最终执行结果:

onInvoke 示例执行结果

理解了 Zone.js 的原理之后,通过走读 Angular 的源码,可以知道 Zone.js 在 Angular 被用来实现只要有异步方法或事件的调用,就会触发变更检测。大体如下:


首先,在 applicatoin_ref.ts 文件中,当 ApplicationRef 构建时就订阅了微任务队列为空的回调事件,其调用了 tick 方法(即变更检测):

订阅微任务为空事件的回调

其次,在 checkStable 方法中,会判断当微任务队列清空时触发 onMicrotaskEmpty 事件(结合上来看,等价于会触发变更检测):

触发微任务为空事件

最后,能够触发 checkStable 方法的调用的地方分别在 Zone.js 的三个钩子函数中,分别是 onInvokeonInvokeTaskonHasTask

ZoneJS钩子函数

比如 onHasTask —— 检测到有或无 ZoneTask 时触发的钩子:

onHasTask例子


另外 Zone.js 中对于异步任务总共分为三类:


Micro Task(微任务):Promise 等创建, nativePromise 是在当前事件循环结束前就要执行的,而打过补丁的 Promise 也会在事件循环结束前执行。

Macro Task (宏任务):setTimeout 等创建,nativesetTimeout 会在将来某个时间被处理。

Event Task :addEventListener 等创建,这些 task 可能被触发多次,也可能一直不会被触发。


其实如果站在浏览器的角度, Event Task 其实可以看做是宏任务,换句话说,所有事件或异步 API 都可以理解成是宏任务或微任务中的一种,而它们的执行顺序在之前的一篇文章中有详细分析,简单来说:

(1)主线程执行完后,会优先检查微任务队列是否还有任务需要执行

(2)第一次轮询结束后,会检查宏任务队列是否还有任务执行,执行完之后检查微任务列表是否还有任务执行,之后将重复这个过程



性能优化原理

页面性能的好坏,最直观的判断是看页面响应是否流畅、是否响应得快。而页面响应其本质上就是把页面状态的变更重新渲染到页面上的过程,站在相对宏观的视角来看, Angular 的变更检测其实只是整个事件响应周期中的一环。用户与页面的所有交互都是通过事件来触发,其整个响应过程大致如下:

事件响应周期

如果考虑优化页面响应的速度,可以从各个阶段入手:

(1)对于触发事件阶段,可以减少事件的触发,来减少整体的变更检测次数和重新渲染

(2)对于 Event Handler 执行逻辑阶段,可以通过优化复杂代码逻辑来减少执行时间

(3)对于 Change Detection 检测数据绑定并更新 DOM 阶段,可以减少变更检测和模板数据的计算次数来减少渲染时间

(4)对于浏览器渲染阶段,则可能需要考虑使用不同浏览器或从硬件配置上进行提升


对于第二、四阶段的相关优化这里不做过多讨论,结合上面提到的 Angular 对于异步任务的分类,针对第一、三阶段的优化方式可以进一步明确:

(1)针对 Macro task 合并请求,尽量减少 tick 的次数

(2)针对 Micro task 合并 tick

(3)针对 Event task 减少 event 的触发和注册事件

(4)tick 分为 check 和 render 两个阶段,减少 check 阶段的计算以及不必要的渲染


前面有提到,大多数情况通过观察页面是否流畅可以判断页面的是否存在性能问题。虽然这种方式简单、直观,但也相对主观,并非是通过精确的数字反映页面的性能到底如何。换言之,我们需要用一个更加有效、精确的指标来衡量什么样的页面才是具备良好性能的。而 Angular 官方也提供了相应的方案,可以通过开启 Angular 的调试工具,来实现对变更检测循环(完成的 tick)的时长监控。


首先,需要使用 Angular 提供的 enableDebugTools 方法,如下:

enableDebugTools 开启调试工具

之后只需要在浏览器的控制台中输入 ng.profiler.timeChangeDetection() ,即可看到当前页面的平均变更检测时间:

控制台输出

从上面可以看出,执行了 692 次变更检测循环(完整的事件响应周期)的平均时间为 0.72 毫秒。如果多运行几次,你会发现每次运行的总次数是不一样、随机的。


官方提供了这样一个判断标准:理想情况下,分析器打印出的时长(单次变更检测循环的时间)应该远低于单个动画帧的时间(16 毫秒)。一般这个时长保持在 3 毫秒下,则说明当前页面的变更检测循环的性能是比较好的。如果超过了这个时长,则就可以结合 Angular 的变更检测机制分析一下是否存在重复的模板计算和变更检测。


性能优化方案

在理解 Angular 优化原理的基础上,我们就可以更有针对性地去进行相应的性能优化:


(1)针对异步任务 ——减少变更检测的次数
  • 使用 NgZone 的 runOutsideAngular 方法执行异步接口
  • 手动触发 Angular 的变更检测

(2)针对 Event Task —— 减少变更检测的次数
  • 将 input 之类的事件换成触发频率更低的事件
  • 对 input valueChanges 事件做的防抖动处理,并不能减少变更检测的次数

事件防抖示例

如上图,防抖动处理只是保证了代码逻辑不会重复运行,但是 valueChanges 的事件却随着 value 的改变而触发(改变几次,就触发几次),而只要有事件触发就会相应触发变更检测。


(3)使用 Pipe ——减少变更检测中的计算次数
  • 将 pipe 定义为 pure pipe(@Pipe 默认是 pure pipe,因此也可以不用显示地设置 pure: true

    import { Piep, PipeTransform } from '@angular/core';
    
    @Pipe({
      name: 'gender',
      pure,
    })
    export class GenderPiep implements PipeTransform {
      transform(value: string): string {
        if (value === 'M') return '男';
        if (value === 'W') return '女';
        return '';
      }
    }
    

关于 Pure/ImPure Pipe:

  • Pure Pipe:如果传入 Pipe 的参数没有改变,则会直接返回之前一次的计算结果

  • ImPure Pipe:每一次变更检测都会重新运行 Pipe 内部的逻辑并返回结果。(简单来说, ImPure Pipe 就等价于普通的 formattedFunction,如果一个页面触发了多次的变更检测,那么 ImPure Pipe 的逻辑就会执行多次)


(4)针对组件 ——减少不必要的变更检测
  • 组件使用 onPush 模式
    • 只有输入属性发生变化时,该组件才会检测
    • 只有该组件或者其子组件中的 DOM 事件触发时,才会触发检测
    • 非 DOM 事件的其他异步事件,只能手动触发检测
    • 声明了 onPush 的子组件,如果输入属性未变化,就不会去做计算和更新
@Component({
  ...
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class XXXComponent {
	....
}

在 Angular 中 显示的设置 @ComponentchangeDetectionChangeDetectionStrategy.OnPush 即开启 onPush 模式(默认不开启),用 OnPush 可以跳过某个组件或者某个父组件以及它下面所有子组件的变化检测,如下所示:

OnPush模式示例


(5)针对模板 ——减少不必要的计算和渲染
  • 列表的循环渲染使用 trackBy
  • 尽量使用缓存值,避免使用方法调用和 get 属性的调用
  • 模板中如果确实有需要调用函数的地方,且是多处调用可以使用模板缓存
  • ngIf 控制组件的展示,放到调用组件的地方控制

(6)其他编码优化建议
  • 不要使用 try/catch 来做流程控制,其会造成很大的时间消耗(记录大量堆栈信息等)
  • 过多的动画会导致页面加载卡顿
  • 长列表可以使用虚拟滚动
  • 针对 preload module 尽量延迟 load, 因为浏览器的 http 请求线程的并发数是有限制的,一旦超过了限制数,后面的请求都会被阻塞挂起
  • 等等


小结

(1)简要讲解了 Angular 是如何使用 Zone.js 来实现变更检测的

(2)在理解了 Angular 的变更检测的基础上,进一步明确了 Angular 性能优化的原理以及判断页面是否具备良好的性能的标准

(3)针对性的提供了一些偏运行时的性能优化方案



参考

Angular 性能检查清单

ZoneJS 的原理与应用

浏览器中 JS 的事件循环机制

Developer Tools for Angular

posted @ 2021-09-30 16:16  OneForCheng  阅读(1302)  评论(0编辑  收藏  举报