前端

為什麼 preventDefault 無法停止全域的 keydown 監聽器

兩個功能綁定了相同的快捷鍵,且兩者都被觸發。`preventDefault` 會取消預設行為,而非其他監聽器。此修復方法在切換後依然有效。

本文由 AI 模型從英文原文翻譯而來,用字可能與原文有所出入。 閱讀英文原文

在同一個編輯器中,有兩個功能都綁定了 Cmd/Ctrl+Enter。其中一個用於發布留言,另一個則會啟動一個昂貴的重新生成作業,該作業會消耗點數並取代現有結果。按下該按鍵會同時觸發兩者。留言框已經呼叫了 preventDefault(),但這絲毫沒有作用,因為 preventDefault 從來就不是處理這種問題的工具。

這個錯誤很容易重現,也很容易被誤診。它還有一個多數團隊會忽略的後半部問題:更改快捷鍵雖然解決了衝突,但可能會讓第一週的情況變得更糟。

共用快捷鍵的實際樣貌

這兩個功能由不同的人在不同的目錄中,相隔數月編寫而成,他們著眼於不同的問題。

編輯器有一個全域快捷鍵。它會監聽 window,檢查是否按下了 Enter 鍵加上一個修飾鍵,並為任何被聚焦的區段啟動重新生成作業:

useEffect(() => {
  const onKeyDown = (e) => {
    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
      e.preventDefault()
      if (focusedId) startExpensiveJob(focusedId)
    }
  }
  window.addEventListener('keydown', onKeyDown)
  return () => window.removeEventListener('keydown', onKeyDown)
}, [focusedId])

後來,新增了一個留言面板作為一個獨立的元件。它遵循了常見的網頁慣例,即 Cmd/Ctrl+Enter 可以提交文字方塊:

<textarea
  onKeyDown={(e) => {
    if (e.nativeEvent.isComposing) return
    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
      e.preventDefault()
      submit()
    }
  }}
/>

單獨來看,這兩個程式碼片段都是合理的。兩個檔案都沒有提及對方。面板從未與編輯器的快捷鍵表進行核對,因為面板不認為自己是編輯器的一部分。

結果是:審閱者點擊一個區段,在評論框中輸入筆記,按下 Cmd+Enter,筆記被儲存的同時,該區段也被靜默地重新生成。這個旨在記錄意見的操作,同時也覆寫了正在審閱的內容。

為何 preventDefault 無法停止其他監聽器

評論框呼叫了 preventDefault()。但它仍然沒有成功,因為 DOM 事件上的三個方法各自做三件不同的事情:

方法 它停止什麼 它不停什麼
preventDefault() 瀏覽器的預設行為(送出、捲動、插入新行) 任何節點上的任何監聽器
stopPropagation() 路徑上更遠的祖先節點上的監聽器 同一節點上的其他監聽器
stopImmediatePropagation() 所有尚未被呼叫的監聽器,包括同一節點上的同層級監聽器 預設行為

preventDefault 會設定一個旗標。事件會繼續傳播,路徑上的每個監聽器仍然會執行。一個從不讀取 event.defaultPrevented 的監聽器將不會注意到有任何反對意見。

為何一次按鍵會執行兩個處理常式 1 冒泡 2 未被阻擋 3 想要的 4 不想要的 留言框 React 根節點 視窗 留言已儲存 任務重新執行 // preventDefault 會設定一個旗標,但不會停止其他監聽器

React 帶來了一個值得注意的細節。自 React 17 起,合成事件是附加在您應用程式的根容器上,而不是在 document 上。您的 onKeyDown prop 並非 textarea 本身的監聽器。它在 React 於其自身的樹狀結構中重播事件時執行,而那時原生事件早已被分派到根部。一個 window 監聽器位於該根部之上,所以無論如何它都會聽到該事件。

這就是為什麼在元件內部加入 stopPropagation() 這種天真的修復方式是脆弱的。它可能有效,因為 React 在底層會呼叫原生的 stopPropagation,且在路徑中根部是位於 window 之下。但這取決於您所用的 React 版本將其監聽器附加在哪裡,而且它會默默地破壞任何您確實想要的其他祖先監聽器,包括那些後來由函式庫新增的監聽器。阻擋傳播是一個針對您上方所有對象的粗暴工具。

更改快捷鍵只是解決了一半問題

最直接的解決辦法是移動其中一個快捷鍵。在我們的例子中,留言框的快捷鍵移至 Shift+Enter,這也是回報此錯誤的人所要求的。

這移除了衝突。但它沒有移除損害,而這正是容易被忽略的部分:對於任何手指已經養成舊習慣的人來說,第一週的情況會變得更糟,而不是更好。

在變更之前,於留言框中按下 Cmd+Enter 會做兩件事,其中一件是您想要的。變更之後,同樣的按鍵操作不會發佈任何內容,卻仍然會啟動那個耗費資源的工作。使用者會遺失他們的筆記,並為一個他們並未要求的重新生成付出代價。您將一個令人困惑的結果,換成了一個純粹具破壞性的結果。

