目錄

  • 序言
  • 變更檢查機制
  • 效能優化原理
  • 效能優化方案
  • 小結
  • 參考

序言

本文將談一談 Angular 的效能優化,並且主要介紹與執行時相關的優化。在談如何優化之前,首先我們需要明確什麼樣的頁面是存在效能問題?好的效能的衡量指標是什麼?效能優化背後的原理又是如何的?如果你對這些問題感興趣,那麼就請繼續讀下去。

變更檢測機制

不同於網路傳輸優化,執行時優化更加關注於 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)
});
});

最終執行結果:

理解了 Zone.js 的原理之後,通過走讀 Angular 的原始碼,可以知道 Zone.js 在 Angular 被用來實現只要有非同步方法或事件的呼叫,就會觸發變更檢測。大體如下:

首先,在 applicatoin_ref.ts 檔案中,當 ApplicationRef 構建時就訂閱了微任務佇列為空的回撥事件,其呼叫了 tick 方法(即變更檢測):

其次,在 checkStable 方法中,會判斷當微任務佇列清空時觸發 onMicrotaskEmpty 事件(結合上來看,等價於會觸發變更檢測):

最後,能夠觸發 checkStable 方法的呼叫的地方分別在 Zone.js 的三個鉤子函式中,分別是 onInvokeonInvokeTaskonHasTask

比如 onHasTask —— 檢測到有或無 ZoneTask 時觸發的鉤子:

另外 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 方法,如下:

之後只需要在瀏覽器的控制檯中輸入 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 可以跳過某個元件或者某個父元件以及它下面所有子元件的變化檢測,如下所示:

(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