技術筆記 前端 Event Loop
前端底層

前端 Event Loop 與 Angular 變更偵測

單執行緒的代價微任務與渲染16.7ms 預算Zone.jsOnPush訂閱洩漏

這一頁的前身是舊筆記「全域底層架構修煉」的第五章(那頁的七個主題已全部拆成獨立內容,原頁不再保留)。原本只有一段 Event Loop 流程圖加兩張小卡,這裡把它補成一條完整的因果鏈:瀏覽器只有一條主執行緒 → 所以「不卡」比「快」難 → 微任務與渲染的排隊規則 → Angular 為什麼需要 Zone.js 來得知「有事發生了」→ 變更偵測的成本從哪來 → OnPush 換到什麼、付出什麼 → 訂閱洩漏為什麼不會報錯。前半段(Event Loop、渲染幀、佈局抖動)跟框架無關,任何前端都適用;後半段以 Angular 為例,因為原素材是 Angular。

前端所有效能問題幾乎都能追回同一個事實:瀏覽器分頁的主執行緒只有一條,而且它不只跑 JavaScript。

主執行緒的工作清單

① 執行 JavaScript
② 計算樣式(哪些 CSS 規則套用到哪些元素)
③ 佈局 Layout(每個元素的位置與尺寸)
④ 繪製 Paint / 合成 Composite(部分可以交給 GPU)
⑤ 處理輸入事件(點擊、輸入、捲動)

這五件事排同一個隊。第 ① 項跑久一點,後面全部延後。
所以「卡頓」的根因通常不是 CPU 不夠快,是主執行緒被某段 JS 佔住了。這跟高可用防禦裡「一個慢依賴佔住 Tomcat 執行緒、拖垮整個服務」是同一個模型——共用的有限資源被慢速操作佔住,只是這裡的共用資源是使用者的畫面。

為什麼不乾脆多開幾條執行緒

因為 DOM 是共享可變狀態。兩條執行緒同時改同一棵樹,就需要鎖;而凡是要鎖的地方就有死鎖與競態。瀏覽器選了另一條路:DOM 只能由主執行緒碰,需要平行運算時用 Web Worker(拿不到 DOM,只能傳訊息)。

  • 單執行緒換到的是:不需要為 DOM 上鎖,寫程式時不必擔心「我讀 offsetWidth 的同時有人在改它」。
  • 付出的代價是:任何一段長時間同步程式碼都會凍結整個畫面——連捲動與輸入游標都會停。
💡 這一頁後面所有機制(微任務、requestAnimationFrame、Zone.js、OnPush),本質上都在回答同一個問題:怎麼安排這條執行緒的工作順序,才不會讓使用者感覺到停頓。

「宏任務、微任務」很多人背得出名詞,但關鍵在順序與「清空」這兩個字。一輪的實際流程是:

① 從任務佇列取出「一個」宏任務,執行到底
     (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 是下一個宏任務 —— 即使延遲寫 0
💡 setTimeout(fn, 0) 從來不是 0。規範規定巢狀超過 5 層後最小延遲會被夾到 4ms,而且它只是「排進佇列」,前面還排著什麼你不知道。要「盡快、但讓瀏覽器先喘口氣」用 setTimeout;要「這一輪結束前一定要跑到」用 queueMicrotask。

60Hz 的螢幕每 16.7ms 更新一次。要讓畫面順,主執行緒必須在每個 16.7ms 內把「JS + 樣式 + 佈局 + 繪製」全部做完——實際留給 JS 的通常只有 10ms 上下。

一個函式跑 300ms 會發生什麼

0ms    使用者點了按鈕
0ms    你的處理函式開始跑(同步,300ms)
16.7ms 螢幕該更新了 —— 主執行緒還在跑 JS,沒有新畫面
33.4ms 螢幕該更新了 —— 還是沒有
...
300ms  函式結束,這時才輪到樣式/佈局/繪製

使用者看到的:按下去「沒反應」,然後畫面突然跳一下。
掉了大約 18 幀。
業界的門檻值:超過 50ms 的同步工作就叫 Long Task(Performance 面板會標紅)。50ms 不是隨便訂的——使用者操作後 100ms 內有回饋才感覺得到「即時」,扣掉排隊與繪製,單一任務的預算大概就是 50ms。

不要用「總耗時」判斷效能

兩段程式碼都花 300ms,體感可以差很多:

  • 一整塊 300ms 的同步迴圈:畫面凍結 300ms,期間點什麼都沒反應。
  • 切成 30 段、每段 10ms、中間讓出主執行緒:總時間可能還變長(多了排程開銷),但畫面全程都在動、點擊也有回應。

讓出主執行緒的手法,由粗到細:setTimeout(fn, 0)(最通用)、requestIdleCallback(不急的工作,瀏覽器有空才跑)、await scheduler.yield()(較新的瀏覽器,語意最精準)、或整段丟進 Web Worker(純運算、不碰 DOM 時最徹底)。

💡 判斷順序:先看「有沒有 Long Task」,再看「總時間多少」。使用者抱怨的通常是前者,而優化總時間往往救不了它。

上一張卡說「微任務要清到空、而且清空過程中新加入的也算」。這句話有一個直接後果:只要微任務一直生出新的微任務,第 ③ 步的渲染就永遠輪不到。

徵狀 → 診斷 → 修法

徵狀:某個「背景同步」功能一開,整頁就完全不能動
      連捲動都停住,但工作管理員看 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); }
