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