passive 的事件監聽器

紫雲飛發表於2016-06-01

很久以前,addEventListener() 的引數約定是這樣的:

addEventListener(type, listener, useCapture)

後來,最後一個引數,也就是控制監聽器是在捕獲階段執行還是在冒泡階段執行的 useCapture 引數,變成了可選引數(傳 true 的情況太少了),成了:

addEventListener(type, listener[, useCapture ])

去年年底,DOM 規範做了修訂:addEventListener() 的第三個引數可以是個物件值了,也就是說第三個引數現在可以是兩種型別的值了:

addEventListener(type, listener[, useCapture ])
addEventListener(type, listener[, options ])

這個修訂是為了擴充套件新的選項,從而自定義更多的行為,目前規範中 options 物件可用的屬性有三個:

addEventListener(type, listener, {
    capture: false,
    passive: false,
    once: false
})

三個屬性都是布林型別的開關,預設值都為 false。其中 capture 屬性等價於以前的 useCapture 引數;once 屬性就是表明該監聽器是一次性的,執行一次後就被自動 removeEventListener 掉,還沒有瀏覽器實現它;passive 屬性是本文的主角,Firefox 和 Chrome 已經實現,先看個 Chrome 官方的視訊介紹(單擊播放):

很多移動端的頁面都會監聽 touchstart 等 touch 事件,像這樣:

document.addEventListener("touchstart", function(e){
    ... // 瀏覽器不知道這裡會不會有 e.preventDefault()
})

由於 touchstart 事件物件的 cancelable 屬性為 true,也就是說它的預設行為可以被監聽器通過 preventDefault() 方法阻止,那它的預設行為是什麼呢,通常來說就是滾動當前頁面(還可能是縮放頁面),如果它的預設行為被阻止了,頁面就必須靜止不動。但瀏覽器無法預先知道一個監聽器會不會呼叫 preventDefault(),它能做的只有等監聽器執行完後再去執行預設行為,而監聽器執行是要耗時的,有些甚至耗時很明顯,這樣就會導致頁面卡頓。視訊裡也說了,即便監聽器是個空函式,也會產生一定的卡頓,畢竟空函式的執行也會耗時。

視訊裡還說了,有 80% 的滾動事件監聽器是不會阻止預設行為的,也就是說大部分情況下,瀏覽器是白等了。所以,passive 監聽器誕生了,passive 的意思是“順從的”,表示它不會對事件的預設行為說 no,瀏覽器知道了一個監聽器是 passive 的,它就可以在兩個執行緒裡同時執行監聽器中的 JavaScript 程式碼和瀏覽器的預設行為了。

下面是在 Chrome for Android 上滾動 cnn.com 頁面的對比視訊,右邊在註冊 touchstart 事件時新增了 {passive: true} 選項,左邊沒有,可以看到,右邊的順暢多了。

假如在一個 passive 的監聽器裡執行了 preventDefault() 會怎麼樣?

假如有人不小心在 passive 的監聽器裡呼叫了 preventDefault(),也無妨,因為 preventDefault() 不會產生任何效果。這裡我用自定義事件演示一下這種情況:

let event = new Event("foo", {  // 建立一個 type 為 foo 的事件物件,可以被阻止預設行為 
  "cancelable": true
})

document.addEventListener("foo", function(event) { // 在 document 上繫結 foo 事件的監聽函式
  console.log(event.defaultPrevented) // false
  event.preventDefault()
  console.log(event.defaultPrevented) // 還是 false,preventDefault() 無效
}, {
  passive: true
})
 
document.dispatchEvent(event) // 派發自定義事件

同時,瀏覽器的開發者工具也會發出警告:

Chrome 下:

Firefox 下:

開發者工具的支援 

除了上面在 passive 的監聽器裡呼叫 preventDefault() 會發出警告外,Chrome 的開發者工具還會:

1. 發現耗時超過 100 毫秒的非 passive 的監聽器,警告你加上 {passive: true}:

2. 給監聽器物件增加 passive 屬性,監聽器物件在普通頁面中是獲取不到的,可以在 Event Listeners 皮膚中和通過呼叫 getEventListeners() Command Line API 獲取到:

