這一頁的前身是舊筆記「全域底層架構修煉」的第五章(那頁的七個主題已全部拆成獨立內容,原頁不再保留)。原本只有一段 Event Loop 流程圖加兩張小卡,這裡把它補成一條完整的因果鏈:瀏覽器只有一條主執行緒 → 所以「不卡」比「快」難 → 微任務與渲染的排隊規則 → Angular 為什麼需要 Zone.js 來得知「有事發生了」→ 變更偵測的成本從哪來 → OnPush 換到什麼、付出什麼 → 訂閱洩漏為什麼不會報錯。前半段(Event Loop、渲染幀、佈局抖動)跟框架無關,任何前端都適用;後半段以 Angular 為例,因為原素材是 Angular。
前端所有效能問題幾乎都能追回同一個事實:瀏覽器分頁的主執行緒只有一條,而且它不只跑 JavaScript。
① 執行 JavaScript
② 計算樣式(哪些 CSS 規則套用到哪些元素)
③ 佈局 Layout(每個元素的位置與尺寸)
④ 繪製 Paint / 合成 Composite(部分可以交給 GPU)
⑤ 處理輸入事件(點擊、輸入、捲動)
這五件事排同一個隊。第 ① 項跑久一點,後面全部延後。因為 DOM 是共享可變狀態。兩條執行緒同時改同一棵樹,就需要鎖;而凡是要鎖的地方就有死鎖與競態。瀏覽器選了另一條路:DOM 只能由主執行緒碰,需要平行運算時用 Web Worker(拿不到 DOM,只能傳訊息)。
offsetWidth 的同時有人在改它」。「宏任務、微任務」很多人背得出名詞,但關鍵在順序與「清空」這兩個字。一輪的實際流程是:
① 從任務佇列取出「一個」宏任務,執行到底
(script 整份、setTimeout 回呼、一次 click 事件……)
② 微任務檢查點:把微任務佇列「徹底清空」
Promise.then/await 後半段/queueMicrotask/MutationObserver
⚠ 清空過程中新加入的微任務也要一起處理完 —— 沒有上限
③ 瀏覽器決定要不要在這一輪渲染(通常對齊螢幕更新率)
requestAnimationFrame 回呼 → 計算樣式 → 佈局 → 繪製
④ 還有空檔的話,跑 requestIdleCallback
⑤ 回到 ①,取下一個宏任務setTimeout(fn, 0) 中間,瀏覽器有機會渲染。Promise.then 中間,畫面不會更新。console.log('1');
setTimeout(() => console.log('4'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('2');
// 輸出:1 2 3 4
// 1、2 屬於「script 這個宏任務」本身
// 3 是這個宏任務結束後的微任務檢查點
// 4 是下一個宏任務 —— 即使延遲寫 0setTimeout(fn, 0) 從來不是 0。規範規定巢狀超過 5 層後最小延遲會被夾到 4ms,而且它只是「排進佇列」,前面還排著什麼你不知道。要「盡快、但讓瀏覽器先喘口氣」用 setTimeout;要「這一輪結束前一定要跑到」用 queueMicrotask。60Hz 的螢幕每 16.7ms 更新一次。要讓畫面順,主執行緒必須在每個 16.7ms 內把「JS + 樣式 + 佈局 + 繪製」全部做完——實際留給 JS 的通常只有 10ms 上下。
0ms 使用者點了按鈕
0ms 你的處理函式開始跑(同步,300ms)
16.7ms 螢幕該更新了 —— 主執行緒還在跑 JS,沒有新畫面
33.4ms 螢幕該更新了 —— 還是沒有
...
300ms 函式結束,這時才輪到樣式/佈局/繪製
使用者看到的:按下去「沒反應」,然後畫面突然跳一下。
掉了大約 18 幀。兩段程式碼都花 300ms,體感可以差很多:
讓出主執行緒的手法,由粗到細:setTimeout(fn, 0)(最通用)、requestIdleCallback(不急的工作,瀏覽器有空才跑)、await scheduler.yield()(較新的瀏覽器,語意最精準)、或整段丟進 Web Worker(純運算、不碰 DOM 時最徹底)。
上一張卡說「微任務要清到空、而且清空過程中新加入的也算」。這句話有一個直接後果:只要微任務一直生出新的微任務,第 ③ 步的渲染就永遠輪不到。
徵狀:某個「背景同步」功能一開,整頁就完全不能動
連捲動都停住,但工作管理員看 CPU 只有一顆核心在忙,
而且沒有任何錯誤訊息
可疑的寫法:
function poll() {
return checkStatus().then(done => {
if (!done) return poll(); // ← 遞迴接在 then 裡
});
}
診斷:checkStatus() 命中快取時同步 resolve
→ 整條遞迴變成「微任務生微任務」,永不結束
→ 微任務檢查點清不完,渲染這一步根本沒被執行到
Performance 面板會看到一個長到離譜的單一任務
修法:兩次輪詢之間換成宏任務,把主執行緒還給瀏覽器
const wait = ms => new Promise(r => setTimeout(r, ms));
while (!(await checkStatus())) { await wait(500); }瀏覽器本來會把樣式改動批次累積,到渲染那一步才算一次佈局。但只要你在中途讀取一個需要佈局才知道的值,它就必須立刻算完——批次被打斷:
// ❌ 讀、寫、讀、寫……每一圈都強迫瀏覽器重算一次佈局
for (const el of items) {
el.style.height = el.offsetHeight + 10 + 'px';
}
// ✅ 先全部讀完,再全部寫 —— 只算一次佈局
const hs = items.map(el => el.offsetHeight);
items.forEach((el, i) => { el.style.height = hs[i] + 10 + 'px'; });會觸發強制佈局的常見屬性:offsetTop/Left/Width/Height、clientWidth/Height、scrollTop、getBoundingClientRect()、getComputedStyle()。它們看起來只是「讀一個數字」,實際成本是整棵樹重新佈局。
從這裡開始換到框架層,但先問一個框架無關的問題:畫面要更新,總得有人知道「資料變了」。這個「知道」怎麼來?
React :你必須呼叫 setState —— 由你明講
Vue :Proxy 攔截屬性存取 —— 讀寫的當下就知道
Angular :你直接寫 this.count++ —— 沒有任何 API 可攔
所以它改成攔「非同步邊界」資料不會自己變。它一定是被某件事觸發的——點擊、計時器、HTTP 回應、Promise 完成。那些事件全部是非同步 API 送進來的。所以只要在「所有非同步回呼跑完」的那一刻檢查一次畫面,就不會漏掉。
Zone.js 做的就是這件事:在應用啟動前,把 setTimeout、setInterval、addEventListener、XMLHttpRequest、Promise 等等全部換成自己的包裝版(monkey patch),藉此得知「非同步工作開始了 / 結束了」。當 Angular 區域內的微任務佇列清空時,它就觸發一次 ApplicationRef.tick()——也就是變更偵測。
this.count++ 畫面就會更新;代價寫在下一段。徵狀:滑鼠一移到圖表上,整個頁面就開始掉幀
診斷:元件裡有 (mousemove) 綁定,或第三方套件加了 mousemove 監聽
→ Zone.js 攔到,每一次 mousemove 都觸發一次完整變更偵測
→ 一秒 60~120 次,每次都從根元件掃過整棵樹
Angular DevTools 的 Profiler 會看到密集且等高的 CD 長條
修法:把高頻事件移出 Angular 區域
constructor(private zone: NgZone) {}
ngOnInit() {
this.zone.runOutsideAngular(() => {
el.addEventListener('mousemove', onMove); // 不觸發 CD
});
}
// 真的需要更新畫面時,再 this.zone.run(() => { ... })runOutsideAngular 的典型對象:mousemove/scroll/resize、動畫的 requestAnimationFrame 迴圈、第三方圖表與地圖套件的內部計時器。
被 Zone.js 觸發之後,Angular 做的事情很機械:從根元件開始,深度優先走過整棵元件樹,把每個模板綁定的新值跟上一次的舊值比一比,不同才更新 DOM。
AppComponent
└─ HeaderComponent 比對 3 個綁定
└─ DashboardComponent 比對 12 個綁定
└─ ChartComponent 比對 8 個綁定
└─ TableComponent 比對 500 個綁定(50 列 × 10 欄)
└─ FooterComponent 比對 2 個綁定
一次 tick = 上面「全部」比對一遍。
即使這次只有 Header 的一個字改了。!==,很便宜;貴的是數量(大列表、深層樹)與模板裡的函式呼叫——{{ calcTotal() }} 每次 CD 都會被叫一次,一秒可能好幾百次。ExpressionChangedAfterItHasBeenCheckedError。成因:父元件的綁定值,在子元件的 ngOnInit/ngAfterViewInit 裡被改掉
→ 父已經檢查完了,子才動它 → 這一輪的畫面與資料不一致
Angular 的態度:這代表「一次單向資料流」被破壞了,
而不是它自己算錯 —— 所以在 dev 模式主動吵你
常見的止痛法(不是解法):
Promise.resolve().then(() => this.value = x); // 推到下一個微任務
真正的解法:
把那個狀態往上提到父元件,或改用 @Output 通知父元件自己改pure pipe(輸入沒變就不重算),或 Signal 的 computed(有快取)。這是最便宜、最不需要改架構的一次優化。Default 策略的問題不是慢,是它沒有任何資訊可以用來跳過——Zone.js 只告訴它「有事發生」,沒說「哪裡變了」。OnPush 就是你主動補上那個資訊:「這個元件的畫面只跟這幾件事有關,其他情況請跳過我和我的子樹。」
① @Input 的「參考」變了(=== 比對,不是內容比對)
② 這個元件(或其子元件)的模板事件被觸發,例如 (click)
③ 模板裡的 async pipe 收到新值
④ 你手動呼叫 markForCheck() / detectChanges()
(Angular 16+ 再加一項:模板讀到的 signal 發出通知)
其他任何原因觸發的 tick,整個子樹直接跳過。徵狀:新增一筆項目後,列表沒有出現新資料;
重新整理或點一下列表裡任何按鈕,它就冒出來了
可疑的寫法:
this.items.push(newItem); // ← 陣列「內容」變了
// 但「參考」沒變
診斷:子元件是 OnPush,@Input 用 === 比對 items
舊參考 === 新參考 → Angular 判定「沒變」→ 跳過整個子樹
第 ② 條(模板事件)能讓它恢復,所以「點一下就好了」
——這正是最難查的線索:它時好時壞
修法:改成產生新參考
this.items = [...this.items, newItem];元件被銷毀,不代表它會被回收。只要還有人握著它的參考,它就是可達的(reachable),GC 不會動它——這跟後端底層 ch04講的 GC 可達性是同一件事,只是換到瀏覽器。
全域 Service(單例,活到頁面關閉)
└─ Subject
└─ observers 陣列
└─ 你在元件裡寫的那個箭頭函式
└─ 閉包捕獲了 this(元件實例)
└─ 元件持有 DOM 節點、子元件、資料……
元件被 destroy 了,但這條鏈沒斷 —— 整包都留在記憶體裡。徵狀:在同一頁之間來回切換幾次後,
① 同一個動作觸發了 N 次(N = 你進出這頁的次數)
② 已經離開的頁面仍在發 HTTP 請求
③ 記憶體用量呈階梯狀上升,不會掉回來
診斷:DevTools → Memory → 切頁前後各拍一次 Heap Snapshot,
比對 Detached HTMLElement 與元件類別的實例數。
實例數等於切頁次數,就是它了。
修法(擇一,別混用):
① 模板用 async pipe —— Angular 自動退訂,能用就用這個
② takeUntilDestroyed()(v16+),或
takeUntil(this.destroy$) + ngOnDestroy 裡 next()+complete()
③ 手動存 Subscription 再 unsubscribe() —— 訂閱一多就容易漏掉HttpClient 的單次請求、timer)不用;不會自己結束的(Subject、fromEvent、interval、router events、store 的 selector)一定要。不確定時就當作要退。「洩漏」在瀏覽器的後果比想像中嚴重:分頁不會像後端服務那樣重啟,使用者可能開著同一個分頁一整天。
回頭看整條鏈就會發現,前面所有的麻煩都來自同一個根:Zone.js 只知道「有事發生」,不知道「什麼變了」。所以 Angular 只能每次都掃整棵樹,然後靠 OnPush 讓你手動補上遺失的資訊。
count = signal(0);
double = computed(() => this.count() * 2); // 有快取,依賴沒變就不重算
模板讀 {{ count() }} 的時候,Angular 記下
「這個畫面用到了這個 signal」
→ count.set(1) 時,它確切知道該更新哪幾個地方
→ 不需要掃樹,也就不需要 Zone.js 來通知它「有事發生」這就是 zoneless 的基礎:拿掉 Zone.js 之後,啟動時少載一份 patch 所有非同步 API 的函式庫,也不再有「隨便一個 setTimeout 就觸發全樹檢查」這件事。變更偵測從推測式(有事就全檢查)變成宣告式(誰用到誰更新)。
markForCheck()。這是實打實的遷移成本。這一頁看起來是前端專題,但它跟Java 後端底層那八章、高可用防禦共用同樣三條主線。把它們對起來,比記住 API 有用得多。
後端:一次回表、一次 GC 停頓、一次連線等待
前端:讀一次 offsetHeight(強迫整棵樹重新佈局)
模板裡一個 {{ calc() }}(一秒被呼叫數百次)
一次 mousemove(在 Angular 區域內=一次全樹檢查)
共通點:原始碼上都只有一行,看不出代價。後端:事務沒回滾、索引失效、連線池慢慢被佔滿
前端:OnPush + mutate → 畫面不更新(沒有例外)
忘記退訂 → 記憶體階梯上升(沒有例外)
微任務遞迴 → 畫面凍結(沒有例外、CPU 也不滿)
共通點:監控面板全綠,只有使用者知道不對勁。後端:先看等待在哪,而不是先把連線池開大
前端:先用 Profiler 找出哪個元件貴,而不是先全站套 OnPush
共通點:旋鈕會讓數字變好看,但根因還在,
而且下次出事時你多了一個變因要排除。示意值。重點不是各段的精確比例,而是:JS 只是其中一段,但它是唯一由你控制的那段——它一超時,後面的樣式、佈局、繪製全部被推到下一幀。
| API | 什麼時候執行 | 會擋住渲染嗎 | 典型用途與陷阱 |
|---|---|---|---|
| queueMicrotask / Promise.then | 當前宏任務結束後,立刻清空 | 會——清空前不渲染 | 確保「這一輪結束前一定執行」。遞迴使用會餓死渲染 |
| setTimeout(fn, 0) | 下一個(或更後面的)宏任務 | 不會——中間可以渲染 | 最通用的「讓出主執行緒」。實際最短約 4ms,且無法保證順序 |
| requestAnimationFrame | 下一次繪製前,樣式與佈局之前 | 不會(但回呼太久會掉幀) | 動畫與需要讀寫佈局的操作。分頁切到背景時會暫停 |
| requestIdleCallback | 一幀處理完還有空檔時 | 不會 | 不急的工作(預先載入、上報)。忙碌時可能一直不執行,要設 timeout |
| Web Worker | 另一條執行緒,完全平行 | 不會 | 純運算的最徹底解法。碰不到 DOM,資料要複製或轉移 |
| 觸發來源 | Default | OnPush | 說明 |
|---|---|---|---|
| 任何 setTimeout/HTTP 回應 | ✅ 全樹檢查 | ❌ 跳過 | Zone.js 只知道「有事發生」,不知道跟這個元件有沒有關係 |
| @Input 參考改變(===) | ✅ | ✅ | 只比參考。push/splice/改物件屬性都不算變 |
| @Input 內容變但參考沒變 | ✅(碰巧會更新) | ❌ 跳過——畫面不更新且不報錯 | OnPush 最常見的坑。修法:產生新參考 |
| 元件自己的模板事件 (click) | ✅ | ✅ | 「點一下就正常了」的來源,會讓偶發 bug 更難查 |
| 模板裡的 async pipe 發新值 | ✅ | ✅ | async pipe 內部會呼叫 markForCheck() |
| 元件外的 Observable 手動 subscribe | ✅ | ❌ 要自己 markForCheck() | 能改用 async pipe 就改,可以少一整類 bug |
| 寫法 | 適用情境 | 優點 | 代價/風險 |
|---|---|---|---|
| 模板 async pipe | 資料只用在模板上 | 自動退訂,且會通知 OnPush 更新 | 需要在 TS 裡加工的資料不適用 |
| takeUntilDestroyed() | Angular 16+ 的任何訂閱 | 一行搞定,不必自己管 Subject | 在注入情境外使用要自行傳入 DestroyRef |
| takeUntil(destroy$) | 舊版 Angular 的標準做法 | 一個 Subject 管全部訂閱 | 必須放在管線「最後一個」運算子,否則後面的訂閱不會被取消 |
| 手動 unsubscribe() | 只有一兩個訂閱的簡單元件 | 直觀,沒有額外概念 | 訂閱一多就會漏;漏掉不會報錯 |
點擊卡片翻面查看答案,共 20 張。