這類 bug 不會報錯、不會進 Sentry、也不會出現在錯誤率的儀表板上——它只是「使用者覺得很卡」。跟後端底層 ch03連線池被佔滿、高可用防禦裡下游變慢一樣:最危險的故障是不會報錯的那種。

同一類的兄弟:佈局抖動(Layout Thrashing)

瀏覽器本來會把樣式改動批次累積,到渲染那一步才算一次佈局。但只要你在中途讀取一個需要佈局才知道的值,它就必須立刻算完——批次被打斷:

// ❌ 讀、寫、讀、寫……每一圈都強迫瀏覽器重算一次佈局
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()——也就是變更偵測。

💡 所以 Angular 的自動更新不是魔法,是「把整個非同步世界包起來」換來的。它的好處是你寫 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 都會被叫一次,一秒可能好幾百次。
  • 綁定的值不該在檢查過程中改變:dev 模式下 Angular 會再跑一次比對,如果值變了就丟 ExpressionChangedAfterItHasBeenCheckedError。

那個惡名昭彰的錯誤,其實在講一件合理的事

成因:父元件的綁定值,在子元件的 ngOnInit/ngAfterViewInit 裡被改掉
      → 父已經檢查完了,子才動它 → 這一輪的畫面與資料不一致

Angular 的態度:這代表「一次單向資料流」被破壞了,
                而不是它自己算錯 —— 所以在 dev 模式主動吵你

常見的止痛法(不是解法):
  Promise.resolve().then(() => this.value = x);  // 推到下一個微任務
真正的解法:
  把那個狀態往上提到父元件,或改用 @Output 通知父元件自己改
模板裡不要放會做事的函式。要算的東西改成:欄位(在資料變動時算好)、pure pipe(輸入沒變就不重算),或 Signal 的 computed(有快取)。這是最便宜、最不需要改架構的一次優化。

Default 策略的問題不是慢,是它沒有任何資訊可以用來跳過——Zone.js 只告訴它「有事發生」,沒說「哪裡變了」。OnPush 就是你主動補上那個資訊:「這個元件的畫面只跟這幾件事有關,其他情況請跳過我和我的子樹。」

OnPush 元件只在這四種情況被檢查

① @Input 的「參考」變了(=== 比對,不是內容比對)
② 這個元件(或其子元件)的模板事件被觸發,例如 (click)
③ 模板裡的 async pipe 收到新值
④ 你手動呼叫 markForCheck() / detectChanges()
   (Angular 16+ 再加一項:模板讀到的 signal 發出通知)

其他任何原因觸發的 tick,整個子樹直接跳過。

真實失敗場景:加了一筆資料,畫面沒動,也沒報錯

徵狀:新增一筆項目後,列表沒有出現新資料;
      重新整理或點一下列表裡任何按鈕,它就冒出來了

可疑的寫法:
  this.items.push(newItem);        // ← 陣列「內容」變了
                                    //   但「參考」沒變

診斷:子元件是 OnPush,@Input 用 === 比對 items
      舊參考 === 新參考 → Angular 判定「沒變」→ 跳過整個子樹
      第 ② 條(模板事件)能讓它恢復,所以「點一下就好了」
      ——這正是最難查的線索:它時好時壞

