關於passive event listener的一次踩坑

RyanLiu發表於2019-03-03

你真的會用addEventListener嗎?

沒錯,的確很標題黨,但是我最近發現我的一個朋友(誰說是我的!)真的不太會用addEventListener

起因

我最近參與了一個開源專案,vue-carousel,Vue生態圈裡的一個輪播元件,由加拿大的一個電商公司SSENSE開源的。然後剛好有人提了一個issue

Unable to preventDefault inside passive event listener due to target being treated as passive

也許有人在開發移動端應用的時候遇到過這個問題,我來為大家復現一下這個場景:

當我們給document新增了touch事件的監聽器的時候,如果同時在handler內部呼叫了event.preventDefault(),這時候瀏覽器(Chrome 56+)就會報一個warning:Unable to preventDefault inside passive event listener due to target being treated as passive

這句警告翻譯過來就是:不能給passive(被動的)事件監聽器preventDefault,因為它被認為是passive。

什麼意思呢?不著急,首先,我們來看一下什麼叫passive event listener。但是在這之前,我們還是得複習一下addEventListener的第三個引數。

不為所知的第三個引數

當我們使用addEventListener的時候,我們一般的寫法是以下:

target.addEventListener(event, handler)
複製程式碼

相信還有人使用更完整的寫法:

target.addEventListener(event, handler, false)
複製程式碼

是的,相信很多人也已經知道了,addEventListener方法是有第三個引數的,我們時常傳入一個false來作為這第三個引數。但是我們不傳其實也一樣,因為這第三個引數預設就是false,是不是覺得自己不明所以地寫了很多冤枉程式碼還自以為很嚴謹?

哈哈,所以我們得來看看,這第三個引數到底是做什麼的。

常規操作下,這第三個引數是一個布林值,叫useCapture,也就是指在DOM樹中,註冊了該listener的元素,是否會先於它下方的任何事件目標,接收到該事件。

我們知道,DOM事件流(event flow)存在三個階段:事件捕獲階段、處於目標階段、事件冒泡階段。如果useCapture設定為false,當前eventTarget就不會在捕獲階段接收該事件。瀏覽器預設我們不會在捕獲階段觸發繫結事件的handler。

但是我相信還是有很多人沒有認真看過addEventListener的文件,第三個引數其實並不一定是一個布林值。他也可以是一個物件,一組配置。

{
	capture: Boolean, // 表示`listener`會在該型別的事件捕獲階段傳播到該`EventTarget`時觸發
	once: Boolean, // 表示`listener`在新增之後最多隻呼叫一次。如果是`true`,`listener`會在其被呼叫之後自動移除
	passive: Boolean, // 表示`listener`永遠不會呼叫`preventDefault()`。如果`listener`仍然呼叫了這個函式,客戶端將會忽略它並丟擲一個控制檯警告
}
複製程式碼

除以上之外,還有一個mozSystemGroup,我們暫時不討論。

所以其實我們還可以在useCapture的基礎上另外配置兩個配置項。

once表示listener在新增之後最多隻呼叫一次。如果是truelistener會在其被呼叫之後自動移除,這跟我們在jQuery時代的once方法比較像。

passive表示listener永遠不會呼叫preventDefault()。如果listener仍然呼叫了這個函式,客戶端將會忽略它並丟擲一個控制檯警告。具體的我們接著討論。

passive event listener

在具體討論passive event listener之前,我們先普及一個知識點。大家可以自己去看看英文文件,不過需要科學上網。

簡而言之就是當我們在滾動頁面的時候(通常是我們監聽touch事件的時候),頁面其實會有一個短暫的停頓(大概200ms),瀏覽器不知道我們是否要preventDefault,所以它需要一個延遲來檢測。這就導致了我們的滑動顯得比較卡頓。

從Chrome 51開始,passive event listener被引進了Chrome,我們可以通過對addEventListener的第三個引數設定{ passive: true }來避免瀏覽器檢測這個我們是否有在touch事件的handler裡呼叫preventDefault。在這個時候,如果我們依然呼叫了preventDefault,就會在控制檯列印一個警告。告訴我們這個preventDefault會被忽略。

當我們給addEventListener的第三個引數設定了{ passive: true },這個事件監聽器就被稱為passive event listener

從Chrome 56開始,如果我們給document繫結touchmove或者touchstart事件的監聽器,這個passive是會被預設設定為true以提高效能,具體chromestatue 文件。但是我們大多數人並不知道這點,並且依舊呼叫了preventDefault。這並不會導致什麼頁面崩潰級的錯誤,但是這可能導致我們忽略了一個頁面效能優化的點,特別是在移動端這種更加重視效能優化的場景下。

相容性

第三個引數是在近段時間才被調整為一組配置項,如果我們需要相容舊版瀏覽器,我們需要寫一些檢測程式碼。

var passiveSupported = false;

try {
  var options = Object.defineProperty({}, "passive", {
    get: function() {
      passiveSupported = true;
    }
  });

  window.addEventListener("test", null, options);
} catch(err) {}
複製程式碼

這段程式碼為passive屬性建立了一個帶有getter函式的options物件;getter設定了一個標識,passiveSupported,被呼叫後就會把其設為true。那意味著如果瀏覽器檢查options物件上的passive值時,passiveSupported將會被設定為true;否則它將保持false。然後我們呼叫addEventListener()去設定一個指定這些選項的空事件處理器,這樣如果瀏覽器將第三個引數認定為物件的話,這些選項值就會被檢查。

你可以利用這個方法檢查options之中任一個值。只需使用與上面類似的程式碼,為選項設定一個getter。然後,當你想實際建立一個是否支援options的事件偵聽器時,你可以這樣做:

someElement.addEventListener("mouseup", handleMouseUp, passiveSupported ? { passive: true } : false);
複製程式碼

我們在someElement這裡新增了一個mouseup。對於第三個引數,如果passiveSupportedtrue,我們傳遞了一個passive值為trueoptions物件;如果相反的話,我們知道要傳遞一個布林值,於是就傳遞false作為useCapture的引數。

PS: 在vue-carousel的那個bug的最開始的PR裡,我將addEventListenerpassive設定為了false,但是這其實不是最優解,最優解應該是去掉監聽器裡的preventDefault,我已經提了另一個PR來解決這個問題。

參考:

相關文章