概述
在日常開發中,我們經常遇到這樣的場景:用户點擊了按鈕,需要更新多個地方的顯示;訂單完成後,需要同時刷新列表、發送通知、更新統計;購物車添加商品時,需要更新數量、計算總價、保存到本地。如果每個地方都直接調用,代碼會變得又亂又難維護。
這時候,自定義事件就派上用場了。它就像廣播電台,某個地方"廣播"了一個消息,所有"收聽"這個消息的地方都能收到通知並做出響應。瀏覽器內置的 click、input 等事件主要處理用户交互,而自定義事件則用來傳遞業務語義,讓代碼更加解耦和靈活。
為什麼需要自定義事件
想象一下,如果沒有自定義事件,我們要實現"用户登錄後,更新用户信息、加載數據、記錄日誌、發送通知"這個功能,可能會寫出這樣的代碼:登錄函數裏調用更新用户信息的函數,更新用户信息的函數裏調用加載數據的函數,加載數據的函數裏調用記錄日誌的函數... 這樣一層套一層,代碼耦合度非常高,改一個地方可能影響其他地方。
自定義事件的出現,完美解決了這個問題。它實現了發佈-訂閲模式,發佈者(觸發事件的地方)和訂閲者(監聽事件的地方)彼此不知道對方的存在,只需要知道事件名稱即可。這樣一來,組件之間可以實現通信而不需要直接依賴,模塊之間可以解耦,便於後續替換和擴展。更重要的是,用業務事件來表達"發生了什麼",比如 "user:login"、"order:completed",讓代碼的語義更加清晰,日誌和監控也更容易理解。在測試時,我們也可以直接構造事件來測試監聽器的行為,不需要依賴真實的業務邏輯。
自定義事件 vs 直接調用 vs 全局狀態
在實際項目中,我們經常會面臨選擇:是用直接調用、全局狀態管理,還是自定義事件?這三種方式各有適用場景,我們來簡單對比一下。
直接調用就像打電話,你知道要打給誰,也知道對方的號碼,雙方有明確的依賴關係。這種方式適合緊鄰模塊之間的同步協作,比如父組件調用子組件的方法。但如果調用鏈太長,就會形成強依賴,耦合度高,改一個地方可能影響整條鏈路。
全局狀態管理(如 Redux、Pinia)就像共享的黑板,所有模塊都可以在上面讀寫數據。這種方式適合跨頁面、跨組件的複雜狀態共享,比如用户信息、主題設置等。但它不太擅長表達瞬時動作,比如"用户剛剛登錄了"這種一次性的通知。
自定義事件則像廣播電台,某個地方發出信號,所有在"收聽"的地方都能收到。它適合表達"發生了什麼"這種瞬時動作,適合跨模塊的鬆耦合觸發與擴展。比如用户登錄後,需要通知多個模塊,用自定義事件就很合適,不需要知道具體有哪些模塊在監聽,也不需要它們之間相互依賴。
創建自定義事件
使用 CustomEvent(推薦)
CustomEvent 是瀏覽器提供的專門用於創建自定義事件的 API,它比普通的 Event 更強大,可以攜帶自定義數據。下面我們來看一個完整的例子。
// 創建自定義事件
// 第一個參數是事件名稱,建議使用有意義的名稱,比如 'userLogin'
// 第二個參數是配置對象,可以設置事件的詳細信息
const myEvent = newCustomEvent('userLogin', {
// detail: 自定義事件攜帶的數據,可以是任何類型
// 這裏我們傳遞了用户名、用户ID和時間戳
detail: {
username: 'zhangsan',
userId: 12345,
timestamp: Date.now()
},
// bubbles: 是否冒泡,true 表示事件會向上冒泡到父元素
// 就像水裏的氣泡會往上冒一樣,事件會從子元素傳播到父元素
bubbles: true,
// cancelable: 是否可以取消,true 表示可以通過 preventDefault() 取消
// 類似原生事件的 cancelable,允許監聽器阻止默認行為
cancelable: true
});
// 監聽自定義事件
// 使用 addEventListener 監聽事件,和監聽原生事件一樣
// 事件名稱要和創建時保持一致:'userLogin'
document.addEventListener('userLogin', (e) => {
// e 是事件對象,e.detail 就是創建事件時傳入的 detail 數據
console.log('用户登錄:', e.detail.username);
console.log('用户ID:', e.detail.userId);
console.log('登錄時間:', newDate(e.detail.timestamp).toLocaleString());
});
// 觸發自定義事件
// dispatchEvent 會觸發事件,所有監聽這個事件的函數都會執行
document.dispatchEvent(myEvent);
使用 Event(不推薦,功能有限)
Event 也可以創建自定義事件,但它不能攜帶數據,功能比較有限。在實際開發中,除非你確實不需要傳遞數據,否則還是建議使用 CustomEvent。
// Event 只能創建事件,不能攜帶數據
const event = new Event('myEvent');
// 觸發事件
document.dispatchEvent(event);
// 監聽事件
document.addEventListener('myEvent', (e) => {
console.log('事件觸發了');
// e.detail 是 undefined,因為 Event 不支持攜帶數據
});
命名規範與數據契約
在實際使用自定義事件時,有一些最佳實踐可以幫助我們寫出更易維護的代碼。首先是命名規範,建議使用"領域:動作"或"領域.動作"的格式,比如 user:login、cart:itemAdded、order.completed。這種命名方式一目瞭然,一眼就能看出這是哪個業務領域、什麼動作。比如看到 user:login,就知道是用户領域的登錄動作。
其次是負載設計,也就是 detail 字段裏放什麼數據。這裏有個原則:只放最小必要的字段,避免攜帶龐大的對象。比如用户登錄事件,只需要傳遞 userId、username 等關鍵信息就夠了,沒必要把整個用户對象都傳過去。這樣既能減少內存佔用,也能讓事件的語義更清晰。
關於可取消行為,cancelable 默認為 false。如果你需要允許監聽器阻止事件的後續處理流程,可以設置為 true。然後在需要阻止的地方,調用 e.preventDefault(),在其他監聽器中通過 e.defaultPrevented 來判斷是否被阻止了。這個機制在表單提交、頁面跳轉等場景很有用。
最後是冒泡策略,bubbles 默認為 false。如果你需要事件在 DOM 樹中向上傳播,可以設置為 true。比如在某個按鈕上觸發事件,如果設置了冒泡,父元素、祖父元素都能收到這個事件。但要注意避免過度廣播,因為事件冒泡會觸發所有父元素的監聽器,可能會影響性能。
常見陷阱
在使用自定義事件時,有一些常見的坑需要注意,稍不留神就可能踩進去。第一個坑是使用匿名函數綁定監聽器,後續無法移除。如果你在某個地方添加了事件監聽器,但使用的是匿名函數,那麼當你想要移除這個監聽器時,會發現找不到對應的函數引用。因為 removeEventListener 需要傳入和 addEventListener 時完全相同的函數引用,匿名函數每次都是新的引用,無法匹配。解決方法是使用命名函數,或者保存函數引用。
第二個坑是濫用 document 作為事件分發器。雖然 document 很方便,但如果所有事件都在 document 上分發,會導致事件風暴,所有監聽器都在 document 上,難以區分和調試。更好的做法是使用更小粒度的分發節點,比如特定的容器元素,這樣事件的作用域更清晰,也更容易管理。
第三個坑是將龐大的狀態對象放進 detail。雖然技術上可以這樣做,但會導致序列化和日誌過載。比如把整個購物車對象、用户完整信息都放進去,不僅佔用內存,日誌也會變得冗長難讀。正確的做法是隻放最小必要的上下文信息,比如事件相關的關鍵字段,其他信息可以通過 ID 去查詢。