修法:改成產生新參考
  this.items = [...this.items, newItem];
又是一個不會報錯的 bug。畫面不更新沒有例外、沒有紅字,只有使用者說「我明明按了」。OnPush 的前提是資料不可變(immutable)——這不是風格偏好,是它的正確性條件。

什麼時候不要用 OnPush

  • 團隊還沒有 immutable 的共識時:只要有人 push/splice/直接改物件屬性,就會產生上面那種偶發性 bug。與其到處灑 markForCheck() 補救,不如先不用。
  • 包在外面的第三方元件不配合時:它可能在 Angular 區域外改狀態,你得自己接管 ChangeDetectorRef。
  • 還沒量過就先全站套用:先用 Angular DevTools 的 Profiler 找出真正貴的那幾個元件(通常是大列表與圖表)。先找根因,再調旋鈕——這條規則在後端底層 ch03調連線池、ch04調 GC 參數時完全一樣。

元件被銷毀,不代表它會被回收。只要還有人握著它的參考,它就是可達的(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 讓你手動補上遺失的資訊。

Signal 補上的正是那個資訊

count = signal(0);
double = computed(() => this.count() * 2);   // 有快取,依賴沒變就不重算

模板讀 {{ count() }} 的時候,Angular 記下
「這個畫面用到了這個 signal」
→ count.set(1) 時,它確切知道該更新哪幾個地方
→ 不需要掃樹,也就不需要 Zone.js 來通知它「有事發生」

這就是 zoneless 的基礎:拿掉 Zone.js 之後,啟動時少載一份 patch 所有非同步 API 的函式庫,也不再有「隨便一個 setTimeout 就觸發全樹檢查」這件事。變更偵測從推測式(有事就全檢查)變成宣告式(誰用到誰更新)。

什麼時候不要急著遷移

  • 專案還在 Zone.js 上而且沒有效能問題:zoneless 要求所有更新來源都得被追蹤到,第三方套件在 Angular 區域外改狀態時就得自己補 markForCheck()。這是實打實的遷移成本。
  • 把 signal 當成「新的 RxJS」:signal 表達的是「現在的值」,RxJS 表達的是「隨時間發生的事件流」。debounce、retry、合併多個來源這類需求仍然是 RxJS 的場子。
  • 為了新語法而重寫:跟設計模式與重構那頁講的一樣——沒有痛過,就不知道接縫該切在哪。先量、再改。

這一頁看起來是前端專題,但它跟Java 後端底層那八章、高可用防禦共用同樣三條主線。把它們對起來,比記住 API 有用得多。

① 每一層都有看不見的成本

後端:一次回表、一次 GC 停頓、一次連線等待
前端:讀一次 offsetHeight(強迫整棵樹重新佈局)
      模板裡一個 {{ calc() }}(一秒被呼叫數百次)
      一次 mousemove(在 Angular 區域內=一次全樹檢查)

共通點:原始碼上都只有一行,看不出代價。

② 最危險的 bug 是不會報錯的那種

後端:事務沒回滾、索引失效、連線池慢慢被佔滿
前端:OnPush + mutate → 畫面不更新(沒有例外)
      忘記退訂 → 記憶體階梯上升(沒有例外)
      微任務遞迴 → 畫面凍結(沒有例外、CPU 也不滿)

共通點:監控面板全綠,只有使用者知道不對勁。

③ 先找根因,再調旋鈕

後端:先看等待在哪,而不是先把連線池開大
前端:先用 Profiler 找出哪個元件貴,而不是先全站套 OnPush

共通點:旋鈕會讓數字變好看,但根因還在,
        而且下次出事時你多了一個變因要排除。
💡 這三條就是本站所有技術主題的共同骨架。遇到沒學過的新技術時,可以直接拿它們當提問清單:這一層的隱藏成本是什麼?它出事時會不會報錯?我現在調的是根因還是旋鈕?
一幀的預算怎麼被吃掉

示意值。重點不是各段的精確比例,而是:JS 只是其中一段,但它是唯一由你控制的那段——它一超時,後面的樣式、佈局、繪製全部被推到下一幀。

四種排程 API:什麼時候跑、會不會擋畫面
API什麼時候執行會擋住渲染嗎典型用途與陷阱
queueMicrotask / Promise.then當前宏任務結束後,立刻清空會——清空前不渲染確保「這一輪結束前一定執行」。遞迴使用會餓死渲染
setTimeout(fn, 0)下一個(或更後面的)宏任務不會——中間可以渲染最通用的「讓出主執行緒」。實際最短約 4ms,且無法保證順序
requestAnimationFrame下一次繪製前,樣式與佈局之前不會(但回呼太久會掉幀)動畫與需要讀寫佈局的操作。分頁切到背景時會暫停
requestIdleCallback一幀處理完還有空檔時不會不急的工作(預先載入、上報)。忙碌時可能一直不執行,要設 timeout
Web Worker另一條執行緒,完全平行不會純運算的最徹底解法。碰不到 DOM,資料要複製或轉移
Default vs OnPush:什麼情況會被檢查
觸發來源DefaultOnPush說明
任何 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()只有一兩個訂閱的簡單元件直觀,沒有額外概念訂閱一多就會漏;漏掉不會報錯

練習題 點選選項查看解析

0 / 10
01 / 10
下面這段程式碼的輸出順序是什麼?<br><code>console.log('A'); setTimeout(() =&gt; console.log('B'), 0); Promise.resolve().then(() =&gt; console.log('C')); console.log('D');</code>
A A B C D
B A D C B
C A D B C
D A C D B
解析
A、D 是 script 這個宏任務本身的同步程式碼;宏任務結束後進入微任務檢查點,跑 C;最後才是下一個宏任務 B。setTimeout 寫 0 也一樣排在微任務後面。
02 / 10
為什麼「微任務要清到空」這件事會導致畫面凍結?
A 因為微任務的優先權比繪製高,而且清空過程中新加入的微任務也要一起處理完,遞迴產生微任務時渲染永遠輪不到
B 因為微任務會佔用 GPU
C 因為微任務數量有上限,超過就會當掉
D 因為微任務會鎖住 DOM
解析
Event Loop 一輪的順序是:一個宏任務 → 清空微任務 → 才有機會渲染。微任務清空時新加入的也算,所以「微任務生微任務」會讓第三步永遠不執行——CPU 只有一顆核心在忙、也沒有任何錯誤訊息。
03 / 10
在迴圈裡寫 <code>el.style.height = el.offsetHeight + 10 + 'px'</code> 為什麼特別慢?
A 因為字串拼接很慢
B 因為 style 是唯讀的
C 因為讀取 offsetHeight 需要最新的佈局結果,會強迫瀏覽器立刻重算——批次被打斷,每一圈都算一次佈局
D 因為每次都會觸發 GC
解析
瀏覽器本來會把樣式改動累積起來、到渲染那步才算一次佈局。但 offsetHeight/getBoundingClientRect() 這類值必須有最新佈局才知道,讀它就強迫立即重算。修法是「先全部讀、再全部寫」。
04 / 10
Angular 為什麼需要 Zone.js?
A 為了支援 TypeScript
B 因為 Angular 讓你直接改屬性(this.count++),沒有 setState 之類的 API 可攔截,只好改成攔截所有非同步 API 來得知「有事發生了」
C 為了讓 RxJS 能運作
D 為了做伺服器端渲染
解析
資料一定是被非同步事件觸發才變的,所以攔住 setTimeout/addEventListener/Promise/XHR 這些邊界就不會漏。代價是它只知道「有事發生」,不知道「什麼變了」——所以每次都得掃整棵樹。
05 / 10
頁面上綁了 (mousemove),一移動滑鼠整頁就掉幀。最直接的修法是什麼?
A 把元件改成 OnPush 就好
B 改用 setInterval 取代
C 用 NgZone.runOutsideAngular() 在 Angular 區域外註冊監聽,真的要更新畫面時再 zone.run()
D 把 mousemove 改成 mouseover
解析
問題不是「檢查得慢」,而是「一秒被觸發一百次」。OnPush 只能減少每次檢查的範圍,runOutsideAngular 則是讓這些事件根本不觸發變更偵測——先解決觸發頻率這個根因。
06 / 10
OnPush 元件的 @Input 是陣列,父元件執行了 <code>this.items.push(x)</code>,結果畫面沒有更新。原因是什麼?
A push 是非同步的
B OnPush 用 === 比對 @Input 的參考,push 只改內容不改參考,所以判定為「沒變」而跳過整個子樹
C 陣列不能當 @Input
D 因為沒有呼叫 ngOnInit
解析
OnPush 的正確性前提是資料不可變。修法是產生新參考:this.items = [...this.items, x]。這個 bug 不會報錯,而且點一下元件內任何按鈕就會恢復(第二個觸發條件),所以特別難查。
07 / 10
為什麼「元件被銷毀」不等於「它會被回收」?
A 因為瀏覽器不做 GC
B 因為 Angular 會快取所有元件
C 因為只要還有人握著它的參考它就是可達的——例如全域 Service 的 Subject 仍持有訂閱回呼,而回呼的閉包捕獲了元件實例
D 因為 DOM 節點無法被回收
解析
參考鏈是:Service → Subject → observers → 你的箭頭函式 → 閉包捕獲的 this(元件)→ DOM 與子元件。鏈沒斷,整包都留著。這跟 Java 的 GC 可達性是同一件事。
08 / 10
關於 <code>takeUntil(this.destroy$)</code>,下面哪一點是實務上最容易踩的?
A 它必須放在 pipe 的最後一個運算子,否則它後面的運算子建立的訂閱不會被取消
B 它只能用一次
C 它不能跟 map 一起用
D 它會讓 Observable 變成同步
解析
takeUntil 只能取消它「上游」的訂閱。放在中間的話,它後面的 switchMap 等運算子產生的內部訂閱仍然存在——看起來有寫退訂,實際還在漏。
09 / 10
為什麼 Signals 讓「拿掉 Zone.js」變得可行?
A 因為 signal 比較快
B 因為模板讀取 signal 時會被記錄依賴關係,Angular 確切知道值改變後該更新哪裡,不需要靠「有事發生」的通知去掃整棵樹
C 因為 signal 不需要變更偵測
D 因為 signal 會自動退訂
解析
變更偵測從「推測式」(有事就全掃)變成「宣告式」(誰用到誰更新)。Zone.js 存在的唯一理由就是提供那個「有事發生」的通知,資訊來源換掉之後它就沒必要了。
10 / 10
使用者抱怨「按下去沒反應」,但你量到那個函式只花 300ms,總時間看起來還好。應該先看什麼?
A 先看網路請求數量
B 先把函式優化到 200ms
C 先看有沒有 Long Task(超過 50ms 的同步工作)——體感問題來自主執行緒被整塊佔住,切成小段讓出主執行緒往往比縮短總時間有效
D 先增加伺服器規格
解析
同樣 300ms,一整塊會凍結畫面 300ms、掉約 18 幀;切成 30 段各 10 毫秒的話總時間可能更長,但畫面全程有回應。先看 Long Task,再看總時間。

點擊卡片翻面查看答案,共 20 張。

QUESTION
為什麼瀏覽器不用多執行緒跑 JS?
點擊翻面
ANSWER
因為 DOM 是共享可變狀態,多執行緒就要上鎖,也就會有死鎖與競態。改成單執行緒+Web Worker(拿不到 DOM)。
點擊翻回
QUESTION
Event Loop 一輪的順序?
點擊翻面
ANSWER
取一個宏任務執行到底 → 清空微任務佇列(新加入的也算)→ 有需要才渲染(rAF → 樣式 → 佈局 → 繪製)→ 有空檔跑 rIC → 下一輪。
點擊翻回
QUESTION
宏任務與微任務最關鍵的差別?
點擊翻面
ANSWER
宏任務一輪只取「一個」,中間可以渲染;微任務要「清到空」,清空前不會渲染。
點擊翻回
QUESTION
setTimeout(fn, 0) 真的是 0 嗎?
點擊翻面
ANSWER
不是。巢狀超過 5 層後最小延遲會被夾到 4ms,而且它只是排進佇列,前面排了什麼你不知道。
點擊翻回
QUESTION
60fps 每幀的預算是多少?實際留給 JS 多少?
點擊翻面
ANSWER
16.7ms;扣掉樣式、佈局、繪製後,實際留給 JS 大約 10ms。超過 50ms 的同步工作稱為 Long Task。
點擊翻回
QUESTION
為什麼「總耗時」不是判斷卡頓的好指標?
點擊翻面
ANSWER
同樣 300ms,一整塊會凍結畫面;切成 30 段讓出主執行緒的話總時間更長,但全程有回應。先看有沒有 Long Task。
點擊翻回
QUESTION
微任務怎麼「餓死」渲染?
點擊翻面
ANSWER
微任務清空時新加入的也要處理完。遞迴產生微任務(例如 then 裡再呼叫自己且上游同步 resolve)會讓渲染那一步永遠輪不到——畫面凍結但不報錯。
點擊翻回
QUESTION
什麼是佈局抖動(Layout Thrashing)?
點擊翻面
ANSWER
在迴圈中交錯讀(offsetHeight、getBoundingClientRect)與寫樣式,讀取強迫瀏覽器立刻重算佈局,批次被打斷。修法:先全部讀,再全部寫。
點擊翻回
QUESTION
Zone.js 到底做了什麼?
點擊翻面
ANSWER
在啟動前 monkey patch 所有非同步 API(setTimeout、addEventListener、Promise、XHR),藉此得知非同步工作結束,然後觸發一次變更偵測。
點擊翻回
QUESTION
Zone.js 的根本限制是什麼?
點擊翻面
ANSWER
它只知道「有事發生」,不知道「什麼變了」——所以每次都得從根元件掃過整棵樹。OnPush 與 Signals 都是在補上這個缺失的資訊。
點擊翻回
QUESTION
高頻事件(mousemove/scroll)讓頁面掉幀,怎麼修?
點擊翻面
ANSWER
NgZone.runOutsideAngular() 在 Angular 區域外註冊監聽,真的要更新畫面時才 zone.run()。先解決觸發頻率,而不是先套 OnPush。
點擊翻回
QUESTION
為什麼模板裡不該寫 {{ calcTotal() }}?
點擊翻面
ANSWER
每次變更偵測都會呼叫它,一秒可能好幾百次。改用欄位、pure pipe 或 computed signal(有快取)。
點擊翻回
QUESTION
ExpressionChangedAfterItHasBeenCheckedError 在講什麼?
點擊翻面
ANSWER
某個綁定值在檢查完之後又被改了(常見於子元件的 ngOnInit/ngAfterViewInit 改父元件的值),代表單向資料流被破壞。dev 模式才會檢查。
點擊翻回
QUESTION
OnPush 元件在哪四種情況會被檢查?
點擊翻面
ANSWER
① @Input 參考改變 ② 元件自己(或子元件)的模板事件 ③ 模板 async pipe 發新值 ④ 手動 markForCheck/detectChanges(v16+ 再加:模板讀到的 signal 更新)。
點擊翻回
QUESTION
OnPush + items.push(x) 為什麼畫面不更新?
點擊翻面
ANSWER
@Input 用 === 比對參考,push 只改內容。修法:this.items = [...this.items, x]。這個 bug 不報錯,而且點一下就會恢復,特別難查。
點擊翻回
QUESTION
元件銷毀後為什麼還會洩漏?
點擊翻面
ANSWER
全域 Service 的 Subject 仍持有訂閱回呼,回呼閉包捕獲了元件實例 → 元件、DOM、子元件整包可達,GC 不會回收。
點擊翻回
QUESTION
什麼樣的 Observable 一定要退訂?
點擊翻面
ANSWER
不會自己結束的:Subject、fromEvent、interval、router events、store selector。會自己結束的(HttpClient 單次請求、timer)不用。不確定就當作要退。
點擊翻回
QUESTION
takeUntil(destroy$) 最容易踩的坑?
點擊翻面
ANSWER
它只能取消上游的訂閱,所以必須放在 pipe 的最後一個運算子;放中間的話後面 switchMap 等產生的內部訂閱仍然會漏。
點擊翻回
QUESTION
Signals 讓 zoneless 變可行的原因?
點擊翻面
ANSWER
模板讀取 signal 時會記錄依賴,值改變時 Angular 確切知道要更新哪裡,不必靠「有事發生」的通知掃整棵樹——從推測式變成宣告式。
點擊翻回
QUESTION
這一頁跟後端章節共用的三條主線?
點擊翻面
ANSWER
① 每一層都有看不見的成本 ② 最危險的 bug 是不會報錯的那種 ③ 先找根因再調旋鈕。
點擊翻回