博客 / 詳情

返回

五萬字瀝血事件 深度學習 事件 循環 事件傳播 異步 脱離新手區 成為事件達人

一 、事件的綜述

​ 首先需要了解幾個術語:

  • 宿主環境:將js引擎作為一個組件包含在內,並且為它提供運行所需的資源的外部系統

​ 就是説,宿主環境提供了所有的資源,比如網絡 文件 渲染 各種功能接口等等,沒有了宿主環境, js引擎就是光桿司令,它就只能空轉,做不了任何事情。

  • 宿主對象:所有不是由 JS 語言本身定義的、而是由環境提供的對象/功能/api,都叫宿主對象。

比如 Fetch document XMLHttpRequest 等等 由宿主環境提供的都叫宿主對象。

而js語言定義的對象,比如 Array, Object, Promise, Math, Map 等等,是js的原生內置對象。

​ 我們常説的 事件循環 任務隊列 都是由宿主環境提供並且管理的。

説 js能怎樣怎樣,實際上是 js語言的能力+宿主環境賦予的能力 。


  1. 事件的核心定義

    JavaScript 中的事件是宿主環境提供的一套標準化的異步消息分發機制,是系統內發生的、可被代碼偵測到的“發生”或“信號” 。

    事件是一種能力,那麼是不是所有的對象 所有的元素 所有的節點,都具備事件的能力呢?

    並不是所有, 這裏就要提到一切的源頭 事件目標 EventTarget

    EventTarget是一個接口 是一個對象 ,你要具備事件的能力,必須要實現這個接口。

    記得紅皮書裏講那個迭代器部分,説要想具備迭代功能,必須實現迭代協議。事件也類似,

    EventTarget是宿主環境提供的一種能力 一種功能 一個對象,擁有了它 就擁有了事件的能力。

    這裏要注意的是,事件 是宿主環境 也就是瀏覽器提供的,並不是js語言本身所有,這點很重要。

    那麼,如何獲得這種能力呢?

    • 繼承源頭

      創建一個類 , extends EventTarget ,直接獲得原生的正宗事件能力。

    • 純手工寫

    • 引入其他事件庫

    • 框架內置

    以上所説,是如何獲得事件的能力, 而在平常的開發中,絕大部分,都是通過原型鏈,直接繼承了EventTarget ,並不需要特地去獲得。

    所以,在很多文章中,並沒有提到EventTarget,因為單純從js的角度來説,它處理的元素 節點 對象 等等 都已經通過原型鏈擁有了 或者通過一些框架內的自定義實現了或者封裝了事件的能力。

  2. 事件的來源

    事件的來源,分兩個層面,一個層面 是規範中定義的來源,靈一個層面 是瀏覽器具體實現的隊列。

    • 規範定義

      • dom源
      • ui用户接口源
      • 網絡源
      • 導航和歷史源
      • 渲染源

      這是幾個主要的事件來源。

    • 瀏覽器的具體實現

      瀏覽器將不同的來源的事件,映射為自己的多個任務隊列,並不是完全按照規範中定義的來源來劃分宏任務隊列的。至於優先級,瀏覽器有自己的優化和調度策略 比如用户交互高優先 防雞鵝調度打撈低優先等等。

      這些隊列,一般來説是依據優先級的大小來劃分。

      • 輸入事件隊列 通常是最高優先級

        處理用户的交互,保證用户打字 滾動 點擊 沒有延遲

      • 計時器事件隊列 普通優先級

        settimeout 等, 有限的優先級,定時器中的回調函數都放在這裏等待執行。(settimeout實際是一個瀏覽器提供的api函數,它是一個同步執行函數,但是做的是異步調度的工作)

      • 普通事件隊列 一般默認優先級

        最常用的隊列 處理邏輯的主戰場 網絡 文件 數據 等等

      • 空閒隊列 最低優先級

        requestIdleCallback

        事件循環完全空了 沒事做的時候 來這裏瞄一眼。

  3. 事件和觀察者模式

    觀察者模式是一種軟件設計模式。它定義了一種一對多的依賴關係。

    • “一” : 指的是被觀察者。當它的狀態發生改變時,它會對外發送通知。
    • “多” : 指的是觀察者。它們一直盯着“被觀察者”,一旦收到通知,就會自動執行相應的操作。

    而事件機制,是對觀察者模式的一個實現。

    我們寫代碼時

    • DOM 節點(如 button 就是 被觀察者
    • 我們寫的回調函數(function() { ... } 就是 **觀察者 **。
    • addEventListener 就是整個觀察者模式的核心api,它安排了一個或多個觀察者去盯着被觀察者。
    • 被觀察者狀態發生改變,觸發通知
  4. 事件和DOM事件

    可能還是有不少朋友對事件這個概念有疑惑。

    事件 是歸屬於宿主環境的,請記住js語言中 並沒有事件的概念。

    事件是一個信號,是系統內發生的任何值得注意的事情。比如:鍵盤按下了、圖片加載完了、網絡斷了、數據到了。。。。。。

    DOM 事件只是這個龐大信號系統中的一部分,專門負責網頁內容(文檔)層面的交互。

    之所以把DOM事件單獨拿出來説,是因為它是我們在編寫代碼時,接觸最多的一類事件。

    DOM 事件 是指發生在 HTML 文檔元素(節點) 上的特定的交互瞬間。
    
    核心特徵: 它們必須依附於某個 DOM 節點(如 <div>, <button>, document)。
    
    典型場景: 用户和網頁 UI 的交互。
    
    常見例子:
    
    click (鼠標點擊)
    
    keydown (鍵盤按下)
    
    submit (表單提交)
    
    touchstart (手指觸摸)
    
    
    

    那麼作為對比,除了DOM事件以外,還有什麼非DOM事件呢?

    A. BOM (Browser Object Model) 事件 / Window 事件
    這些事件發生在瀏覽器窗口層級,而不是具體的 HTML 標籤上。
    
    resize: 瀏覽器窗口大小被改變。
    
    scroll: 頁面滾動(雖然常綁定在 document,但本質是視圖窗口的行為)。
    
    hashchange: URL 的錨點(#後面部分)發生變化(單頁應用路由的基礎)。
    
    storage: localStorage 或 sessionStorage 被修改時觸發(用於跨標籤頁通信)。
    
    online/offline: 網絡連接狀態斷開或恢復。
    
    B. 網絡請求事件 (Network Events)
    當 JS 發起異步請求時,請求的狀態變化也是事件。
    
    XMLHttpRequest (AJAX):
    
    readystatechange: 請求狀態改變。
    
    progress: 下載進度。
    
    load/error/timeout: 請求成功、失敗或超時。
    
    WebSocket:
    
    open, message, close, error。
    
    C. 媒體事件 (Media Events)
    專門針對 <video> 和 <audio> 對象的播放狀態。
    
    play / pause: 播放/暫停。
    
    ended: 播放結束。
    
    volumechange: 音量改變。
    
    waiting: 緩衝中。
    
    D. 跨線程/跨窗口通信事件
    Web Worker: message 事件(主線程和 Worker 線程互相發消息)。
    
    iframe: message 事件(父頁面和子頁面通信,即 postMessage)。
    
    E. 開發者自定義事件 (Custom Events)
    這是最高級的用法。不由瀏覽器觸發,而是由代碼手動觸發。
    
    使用 new CustomEvent() 創建,使用 dispatchEvent() 發送。
    
    用途: 用於組件間通信。可以手動派發一個事件,而不是依賴點擊。
    

    那麼,DOM事件和非DOM事件,有什麼區別嗎?

    DOM 事件:

    因為 DOM 結構本身是一棵樹(Tree)。 當你點擊一個按鈕時,你不僅僅是點擊了這個按鈕,你同時點擊了包裹它的 div,點擊了 body,點擊了 html,甚至點擊了整個瀏覽器窗口。

    • 特徵: 事件會在 DOM 樹上“旅行”。
    • 路徑: 捕獲階段(從外向內) -> 目標階段(到達節點) -> 冒泡階段(從內向外)。
    • 結果: 你可以在父節點(比如 div)上監聽到子節點(button)的事件。這就是事件委託的基礎。

    非 DOM 事件:

    比如 XMLHttpRequest(網絡請求)或 Worker(線程通信)。它們的對象沒有“父節點”的概念,它們是內存中獨立的 JS 對象。

    • 特徵: 只有目標階段
    • 路徑: 事件直接發送給該對象,觸發完就結束了。它不會傳給它的“上級”(因為它沒有上級)。
    • 結果: 你不可能在 window 上通過冒泡監聽到某個具體 ajax 請求的 load 事件(除非你自己手動去轉發)。

    除了上面所説的傳播機制不同,還有一個極其重要的區別:與瀏覽器原生行為的綁定。

    • DOM 事件: 通常帶有瀏覽器的默認行為

      • <a> 標籤的 click 會導致跳轉。
      • <form>submit 會導致刷新頁面。
      • 鍵盤的 keydown 會導致輸入文字。
      • 因此: DOM 事件提供了 e.preventDefault() 來阻止這些行為。
    • 非 DOM 事件: 通常純粹是信息通知

      • XHRload 只是告訴你加載完了。

      • 因此: 非DOM事件通常沒有(但有例外)所謂的“默認行為”可供阻止。你調用 e.preventDefault() 沒有任何意義。

  5. 事件和事件對象event

    通常來説,一個事件,之所以能成為事件, 要具有三個特質:

    • 遵循觀察者模式
    • 攜帶事件的現場數據
    • 可觀測的發生或狀態的改變

    那麼 攜帶事件的現場數據 ,這個就是要講的event了。

    很多文章説,事件發生 比如鼠標被點擊 馬上就有事件對象被創建, 這個説法其實並不準確。

    嚴謹的描述 event 的創建時機:在事件被包裝成任務 放入紅任務隊列排隊 然後被取出開始執行,執行的第一步 是進行命中測試,確定事件發生的目標, 第二步,才是創建事件對象 。 第三步 是路徑計算,確定傳播路徑。

    關於具體的流程,下面會詳細講。這部分作為綜述,只是講事件對象本身。

    在js層面的事件對象被創建之前,所有的相關信息,只是作為一個內存中的 c++ 結構體存在。

    那麼 這裏可以再給事件對象一個較為明確的定義:

    事件對象是瀏覽器將底層存有事件信息的 C++ 結構包裝成 JS 對象,並在路徑計算前完成創建,目的是為了讓路徑計算算法能讀取其配置,並再氣候的傳播過程中充當一個攜帶現場數據及動態上下文的載體

    我們知道,以前的很長一段時間,前端的情況是 先有規範 再有實現 或者 先有實現 才有規範 或者雖有規範 但是實現不完全符合規範 ,總之是比較混亂,但是現在的情況已經好了很多,我們已經可以逐漸的信賴規範了。學習的時候 儘管實現上有些許的差別,但是可以用規範去加強理解。

    規範含義:在 ECMAScript 相關的規範中,[[ ]] 形式的名字表示一種抽象的內部插槽,它們定義了對象在語義上的內部狀態或行為。它們是規範用來描述對象如何工作的術語,不是 JS 層能直接訪問的普通屬性。

    實現層面:js引擎和瀏覽器會用各種方式來實現這些規範中定義的抽象的內部插槽。

    JS 提供的可訪問接口:很多內部插槽會通過公開的屬性或方法提供出來(例如 event.typeevent.targetevent.bubbles 等,不止事件對象,js的其他對象也是如此。),這些公開接口並不是“直接讀寫了內部插槽”,而是這些內部狀態的一種通過api暴露出來的方式。

    因為事件對象可以説是事件中最重要的部分,所以,很有必要重點來學習,下面 我們用比較大的篇幅來詳細學習事件對象。

    事件對象,從js的角度來講,它確實是一個真正意義上的對象,我們平常從紅皮書 或者權威指南上看到的js對象的定義,略有簡化,請記住這個終極理解:

    js對象的本質 = 非原始值 + 屬性記錄集合 + 原型鏈繼承 + 由內部槽/內部方法決定行為

    從這個角度來説, 事件對象完全符合js對象的本質定義。

    讀過js紅皮書的朋友也許記得,在不少章節中 都有 [[...]] 這樣的內部屬性的寫法,也就是上面所説的內部插槽。

    我們首先介紹js事件對象的內部插槽:

    核心狀態插槽

    定義在 Event 接口中,所有事件對象共用。

    內部槽位 類型 描述
    [[type]] String 事件類型(如 "click", "load")。初始化時設定。
    [[target]] EventTarget? 初始派發目標。在 dispatchEvent 調用時被設定。
    [[relatedTarget]] EventTarget? 與事件相關的次要目標(主要用於 MouseEventFocusEvent)。注意:它也參與重定位。
    [[currentTarget]] EventTarget? 當前正在執行監聽器的對象。在傳播過程中實時更新,派發結束後重置為 null。
    [[eventPhase]] Integer 當前階段:0 (NONE), 1 (CAPTURING), 2 (AT_TARGET), 3 (BUBBLING)。
    [[timeStamp]] DOMHighResTimeStamp 事件創建時間(相對於 Time Origin 的高精度時間戳)。
    [[isTrusted]] Boolean true 表示由 UA(瀏覽器)生成;false 表示由腳本創建。
    [[path]] List 傳播路徑。由一系列結構體組成,每個結構體包含 item (invocation target) 等信息。
    [[touch target list]] List (僅用於觸摸邏輯)用於處理“隱式捕獲”,即手指移出元素後仍將事件發送給初始目標。

    [[path]] 是傳播路徑,關於它的結構和填充,我們後面會詳細的學習。

    標誌位插槽

    通常在實現中會被壓縮為一個 Bit Field 以節省內存。

    內部槽位 (Flag) 描述
    [[stop propagation flag]] 設置後停止向後續節點傳播(stopPropagation)。
    [[stop immediate propagation flag]] 設置後停止傳播停止當前節點剩餘監聽器的執行。
    [[canceled flag]] 設置後表示默認行為被阻止(preventDefault)。
    [[in passive listener flag]] 標識當前是否處於 passive 監聽器中(此時忽略 preventDefault)。
    [[composed flag]] 標識事件是否可以穿越 Shadow DOM 邊界傳播。
    [[initialized flag]] 標識事件對象是否已完成初始化(防止重複調用 initEvent)。
    [[dispatch flag]] 標識事件是否正在派發中(防止重入/多次 dispatch)。
    [[bubbles]] 標識事件是否支持冒泡。
    [[cancelable]] 標識事件的默認行為是否可取消。
    子類專用槽位

    根據事件類型(C++ 類)的不同,按需存在的槽位。以下列舉最核心的幾類。

    a. CustomEvent 接口

    內部槽位 描述
    [[detail]] 存儲開發者傳入的自定義數據(payload)。

    b. UIEvent 接口 (鼠標、鍵盤事件的基類)

    內部槽位 描述
    [[view]] 通常指向 WindowProxy(即 window 對象)。
    [[detail]] 對於 UI 事件通常是數字(如點擊次數),不同於 CustomEvent 的 detail。

    c. MouseEvent 接口

    內部槽位 描述
    [[screenX]], [[screenY]] 屏幕絕對座標。
    [[clientX]], [[clientY]] 視口(viewport)相對座標。
    [[ctrlKey]], [[shiftKey]], [[altKey]], [[metaKey]] 修飾鍵狀態(按下為 true)。
    [[button]] 觸發事件的按鍵(0:左,1:中,2:右)。
    [[buttons]] 當前按下的按鍵(位掩碼,例如 1=Left、2=Right、4=Middle、8=Back、16=Forward)。

    d. KeyboardEvent 接口

    內部槽位 描述
    [[key]] 鍵值字符串(如 "Enter")。
    [[code]] 物理按鍵代碼(如 "KeyA")。
    [[location]] 按鍵位置(如 DOM_KEY_LOCATION_STANDARD)。
    [[repeat]] 是否為長按自動重複。
    [[isComposing]] 是否在輸入法(IME)組合過程中。
    結構化/底層實現槽位
    內部槽位 描述
    [[Prototype]] 指向 Event.prototype 或子類原型。
    [[Extensible]] 對象是否可擴展。
    [[NativePointer]][[EmbedderField]] 這是js包裝對象中存儲的指針,指向底層C++ 的 原始對象

    最後還有內部槽位通過對象屬性對外提供的可訪問的部分,即公開接口,在後面的部分會詳細學習。

    上面是出於對知識的完整性考慮,列出的表格, 在實際學習中, 前端開發者,瞭解到事件對象的插槽/槽位的深度,就已經是極限了,再繼續深入學習,就是對應的c++結構,毫無必要。

    而沒有列出的path路徑字段和內部槽位對外提供的可訪問接口,後面會專門學習。

    我們繼續回到事件對象的創建,有兩種創建方式:

    • 原生事件的創建

      比如鼠標點擊 網絡事件 等等,這類事件,是在宏任務被取出,執行第一步命中測試,取得具體目標,第二步創建事件對象時創建的, 一旦確定了目標元素,瀏覽器引擎(C++ 層,而不是 JS 引擎)就會實例化一個 Event 對象(例如 MouseEventPointerEvent)。這個對象是宿主對象,它被填充了所有相關的上下文信息:target(剛剛找到的元素)、currentTarget(最初為 null)、座標、時間戳、bubbles 屬性等。(這些信息,原本是存在於值錢的c++結構中。) 這個時候,瀏覽器會在 JS 環境上創建一個 JS wrapper。這個 wrapper 和底層的 C++ 對象互相關聯(wrapper 內含對宿主對象的指針/引用,就是上面表格中的[[NativePointer]][[EmbedderField]],而宿主對象則通常保存一個對該js包裝對象的弱引用或記錄,以便於重複利用該js對象)。

      至此,js已經有了事件對象,雖然是‘包裝對象’,但是依舊是真正意義上的js對象。

    • js創建的事件對象

      是在js代碼中自己創建的,通常使用 new ,在最新的紅寶書第5版裏,依舊在使用createevent的方式,已經不建議使用了。在自己new事件對象時,需要知道自己使用哪種具體事件的構造函數,因為每種具體的構造函數所擁有的內部槽位不同,無法混用或通用。

      另外,js創建的事件對象,是同步創建的,執行到new代碼,對象事件就立即生成, 這和原生的事件對象的創建不同。 自己new的事件對象 是純正的js對象, 原生事件對象 是包裝對象, 但是 他們都是真正的js對象。

    事件對象的創建詳細過程將在後面的事件的生命週期部分介紹。

二、 事件的完整生命週期

在第一部分中, 介紹了事件中的一些重要的知識點。

重要的是eventtarget和event。

請注意,不要把這兩個概念搞混淆了。

EventTarget 是一切的源頭,它讓某個東西,具備了事件處理能力。任何能處理事件的東西,都必須是已經實現了(繼承也好 自己寫也好 使用第三方庫也好 )這個接口。

Event 是 一次事件的全部內容與狀態的載體 它包含一次事件中的所有狀態 (所有狀態 所有關聯到的對象 所有動態行為等等)

在這第二部分裏,我們介紹事件的完整生命週期。

以一個物理點擊事件為例,他的整個生命流程如下:

  1. 物理信號: 用户在硬件(例如鼠標或觸摸屏)上按下。設備向操作系統 (OS) 發送一個硬件中斷信號,並附帶位置數據。

  2. OS 路由: 操作系統(例如 Windows、macOS、Android)接收該信號,確定哪個應用程序處於活動狀態(即瀏覽器),並將此低級輸入(例如“鼠標按下,座標 X:Y”)傳遞給瀏覽器的瀏覽器進程 (Browser Process)

  3. IPC 到渲染器: 瀏覽器進程負責瀏覽器的“外殼”(地址欄、選項卡),但它不知道選項卡內的內容。它通過進程間通信 (IPC) 將事件(例如 mousedown)和座標發送到負責該選項卡的渲染器進程 (Renderer Process)

  4. 合成器線程接收: 在渲染器進程中,事件首先由合成器線程 (Compositor Thread) 接收。該線程獨立於主線程(js運行的地方)運行,負責平滑地合成頁面的各個層(例如,用於平滑滾動)。

  5. 合成器命中測試: 合成器線程執行一次“快速”命中測試。它檢查事件座標是否落在它標記為“非快速滾動區域”(Non-Fast Scrollable Region) 的地方。該區域是頁面上附加了事件處理程序(如 touchstartclick 監聽器)的區域 。

  6. 事件路由決策:

    • 如果事件不在非快速滾動區域(例如,在可滾動的空白區域),合成器線程可以立即處理它(例如,開始滾動頁面),而無需等待主線程 。

    • 如果事件非快速滾動區域,合成器線程必須將該事件轉發到主線程 (Main Thread),因為只有主線程才能運行 JavaScript 。

    • 在合成器線程的決策邏輯中,存在一個關鍵的性能瓶頸:當合成器線程發現觸點位於“非快速滾動區域”(即綁定了 touchstart/wheel 等事件)時,默認情況下,它必須掛起頁面的滾動渲染,先向主線程發送事件信號,並同步等待 JS 回調函數的執行結果。

      為什麼要等?因為瀏覽器無法預知你的代碼中是否會調用 e.preventDefault() 來阻止默認的滾動行為。這種“跨線程的同步等待”一旦遇上主線程繁忙,就是造成移動端頁面滑動卡頓(Scroll Jank)的根本原因。

      { passive: true } 的本質,是開發者向瀏覽器簽署的一份“異步執行承諾書”

      通過這個標記,你告訴合成器線程:“請直接開始滾動渲染,不要等我。我承諾在回調函數中絕不調用 preventDefault()。”

      一旦建立了這個協定,合成器線程就會立即處理滾動幀(保證絲滑流暢),同時將事件以非阻塞的方式發送給主線程去執行邏輯。此時,即便你違約在回調中強行調用了 preventDefault(),瀏覽器也會直接忽略該指令並在控制枱拋出警告。

  7. 排隊成為宏任務: 當事件(現在是 C++ 層面上的一個結構)到達主線程時,它不會立即執行。它被封裝並放入紅任務隊列(也稱為“任務隊列”或“回調隊列”)中,等待執行。此時,它已成為 JavaScript 事件循環模型的一部分。

  8. 事件循環出隊與任務啓動: JavaScript 事件循環機制持續監控着狀態。當主線程的調用棧為空,且微任務隊列也被清空(確保前一個循環徹底結束)時,事件循環才會從宏任務隊列中取出那個排隊已久的 mousedown 任務。 注意: 取出這個任務,標誌着瀏覽器開始執行該任務內部包含的一系列邏輯

  9. 主線程命中測試(深度): 任務執行的第一步是在主線程上進行“深度”命中測試。與合成器線程(只知道圖層)不同,主線程擁有完整的 DOM 樹、CSS 樣式和佈局信息。它使用這些數據(特別是“繪製記錄”)來精確確定事件座標下最頂層的 DOM 元素。這個元素將成為 event.target

  10. 創建事件對象: 一旦確定了目標元素,瀏覽器引擎(C++ 層,而不是 JS 引擎)就會實例化一個 Event 對象(例如 MouseEventPointerEvent)。這個對象(一個“宿主對象”)被填充了所有相關的上下文信息:target(剛剛找到的元素)、currentTarget(最初為 null)、座標、時間戳、bubbles 屬性等。

    注意 :現在 瀏覽器引擎會讓js引擎創建js層面的事件對象,就是把c++層的宿主對象包裝成js層的事件對象。但是,瀏覽器出於優化的考慮,也許會採用懶加載的方式 在第11步完成後,按需讓js引擎創建js事件對象。 不過從整個流程的合理性來説,可以認為此時 js事件對象也被創建。

  11. 確定傳播路徑: 瀏覽器根據 DOM 樹結構計算事件的完整傳播路徑。這是一個包含從 window 開始,一直向下到 event.target 的所有祖先元素,然後再回到 window 的有序數組。

  12. 開始調度(捕獲階段): 任務現在開始沿着計算出的路徑“調度”事件對象。它從 window 開始,向下傳播到目標,在每個節點上觸發已註冊為在捕獲階段運行({capture: true})的 JavaScript 監聽器 。

  13. 目標階段: 這是一個特殊的階段。規範在實現上並沒有一個獨立的“目標階段循環”,而是將其拆解到了另外兩個階段中。

    1. 捕獲遍歷到達目標時,瀏覽器會將目標標記為 AT_TARGET,並執行目標上所有 capture: true 的監聽器。
    2. 冒泡遍歷開始時,瀏覽器再次訪問目標,將其標記為 AT_TARGET,並執行目標上所有 capture: false(非捕獲)的監聽器。

    所以,實質上是捕獲類監聽器先執行,非捕獲類監聽器後執行同類監聽器內部,才按添加順序執行。

  14. 冒泡階段: 事件隨後從 event.target 向上傳播回 window,在路徑上的每個祖先元素上觸發標準的(冒泡階段)JavaScript 監聽器 。

  15. 任務完成: 一旦事件到達 window 並且所有處理程序都已運行(前提是沒有調用 stopPropagation()),這個宏任務就完成了。

  16. 微任務檢查點: 在事件循環查找下一個宏任務之前,它會立即執行並清空微任務隊列中的所有任務(例如,在事件處理程序中調度的 Promise.then() 回調)。

  17. 渲染: 在微任務隊列清空後,瀏覽器現在有機會執行渲染更新(重繪頁面)。

  18. 循環: 事件循環現在返回宏任務隊列,以查找下一個任務。

下面,我們將以這整個流程為線索,介紹幾個重要的步驟

從結構的角度來講 物理點擊 瀏覽器c++層創建初始結構 包裝成宏任務入隊列 被取出執行 命中測試 確定目標元素 瀏覽器c++層將初始結構和目標組合一起 創建了一個新的c++結構 填充槽位 瀏覽器調用js引擎 讓js創建js層的事件對象 (包裝了c++層的結構)填充關鍵槽位(可能有懶加載) 建立和c++結構的關聯。

在具體的瀏覽器實現中, 有時會將第11步計算傳播路徑提前, 先計算傳播路徑 再開始創建js的事件對象。主要目的是可以通過先計算傳播路徑,確定是否有針對具體目標的監聽, 假如沒有, 那就根本沒必要創建js的事件對象了。

介紹一下內部插槽的填充:

在命中測試完成 確定了具體的目標元素, 這個時候瀏覽器會創建一個c++事件實例結構,包括了最初的那個結構 又包括了目標元素,還有和事件類型相對應的內部槽位, 這是因為瀏覽器會根據事件的不同 調用不同的構造函數 創建不同的c++事件實例。每種事件實例都有專屬於自己的槽位, 同時還有通用槽位。

瀏覽器創建事件對應的c++事件實例,填充內部槽位,我們先介紹靜態數據, 這些數據一旦填充,在整個生命週期就不會改變。以下以一個點擊事件為例

  • [[type]] 根據事件類型(如 "click")硬編碼 靜態只讀

  • [[isTrusted]] 值被設為true(因為是瀏覽器原生觸發,如果是腳本模擬自定義 則為假)靜態只讀

  • [[timeStamp]] 讀取當前的高精度時間 靜態 (只讀)

  • [[target]] 指向命中測試找到的最深層的那個 DOM 節點。 半靜態 (Shadow DOM 中表現不同)

  • [[NativePointer]] 指向底層的 C++ 結構體地址。 靜態 (內部引用)

  • [[screenX/Y]] 讀取操作系統傳入的硬件光標座標數據。 靜態

  • [[bubbles]] 根據事件類型查表確定(例如 "click" 默認為 true,而 "focus" 或 "scroll" 默認為 false)。 狀態: 靜態 (只讀)

  • [[cancelable]] 根據事件類型查表確定(指示該事件是否允許通過腳本取消默認行為)。 狀態: 靜態 (只讀)

  • [[defaultPrevented]] 初始化為 false。 僅當腳本調用 event.preventDefault()[[cancelable]] 為真時,該值才會被修改為 true狀態: 動態 (可變)

  • [[propagationStopped]] 初始化為 false。 這是一個內部控制標誌,當腳本調用 event.stopPropagation() 時被設為 true,用於通知事件分發器停止遍歷後續路徑。 狀態: 動態 (內部標記/不可見)

  • [[underlying_platform_event]] 保存對原始底層硬件輸入結構的 C++ 指針引用 就是最開始的那個初始c++結構。這實現了“零拷貝”機制,僅在 JS 訪問特定屬性(如 pressure, tiltX)時才通過此指針去讀取底層數據。 狀態: 靜態 (內部引用)

注意:此時,[[currentTarget]] 還是 null[[eventPhase]]NONE (0)

這裏插一段,寫這篇文章,一是自己需要對知識的總結歸納 二是希望寫出來 是種分享,大數據時代 我們除了獲取,不能忘記提供,關於木有圖片。。。是因為我沒有圖牀。。。其實就是懶。關於木有代碼實例。。。還是因為懶。我喜歡用文字來描述來表達,雖然可能很多地方表達能力跟不上自己的想法。。。我是盡力了。其實我是有整篇的寫作意圖和明確的串聯線索,只是寫的多了,有時候就忘記或者是跑偏了,反正就是能力跟不上,反正就一個特點:字多。 大家當成小説看吧,其實我以前是寫網文的。

我們學習到現在,已經能青春地意識到:

  1. “真身”在下層: C++ 層面的事件實例 才是真正意義上完整、權威的事件狀態載體。它用有物理原始數據、DOM 傳播的實時狀態指針以及所有標準定義的內部槽位。
  2. “外殼”在上層: 我們在代碼中操作的 JS 事件對象,本質上只是js 引擎創建的一個 代理殼 (Proxy/Wrapper)
  3. 核心連接: 這個殼內部並不直接存儲大量數據,它最核心的東西是一個指向 C++ 結構的 內部指針 ([[NativePointer]])
  4. 數據獲取的方式: 當我們在 JS 中訪問屬性時,並不是簡單的讀取內存,而是根據屬性的特性,觸發了不同的底層機制:
    • 實時透傳 : 對於動態變化的數據(如 currentTarget, eventPhase),JS 對象通過 Getter 訪問器 直接穿透到 C++ 結構中讀取最新值。
    • 懶加載: 對於昂貴的計算屬性(如 composedPath() 或標準化的 path),只有當 JS 第一次請求時,C++ 才會計算並將其轉換為 JS 數組,然後掛載到 JS 對象上。
    • 緩存和優化: 對於靜態不可變數據(如 type, timeStamp, isTrusted),js引擎可能會在第一次讀取後將結果緩存在 JS 殼的“快照”中,以避免頻繁跨越 C++/JS 邊界帶來的性能損耗。
  5. 可擴展性: 這個 JS 殼雖然是代理,但它也是一個標準的 JS 對象。因此,我們手動添加的自定義屬性(如 e.myTag = "test")是保存在 JS 殼 自己的堆內存中的,C++ 層對這些數據一無所知。

下面開始計算傳播路徑

  • 瀏覽器使用內部算法, 從 [[target]] 開始,沿着父節點一直向上找,直到 window。這個過程會填充非常重要的 [[path]] 插槽。

  • 現在我們開始詳細的介紹path內部插槽的構成和用途

    假如不包括 Shadow DOM,那麼路徑的計算和確定,將是非常簡單的 沿着target一直向上找到window就可以了。但是正因為Shadow DOM的存在,讓傳播路徑的計算成了一個微有難度的工作。

    簡單的描述一下概念,不算嚴謹,但可以當作瞭解。

    一個dom樹中, 一個元素被掛載了一個影子dom,那麼 該元素被叫為 host, 然後,邏輯上看 以host為根, 有了兩顆樹, 一棵是剛掛載的影dom樹 另一棵是host原本的子節點元素樹。 而掛載的影dom樹,並不是直接掛載 而是用一個root 掛在host上,root下面 才是影dom。 host原本的子元素樹 叫光dom 。

    看這部分內容的朋友,應該是對shadow dom已經有了解的, 以上簡單介紹,只是為了後面方便使用影dom 光dom host root 等名詞。

    在第一部分,曾為了知識的完整性,列出了內部插槽的其他部分的列表。 這裏這部分作為可跳過的選看部分,將詳細的介紹path內容,這部分內容我個人認為在可跳過的內容中,算是重要的,所以打算用略大的篇幅來講,不感興趣的朋友依舊可以跳過這部分 。

    path中 是一個結構列表,每一項都是一個結構,對應着事件傳播路徑中的一個元素 嚴謹的説 對應着一個具備事件能力 即實現了targetevent接口 的對象。通過此列表,就可以觀察到 本次事件的完整傳播路徑。 而且 事件的傳播路徑 是一次性創建, 創建好以後, 不會再更改,存在於事件的整個生命週期。傳播路徑是固定的,但是監聽的調用等等還是會動態變化,這裏只是講路徑的確定, 監聽和傳播過程 後面部分會詳細講。

    在最新的權威文檔中,path中的每一項,都有7個字段,下面逐一介紹

    1. invocation target(調用目標)

      • 類型:一個 EventTarget 對象(通常是 Node / Element / Document / Window,也可以是其它實現了 EventTarget 的對象)。
      • 描述: 這是該路徑項對應的實際 DOM 目標。通常來説,就是當前的節點。
    2. invocation-target-in-shadow-tree (調用目標是否在 Shadow Tree 中)

    • 類型: Boolean
    • 描述: 一個布爾值,標記 invocation target 是否位於 Shadow DOM 樹內部。
    • 作用: 用於處理 Shadow DOM 邊界的事件封裝(Encapsulation)。此標誌影響分派算法在決定重定位(retargeting)和階段(capturing/at-target/bubbling)時的行為,以及是否需要將目標“影子化/重定向”給 shadow host 等邏輯。規範在處理路徑和設置 eventPhasecurrentTarget 時會檢查此值。
    1. shadow-adjusted target (Shadow 修正目標)

      • 類型 要麼是 null,要麼是一個 潛在的事件目標(potential event target)

      • 描述(最關鍵)

        • 這是“對監聽器可見的那個目標(retargeted target)” 具體説,當事件從一個 shadow tree 向外傳播(或在 shadow 邊界處被觀察)時,瀏覽器會把實際原始目標根據監聽器位置做重定位,重定位後的對象就稱為 shadow-adjusted target

        • 在事件分派過程中:如果path中的某個項的 shadow-adjusted target 非空,規範把該 struct 視為“AT_TARGET”類型的位置(用於設置 eventPhase = AT_TARGET),並用它來決定在該位置要以什麼 target 值去調用監聽器。

      • 舉例:若真實事件發生在 shadow 內部的某個 div,當在 shadow host(外部)上觸發監聽器時,shadow-adjusted target 可能是 host(或 host 的某個可見代理),而不是內部真實 div,從而實現了 Shadow DOM 的封裝(retargeting)

      • 再舉例:當事件從影DOM 冒泡到光DOM 時,為了保持封裝性,外部不應看到內部的真實節點。這個字段決定了在當前項所處的位置上,開發者調用 event.target 時應該返回哪個節點(通常是 host,即影子的宿主,而不是影dom內部真實的節點)。

      • 再再舉例 算了,不舉了

    2. relatedTarget (相關目標)

      • 類型 null 或 一個 潛在事件目標

      • 描述 用於那些有“related target”語義的事件(例如 mouseover / mouseout、焦點事件中的 relatedTarget 等)來記錄在該路徑層次上與當前 invocation target 相關聯的另一個目標(經過 retargeting 後可能是不同的對象)。

        簡單來説 就是類似於 shadow-adjusted target,但是專門用於修正 event.relatedTarget

        比如在 mouseover/mouseout 事件中,如果相關元素在 Shadow DOM 內部,這個字段確保外部只能看到 Shadow Host,而不是內部細節。

      • 注意relatedTarget 的值也會受到 shadow tree 封裝/重定位規則影響

    3. touch target list (觸摸目標列表)

      • 類型 / 含義:一個“潛在事件目標”列表(sequence/list)List of Touch objects。主要用於觸摸/多點觸控相關的事件以記錄與路徑中該 struct 相關的所有觸摸目標(比如 touchstart 的多個觸點)。
      • 語義 / 用途:在分派觸摸/Pointer 類型的事件時,規範需要知道該路徑層上哪些具體觸摸點是相關的,以便在給監聽器報告事件時能確定哪些觸點屬於當前 currentTarget 的上下文。
      • 專門用於觸摸事件(Touch Events)。當觸摸點在 Shadow DOM 內部移動時,需要修正觸摸點的 target 屬性,以符合 Shadow DOM 的重定標(Retargeting)規則。
    4. root-of-closed-tree (是否為封閉樹的根)

      • 類型 / 含義:布爾值。表示該 struct 表示的 invocation target(或其相關信息)是否處在一個 closed shadow tree 的根(即該 struct 表示的那一層涉及到一個 closed shadow root)。
      • 描述: 標記該路徑項是否是一個模式為 closed 的 Shadow Root。
      • 如果為 true,則在使用 composedPath() 獲取路徑時,路徑會在這個節點被截斷,外部無法通過 API 獲取到封閉 Shadow DOM 內部的節點。
      • 這個標誌用於實現 closed shadow tree 的封裝保護:當 root-of-closed-tree 為真時,規範在構建對外暴露的 invocation target 列表或在清理路徑時會採取特殊處理(例如阻止 closed tree 內部節點出現在 composedPath() 的對外結果中,或在路徑清理時決定是否插入清理 struct 等)。通俗説:它幫助瀏覽器決定“哪些內部節點必須對外屏蔽”。
    5. slot-in-closed-tree (是否為封閉樹中的 Slot)

      • 類型 / 含義:布爾值。表示在路徑構建時當前節點是不是“一個處在 closed shadow tree 中的 slot(slot-in-closed-tree)”的上下文標記。
      • 語義 / 作用:與插槽(<slot>)與被插入的 light DOM 元素相關的路徑構建有關。規範在把路徑 append 到 event.path 時把這個標誌一併記錄,以便 later 在決定 clearTargets、retargeting、以及是否把某些 struct 暴露到對外路徑(或觸發 activation behavior)時使用。簡單説,它用於正確處理插槽 + closed shadow tree 的組合場景
      • 同樣用於控制 composedPath() 的暴露範圍,確保封閉樹的內部結構不泄露。

    以上7個字段,是規範規定的path中的字段,屬於內部使用的數據,在我們的js層,並不能直接使用。

    新的一天,有點忘記進度了,上面講了path的7個規範定義的字段。目的是為了下面講傳播路徑的計算。前面好像也講過, 如果是沒有影dom,那麼從命中具體目標以後,直接網上挨個找爸爸,挨個填path中的項。很簡單。 但是因為有了影dom,路徑的計算有點繁瑣。

    那麼被插槽進影dom的光dom元素,發生的事件它的路徑如何呢?

    如果composed為真,此被slotted的元素上發生事件,路徑為 此光dom--影slot--影root--光host--Document

    如果composed為假,路徑依舊是 此光dom--影slot--影root--光host--Document

    這是因為規範規定:

    composed:false 只是 一個必要條件,但不是充分條件;還要滿足 “該 shadow root 是事件目標所在根” 這個前提,才會被攔截返回 null(從而阻止繼續向上到 host)。

    如果事件目標的根不是該 shadow root(例如目標屬於 light tree,其根是 document),那麼該 shadow root 不會返回 null,而是返回 host —— 事件繼續傳播。

    也就是説,被插槽進影dom的光dom元素, 依舊歸屬於光dom樹,在它身上發生的可冒泡事件,在影dom它的slot位置開始,經歷 此元素---slot---root 達到影dom邊界,此時 規範定義了判斷算法,必須要滿足兩個條件,才會被攔截, 一 是 composed為假 二是 該發生事件的元素的根是影root, 這樣才會被攔截。

    被插槽進影dom的光dom元素,歸屬於光dom樹, 它的根為document, 而不是影root,所以不滿足攔截條件。

    很多資料或者文章把composed為假的情況 絕對化,從規範上説 是不對的。

    對於影dom內部的元素髮生的事件,composed為假會攔截,因為他們同時符合根為影root的條件。

    但是對於歸屬於外部光dom的被插槽元素來説,它的根為document,不符合條件, 所以不會攔截。

    上面第4個 relatedTarget 比較有意思,可以稍微瞭解一下

    • 含義:對於 mouseover/mouseoutfocusin/focusout 等“有關聯目標”的事件,記錄相關目標。

      這個字段,並不是所有事件都具有,一般是有節點間轉移的動作的事件才有。

      假如有元素a和b,鼠標此時在a上,現在,把鼠標從a移到b,那麼,對於a來説,在它身上發生了mouseout事件,鼠標離開, 創建這個事件對象的時候,path路徑中,target當然是a,而relatedtarget表示關聯目標,就是和target對應的一個目標,因為鼠標是移動到了b身上,所以relatedtarget就是b。

      如果我們從b的角度來看,在b的身上發生了mouseover事件,鼠標到了b身上,那麼這個事件對象創建,它的path路徑的target是b, 和它關聯的目標 relatedtarget則是a,因為鼠標從a過來的。

      請注意,relatedtarget 也遵守 影子DOM 的重定位規則。 從影dom外面看,如果 relatedtarget 指向的是 影DOM 內部的元素,它也會被替換為 Host

    • 用處:

      1. 在調用監聽器時提供上下文(比如判斷鼠標是從哪裏移進來的)。
      2. 作為事件觸發的裁決依據:瀏覽器會對比重定位後的 targetrelatedtarget。如果在某一層級,兩個變成了同一個對象(例如都變成了 host),瀏覽器會認為沒有發生實質性的交互,從而阻止該事件在這一層的觸發。

    上面第3個 shadow-adjusted target (Shadow 修正目標)

    shadow-adjusted target

    • 含義:對於當前項 來説的目標,請注意,這個target是path中的一個字段,要和在event事件對象的內部插槽中,還有一個靜態的原始目標對象相區別 不要搞混。通俗的解釋:在影dom中 path中的target始終是發起事件的那個真正目標。 而跨出影dom後 該target變為host, 以host代替影dom內真正的事件發起目標,以實現不讓外人偷窺到影dom內部情況的效果。也就是説,如果不存在影dom,則該target 始終都是事件的實際發起元素。 而在影dom中的項目上,也是事件的實際發起元素,但是越過root,在host這一項上,該target變為host,並且一直保持到window項。

      請注意

      對於被插槽進影dom的光dom元素,因為它依舊歸屬光dom,所以在影內 影外 host上,它的該target值都為真正的光dom本身。

    • 用處:在該項上的監聽器中讀取 event.target時要顯示的對象。

      當該項表示的事件對象為被插槽進影dom中的時候,無論在何處 其值為真正本體。

      當事件源是影dom內部元素,該項位於影dom外時,顯示host,位於影dom內時 顯示真正事件發起目標, 位於host時,顯示host。

    經過前面的大段鋪墊,現在開始學習傳播路徑構建 這次是真的真的了。

    之所以執着於大段的講path的字段和事件對象的內部插槽,主要是我個人認為,js層面的公開的api,只是對內部數據的整合和包裝,只學習他們,無法真正準確的瞭解事件傳播路徑的算法和之後的事件傳播及處理,以及這些內部數據和標誌位之間的配合所帶來的對於外部來説 比較不好理解的現象。 當然 限於能力,寫的比較散亂,從開始到現在 一大半都算超綱注水,所以要趕快回歸。 觀看時跳過上面的這一大部分就好了。

    事件傳播路徑的構建,是一個算法,它是以當前DOM結構為基礎的,不需要JS參與的行為。完全依靠dom結構,加上影dom的邊際規則,逐步構建出來一條物理路徑。所以 直到最後路徑構建完成,我們都看不到監聽等js的一根毛。 就類似於 傳説中的低耦合,甚至是解耦,沒耦。 路徑的構建,基本上是瀏覽器引擎的活, 至於以後監聽什麼的,和路徑沒關係, 不管你聽不聽 路就在那裏。不管你走不走,路也就在那裏。 以後,js的監聽和動態的設置活動,屬於邏輯上的, 而現在構建出來的路徑,屬於物理上的。只需要注意一點 當前節點的狀態,有可能是由之前節點創建時的js層面參與決定的, 但是也僅此而已,路徑的創建,js一直是靠邊站的。反正,就是這麼個意思吧。

    其實吧 寫到這裏 有點沮喪。。。 也就是一個遍歷算法,做了那麼多的鋪墊,早點直接説不就得了嗎。然後我又想到前幾天刷到的小片段: 入職以後前三個月每月工資2000,第四個月開始4000, 大智慧的朋友説 那你等到第四個月再去入職。

    繼續碼字

    這裏就不得不引出兩個比較重要的概念:

    • 合成/組成樹 扁平樹( Composed Tree flat tree )

      雖然名字是兩個 但都是指的同一個東西,組成樹的意思主要突出它的來源並不單一,比如光dom 影dom slot 等,然後根據規則組成的一棵樹。

      扁平樹的意思是從組成以後是一個整體的角度來講的, flat表示消除了原本的影dom和光dom的隔閡,把正確的slot的內容投影拍扁進影dom槽中,表示是一棵單一的連續的樹。

      注意 歸屬並沒有改變。

      扁平樹並不是一棵完整的或者部分的真實存在的樹,它是一種規範中抽象定義,在實現中,邏輯存在,在需要時,動態計算出來的一種邏輯樹。

      從名字 從存在形式 都已經説了,那麼 它的內容是什麼呢

      扁平樹是以整棵 DOM 樹為基礎,但在遇到宿主host時,會使用其 影dom結構來替代原本的內容,同時將光 DOM 中被選中的節點“投影”進 影DOM 的插槽中,最終形成的一棵樹。

      那麼這裏要特別注意,在物理上,並沒有什麼變化,host下依舊是一棵光dom 一棵影dom, 扁平樹是一種抽象的邏輯樹,按照規則 把它需要的東西 提取出來。物理上 原來怎麼樣 現在還是怎麼樣。

      ---------為了説清這扁平樹 我可費了老大勁,改了好多遍。

    • 渲染樹

      渲染樹是基於扁平樹,使用css規則,生成的用於佈局和繪製的樹。

      好像暫時用不到渲染樹,先不詳細説了,後面講到渲染再説。

    dom樹 物理存儲結構,只有原始的層級。

    扁平樹 抽象出來的 , 打通了光dom和影dom的隔閡 有邏輯層次結構 。

    渲染樹 扁平樹加css規則 有視覺呈現結構。

    事件傳播路徑的構建算法,大約有百分之七八十的內容,都以零散的方式在前面介紹過了,還剩一個系統性的算法描述作為總結,但是有點猶豫,因為雖然算法很簡單,但是牽扯到的字段和標誌位比較繁瑣,需要比較大的篇幅來講述,而這部分內容 在前端開發中,百分之八九十的可能性是用不到的。但是在寫組件寫庫寫shadowdom以及排除一些bug的時候,卻是神兵利器。所以打算放在第三部分事件的傳播和處理部分再詳細介紹。

    現在我們來思考一下,就是真的只用腦子思考,在寫這整篇文章的時候 有些知識點 ,甚至在有些地方多次反覆的強調它所處的階段 所在的位置等等,就是不斷的試圖在讀者的腦中 構建出一個完整的事件模型,説到流程,你可以想到主要步驟, 説到光dom影dom 你可以想到一棵樹。

    一棵dom樹,某個節點被掛上了新的樹,於是 這個節點成了host,下面有了兩棵樹 一棵光dom 一棵影dom。 這裏有個問題,就是以哪棵樹為着眼點,不少朋友認為,光dom是正宗嫡系,當然要以光dom為着眼點,關注上面有沒有被slot等等, 其實是不恰當的。 要以影dom為主,以影dom的角度來看, 影dom被掛上來,就是接管 代替了光dom,我就是老大 看我的。 你光dom想幹點啥,必須投影到我這裏,你現在就是我的備件倉庫/展銷廳, 我給你機會,你才能出現。

    這是一種思考模型,從物理dom樹 到扁平樹的轉變,雖然光dom始終是host的子樹,是物理存在的,但是在思考時 使用扁平樹的角度, 因為扁平樹和渲染樹對光dom是默認忽視的。

    那麼 我們再稍微延申一下, 假如光dom樹 沒有被slot, 然後 我在js層面手動派發事件,會出現什麼情況? 我在以前剛開始學習時,曾誤以為,傳播路徑有兩條 一條物理的 一條扁平的,後來才糾正過來,始終就是一條路,依靠算法來決定怎麼走。

    依舊是路徑構建算法,算法規定,A node’s get the parent algorithm, given an event, returns the node’s assigned slot, if node is assigned; otherwise node’s parent. 就是沒有被slot的節點找到的父親是物理鏈路上的父親。

    也就是説 事件會傳播到host 然後繼續傳播到document。 js可以對它像對其他元素一樣 進行操作。包括監聽 派發 允許slot等等等。 唯一的問題 就是因為它沒有進扁平樹 也就進不了渲染樹,在視覺上是不在的。 看不到 所以除了設置slot加入扁平樹的操作以外, 其他的操作 要慎重,避免各種bug的產生。

    影子dom也挺有趣的,給事件傳播路徑上增添了很多色彩,影子哥和事件傳播路徑的構建有關的內容,好像也差不多了。如果後續其他部分裏還牽扯到Shadow DOM的內容,到時候想起來再講吧。

    這第二部分馬上寫完了,最後總結昇華一下

    記得在第一部分,提到了觀察者模式,我們再再再的把它具體到事件上來。

    事件的發生,不管是因為什麼原因,它的本質,就是 期待關注 。

    我有事,我有事啊,我説我有事了,這是事件的發生,我不需要知道誰會處理它,也不知道什麼時候會被處理。

    三個階段,捕獲 目標 冒泡 提供了時間上的選擇權 和處理的策略

    捕獲是攔截和預處理

    目標是現場自己的處理

    冒泡是兜底和總結。

    傳播路徑,提供了空間和層次上的哨位 路徑上不同的哨位,有着自己的不同的職責和環境,他們看待經過自己的事件,是用自己所在的哨位職責來觀察的,同一個點擊事件,在buttnn上看,是用户點按鈕了, 在form上看, 是用户可能在提交表單, 在document上看,是用户還活着 沒噶呢。 就是説 你可以選擇 在具體哪個抽象層級上來處理這個業務洛基。

    而三個階段和傳播路徑的結合,就是對於事件處理的從時間到空間到層次上的結合,擇優選擇,比如事件委託該放在哪裏? 時間上,當然是選擇到最後的冒泡了,空間和層次上 當然是選擇越高越好了 大內總管那崗位厲害。而每個哨位 也有自己的小權力 比如這事歸我管,不往上送了,比如這事只歸我管 不給周圍同事了。

    最後 我們進入玄幻模式: 事件流為我們提供了時間空間層次三維立體的選擇權。

三、 事件的傳播和處理

簡介

我們將介紹事件的傳播和處理, 這部分的內容,相關的介紹 文章 帖子 可謂是汗牛充棟棟棟棟棟。。。但是 基本上都是先講三個階段 然後扔出幾個api 然後幾段示例代碼 然後總結一下 完事。 相信有很多朋友 看過以後 感覺是看了一些什麼,但是仔細想想 又似乎是什麼都沒看,毫無獲得感。其實這就是因為很多文章 都是知識的羅列,就像 教你説 哎 小明 你看 你按一下這個開關 燈就亮了 再按一下開關 燈就滅了。 但是 只有羅列 沒有知識之間的橋樑 沒有構建出一個合適的思考模式,只是浮於表面的認知。 你按一下開關 燈沒亮 哎呀 咋回事嘞? 或者按一下開關 燈沒滅 或者你按一下開關 砰的一聲 燈炸了 只能誇它炸的響 不知道它為什麼炸 。我儘量不走尋常路,從其他的角度,我們一起來學習事件的傳播和處理。風格依舊跟第一部分和第二部分一樣, 沒圖沒碼 網文風格,但是對描述和表達的準確性 依舊值得信賴,我會力求表達準確 符合規範 貼合實現。

複習EventTarget

第一部分講了事件的一些重要知識點,第二部分講了事件的完整生命週期,並且用了大量篇幅介紹了傳播路徑的構建和shadow dom 以及事件對象。

這是第三部分

前面兩部分是純練內力,這部分有點內力外放的意思。現在我們一起修煉吧。

一切的源頭是 eventtarget,前面已經多次提到,想具備事件能力,必須實現eventtarget接口。

eventtarget是一個接口 一種能力 一個對象。 從對象角度來説, dom事件中的那些節點,基本上都是以原型鏈的形式默認繼承了eventtarget這個對象。

在前面第一部分, 我們曾給出了一個js對象的新的定義:

js對象的本質 = 非原始值 + 屬性記錄集合 + 原型鏈繼承 + 由內部槽/內部方法決定行為

eventtarget作為對象,那就必然可以用這個定義來解釋。

一個實現了eventtarget接口的對象,具備了事件能力,那麼將它用上面的定義來解釋:

  • 非原始值:它當然是個對象引用。

  • 原型鏈繼承:打開 F12,在控制枱裏敲入一行命令:console.dir(document.createElement('div')) 回車之後,會得到一個純淨的 div 對象。

    順着它的 [[Prototype]](或者 __proto__)一層一層往上翻 從 HTMLDivElementHTMLElement,再到 ElementNode 再然後,就看到我們的主角了 EventTarget

  • 屬性記錄集合:當然可以隨便添加屬性。

  • 現在還剩下的就是最重要的了 ------ 由內部槽/內部方法決定行為

EventTarget 內部,有一個js層面看不到的內部槽位。 規範中它的名字叫 Event Listener List事件監聽器列表)。

正是因為有了這份列表,它才從普通的 JS 對象,進化成了一個擁有事件能力的對象。

因為它是對象內部的一個屬性/槽位, 可以這樣表示 [[eventlistenerlist]]

從名字就可以看出,它是一份列表,由每一條列表項組成。

下面我們介紹一下每份表項的構成,你就會明白了。

事件監聽列表的組成

跟全文的第一部分和第二部分一樣,依舊延續哨位這個比喻。

事件監聽列表,每個表項 ,由7個字段構成。

  1. type
  • 類型:字符串 (String)
  • 含義:這個表項具體負責哪塊業務?是 click 組的,還是 keydown 組的?
  • 作用:這是最基礎的索引。比如當類型為click時,只有 type 為 "click" 的表項會被調出。
  1. callback
  • 類型:函數 或 對象 (EventListener Object)
  • 含義:具體幹活的回調函數。
  • 細節:通常我們傳的是一個 JS 函數。規範裏講的,也支持傳一個對象,似乎很少使用。
  1. capture
  • 類型:布爾值 (Boolean)
  • 含義這是最重要的身份標記之一。
    • true:屬於捕獲組。事件從 Window 往下傳的時候,就要注意了。
    • false:屬於冒泡組。事件從 Target 往上冒的時候,就要注意了。
  • 核心規則:它是“去重複算法”的三大要素之一。同一個函數,如果分別註冊了捕獲和冒泡,那是兩條完全獨立的表項。
  1. passive
  • 類型:布爾值 (Boolean)
  • 含義:這是一個關於性能的字段。
    • true:該表項簽署承諾書,保證在執行過程中絕不調用 preventDefault()(不攔路)。
    • false:保留攔路的權力。
  • 有默認:瀏覽器為了保證移動端滾動的絲滑,對於 touchstartwheel 這種高頻事件,會在Window、Document 等頂層對象上默認幫你勾選為 true,以便合成器線程能直接渲染滾動幀。
  1. once
  • 類型:布爾值 (Boolean)
  • 含義一次性用品。
    • true:幹完這一票就走人。
  • 機制:當這個回調被執行之後,哨位會自動把這份表項從列表中物理刪除。
  1. signal
  • 類型:AbortSignal 對象
  • 含義:這是 AbortController 帶來的新機制。
    • 機制:你把一個遙控器(Signal)交給哨位。以後你想離職,不用專門跑一趟(調用 removeEventListener),你只需要在外面按一下遙控器(調用 abort()),哨位裏對應的列表項就會自動銷燬。
    • 細節:如果你遞交申請的時候,手裏的遙控器顯示“已引爆”(aborted),是根本不會受理你的註冊請求的。
  1. removed
  • 類型:布爾值 (Boolean)
  • 含義這是唯一的內部專用字段,開發者不可見。
    • 作用:解決同一事件中的併發問題。
    • 場景:當在同一個事件派發中,在當前正在工作的哨位上,如果前一個表項中的回調,把後面的表項移除了,此字段起作用。
    • 邏輯:為了不打亂正在進行的循環索引,哨位不會立刻移除表項,而是悄悄在這個字段打個勾(removed: true),類似於軟刪除。等輪到被打勾的表項的時候,直接跳過,既不執行也不報錯。

看到這些字段,是不是感覺特別熟悉? 注意 這些字段 現在是在eventtarget中的內部槽位中,僅限內部使用, 那麼,它們是怎麼被外面js層改變設置的呢?

eventtarget的對外窗口

前面反覆的説 eventtarget是事件機制的源頭,必須實現這個接口 才能具備事件的能力。dom事件中的節點,都是默認通過原型鏈繼承了eventtarget對象。 那麼,我們在js層面,如何使用呢?

eventtarget提供了三個api給我們,這也是它的核心功能,註冊 銷燬 觸發。

前面講過,一個事件 之所以能成為事件, 要具備三個特質:

一是遵循觀察者模式,二是攜帶現場數據 三是可觀察的變化。

那麼eventtarget提供的這三個api,之所以是核心能力,就是因為用這3個api,實現了觀察者模式。

addeventlistener註冊 添加觀察者

removeeventlistener退訂 移除觀察者

dispatchevent觸發 發佈者發佈

這三個api,加上event,構成了大部分前端開發者的事件機制的知識體系,那麼 假如再加上 事件監聽列表 。。。。。。你就功力大漲,凝聚金丹進階了。

1. addEventListener

給元素添加事件,經歷過三個時期:

  • HTML 屬性綁定 (Inline Event Handlers)

    這是最早期的 web 開發形態,直到現在,依然可以在很多老舊系統或者為了圖方便的 demo 中看到它的身影。

    這種方式, 雖然看起來簡單,直接把代碼寫在標籤裏,但它背後發生的事情其實非常不科學。

    比如 <div onclick="console.log(id)"> ,瀏覽器並不是直接運行這段代碼。 瀏覽器引擎在解析 HTML 時,會把onclick屬性裏的字符串console.log(id)提取出來,然後動態創建一個函數。它通常使用了一個在現代 JS 中已經被強烈建議不再使用的 with 語法,強行擴展了作用域鏈。

    瀏覽器生成的代碼邏輯大致如下(偽代碼):

    function(event) {
        with(document) {
            with(this.form) { // ...
                with(this) {
                    // 自己的代碼被包裹在這裏了
                    console.log(id); 
                }
            }
        }
    }
    

    這就是為什麼這種方式,可以直接在html裏使用event , id,document的console的原因。

    這種方式是強耦合的典型,HTML 和 JS 邏輯死死糾纏在一起。而且,因為 with 語法的存在,變量查找路徑變得極其複雜,極易引發性能問題和意想不到的 Bug。而且,這種內聯腳本,經常會因為安全問題,被禁止運行。

  • DOM0 級事件處理 (DOM0 Event Handlers)

    隨着 JS 的地位提升,還想再提升 再提升 於是就希望能把邏輯從 HTML 中剝離出來。於是出現了 DOM0 級綁定。

    btn.onclick = function() {
        console.log('你好了吧');
    }
    

    這種方式的本質,是對 DOM 對象上的一個屬性進行賦值

    (重要)這兩種方式的總結

    現在我們來擼一下思路,事件被包裝成任務,放入宏任務隊列, 然後被取出執行,精確命中,創建事件對象,構建傳播路徑, 此時,就進入調度階段。 此時 我們把目光放在傳播路上的某一個節點/元素/eventtarget 上面,它的內部 有一個自它出生就有的一個事件監聽列表,該列表初始為空, 而此節點,作為一個對象,一個元素,它本身是有自己的屬性的,比如 src屬性 onclick屬性,等等。。。以onclick為例,它是節點對象元素標籤的一個屬性,它在事件監聽列表中,擁有一個單獨的席位,初始為空,並不實際佔有位置。 按照註冊順序來排列監聽列表。 比如首先 add了幾個回調, 然後又以btn.onclick=fn的方式註冊了onclick, 那麼 onclick是排在最後的。 又比如,首先以html的寫法onclick="console.log(id)"的方式內聯註冊了onclick,那麼在html解析時,該屬性就被註冊了,它就排列在事件監聽列表的首位。

    還有一個重要的地方,就是 onclick是作為節點的一個固有屬性存在的,它的值只能有一個,多次賦值會被覆蓋。

    而後面將要講的add的方式添加的,是附加的方式,可以添加多個。

    最後再次總結一下:

    對於 HTML 屬性綁定和 DOM0 (btn.onclick) 綁定,它們在瀏覽器內部,其實共享同一個內部槽位: 它們會在事件監聽列表中尋找(或者創建)一個帶有特殊標記的表項。

    • 唯一性: 這個表項,對於同一種事件類型(比如點擊),只能有一個
    • 獨佔性: 無論你賦值多少次 btn.onclick = fn,瀏覽器做的不是“添加”,而是“原地換人”。它找到那個表項,把裏面的 callback 字段擦掉,填入新的函數。這就是為什麼 onclick 永遠只能綁定一個處理函數,因為它霸佔了這個唯一的列表項。
    • 生命週期: 如果你把 btn.onclick 設為 null,瀏覽器就會把這個 表項從列表中物理移除
  • DOM2級事件監聽addEventListener

    隨着 Web 應用越來越複雜,組件化開發成為主流,如果一個按鈕既要發送統計數據,又要執行業務邏輯,還要觸發 UI 動畫,用 onclick 就會互相打架。於是,addEventListener 誕生了。

    它的邏輯和以前完全不同。dom0是獨佔和唯一,那麼 addEventListener 就是 “追加”

    btn.addEventListener('click', fn1);
    btn.addEventListener('click', fn2);
    

    調用這個 API 時,瀏覽器它只會做一個動作:Append(追加)。 它創建一個新的表項,填好 typecallback,然後直接把它掛在列表的末尾

    它的優勢:

    1. 無限疊加:你可以添加無數個監聽器,它們和平共處。當事件觸發時,瀏覽器會按照列表中的順序(也就是註冊的順序),依次執行它們。

    2. 精細化控制:這是 DOM0 做不到的。

      • 你可以控制是在捕獲階段觸發還是冒泡階段觸發(通過 capture 選項)。

      • 你可以控制它是否只執行一次(once: true)。

      • 你可以承諾不阻止默認行為以提升滾動性能(passive: true)。

      • 你可以隨時用信號終止它(signal)。

    那麼,是不是真的可以無限疊加任何監聽呢?並不是。

    為了防止你因為代碼邏輯混亂或者一時糊塗手抖而重複註冊同一個函數,瀏覽器在追加之前,會有一個嚴格的查重機制

    這道機制只認三個字段:

    1. type(事件類型)
    2. callback(回調函數引用)
    3. capture(捕獲狀態)

    請注意,只有這三個! passiveoncesignal 這些後來加入的參數,不參與去重判斷。

    就是説 如果你先註冊了一個 { passive: true } 的點擊事件,然後又註冊了一個一模一樣的函數,但是參數變成了 { passive: false }。 瀏覽器會對照字段:

    • Type 一樣嗎?一樣 (click)。
    • Callback 一樣嗎?一樣 (同一個函數引用)。
    • Capture 一樣嗎?一樣 (默認都是 false)。

    結果判定為重複人員! 瀏覽器會直接忽略第二次的註冊請求。列表裏依然只有第一次的那條記錄。尤其要注意capture這個字段,前兩個字段一樣,第三個,真和假 可以同時存在於監聽列表中。

    還有一點

    addEventListener的第二個參數,也可以是一個對象,這個對象裏面,必須實現一個handleEvent方法:

    const myObj = {
        message: 'Hello World',
        handleEvent: function(event) {
            // 這裏的 this,自動指向 myObj 對象本身
            console.log(this.message); 
            console.log(event.type);
        }
    };
    
    // 傳入的是對象,而不是函數
    btn.addEventListener('click', myObj);
    

    這種方式一是有利封裝 二是不用綁定this,三是移除方便 。但這種傳對象的方式我們平時使用不多。

2. removeEventListener

有註冊就有註銷,addeventlistener是往事件監聽列表裏添加觀察者,removeeventlistener 就是用來把觀察者從列表中請出去的。 它的工作很簡單,就是使用上面提到的那三個字段去列表裏找人:

  1. type

  2. callback

  3. capture

    符合條件,就請出去了。

這也是紅寶書上説的,必須符合三個條件的原因,因為添加的時候,用這三個條件判斷是否是重複添加, 所以用這三個字段,可以唯一表示事件監聽列表裏的某一項,那麼在移除時,依舊是使用這三個字段來尋找。

那麼問題來了,記得不要使用箭頭函數當回調。因為,回調函數,如果是匿名的,你在註冊時,它是一個對象,有一個內存地址, 你在移除時,寫的回調,雖然和註冊時是一樣的內容,但是它是另一個不同的對象,有另一個不同的內存地址, 移除時並不是比對內容,而是比對的內存地址。地址不同,當然移除不掉的。

// 註冊
btn.addEventListener('click', () => { console.log('猜猜我是誰') });
// 試圖移除
btn.removeEventListener('click', () => { console.log('猜猜我是誰') });

還有一點 要特別注意capture 是必須要匹配的!

在瀏覽器的眼中,捕獲階段的監聽器冒泡階段的監聽器,是完全不同的,

// 註冊了一個捕獲階段的監聽器
btn.addEventListener('click', handler, { capture: true });

// 試圖移除一個冒泡階段的監聽器
btn.removeEventListener('click', handler, { capture: false }); // 失敗嘞

雖然函數一樣,類型一樣,但一個是捕獲階段,一個是非捕獲階段,瀏覽器認為它們不是同一個列表項。 要想移除上面那個,就必須顯式地寫上 { capture: true }

關於事件監聽列表種的第7個字段removed

還記得我們在前面介紹監聽列表的7個字段時,提到的那個 內部專用字段 removed 嗎? 我們在這裏略為介紹一下。

想象一下,假如有一個按鈕,練功走火入魔了,居然註冊了 10 個點擊事件監聽器。 當點擊發生時,瀏覽器開始在一個 for 循環 中遍歷這 10 個監聽器,依次執行。 假設執行到第 3 個監聽器時,它的代碼裏調用了 removeEventListener,把第 4 個監聽器給刪了。如果瀏覽器直接把第 4 個項從數組裏 物理刪除,數組長度這就變短了,後面的元素下標全部前移。 原來的第 5 個變成了第 4 個。 而循環的索引 i 此時加到了 4。 後果就是 ,原來的第 4 個被刪了,原來的第 5 個被跳過了。

為了避免這種遍歷中修改所帶來的索引bug,瀏覽器採用了 “軟刪除” 策略。

當調用 removeEventListener 時:

  1. 瀏覽器找到了對應的表項。
  2. 不會立即把它從內存裏刪除。
  3. 它只是悄悄地把該表項的 removed 標誌位設為 true

在事件派發的循環中: 當輪到這個表項時,瀏覽器會先看一眼:“哎呀 removed 是 true?” 然後 直接跳過不執行,繼續下一個。

等到這一輪事件循環徹底結束,或者在未來的某個空閒時刻,瀏覽器才會真正地回收這些“被標記的殭屍”,釋放內存。 這就是為什麼説 removeEventListener 是一個邏輯上的刪除,而不是物理上的立即消滅。這就是這個removed字段的用途。

那麼 問題又來了, 哎呀,這麼麻煩丫,刪點東西 又要這 又要那的,有沒有更先進的辦法呢? 這就是事件監聽列表中 第6個字段signal 出現的意義了。

在前面,我們特別的講了,不要傳匿名函數進去當回調,因為想移除的時候,會匹配不到。那麼現在有了signal的加持,匿名函數也能支楞幾下了。

const controller = new AbortController();
// 註冊時,把銷燬信號傳進去
btn.addEventListener('click', () => { console.log('你們逮不到我'); }, { signal: controller.signal });

// 想移除時,不需要知道函數是誰,直接按下引爆---砰
controller.abort(); 

AbortController 是一個構造函數, 使用new AbortController() 實例化出一個控制器對象。

這個對象很簡單,包含一個signal屬性,一個abort方法

這個對象是宿主環境提供的

AbortController 的出現,就是為了提供一種通用的取消機制。

使用 removeEventListener 時,必須使用回調函數的引用。但是用 AbortController,不需要管回調函數是誰,只需要控制那個信號。

而且,可以一對多的控制,可以把同一個 signal 傳給 10 個不同的 addEventListener,甚至傳給幾個 fetch 請求。當調用一次 controller.abort() 時,這 10 個事件監聽器和那幾個網絡請求,會同時停止。一鍵清理,厲害大了。

3. dispatchevent

dispatchevent的執行,和內部的派發過程是一樣的,可以認為,它是內部的派發算法給js層面提供的一個接口。具體的執行,在後面會有超大的篇幅來講

在這部分 我們主要講一下自定義event

在前面的第一部分講解event的時候,我們説 自己創建event對象,需要使用對應的構造函數,因為內部槽位有通用的 也有專用的。

  • new Event()

    const evt = new Event('boom');

    這種,就純粹是個消息通知,聽個響而已,派發它,只能用於通知,看到通知,就回調。

  • new CustomEvent()

    DOM 規範專門提供了:CustomEvent。 它是我們日常開發中最常用的方式。

    const payload = {
        username: '阿祖',
        action: '收手吧 外面全是成龍'
    };
    
    // 第二個參數是配置對象
    const evt = new CustomEvent('police-arrive', { 
        detail: payload 
    });
    
    document.addEventListener('police-arrive', (e) => {
        console.log(e.detail.username); // 阿祖
    });
    

    detail裏面可以放任意類型的內容,使用非常方便。

  • 使用 EventInit 可配置對象

    對於上面這兩種 event和customevent,還可以使用配置對象對他們進行配置。

    實際上,這種配置,是對於event內部插槽的修改,對於這兩種屬於基類的,只能配置

    三個功能: 是否可冒泡bubbles 是否可取消cancelable 是否可跨影dom邊界composed,他們初始默認都為假。

    對於一般使用,以customevent加detail加三個配置項 居多。

  • 繼承 Event 類

    使用 class myEvent extends Event {}

    這種深度定製,可定製事件類型 可定製高內聚的邏輯。

    但是寫起來比較麻煩。

    可能有新手朋友會有疑問 我new event 然後自己添加,和我使用extends event繼承,有什麼區別嗎? 不都是要自己添加嗎? 對於特別簡單的,當然可以new以後添加,但是稍微複雜點的,儘量使用繼承,new加上添加,會有不可預知的安全問題,強類型,封裝性 ,安全性,可固化配置。。這些優勢,足夠驅使選擇繼承的方式了吧。

  • 那麼 我想精確的造一個點擊事件怎麼辦

    這就需要擁有特定專用內部槽位的子類出場了,點擊事件是MouseEvent

    const perfectClick = new MouseEvent('click', {
    //下面的配置項目,就相當於修改event對象中的內部槽位
    //每種子類,擁有通用內部插槽, 也必須有自己的專用內部槽
    
        // 1. 基礎配置 通用槽(繼承自 EventInit)
        bubbles: true,       // 必須為 true,否則父元素收不到冒泡
        cancelable: true,    // 必須為 true,否則無法 preventDefault
        composed: true,      // 穿透 Shadow DOM
        
        // 2. 視覺上下文(繼承自 UIEventInit)
        view: window,        // 綁定當前窗口
        
        // 3. 物理信息 這是鼠標事件的專用內部槽(MouseEventInit 特有)
        clientX: 100,        // 鼠標相對於視口的水平座標
        clientY: 200,        // 鼠標相對於視口的垂直座標
        screenX: 100,        // 相對於屏幕的座標
        screenY: 200,
        
        // 4. 按鍵詳情 依舊是鼠標事件專用內部槽
        button: 0,           // 0: 左鍵, 1: 中鍵, 2: 右鍵
        buttons: 1,          // 當前按下的鍵的位掩碼 (1 代表左鍵被按下)
        
        // 5. 修飾鍵 配合鍵盤使用
        ctrlKey: false,
        altKey: false,
        shiftKey: true,      // 假裝用户同時按住了 Shift
        metaKey: false,      
        
        // 6. 關聯目標   這個內部槽位的詳細説明  請參見本文的第一部分
        relatedTarget: null  // mouseover/out 時有用
    });
    
    // 開車嘍~~~
    btn.dispatchEvent(perfectClick);
    

    這部分內容,是event的創建, 因為dispatchevent派發 就必須講到這部分。所以就放在這裏了。

    關於dispatchevent,下面專門詳細的介紹。

事件的派發和處理

  1. 梳理線索 整理思路

    現在,我們來快速梳理一下我們已經學過並掌握的知識脈絡

    • 事件對象

      事件的三個特質 ,1是遵循觀察者模式,這樣才能發佈-訂閲-移除-處理 ,2是攜帶事件的現場數據,這就是event對象,事件的傳播以它為主, 3是可觀測的發生活改變,這個就不用説了。

      事件對象event的創建是在什麼時候?回憶一下第二部分的流程,以點擊事件為例,物理信號-操作系統路由-進程間通信給到渲染器-合成線程接收進行預先獨立合成-合成器進行一次大致的命中測試-事件路由決策-被封裝成任務進入宏任務隊列-取出開始執行-深度命中測試找出目標-創建js層event-構建事件傳播路徑

      (實現上以v8/blink為例)

      通常 在創建js層事件對象 構建事件傳播路徑 甚至包括調度部分 明顯的界限不好區分,因為有瀏覽器的實現差別和優化策略的不同,但是並不影響我們理解。

      event是貫穿全程的唯一信物。它是一個底層 C++ 對象,內部包含了大量的內部插槽,JS 層的 event 對象只是它的一個淺層包裝殼/代理。

      身份信息

      • [[type]]:事件類型(如 "click", "mousedown")。
      • [[isTrusted]]true(瀏覽器生成)或 false(用户腳本生成)。
      • [[timeStamp]]:高精度時間戳(事件創建那一刻的時間)。
      • [[target]]原始目標。即精確的命中測試(Hit Test)找到的最精確的 DOM 節點。注意:這個值永遠不變,但在傳播過程中對外暴露的 event.target 屬性會騙人---因為有可能存在影dom的情況。
      • [[relatedTarget]]:(僅限 mouseover/out 等具有關聯對應節點的情況)相關的那個節點(原始值)。

      靜態配置

      • [[bubbles]]:布爾值。決定是否允許進入冒泡。
      • [[cancelable]]:布爾值。決定 preventDefault() 是否生效。
      • [[composed]]:布爾值。決定事件是否能穿透 Shadow DOM 邊界傳播。

      動態控制標誌位 初始狀態均為關閉,隨 JS 代碼執行動態變化。

      • [[stop propagation flag]]封路標記。若為 true,當前節點執行完後,停止傳播。
      • [[stop immediate propagation flag]]熄火標記。若為 true,當前節點剩餘監聽器不執行,且停止傳播。
      • [[canceled flag]]撤銷標記。若為 true(即調用了 preventDefault),後續將阻止默認行為或觸發 UI 回滾。
      • [[in passive listener flag]]靜默標記。標識當前是否處於 passive 監聽器中(此時忽略 preventDefault)。
      • [[dispatch flag]]運行標記。標識該事件是否正在派發中(防止同一個 Event 對象被重複 dispatch)。

      極其重要的內部槽位

      • [[Path]]傳播路徑列表

      傳播路徑列表存儲在 Event 對象的 [[Path]] 插槽裏。 它是靜態的。一旦派發開始前計算完成,它就鎖死了。即使你在某個回調裏把父元素刪了,事件傳播依舊會沿着已經計算好並歲鎖死的路徑傳播。

      列表中的每一項 不是簡單的 DOM 節點,而是一個結構體,包含以下7個字段:

      1. item (當前哨位)

        具體的 DOM 對象(Window, Document, Element, ShadowRoot 等)。 這是 currentTarget 在當前的真實指向。

      2. target (Shadow 修正目標)

        關鍵數據。這是算法預先計算好的、在當前的哨位應該對外暴露的 event.target

        邏輯:如果當前哨位是 Shadow Host,這裏就是 Host;如果是在 Shadow DOM 內部,這裏就是真實的內部節點。(為了封裝性而撒的謊)。

      3. relatedTarget (Shadow 修正關聯目標)

        同上。預先計算好的、對外顯示的 event.relatedTarget

      4. touch target list: (僅限 Touch 事件)

        經過 Shadow DOM 邊界修正後的觸點列表。

      5. root-of-closed-tree

        布爾值。標記該路徑項是否是一個 closed 模式的 Shadow Root。用於隱私保護。

      6. slot-in-closed-tree

        布爾值。用於處理複雜的 Slot 分發場景。

      7. invocation-target-in-shadow-tree

        布爾值。標記當前哨位是否位於 Shadow DOM 樹內部。

    • 節點上的監聽列表 (The Listener Lists)

      雖然它們是即時讀取的,但它們客觀存在於每一個 DOM 節點上。

      • 持有者:每一個實現了EventTarget 接口的dom對象。
      • 數據結構:事件監聽器列表。
      • 每個列表項包含字段
        • type (事件類型)
        • callback (函數或對象)
        • capture (捕獲標記)
        • passive (性能標記)
        • once (一次性標記)
        • signal (引爆銷燬信號)
        • removed (軟刪除標記 - 初始為 false)
  2. 派發與回調調用

    經過上面的快速梳理 ,我們已經知道,有三樣最重要的東西 事件對象 傳播路徑表 傳播路上的每個節點的監聽列表。

    現在我們開始發車吧,開啓一段有趣的旅程。

    嘀嘀嘀 喇叭響了,瀏覽器引擎啓動了主循環,這輛車,要跑兩個半程。

    1 capture 去程, 從window向下,達到事件目標核心target

    2 bubble 回程, 從目標核心 target浮起,一路冒泡到window。

    現在我們把車子放慢 再放慢, 停在某一站

    第一步 偽裝與身份切換 retargeting

    車門還沒開,瀏覽器引擎先搞搞偽裝,它必須修改event中的數據,以便符合自己在此站點/節點的身份,也為了欺騙此地哨位。

    • 鎖定現場 (currentTarget):

      瀏覽器引擎將 event.currentTarget 指針,鎖向當前這一站的 DOM 節點。確定當事人。

    • 撒一個完美的謊 (target 重定向):

      這裏涉及到 Shadow DOM 的機密。引擎迅速讀取event對象中的path內部槽位中的當前結構中的 shadow-adjusted target內容,覆蓋了 event.target。

      從之前的學習中,我們知道這個值是根據影dom修正過的值,此時直接覆蓋。

      shadow-adjusted target的值 針對當前的節點 始終都是正確的,這個覆蓋的步驟,是必做的一步。也是每經過一個哨位,都必做的一步。

    第二步 精確的時間段控制

    • 捕獲階段 1 車還在去程的路上,離終點還遠呢

    • 冒泡階段 3 車已經返程,快完事了

    • 目標階段 2 這是最忙碌的換向站點。

      實際上 車會兩次經過這裏, 捕獲階段到達,引擎讓捕獲組的來,即找出 capture: true

    ​ 冒泡階段到達,引擎讓冒泡組的來,即找出 capture: false

    ​ 尤其是在目標階段 ,目標元素上既會執行 capture:true 的監聽器,也會執行 capture:false 的監聽器;根據最新的規範:通常 capture 監聽器先執行,然後再執行非捕獲監聽器(除非 stopImmediatePropagation() 等標誌打斷)。

    第三步 提取與快照

    此時,引擎敲開當前哨位的門,索要該節點的事件監聽列表。

    • 哨位給出原始事件監聽列表
    • 引擎拍個照片,形成快照,依據快照進行後續操作。

    那麼 假如某個回調使用add添加了幾個監聽,新加的幾個 會正常附加在原始事件監聽列表尾部,

    但是因為引擎是根據 快照 來執行,所以本輪派發沒有新添加的份。

    假如 某個回調 把它後面的回調移除了,原始事件監聽列表中的回調,就真的被移除了,同時移除操作還會將該被移除的回調的removed字段設置為true。看到這裏 你可能有疑問,不是被移除了 怎麼還能設置它的字段? 實際上, 不管是原始列表 還是快照, 都是使用的指針, 指向的真正的本體。原始列表中 該字段被標記為軟刪除,操作的是本體上的該字段,然後移除原始列表中的指針, 本體仍然健在,因為還有快照中的引用在指向它,不能銷燬。

    另外 快照是按照事件類型匹配後的完整監聽器列表,並不是完整的原始事件監聽列表。

    規範中規定,先取得完整的事件監聽列表的快照,然後進行包括type在內的各項比對,

    但是在瀏覽器的實際實現中,已經預先使用了按照事件類型分組 或者其他便捷的組織方式,

    所以得到的快照,直接便是按照事件類型匹配好了的列表。

    其它條件capture/bubble、once、removed、abort、passive 都在執行階段對快照 中的每一項逐條檢查。

    第四步 內部循環

    現在 瀏覽器引擎拿着快照,開始點名核對

    • 指紋核對

      type核對(一般在取得快照時,得到的是已經匹配過當前事件類型的列表了)

      phase 階段核對,瀏覽器引擎 根據自己的一套規則,確定當前的所處階段,以此來過濾回調。

    • 狀態檢查

      removed? 引擎發現這個名字上有removed標記,直接跳過。

      aborted? 引擎看了眼abortsignal,標誌為真?直接跳過。

      關於這個信號,再詳細介紹一下,依舊是 監聽項本體在堆內存中,Signal 對象 (Controller)也在堆內存裏,監聽項本體保存對signal的引用。當有js代碼調用 controller.abort()時,JS 引擎找到內存裏的 Signal 對象,把它的 aborted 字段從 false 改為 true。另外 在abort()被調用的時候,原始事件監聽列表中的該項,也即時被刪除 如果還在派發中,則快照上依然保留該項,以防索引bug,但是被標記為軟刪除 。 實際上,在signal對象內部,也被瀏覽器註冊了一個回調函數,用於主動清理工作,這個內容太超綱了 略過。

      當引擎按快照裏的順序,開始檢查核對該項時,檢查到aborted字段,由快照指針 找到監聽項本體,順着其持有的signal對象的指針,找到signal對象,發現狀態為aborted: true ,則直接跳過。

    • once 機制

      瀏覽器引擎看到once為真的標記,立即把該項從原始事件監聽列表中移除。

      現在只在快照裏了,只能執行這一次。

    • passive機制

      看到 passive: true,瀏覽器引擎給 Event 對象打了個鋼印:“忽略反對意見”。

      此時你在回調裏無論怎麼 preventDefault(),都是沒用的,瀏覽器甚至還會在控制枱貼一張警告條:“別喊了,你就算喊 破喉嚨,也沒用的。”

    • 執行回調與異常抵抗

      終於,js引擎出場,調用回調函數,開始執行。

      突然,異常出現,某個回調函數崩了,拋出error,

      瀏覽器進行記錄,顯示在控制枱上,

      然後開始快照裏的下一條監聽項的核查比對。

    • 檢查與制動

      每一個回調執行完,瀏覽器引擎都會檢查event事件對象中的各種標誌位,js代碼剛才有沒有搞小動作?

      檢查 [[stop immediate propagation flag]]

      如果為真,直接散夥,循環中斷,轉去判斷是否執行默認。

      檢查 [[stop propagation flag]]

      如果為真,幹完這票就收工。快照上的監聽項依次幹完, 然後轉去判斷是否執行默認。

    • 默認行為的處理

      無論是順利跑完了全程,還是半路被停止或者是乾脆原地散夥,JS 的邏輯階段都宣告結束。

      此時,瀏覽器引擎會做最後的清算(注意:停止傳播不等於取消默認行為):

      1. 返回值生成dispatchEvent 會返回一個布爾值。

        • 當且僅當事件可取消(cancelable: true) 且至少有一個監聽器調用了 preventDefault() 時 返回 false
        • 否則 返回 true
      2. 默認行為

        • 引擎只看 [[canceled flag]]

        • 哪怕傳播在第一站就停止了,只要沒人反對(調用 preventDefault),瀏覽器依然會執行默認行為(如跳轉鏈接、提交表單)。

    此時,同步的 dispatchEvent 調用棧清空並返回。

    微任務開始了。

一些重要知識點詳解

  1. 在某個節點,瀏覽器是如何知道當前所處的階段?

    當事件傳播來到某個 哨位/節點/標籤/實現了eventtarget接口的對象/dom元素 , 當前哨位裏,是有原始的事件監聽器列表,並沒有當前事件動態走向的所處階段,那麼瀏覽器是怎麼得到這個階段呢?

    很多朋友會説:引擎當然知道 它就是boss 啥都知道,咱只要知道它知道就行了。

    話是不錯,可但是,我們還是有必要了解一下的。

    在事件傳播時隨身攜帶的event對象中,內部插槽[[path]]存着計算好的路徑,每條路徑,都是一個列表,裏面有7個字段。

    引擎使用這種存儲方式 Path[0] = target Path[last] = window 來存儲需要走的半程

    • **捕獲循環 **:
      • 引擎設置 iPath.length - 1 (Window) 開始,遞減到 1 (Target 的父親)。
      • 只要循環在這個範圍內,引擎就強行把 eventPhase 設為 CAPTURING-PHASE (1)
      • 只要沒走到索引 0,且我在倒着走,那我就是在捕獲階段
    • 目標循環 :
      • 引擎設置 i = 0
      • 只要 i 是 0,引擎就強行把 eventPhase 設為 AT_TARGET (2)
      • 我踩在終點上了。
    • 冒泡循環
      • 引擎設置 i1 (Target 的父親) 開始,遞增到 Path.length - 1 (Window)。
      • 只要循環在這個範圍內,引擎就強行把 eventPhase 設為 BUBBLING_PHASE (3)
      • 我已經離開索引 0 了,且我在正着走,那我就是在冒泡。

    so 並不是 phase 決定了怎麼走,而是 “怎麼走”決定了 phase。

    引擎使用Path,通過控制遍歷的起點、終點和方向,從而精準地定義了當前的“時空狀態”,這就是它為什麼在某一節點,能用自己知道的 所處階段,去和節點內部的原始事件監聽列表的快照進行對比核查的原因。

    即使在Shadow DOM存在的情況下,path依然正確有效。

    比如需要確定target時

    event.target = Path[i].shadow_adjusted_target

    • 如果 i 在影內,修正目標字段裏存的就是內部節點。
    • 如果 i 在影外,修正目標字段裏存的就是 Host。

    總結就是

    瀏覽器引擎確定狀態的方式,不是“動態感知”,而是“讀取預設”

    • 所處階段:由循環索引決定。
    • 當前哨位所能看到的目標:由 Path 裏的預存字段決定。
    • 當前節點:由 Path 裏的 item 字段決定。

    這就是為什麼派發算法如此高效——因為它不需要思考,只需要查表

  2. 在某哨位 對比核查事件監聽器列表時,是全部核查完畢,然後依次執行,還是核查出來一個,就執行一個?

    這是嚴格按照,揪出來一個 就執行一個的方式。

    這裏有一個極易產生的誤解。很多朋友認為瀏覽器是先把快照裏的所有人都擼了一遍,挑出合格的,組成一個新的待執行隊列,然後一口氣執行完。這是錯的。

    瀏覽器的執行邏輯,是嚴格的 “揪出來一個,處理一個”串行模式。

    for 循環的每一次迭代中,引擎做的事情是完整的閉環:

    1. 點名:根據索引 i,從快照裏指向第 i 個監聽器。
    2. 立即核查
      • “ 你現在被 removed 了嗎?” (檢查 removed 標記)
      • “ 你的 signal 炸了嗎?” (檢查 aborted 狀態)
      • “ 你是這個階段的嗎?” (檢查 capture/phase)
    3. **立即執行 **:
      • 如果核查通過,立刻、馬上、同步調用你的回調函數。
      • 之所以説是 串行,是因為 回調函數的執行,是控制權的移交,必須由js引擎來幹活了。瀏覽器引擎先去抽根煙了。
      • 注意:此時,第 i+1 個監聽器還在隊列裏等着,所有人都不知道它合不合格。
    4. 後果
      • 正因為是“執行完一個”才去“找下一個”,所以當前這個回調函數裏的操作,能直接決定後續監聽器的命運。
      • 比如你在第 i 個回調裏調用了 stopImmediatePropagation(),引擎在準備進入 i+1 循環之前一檢查:“欸,熄火標記亮了?” duang的一聲,循環直接 break,第 i+1 個監聽器連核查的機會都沒有,大家直接散夥。

    總結就是: 瀏覽器不是“批處理”,而是嚴格的“單步迭代”。 快照保證了“人員名單”不許變(後面新來的進不來),但“生存狀態”是每一次迭代時實時核查的。

  3. 在某個節點上,是 1 對 1 還是 1 對 N?

    假如在某個子元素(比如按鈕 B)上發生了一個點擊事件。事件一路火花帶閃電,來到了頂層節點(比如容器 S)。 此時,容器 S 上註冊了好幾個 click 類型的監聽器:有的負責挖坑,有的負責埋雷,有的負責點火,但他們都屬於click類型。 那麼問題來了:當事件傳播到 S 時,是“精準命中”某一個回調執行?還是所有相關的回調都會被執行?

    很多朋友會脱口而出,當然是 1 對 1:“我明明是點的按鈕 B,瀏覽器應該很聰明,只執行那個我當初註冊的那個處理 B 的回調吧?”

    正確的答案是 瀏覽器引擎執行的是 1對 N

    還不是很明白的朋友,可以先看一下前面的 派發與回調調用 這一部分內容。

    當事件傳播的車開到頂層節點 S 時,瀏覽器引擎拿出 S 的監聽器列表(快照),開始選人幹活。 它的篩選標準非常簡單粗暴:

    1. Type 對嗎? (事件是 click,你監聽的也是 click 嗎?對。)
    2. Phase 對嗎? (我是冒泡過來的,你是監聽冒泡的嗎?對。)
    3. Flag 正常嗎? (沒被 remove 吧?signal 沒炸吧?正常。)

    只要這三條符合,不管你回調函數裏寫了什麼,統統揪出來幹活

    它的策略就是:全部喚醒,依次執行

    那麼 怎麼辦呢? 當然是在回調函數裏判斷了,除了有些業務邏輯需要來着不拒,比如訪客點擊,每個點擊都要記錄,不需要加判斷,除此以外,第一行代碼都是身份判斷 因為如果不判斷,作為回調函數來講,不管誰的點擊事件來了, 它都得執行一遍。

    而作為事件本身來説,它只希望自己期望的回調被執行,其他的回調必須拒絕它。

    對於基於事件委託的業務邏輯來説,第一行代碼永遠都是身份判斷,

    所以,回調函數裏的身份判斷,萬萬少不得。

    這裏我們再引入一個狠角色 stopImmediatePropagation

    一個點擊事件,可能會有幾個點擊事件監聽項在等着,當某個監聽項調用了stopImmediatePropagation, 好了 都別等了 立刻散夥收工。那麼問題又來了,假如有好幾個監聽項在排隊, 我不能精確的保證 該在何處調用這個api?這又是一個問題,所以 要保證你所期待的那個監聽項是排在第一 或者是你可以明確的知道 應該在哪裏調用

    比如 兩個點擊事件項 A是校驗 B是提交 你校驗不過,可以直接祭出大殺器stopImmediatePropagation,立即阻止了B的排隊執行。

    其實這個函數通常在第三方庫裏使用,因為那些庫的初始化 都是先於用户代碼,所以庫在初始化時會搶先註冊監聽,通過在適當的時候 使用stopImmediatePropagation來一票否決,實現自己的判斷 校驗 安全攔截等類似功能。

這是全篇文章的第三部分,這部分內容,我覺得還是比較容易理解的,尤其是前半部分,一般新手朋友,讀兩三遍,應該能收穫不少。事件監聽器列表,只要花幾個小時,瞭解一下這個表,對於實際開發中的不少問題,就能心中有數,不知為什麼 基本上沒有人講解。

第四篇是事件的循環和異步, 我們下一篇再見。

參考列表:

  • developer.mozilla.org

  • dom.spec.whatwg.org

  • html.spec.whatwg.org

  • tc39.es

  • developer.chrome.com

  • w3.org

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.