若要在替換檢視的 DOM 內容後讓點擊處理常式繼續運作,請將一個事件監聽器附加至更新後仍會保留的父元素。本指南會示範如何在 Mac 上的 Safari 中,使用一個小型的合成圖塊選擇器實作並檢查這種模式。

任務範圍明確:重建 JavaScript 檢視的內容,同時讓按鈕保持可操作,並保留目前的選取項目。你需要具備基本的 JavaScript 知識,並使用最新版 Safari。範例使用標準瀏覽器 API,不需要安裝框架或套件。

為什麼替換 DOM 後點擊會失效?

使用 addEventListener() 註冊的事件監聽器屬於註冊它的事件目標。將值指定給容器的 innerHTML 時,瀏覽器會解析提供的標記,並替換容器的子孫節點。因此,新建立的按鈕不會繼承附加在舊按鈕上的事件監聽器,即使兩者的 ID 和標籤相同也一樣。請參閱 MDN 的 innerHTML 參考資料和 DOM 標準的事件監聽器模型。

實用的區別在於:替換容器的內容,與替換容器本身,是兩回事。如果程式保留容器,只重建其子項,附加在容器上的事件監聽器就能保留。如果外層的呈現作業也會替換容器,你就必須選擇一個仍會保留的祖先元素,或在新容器初始化時附加事件監聽器。

對一般會冒泡的點擊事件,事件委派讓父元素能處理源自其子孫節點的互動。你不必為每個替換後的按鈕分別綁定事件,而是可以綁定穩定的父元素,並在事件發生時找出相關按鈕。MDN 的事件冒泡指南說明了這種模式。

事件監聽器應放在哪裡?

先找出呈現作業會替換的確切 DOM 範圍,然後在該範圍之外選擇最適合且範圍最小的父元素。以下決策指南將已記載的替換與事件監聽器行為套用至常見的更新模式。MDN:replaceChildren()、DOM 標準:EventTarget。

更新方式 事件監聽器位置 生命週期需求
重建現有檢視根節點的子項 從該根節點委派點擊事件 在建立根節點時註冊一次
同時替換檢視根節點 從仍會保留的外層容器委派事件,或綁定新根節點 清理先前檢視的註冊項目
建立需要個別事件處理常式的按鈕 建立時為每個新按鈕綁定事件 在新控制項可供使用前完成綁定
重新載入整份文件 在新文件中初始化事件監聽器 再次執行一般頁面初始化

以下範例的結構會維持為一個檢視根節點,其中包含標題、狀態、依序排列的一組圖塊選項,以及重新整理控制項。每次呈現都會依相同順序重建這些子項。只有檢視根節點、事件監聽器和 JavaScript 狀態會保留。

在 Safari 中建立可安全替換的完整檢視

先準備一個可丟棄的瀏覽器分頁。在 Mac 上開啟 Safari 的 設定⋯,選取 進階,並啟用 顯示網頁開發者功能。透過 開發 → 顯示網頁檢閱器 開啟 Web Inspector,或按下 Option-Command-I。這些步驟記載於 WebKit 的啟用 Web Inspector 指南。

前往 about:blank,開啟 Web Inspector,並選取 主控台。將下方完整程式貼到提示字元並執行。WebKit 說明,主控台提示字元會在受檢頁面所選的執行環境中評估 JavaScript。使用空白分頁,讓示範與你的應用程式分開。

這是一個完整的瀏覽器程式:它會建立自己的 DOM、定義範例資料、掛載檢視,並提供清理方法。它使用 replaceChildren() 替換檢視的子項;這個方法接受節點物件,並替換現有的子項集合。MDN 的方法參考資料說明了這項操作。

(() => {
  window.domClickDemo?.destroy();

  const root = document.createElement("section");
  root.id = "dom-click-demo";
  const controller = new AbortController();
  const items = [
    { id: "a", label: "Tile A" },
    { id: "b", label: "Tile B" },
    { id: "c", label: "Tile C" }
  ];
  const state = { selected: "None", refreshes: 0 };

  function makeButton(action, label, id) {
    const button = document.createElement("button");
    button.type = "button";
    button.dataset.action = action;
    if (id !== undefined) button.dataset.id = id;
    const caption = document.createElement("span");
    caption.textContent = label;
    button.append(caption);
    return button;
  }

  function render() {
    const heading = document.createElement("h2");
    heading.textContent = "Tile picker";
    const status = document.createElement("p");
    status.textContent =
      `Selected: ${state.selected}; refreshes: ${state.refreshes}`;
    const list = document.createElement("ul");
    for (const item of items) {
      const row = document.createElement("li");
      row.append(makeButton("select", item.label, item.id));
      list.append(row);
    }
    const refresh = makeButton("refresh", "Replace view content");
    root.replaceChildren(heading, status, list, refresh);
  }

  function handleClick(event) {
    if (!(event.target instanceof Element)) return;
    const button = event.target.closest("button[data-action]");
    if (!button || !root.contains(button) || button.disabled) return;

    if (button.dataset.action === "select") {
      const item = items.find(item => item.id === button.dataset.id);
      if (!item) return;
      state.selected = item.label;
    } else if (button.dataset.action === "refresh") {
      state.refreshes += 1;
    } else {
      return;
    }
    render();
  }

  root.addEventListener("click", handleClick, {
    signal: controller.signal
  });
  render();
  document.body.append(root);

  window.domClickDemo = {
    root,
    destroy() {
      controller.abort();
      root.remove();
    }
  };
})();