因此,移動快捷鍵需要一個配套措施:全域處理常式必須忽略來自留言框的按鍵輸入。

使用白名單標記優於全面的輸入檢查

第一個直覺是讓全域處理常式略過所有文字輸入:

const tag = e.target.tagName
if (tag === 'TEXTAREA' || tag === 'INPUT') return

在這裡不要這麼做。在我們的編輯器中,這個快捷鍵的主要用途是在區段中編輯文字時按下 Cmd+Enter:處理常式會讓欄位失焦以送出編輯內容,然後用新的內容重新生成。全盤的輸入檢查會為了修復次要衝突而扼殺主要功能。

可行的規則是使用白名單。標記出應抑制全域快捷鍵的特定欄位,並讓其他所有東西保持現有行為:

<textarea data-comment-input onKeyDown={...} />
const target = e.target
if (target?.closest?.('[data-comment-input]')) return
e.preventDefault()

有三個細節讓這個方法得以成立:

  • closest() 會從節點本身開始尋找,所以只要將屬性放在 textarea 上就夠了。你不需要包裝元素。
  • 防護檢查必須在 preventDefault() 之前執行。如果你先取消了預設行為,即使你決定跳過處理函式,Enter 鍵也會停止在留言框中插入新行。
  • **可選串聯呼叫 ?. 很重要。**當焦點在 document 上,或事件目標不是一個元素時,closest 方法並不存在,此時若直接呼叫,會在全域監聽器中拋出錯誤。

在這裡,屬性選擇器比元件層級的旗標更好,因為檢查發生在遠離元件的地方。全域處理函式無法存取留言面板中的 React 狀態,但它確實能存取事件來源的 DOM 節點。

同樣的道理也適用於預設行為的方向。白名單能安全地應對失敗:任何你忘記標記的欄位都會完全照舊運作。而黑名單(「除了區段編輯器外全部跳過」)則會以相反的方式失敗,破壞你從未想過的既有功能。

在自己的應用程式中該檢查什麼

如果你有任何全域鍵盤監聽器,請逐一檢查以下清單:

  1. 盤點快捷鍵。 分別用 Grep 搜尋 addEventListener('keydown' 和框架的按鍵處理常式。衝突就存在於這兩份清單的間隙中,因為撰寫各自清單的人很少會去看另一份。
  2. 計算每個綁定的處理常式數量。 對於每種組合,問問自己今天會執行多少個程式碼路徑。超過一個就是個潛在的錯誤,等著有習慣的用戶去觸發。
  3. 檢查每種輸入變體,而不僅僅是顯而易見的那種。 我們的留言面板有四個文字欄位:新增留言、編輯留言、新增回覆、編輯回覆。報告中只提到了第一個。只修復四個中的一個,會導致單一面板內的行為不一致。
  4. 決定衝突的歸屬。 涉及金錢或會變動資料的功能應該是讓步的一方,因為錯過一次按鍵輸入的代價,比執行一個非預期的工作要低。
  5. 驗證轉換過程,而不僅僅是最終狀態。 按下舊的快捷鍵,並確認結果是無害的。這正是你的測試計畫會忽略的情況。

我應該改用 stopImmediatePropagation 嗎?

只有當你擁有路徑上所有的監聽器,並且希望它們全都不執行時才用。它也會停止同一節點上的同層級監聽器,這通常超出了你的本意。在全域處理常式中使用針對性的防護措施,一年後會更容易理解,因為例外情況就寫在它所修改的行為旁邊。

為什麼不讓全域處理常式檢查 event.defaultPrevented?

當本地處理常式總是取消預設行為時,這方法是可行的,而且只需修改一行程式碼。但這也是一個較弱的契約:任何因正當理由而跳過 preventDefault 的本地處理常式,都會默默地重新啟用全域操作。標記屬性則能明確表達你的意圖,也就是「這個區域不參與全域快捷鍵」。

這也適用於 React 之外的環境嗎?

是的。合成事件系統改變了本地處理常式的執行位置,但不會改變結果。在原生 JavaScript、Vue 或 Svelte 中,同一路徑上的兩個監聽器都會執行。關於 React 的細節只是解釋了為什麼在元件內部使用 stopPropagation 有時看似有效,有時卻不然。

使用快捷鍵函式庫是真正的解決方案嗎?

一個帶有中央註冊中心的函式庫確實能防止這類錯誤,因為在註冊時重複註冊相同的綁定會變得顯而易見。對於一個擁有許多快捷鍵的應用程式來說,這是一個好的方向。但這比加入一個防護措施的改動要大,而且除非每個功能都透過它來路由,否則也無濟於事——而這正是最初失敗的那個假設。

這個普遍的教訓很小,值得記住:preventDefault 是在與瀏覽器溝通,而不是與你的其他程式碼溝通。當兩個功能需要共用一個按鍵時,必須讓其中一個知道另一個的存在,而最安全的方式就是在事件所攜帶的 DOM 節點上做個標記。