題目與適用情境
考慮一個使用 React 19.2 的即時訂單儀表板:
function LiveOrders({ tenantId }: { tenantId: string }) {
const [filter, setFilter] = useState("all")
const [count, setCount] = useState(0)
useEffect(() => {
const connection = connect(tenantId)
connection.on("order", (order) => {
if (matches(order, filter)) {
setCount(count + 1)
}
})
return () => connection.close()
}, [])
// 省略畫面與控制項。
}使用者修改篩選條件後,回呼仍按照 "all" 處理。短時間連續收到多筆訂單時,計數可能 一直停在 1。元件收到新的 tenantId 後,也仍連線到第一個租戶。把所有被引用的值都放進 相依項目看似能解決舊值問題,但計數或篩選條件每次變化都會關閉並重建連線。
請解釋這些故障為何發生、如何證明究竟哪個值已過時,以及如何讓訂閱維持正確生命週期, 同時讓回呼讀取最新一次已提交渲染中的值。
這是一道具備實務代表性的前端面試題:公開的中英文 React 面試資料都直接考察陳舊閉包、 Hooks 規則與 useEffect 相依項目。真正的考點不是背誦某個補丁,而是判斷一個值應該重新 啟動 Effect、參與狀態轉換,還是只由 Effect 管理的回呼進行非響應式讀取。
面試官考察重點
第一,應徵者能否正確解釋機制。每次渲染都會得到一份 props 與 state 快照,該次渲染建立 的函式會閉包捕捉這份快照。後續渲染不會修改原函式中的 filter、count 或 tenantId 區域變數。外部系統若一直保留第一次渲染建立的回呼,該回呼就會一直讀取第一次的值。 閉包行為本身符合 JavaScript 語意,問題在於 Effect 宣告的同步關係不完整。
第二,應徵者能否區分四類需求:
| 需求 | 正確工具 | 改變的行為 | |---|---|---| | 外部資源必須跟隨某個響應式值 | Effect 相依項目 | 清除並重新同步 Effect | | 新狀態由舊狀態推導 | 函式式更新 | 依 React 佇列中的目前狀態計算 | | Effect 管理的回呼要讀取最新提交值,但不應重新訂閱 | React 19.2 的 useEffectEvent | 非響應式讀取目前 props 與 state | | 命令式且不參與渲染的可變資料需要穩定身分 | ref | 儲存由開發者手動同步的可變值 |
第三,應徵者能否辨認假修正。useCallback(fn, []) 只能穩定函式身分,也會保留第一次渲染 的閉包。停用 exhaustive-deps 只是隱藏證據。每次計數變化都重建 WebSocket 雖然能取得 新值,卻定義了錯誤的資源生命週期。
最後,應徵者是否涵蓋清除與非同步順序。回呼讀到最新值,不代表舊請求已取消,也不能阻止 較晚回傳的舊回應覆蓋新結果,更不會自動移除洩漏的事件監聽器。這些是獨立的並行與生命週期 責任。
回答前需要釐清的問題
- 哪些值決定訂閱身分? 如果
tenantId決定伺服器端事件串流,變化時必須重新連線;
本機顯示篩選通常不需要重新連線。
- 篩選條件變化後是否要重新處理歷史事件? 這決定篩選條件只用於回呼,還是也要發起
新查詢或新訂閱。
- 回呼是否從舊狀態推導新狀態? 計數加一應使用函式式更新,即使回呼中的其他讀取都是
最新值。
- 正式環境使用哪個 React 版本?
useEffectEvent從 React 19.2 起可用。舊版本可把謹慎
同步的 ref 當成相容方案。
- 外部 API 能否只替換監聽器而不重新連線? 有些函式庫把
subscribe與
updateHandler 分開,可以採用更小的生命週期邊界。
- 回呼是否會啟動非同步請求? 如果會,除了修正捕捉值,也要定義取消或請求世代檢查。
- 開發環境是否啟用 Strict Mode? 額外一次建立與清除可以揭露清除缺漏,但不會製造
陳舊閉包。
30 秒回答框架
「React 回呼讀取的是建立它那次渲染中的 props 與 state 快照。這裡的 Effect 只執行一次, 所以連線一直保留首個租戶、篩選條件與計數。我會讓訂閱相依於 tenantId,因為它決定連線到 哪個外部資源;計數使用 setCount(current => current + 1),因為新值由舊值推導。訂單回呼在 React 19.2 可以用 useEffectEvent 讀取最新篩選條件,而不因篩選變化重新連線。我不會停用 相依性檢查,也不會把 useCallback([]) 當成新鮮度修正。ref 可作為不參與渲染資料的手動 相容方案;非同步結果仍需取消或版本檢查。」
分步深入解答
第一步:把每次渲染視為不可變快照。
假設第一次渲染時的值是:
tenantId = "tenant-a"
filter = "all"
count = 0Effect 建立連線,並建立引用這些區域變數的回呼。之後執行 setFilter("paid") 會產生另一次 渲染與新的 filter 變數,不會修改原回呼捕捉的變數。空相依陣列宣告 Effect 永遠不必再次 同步,因此外部連線一直保留最初的回呼。
不必猜測,就能推導出三個故障:
matches(order, filter)一直使用"all"。- 每個符合條件的事件都執行
setCount(0 + 1),連續事件只是重複把狀態設成1,無法累加。 - props 變化後,連線仍屬於
"tenant-a"。
除錯時可以同時記錄渲染序號、渲染期間看到的值、回呼中看到的值,並另外記錄連線 ID。如此能 分辨是回呼過時、訂閱沒有重建,或伺服器傳送了錯誤資料。日誌不應包含訂單隱私內容。
第二步:修改相依項目前,先依語意為每個讀取分類。
逐一詢問 Effect 為何讀取這些值:
tenantId決定同步哪一條外部事件串流,具有響應性,應放進相依項目。count只用於計算下一個計數。改成函式式更新後,就不再需要捕捉它。filter決定未來事件如何處理,但變化時不應中斷租戶連線。監聽器需要讀取最新值,卻不應
讓訂閱對篩選條件產生響應。
這一步能避免兩個極端:一個是永不更新的空相依陣列,另一個是任何渲染相關值變化都重新啟動 昂貴資源的相依陣列。
第三步:用函式式更新修正狀態轉換。
把:
setCount(count + 1)改成:
setCount((current) => current + 1)React 會把更新函式加入佇列,並在計算下一次渲染時傳入當時待處理的狀態。因此,即使回呼很早 就註冊,或 React 對更新進行批次處理,十個事件也會組合成十次遞增。這個修改只消除對 count 的捕捉,不能讓 filter 或 tenantId 自動變新。
第四步:把真正需要重新同步資源的值宣告為相依項目。
連線身分包含 tenantId,所以 Effect 必須相依於它。租戶變化時,React 先執行上一個 Effect 的清除,再建立新連線。清除必須移除監聽器或關閉舊連線,避免舊租戶的事件更新目前頁面。
把 filter 與 count 也加進去,確實能讓每個新回呼讀取目前值,但也同時改變連線生命週期。 一次計數更新就會中斷並重新連線,可能遺失事件、在重疊清除期間產生重複事件、重設伺服器端 游標,並製造額外負載。相依陣列是行為規格,不是為了消除 lint 警告而隨意調整的效能提示。
第五步:在 React 19.2 中,用 Effect Event 讀取最新的非響應式值。
修正後的元件可以分開連線生命週期與回呼新鮮度:
function LiveOrders({ tenantId }: { tenantId: string }) {
const [filter, setFilter] = useState("all")
const [count, setCount] = useState(0)
const onOrder = useEffectEvent((order: Order) => {
if (matches(order, filter)) {
setCount((current) => current + 1)
}
})
useEffect(() => {
const connection = connect(tenantId)
connection.on("order", onOrder)
return () => connection.close()
}, [tenantId])
// 省略畫面與控制項。
}onOrder 始終讀取最新一次已提交的 filter,Effect 仍只在 tenantId 變化時重新同步。 Effect Event 應由 Effect 或與該 Effect 相連的程式碼呼叫,例如它註冊的監聽器;它不是一般 事件處理函式的通用替代品,不應在元件樹中任意傳遞,也不能用來隱藏原本就應重新同步資源的值。
不要把 Effect Event 加入相依陣列。目前 React 的 lint 規則理解它的非響應式用途。如果 lint 回報其他值,應把警告視為設計回饋並調整程式碼結構,而不是停用規則。
第六步:理解 ref 的適用邊界與成本。
在 React 19.2 之前,常見的相容作法是把最新值放進 ref:
const filterRef = useRef(filter)
useEffect(() => {
filterRef.current = filter
}, [filter])
useEffect(() => {
const connection = connect(tenantId)
connection.on("order", (order) => {
if (matches(order, filterRef.current)) {
setCount((current) => current + 1)
}
})
return () => connection.close()
}, [tenantId])ref 物件在多次渲染之間保持穩定,修改 current 不會觸發渲染。因此,ref 適合儲存最新處理 函式、計時器 ID 或外部 API 控制代碼等不直接顯示的命令式值。代價是必須手動同步:相依性 檢查器無法證明 filterRef.current 一定正確更新,漏掉同步 Effect 就會悄悄恢復舊值問題。 不要為了減少渲染而把畫面顯示狀態移進 ref;除了文件規定的初始化模式,也不要在渲染期間 讀寫 ref。
第七步:讓 useCallback 與 memo 只負責身分。
useCallback 會快取函式身分,直到某個相依項目變化。它適用於 memo 子元件或外部 API 關心 函式身分的情境,但不會自動提供最新值:
const onOrder = useCallback((order: Order) => {
if (matches(order, filter)) {
setCount((current) => current + 1)
}
}, [])這個版本仍捕捉初始篩選條件。加上 [filter] 會產生新函式,但外部連線還必須正確替換監聽器。 如果移除監聽器要求同一個函式身分,每次 Effect 的建立與清除必須使用該次渲染中的同一個 回呼實例。memo 解決身分問題,不解決同步生命週期問題。
第八步:分開處理非同步結果順序。
假設最新篩選條件會發起請求。舊的 "all" 請求可能比新的 "paid" 請求更晚回傳,並覆蓋 新結果。即使回呼讀取最新篩選條件,也不能阻止舊請求完成。API 支援取消時,應在 Effect 清除 中使用 AbortController 終止過時工作;否則為每次請求分配單調遞增的世代,只允許目前世代 寫入狀態。
清除也必須保持對稱:
- 關閉本次建立所建立的那個連線;
- 移除本次建立所註冊的那個監聽器;
- 清除 interval 與 timeout;
- 取消或作廢尚未完成的非同步工作;
- 讓清除後遲到的回呼不會產生副作用。
Strict Mode 在開發環境執行的額外「建立—清除—建立」循環正好可以提供證據。出現重複連線, 表示清除不完整;這不代表 Strict Mode 製造了陳舊閉包。
第九步:每次只改變一個面向進行驗證。
| 測試 | 預期結果 | |---|---| | React 再次渲染前連續傳送三筆符合條件的訂單 | 計數增加三次 | | 修改 filter 後傳送一筆訂單 | 按新篩選條件處理,連線不重建 | | 修改 tenantId | 舊連線關閉一次,新租戶連線一次 | | 清除後從舊連線傳送事件 | 目前畫面不變 | | 兩個請求以相反順序回傳 | 只有最新請求可以寫入 | | 開發環境使用 Strict Mode | 建立與清除對稱,不殘留重複監聽器 | | 重新啟用 Hook 相依性 lint 規則 | 不存在被停用或無法解釋的相依性警告 |
正式環境可以監控連線建立與清除次數、活動連線 ID、租戶切換、遲到事件捨棄數與取消請求數。 不要為了排查回呼新鮮度而記錄訂單隱私欄位。
高品質示範回答
「我會先從 React 的渲染模型解釋。每次渲染都有一份 props 與 state 快照,該次渲染建立的 函式會閉包捕捉這份快照。這個 Effect 使用空相依陣列,所以外部連線一直保留第一次渲染建立 的回呼,自然會看到首個租戶、篩選條件與計數。
修改相依項目前,我會先依用途為每個值分類。tenantId 決定連線到哪個外部資源,應進入相依 陣列;它變化時必須關閉舊連線並開啟新連線。count 只用於推導下一個狀態,所以應改為 setCount(current => current + 1),讓連續事件依 React 佇列中的狀態累加。訂單監聽器需要 讀取最新 filter,但篩選變化不應重建連線。目前應用程式使用 React 19.2,因此我會把篩選 邏輯放進 useEffectEvent,再由只相依於 tenantId 的 Effect 註冊這個 Effect Event。
我不會停用 exhaustive-deps,因為相依陣列描述同步關係;也不會用 useCallback([]) 修正 新鮮度,因為它仍保留初始閉包。舊版 React 可以用由 filter 同步的 ref 當成相容方案,但 這個作法依賴人工維護,相依性分析也看不見它。
最後,我會驗證連續事件累加、篩選變化不重新連線、租戶變化恰好執行一次清除與一次建立、 舊連線遲到事件無效,以及開發環境 Strict Mode。若回呼會發起請求,還要取消過時請求或檢查 世代,因為閉包讀到最新值並不能解決亂序回傳。」
常見錯誤
- 把所有閉包都稱為缺陷 → 回呼捕捉渲染快照是預期行為 → **定位「被長期保留的回呼」與
「預期同步關係」之間的矛盾。**
- 停用
exhaustive-deps→ 程式碼一邊讀取響應式值,一邊宣稱它們永遠不影響同步 →
調整程式碼,讓相依陣列如實描述 Effect。
- 把所有 state 都加入相依項目 → 每次更新都透過重建昂貴訂閱取得新值 → **拆開資源身分
與回呼的最新值讀取。**
- 使用
useCallback(fn, [])→ 穩定身分也凍結了第一次閉包 → **給useCallback正確
相依項目,或選擇符合目標語意的工具。**
- 用 ref 取代 state → ref 寫入不會觸發渲染,可能與可見畫面分離 → **顯示資料留在 state,
ref 只儲存命令式可變資料。**
- 用
useEffectEvent隱藏真實相依性 → 外部資源該變化時,Effect 不再響應 → **決定資源
身分的值仍必須進入相依項目。**
- 修正篩選條件,卻保留
setCount(count + 1)→ 連續事件仍從捕捉的計數覆蓋狀態 →
從舊狀態推導新狀態時使用函式式更新。
- 忽略清除與請求順序 → 洩漏監聽器與遲到回應仍會破壞畫面 → **依生命週期移除、關閉、
取消或檢查每項操作。**
追問及應對
追問 1:為什麼點擊處理函式裡的 setTimeout 會讀到舊 state?
timeout 回呼屬於發生點擊的那次渲染。更新 state 會安排新渲染,不會修改該處理函式中的區域 state 變數。如果延遲動作原本就要回報點擊當下的值,這份快照是正確的;如果必須讀取最新 提交值,應在 Effect 相關程式碼中明確使用 Effect Event,或為命令式回呼謹慎維護 ref。
追問 2:為什麼不直接把 filter 放進 Effect 相依項目?
如果篩選條件變化就應重新同步外部系統,這樣做是正確的。例如伺服器端訂閱原本就依篩選條件 建立,就應加入相依項目並重新連線。本題假設租戶事件串流不變,篩選只是本機處理策略,因此 重新連線會定義錯誤的生命週期。回答前應先說明產品與 API 假設。
追問 3:useEffectEvent 會取代一般事件處理函式嗎?
不會。按鈕點擊通常由渲染期間建立的事件處理函式處理。Effect Event 用於由 Effect 或與它 相連的程式碼觸發的非響應式邏輯,例如計時器或訂閱監聽器。它應留在使用它的 Effect 附近, 不能變成元件間傳遞回呼的通用方式。
追問 4:函式式更新能解決所有陳舊閉包嗎?
不能。它只解決「某個 state 的新值由該 state 的舊值推導」這類狀態轉換,無法讓 prop、其他 state 或外部設定自動變新。本題中它解決計數累加,不能修正租戶連線或篩選讀取。
追問 5:什麼時候應該用 ref 而不是 state?
一個值需要跨渲染保留、變化時不應更新畫面,且只以命令式方式使用時,可以用 ref,例如 計時器 ID、DOM 節點、連線控制代碼或相容性的最新值容器。渲染結果依賴的值應使用 state。 ref 把新鮮度責任交給開發者,因此必須記錄並測試同步位置。
追問 6:Strict Mode 如何協助排查這個問題?
開發環境的 Strict Mode 會為 Effect 額外執行一次建立與清除。如果最後留下兩組監聽器或連線, 表示清除不完整,或清除使用了錯誤的回呼身分。清除正確時,額外循環後只應留下單一活動訂閱。 無論是否啟用 Strict Mode,舊快照都遵循正常的 JavaScript 閉包語意。
追問 7:如果事件剛好在舊連線清除和新連線建立之間到達,該怎麼辦?
需要明確外部系統的投遞保證。用戶端可能需要可恢復游標、序號、重播視窗,或「先取快照再接 事件串流」的協定。React 相依項目只能管理本機生命週期,無法保證伺服器重新連線期間零遺失; 這是獨立的協定需求,也應另外驗證。
追問 8:如何避免舊 fetch 覆蓋新結果?
API 支援取消時,在 Effect 清除中終止過時請求;否則為每個請求分配世代,只有完成時仍屬於 目前世代的請求才能更新 state。只讀取最新值的 ref 不會取消網路工作,不能當成順序保證。