事件監聽器會在 render() 之外註冊。每次選取項目或重新整理時,都會建立新的按鈕,但所有按鈕仍位於原本的根節點之下。狀態物件也位於呈現子樹之外,因此替換狀態段落不會重設選取的圖塊或重新整理次數。

巢狀標題是刻意安排的。點擊按鈕文字時,事件目標可能是其子元素。closest("button[data-action]") 會在事件目標及其祖先節點中搜尋符合的控制項,避免處理常式只在按鈕本身是事件目標時才運作。MDN 的 closest() 參考資料說明這項搜尋,以及找不到符合項目時傳回的 null。

包含關係檢查可確保符合的控制項位於此檢視範圍內。DOM 標準以提供的節點是該節點本身或其子孫節點來定義 contains()。DOM 標準:Node.contains()。

分開處理初始化、呈現和清除

此範例明確分為三項職責。初始化會建立根節點並註冊其事件監聽器。呈現會根據狀態更新根節點的子項。清除則會移除事件監聽器和根節點。這個分工就是此實作的生命週期契約;將它改用於你的應用程式時,請保留相同的職責。

事件監聽器會接收一個 AbortSignal,而 destroy() 會中止其控制器。如 MDN 的 addEventListener() 參考資料所述,中止會移除相關的註冊項目。再次執行完整程式時,會先清除先前的示範,避免累積多個示範檢視。

不要在每次呈現時都建立新的匿名事件監聽器。重複執行匿名函式運算式會建立不同的函式物件,因此註冊項目可能會在仍保留的根節點上累積。重用相同的回呼、事件類型和捕獲設定,可避免重複註冊;不過,將綁定工作保留在初始化階段,能讓事件監聽器的管理權責更清楚。MDN:addEventListener()、DOM 標準:新增事件監聽器。

檢查重複替換、選取狀態和清理

依照以下步驟測試範例。這些預期結果來自程式明確的狀態更新,並非聲稱已完成 Safari 測試。

  1. 點擊 Tile B。狀態應顯示:Selected: Tile B; refreshes: 0。
  2. 點擊 Replace view content 三次。狀態應顯示:Selected: Tile B; refreshes: 3。
  3. 點擊 Tile C 的文字。狀態應變更為:Selected: Tile C; refreshes: 3。
  4. 點擊標題或狀態段落。兩個值都不應改變。
  5. 在主控台中執行 window.domClickDemo.destroy()。示範應會消失。
  6. 再次執行完整程式。畫面應出現一個全新的檢視,並顯示 Selected: None; refreshes: 0。

前三項檢查涵蓋內容替換、狀態保留和巢狀點擊目標。第四項檢查動作篩選。最後兩項則檢查清理和初始化流程。這些步驟會測試實際的生命週期,而不只是確認初始按鈕能運作。

程式的 JavaScript 語法已使用 Node 檢查,另外也透過獨立且可確定重現的 EventTarget 檢查,確認了回呼去重,以及透過中止訊號移除事件監聽器的行為。這項檢查並未執行瀏覽器 DOM 呈現或 Safari 互動;請在 Mac 上依照上述步驟驗證。

如果處理常式在你的應用程式中仍然失效,該怎麼辦?

找出行為首次偏離預期的位置。檢查應用程式的替換範圍、事件路徑和狀態更新時,請保持範例的簡潔範圍。

症狀 優先檢查 適當的修正方式
外層檢視重新整理前,點擊都正常 事件監聽器所在的根節點是否也被替換? 在初始化時綁定新根節點,或選擇仍會保留的父元素
點擊事件有傳入,但沒有執行任何動作 新控制項是否仍符合選擇器及動作值? 保留控制項的選擇器與動作契約
點擊按鈕文字時的行為不同 處理常式是否只檢查確切的事件目標? 使用 closest() 找出控制項
一次點擊讓狀態更新好幾次 是否有多個註冊項目,或直接與委派處理常式同時運作? 由單一初始化流程負責管理該動作
處理常式有執行,但畫面重設了 是否在呈現期間重新建立狀態? 將必要狀態保留在會被替換的子樹之外

也請檢查事件傳遞。子孫節點的處理常式若呼叫 stopPropagation(),可能會阻止冒泡事件到達委派事件監聽器。MDN 說明了停止事件傳遞和事件委派。變更事件傳遞階段前,先了解為什麼事件傳遞會被停止,因為變更階段也會改變執行順序。

如果必須使用按鈕的直接事件監聽器,請在建立替換按鈕時附加它們。只執行一次的查詢與綁定作業無法初始化後續呈現才建立的節點:事件監聽器註冊項目屬於各自的事件目標。同樣地,若呈現器丟棄了根節點,事件委派也無法保留該根節點上的事件監聽器。這些界線來自 DOM 標準的事件監聽器模型。

就這項任務而言,可靠的安排是使用穩定的檢視根節點、單一委派事件監聽器、存放於呈現子項之外的狀態,以及明確的清理流程。確認每次更新後有哪些節點會保留,據此放置事件監聽器,並在將變更整合至較大的檢視前,驗證連續多次替換的結果。

來源