題目與適用情境
一個任務清單最多顯示數千列,資料列會被分頁結果、即時更新和使用者操作動態插入或移除。每列有 編輯、刪除和切換狀態按鈕,按鈕內還可能包含圖示或文字元素。清單中允許巢狀放置一個獨立的子任務 清單。請只在外層清單註冊一個 click 監聽器,根據被點擊控制項的 data-action 與所屬列的 data-task-id 分派操作。
答案必須解釋事件從祖先到目標再返回祖先的傳播過程,區分 event.target 與 event.currentTarget,並防止外層清單誤處理巢狀清單的操作。還要說明哪些事件不能直接依賴 冒泡委派、子節點中斷傳播會發生什麼、Shadow DOM 如何改變可見事件路徑,以及元件卸載時如何 移除監聽器。
本題面向中級前端、Web 平台與全端職位。核心是瀏覽器 DOM 事件分派和 UI 所有權邊界,因此歸入 前端分類;它不考查使用者態發布訂閱容器,也不考查 task、microtask 或繪製排程。
面試官考察重點
強回答會先建立準確模型。事件分派會沿事件路徑經歷捕獲階段、目標階段,以及在事件允許冒泡時 經歷冒泡階段。addEventListener 預設註冊冒泡監聽器;設定 capture: true 才會在向目標傳播時 執行。只說「事件會往上冒泡」無法解釋捕獲監聽器、非冒泡事件或 Shadow DOM 邊界。
實作品質體現在點擊路由上。target 表示事件被分派到的目標;currentTarget 表示目前正在 執行的監聽器所繫結的物件。在清單父層處理器中,前者可能是按鈕裡的圖示,後者是清單本身。 直接比較標籤名稱會漏掉內部元素,可靠方案會從 target 使用 closest() 找到動作控制項,再驗證 它屬於目前委派根。
所有權邊界區分普通回答與可上線方案。contains() 可排除不屬於容器的匹配項,但容器內仍可能 有另一個獨立委派根。用「最近的委派根必須等於目前清單」表達所有權,才能避免外層與巢狀清單同時執行動作。 動作名稱也應經過白名單分派,不能把 DOM 字串直接當成任意函式名稱。
驗證計畫應直接涵蓋失敗情況:內部元素點擊、動態新增、停用按鈕、巢狀根、傳播被 停止、focus 類事件、Shadow DOM、非同步回呼和卸載清理都應有明確預期。委派減少監聽器數量並 自然涵蓋動態子節點,但小型穩定介面直接繫結更清楚時,不必為了「最佳化」強行委派。
回答前需要釐清的問題
- 要委派哪些事件?
click通常會冒泡,適合本題。若要委派focus、blur、mouseenter
或 mouseleave,必須選擇捕獲監聽、可冒泡的替代事件,或直接繫結;選擇會改變實作和語意。
- 清單內是否存在巢狀元件或 Shadow DOM? 只有平坦清單時,
contains()基本足夠;巢狀所有權
需要獨立根標記,Shadow DOM 則需要定義元件對外事件契約。
- 按鈕內部結構是否穩定? 如果點擊目標可能是圖示或文字,必須使用
closest();只有目標
永遠就是動作元素時,matches() 才足夠。
- 誰可以呼叫
stopPropagation()? 若子元件允許停止傳播,祖先冒泡監聽器不能保證收到事件。
應先釐清元件契約,不能偷偷把監聽器改到捕獲階段並提前執行業務動作。
- 控制項如何表達停用? 原生按鈕使用
disabled;自訂控制項還需要無障礙和鍵盤行為,不能只靠
一個 CSS class 讓委派處理器猜測狀態。
- 監聽器生命週期由誰管理? 頁面級常駐根可以長期存在;可掛載、卸載的元件必須保留函式參照
或使用 AbortSignal,否則重複掛載會重複處理一次點擊。
- 每個動作是否需要阻止預設行為? 只有連結導向或表單送出等預設行為與產品意圖衝突時才呼叫
preventDefault();它與停止傳播是兩條獨立控制線。
30 秒回答框架
「事件先沿路徑捕獲到目標,在目標上執行監聽器,再在 bubbles 為真時向祖先冒泡。父層的預設 監聽器位於冒泡階段,所以可以用一個監聽器處理動態子節點。處理器裡我先確認 target 是 Element,再用 closest('[data-action]') 找動作按鈕;接著檢查 contains,並確認最近的委派根 就是目前清單,避免巢狀清單串線。currentTarget 是繫結監聽器的清單,只在處理器執行期間可靠。 我用動作白名單分派,並用 AbortController 清理。對於不冒泡事件、stopPropagation 和 Shadow DOM,我會按事件與元件邊界選擇捕獲、可冒泡替代事件、直接繫結或元件自訂事件,最後用巢狀 目標、動態列和卸載後重新掛載案例驗證。」
分步深入解答
先畫出傳播路徑。假設點擊發生在按鈕內的圖示上,路徑包含文件、外層清單、任務列、按鈕和圖示。 捕獲監聽器按從外到內的方向接近目標;到達目標後執行目標上的適用監聽器;如果事件的 bubbles 為真,再從內到外執行祖先的冒泡監聽器。事件路徑由分派演算法決定,DOM 結構和 Shadow DOM 邊界會參與路徑建構。
target 與 currentTarget 回答不同問題。target 用來問「這次互動從哪裡發起」,在同一次一般 DOM 冒泡中通常保持不變;currentTarget 用來問「現在執行的是哪個物件上的監聽器」,會隨處理器 改變,而且處理器返回後值為 null。非同步邏輯需要的清單參照或資料應在同步處理器內保存,不能在 await 後再依賴 event.currentTarget。
下面的實作把匹配、所有權、狀態與動作分派分開。假設清單根帶有 data-delegation-root,每列帶 data-task-id,動作使用原生按鈕及 data-action。openEditor、deleteTask 與 toggleTask 代表既有業務函式:
const list = document.querySelector("#task-list");
if (!(list instanceof HTMLElement)) {
throw new Error("task list was not found");
}
const handlers = new Map([
["edit", (taskId) => openEditor(taskId)],
["delete", (taskId) => deleteTask(taskId)],
["toggle", (taskId) => toggleTask(taskId)],
]);
const controller = new AbortController();
list.addEventListener(
"click",
(event) => {
const target = event.target;
if (!(target instanceof Element)) return;
const action = target.closest("[data-action]");
if (!(action instanceof HTMLButtonElement)) return;
if (!list.contains(action)) return;
if (action.closest("[data-delegation-root]") !== list) return;
if (action.disabled) return;
const row = action.closest("[data-task-id]");
if (!(row instanceof HTMLElement) || !list.contains(row)) return;
const actionName = action.dataset.action;
const taskId = row.dataset.taskId;
if (!actionName || !taskId) return;
const handler = handlers.get(actionName);
if (!handler) return;
handler(taskId);
},
{ signal: controller.signal },
);
function cleanupTaskListDelegation() {
controller.abort();
}target.closest() 從實際點擊的圖示向上找到按鈕,因此內部標記變化不會破壞路由。 list.contains(action) 確認匹配項仍位於清單內;最近委派根檢查進一步拒絕巢狀清單的按鈕。 Map 只允許三個已知動作,未知 data-action 安全退出。明確的停用檢查記錄了處理器契約;即使 未來改成程式化分派或調整標記,停用動作仍會被拒絕。
傳播控制必須分開解釋。stopPropagation() 阻止事件繼續走捕獲或冒泡路徑,卻不會阻止預設動作, 也不會阻止同一元素上其他監聽器;後者需要 stopImmediatePropagation()。preventDefault() 只取消可取消事件的預設動作,並不停止傳播。把三個 API 當成同一件事,會導致連結仍導向、祖先 收不到事件,或同節點處理器意外繼續執行。
非冒泡事件要逐一選擇策略。focus 與 blur 可在祖先使用捕獲監聽,或改用會冒泡的 focusin 與 focusout。mouseenter 與 mouseleave 可直接繫結;若改用會冒泡的 mouseover 與 mouseout,還要用 relatedTarget 過濾在後代之間移動產生的額外事件。捲動容器通常直接繫結 更清楚。事件名稱相近不代表語意完全等價。
Shadow DOM 是元件邊界,不應把文件級委派建立在元件內部選擇器上。使用者代理產生的 click 等 UI 事件通常可以跨越 Shadow 邊界,但外部看到的 target 可能被重定向為宿主。composedPath() 可 檢查開放 Shadow Root 的傳播路徑,卻不會暴露關閉 Shadow Root 的內部節點。需要對外傳達動作的 Web Component 應在內部處理細節,再發出明確、允許冒泡並可跨邊界的元件事件;外部只消費穩定 契約。
最後驗證行為,不只檢查監聽器數量。記錄捕獲、目標和冒泡監聽器的執行順序;點擊按鈕文字和內部 圖示;新增一列後不重新繫結就點擊;刪除列後確認舊節點不再觸發;點擊巢狀清單確認只有內層處理; 測試停用按鈕和未知動作;讓子節點停止傳播觀察祖先失效;涵蓋 focus 策略與 Shadow DOM;呼叫 cleanupTaskListDelegation() 後確認點擊不再執行,再重新掛載一次確認沒有重複處理。
高品質示範回答
「我會先確認我們委派的是會冒泡的 click。瀏覽器沿事件路徑先捕獲到目標,再在目標處理,最後在 允許冒泡時回到祖先。父層預設監聽器在冒泡階段執行。event.target 是這次分派的目標,可能是 按鈕裡的圖示;event.currentTarget 是繫結處理器的清單,而且只在處理器執行期間有效。
實作上,我從 target 呼叫 closest('[data-action]'),確認結果是按鈕且屬於目前清單。因為清單裡 允許巢狀另一個委派元件,我還會要求動作最近的 data-delegation-root 就是目前清單。接著找到帶 data-task-id 的列,用 Map 白名單把 edit、delete、toggle 分派到既有函式。動態新增列自然會被 同一個祖先監聽器涵蓋,卸載時用 AbortController 一次移除監聽器。
我不會把所有事件強塞進冒泡委派。focus 可以用捕獲或 focusin,mouseenter 與 mouseover 的語意 不同;子元件呼叫 stopPropagation 會讓祖先冒泡處理器收不到事件。跨 Shadow DOM 時 target 可能 被重定向,關閉根的內部路徑也不會暴露,所以元件應發布穩定的外部事件,而不是讓頁面依賴內部 選擇器。
驗證時我會涵蓋內部圖示、動態列、巢狀根、停用和未知動作、傳播中斷、Shadow DOM,以及卸載後 重新掛載。少量穩定按鈕直接繫結更容易理解時,我會直接繫結;委派的主要價值是清楚的所有權邊界、 動態子節點支援和統一生命週期,不是一個脫離情境的效能口號。」
常見錯誤
- 只檢查
event.target.matches('button')→ 點擊按鈕內的圖示時 target 不是按鈕 →
用 closest() 向上找動作元素。
- 找到
closest()結果就執行 → 匹配項可能屬於另一個容器或巢狀委派根 →
同時檢查 contains() 和最近所有權根。
- 把
currentTarget當成點擊元素 → 父層委派中它永遠指向目前處理器繫結的清單 →
用 target 定位來源,用 currentTarget 確認處理邊界。
- 在
await後讀取event.currentTarget→ 處理器已經返回,值會變為null→
同步階段保存需要的元素參照和資料。
- 認為
preventDefault()會停止冒泡 → 預設動作可能被取消,祖先監聽器仍繼續執行 →
分別決定是否取消預設行為和是否停止傳播。
- 為所有事件套同一委派模板 → focus、mouseenter 等不會按 click 的方式冒泡 →
按事件語意選擇捕獲、替代事件或直接繫結。
- 在文件根讀取 Shadow DOM 內部選擇器 → target 會重定向,關閉根也隱藏內部路徑 →
由元件處理內部互動並發布穩定外部事件。
- 每次掛載都新增匿名監聽器且不清理 → 一次點擊執行多次並保留舊狀態 →
保存可移除參照或使用 AbortSignal 管理生命週期。
- 宣稱委派一定更快 → 小型穩定介面會增加過濾和路由複雜度 →
根據節點動態性、所有權和維護成本選擇。
追問及應對
追問一:子按鈕呼叫了 stopPropagation,外層委派必須仍然運作,怎麼辦?
先確認誰擁有 click 語意。若子元件無權阻斷,應移除該呼叫並透過元件契約修正。捕獲監聽器確實會 在事件到達子按鈕前觀察到事件,但在捕獲階段直接執行刪除等業務動作會早於目標處理器和預設行為, 改變順序與取消機會。只有產品明確要求捕獲階段接管時才這樣做,並補充順序、取消與重複執行測試。
追問二:表單需要統一處理 focus,如何委派?
focus 不冒泡,但祖先可以用 capture: true 觀察它;也可以使用會冒泡的 focusin。如果只需 統一顯示欄位提示,focusin 的冒泡模型通常更直觀。無論選擇哪種,都要驗證鍵盤、滑鼠和程式化 聚焦,並避免把焦點進入複合元件內部的每次移動誤報成離開整個元件。
追問三:動作按鈕位於關閉的 Shadow Root 內,頁面級清單如何識別?
頁面不應識別關閉根內部按鈕。元件在內部處理 click,再從宿主發布語意事件,例如「任務動作」, 事件資料只包含穩定的動作與任務識別碼,並明確允許冒泡和跨 Shadow 邊界。頁面委派這個元件事件。 這樣關閉根仍保持封裝,頁面也不依賴 composedPath() 中不會出現的內部節點。
追問四:處理器需要非同步確認,怎樣避免 DOM 變化導致操作錯列?
在同步處理器中先讀取並驗證不可變的 taskId 與 actionName,再進入非同步流程。確認返回後按 taskId 查詢目前業務狀態,而不是繼續持有舊列並假設它仍連接在 DOM 中。需要防止重複操作時, 還應按任務與動作設定進行中狀態或請求冪等鍵。
追問五:什麼時候應放棄事件委派?
節點數量少且穩定、每個控制項行為差異很大、事件不冒泡,或元件邊界要求局部直接處理時,直接監聽 更容易審查。高頻事件若讓根處理器對大量無關事件反覆執行複雜選擇器,也應縮小委派根或直接繫結。 先用效能記錄驗證瓶頸,再決定是否因效能改變結構。