Firefox 的開發者工具目前還沒有這些。

現在該如何呼叫 removeEventListener? 

以前,在第三個引數是布林值的時候,addEventListener("foo", listener, true) 新增的監聽器,必須用 removeEventListener("foo", listener, true) 才能刪除掉。因為這個監聽器也有可能還註冊在了冒泡階段,那樣的話,同一個監聽器實際上對應著兩個監聽器物件(通過 getEventListeners() 可看到)。

那現在 addEventListener("foo", listener, {passive: true}) 新增的監聽器該如何刪除呢?答案是 removeEventListener("foo", listener) 就可以了,passive 可以省略,原因是:在瀏覽器內部,用來儲存監聽器的 map 的 key 是由事件型別,監聽器函式,是否捕獲這三者組成的,passive 和 once 不在其中,理由顯而易見,一個監聽器同時是 passive 和非 passive(以及同時是 once 和非 once)是說不通的,如果你新增了兩者,那麼後新增的不算,瀏覽器會認為新增過了:

addEventListener("foo", listener, {passive: true})
addEventListener("foo", listener, {passive: false}) // 這句不算

addEventListener("bar", listener, {passive: false})
addEventListener("bar", listener, {passive: true})  // 這句不算

所以說在 removeEventListener 的時候永遠不需寫 passive 和 once,但 capture 可能要:

addEventListener("foo", listener, {capture: true})
removeEventListener("foo", listener, {capture: true}) // {capture: true} 必須加,當然 {capture: true} 換成 true 也可以

passive 不能保證什麼

passive 監聽器能保證的只有一點,那就是呼叫 preventDefault() 無效,至於瀏覽器對預設行為卡頓的優化,那是瀏覽器的事情,是在規範要求之外的。鑑於這個新特性本來就是為解決滾動和觸控事件的卡頓而發明的,目前 Chrome 和 Firefox 支援優化的事件型別也僅限這類事件,比如 touchstart,touchmove,wheel 等事件,具體的事件列表我無法提供,也許得研讀原始碼才行。

但我可以列舉幾個瀏覽器不優化的事件型別,還附帶 demo:

除了這三種事件型別外,所有 Cancelable 為 true 的事件型別理論上都是可以有這種優化的, 可以看看這個 UI 事件型別列表,還有這個觸控事件型別列表,注意 Cancelable 列。

我諮詢了 Chrome 工程師,有沒有優化滾動和觸控事件型別之外事件型別的計劃,答覆是目前沒有:

Firefox 的 APZ 優化

在 passive 規範之前,Firefox 就已經有了自己對滾動觸控行為卡頓問題的優化,其中有個關鍵做法是,不尊重 preventDefault():如果在一定時間之內沒有呼叫 preventDefault(),那 Firefox 就假定你不會阻止預設滾動了,比如執行下面這句後,頁面會無法滾動:

addEventListener("wheel", function (e) {
    e.preventDefault()
})

但執行這句後照樣能滾動:

addEventListener("wheel", function (e) {
    sleep(300)
    e.preventDefault() // 這句在 Firefox 中無效
})

這篇部落格講了 APZ 優化:Smoother scrolling in Firefox 46 with APZ

特性檢測

下面是從 Modernizr 裡複製的檢測指令碼:

var supportsPassiveOption = false;
try {
  var opts = Object.defineProperty({}, 'passive', {
    get: function() {
      supportsPassiveOption = true;
    }
  });
  window.addEventListener('test', null, opts);
} catch (e) {}

可以這麼用:

if (supportsPassiveOption) {
    document.addEventListener("foo", listener, {passive: true}) // 舊瀏覽器裡第三引數會被自動轉成 true,不是我們想要的
}
else {
    document.addEventListener("foo", listener)
}

要 passive 都得 passive

對於在同一個 DOM 物件身上新增的同一型別事件的監聽器,只要有一個不是 passive 的,那瀏覽器就無法優化。

各大框架目前還沒一個使用該特性

https://github.com/facebook/react/issues/6436

https://github.com/angular/angular/issues/8866

https://github.com/emberjs/ember.js/issues/12783

https://github.com/Polymer/polymer/issues/3604

https://github.com/jquery/jquery/issues/2871

相關文章