精讀《深入瞭解現代瀏覽器四》

黃子毅發表於2021-12-20

Inside look at modern web browser 是介紹瀏覽器實現原理的系列文章,共 4 篇,本次精讀介紹第四篇。

概述

前幾章介紹了瀏覽器的基礎程式、執行緒以及它們之間協同的關係,並重點說到了渲染程式是如何處理頁面繪製的,那麼最後一章也就深入到了瀏覽器是如何處理頁面中事件的。

全篇站在瀏覽器實現的視角思考問題,非常有趣。

輸入進入合成器

這是第一小節的標題。乍一看可能不明白在說什麼,但這句話就是本文的核心知識點。為了更好的理解這句話,先要解釋輸入與合成器是什麼:

  • 輸入:不僅包括輸入框的輸入,其實所有使用者操作在瀏覽器眼中都是輸入,比如滾動、點選、滑鼠移動等等。
  • 合成器:第三節說過的,渲染的最後一步,這一步在 GPU 進行光柵化繪圖,如果與瀏覽器主執行緒解耦的化效率會非常高。

所以輸入進入合成器的意思是指,在瀏覽器實際執行的環境中,合成器不得不響應輸入,這可能會導致合成器本身渲染被阻塞,導致頁面卡頓。

"non-fast" 滾動區域

由於 js 程式碼可以繫結事件監聽,而且事件監聽中存在一種 preventDefault() 的 API 可以阻止事件的原生效果比如滾動,所以在一個頁面中,瀏覽器會對所有建立了此監聽的區塊標記為 "non-fast" 滾動區域。

注意,只要建立了 onwheel 事件監聽就會標記,而不是說呼叫了 preventDefault() 才會標記,因為瀏覽器不可能知道業務什麼時候呼叫,所以只能一刀切。

為什麼這種區域被稱為 "non-fast"?因為在這個區域觸發事件時,合成器必須與渲染程式通訊,讓渲染程式執行 js 事件監聽程式碼並獲得使用者指令,比如是否呼叫了 preventDefault() 來阻止滾動?如果阻止了就終止滾動,如果沒有阻止才會繼續滾動,如果最終結果是不阻止,但這個等待時間消耗是巨大的,在低效能裝置比如手機上,滾動延遲甚至有 10~100ms。

然而這並不是裝置效能差導致的,因為滾動是在合成器發生的,如果它可以不與渲染程式通訊,那麼即便是 500 元的安卓機也可以流暢的滾動。

注意事件委託

更有意思的是,瀏覽器支援一種事件委託的 API,它可以將事件委託到其父節點一併監聽。

這本是一個非常方便的 API,但對瀏覽器實現可能是一個災難:

document.body.addEventListener('touchstart', event => {
  if (event.target === area) {
    event.preventDefault();
  }
});

如果瀏覽器解析到上面的程式碼,只能用無語來形容。因為這意味著必須對全頁面都進行 "non-fast" 標記,因為程式碼委託的是整個 document!這會導致滾動非常慢,因為在頁面任何地方滾動都要發生一次合成器與渲染程式的通訊。

所以最好的辦法就是不要寫這種監聽。但還有一種方案是,告訴瀏覽器你不會 preventDefault(),這是因為 chrome 通過對應用原始碼統計後發現,大約 80% 的事件監聽沒有 preventDefault(),而僅僅是做別的事情,所以合成器應該可以與渲染程式的事件處理並行進行,這樣既不卡頓,邏輯也不會丟失。所以新增了一種 passive: true 的標記,標識當前事件可以並行處理:

document.body.addEventListener('touchstart', event => {
  if (event.target === area) {
    event.preventDefault()
  }
 }, {passive: true});

這樣就不會卡頓了,但 preventDefault() 也會失效。

檢查事件是否可取消

對於 passive: true 的情況,事件就實際上變得不可取消了,所以我們最好在程式碼裡做一層判斷:

document.body.addEventListener('touchstart', event => {
  if (event.cancelable && event.target === area) {
    event.preventDefault()
  }
 }, {passive: true});

然而這僅僅是阻止執行沒有意義的 preventDefault(),並不能阻止滾動。這種情況下,最好的辦法是通過 css 申明來阻止橫向移動,因為這個判斷不會發生在渲染程式,所以不會導致合成器與渲染程式的通訊:

#area {
  touch-action: pan-x;
}

事件合併

由於事件觸發頻率可能比瀏覽器幀率還要高(1 秒 120 次),如果瀏覽器堅持對每個事件都進行響應,而一次事件都必須在 js 裡響應一次的話,會導致大量事件阻塞,因為當 FPS 為 60 時,一秒也僅能執行 60 次事件響應,所以事件積壓是無法避免的。

為了解決這個問題,瀏覽器在針對可能導致積壓的事件,比如滾動事件時,將多個事件合併到一次 js 中,僅保留最終狀態。

如果不希望丟掉事件中間過程,可以使用 getCoalescedEvents 從合併事件中找回每一步事件的狀態:

window.addEventListener('pointermove', event => {
  const events = event.getCoalescedEvents();
  for (let event of events) {
    const x = event.pageX;
    const y = event.pageY;
    // draw a line using x and y coordinates.
  }
});

精讀

只要我們認識到事件監聽必須執行在渲染程式,而現代瀏覽器許多高效能 “渲染” 其實都在合成層採用 GPU 做,所以看上去方便的事件監聽肯定會拖慢頁面流暢度。

但就這件事在 React 17 中有過一次討論 Touch/Wheel Event Passiveness in React 17(實際上在即將到來的 18 該問題還在討論中 React 18 not passive wheel / touch event listeners support),因為 React 可以直接在元素上監聽 Touch、Wheel 事件,但其實框架採用了委託的方式在 document(後在 app 根節點)統一監聽,這就導致了使用者根本無從決定事件是否為 passive,如果框架預設 passive,會導致 preventDefault() 失效,否則效能得不到優化。

就結論而言,React 目前還是對幾個受影響的事件 touchstart touchmove wheel 採用 passive 模式,即:

const Test = () => (
  <div
    // 沒有用的,無法阻止滾動,因為委託處預設 passive
    onWheel={event => event.preventDefault()}
  >
    ...
  </div>
)

雖然結論如此而且對效能友好,但並不是一個讓所有人都能滿意的方案,我們看看當時 Dan 是如何思考,並給了哪些解決方案的。

首先背景是,React 16 事件委託繫結在 document 上,React 17 事件委託繫結在 App 根節點上,而根據 chrome 的優化,繫結在 document 的事件委託預設是 passive 的,而其它節點的不會,因此對 React 17 來說,如果什麼都不做,僅改變繫結節點位置,就會存在一個 Break Change。

  1. 第一種方案是堅持 Chrome 效能優化的精神,委託時依然 pasive 處理。這樣處理至少和 React 16 一樣,preventDefault() 都是失效的,雖然不正確,但至少不是 BreakChange。
  2. 第二種方案即什麼都不做,這導致原本預設 passive 的因為繫結到非 document 節點上而 non-passive 了,這樣做不僅有效能問題,而且 API 會存在 BreackChange,雖然這種做法更 “原生”。
  3. touch/wheel 不再採用委託,意味著瀏覽器可以有更少的 "non-fast" 區域,而 preventDefault() 也可以生效了。

最終選擇了第一個方案,因為暫時不希望在 React API 層面出現行為不一致的 BreakChange。

然而 React 18 是一次 BreakChange 的時機,目前還沒有進一步定論。

總結

從瀏覽器角度看待問題會讓你具備上帝視角而不是開發者視角,你不會再覺得一些奇奇怪怪的優化邏輯是 Hack 了,因為你瞭解瀏覽器背後是如何理解與實現的。

不過我們也會看到一些和實現強繫結的無奈,在前端開發框架實現時造成了不可避免的困擾。畢竟作為一個不瞭解瀏覽器實現的開發者,自然會認為 preventDefault() 繫結在滾動事件時,一定可以阻止預設滾動行為呀,但為什麼因為:

  • 瀏覽器分為合成層和渲染程式,通訊成本較高導致滾動事件監聽會引發滾動卡頓。
  • 為了避免通訊,瀏覽器預設為 document 繫結開啟 passive 策略減少 "non-fast" 區域。
  • 開啟了 passive 的事件監聽 preventDefault() 會失效,因為這層實現在 js 裡而不是 GPU。
  • React16 採用事件代理,把元素 onWheel 代理到 document 節點而非當前節點。
  • React17 將 document 節點繫結下移到了 App 根節點,因此瀏覽器優化後的 passive 失效了。
  • React 為了保持 API 不發生 BreakChange,因此將 App 根節點繫結的事件委託預設補上了 passive,使其表現與繫結在 document 一樣。

總之就是 React 與瀏覽器實現背後的糾紛,導致滾動行為阻止失效,而這個結果鏈條傳導到了開發者身上,而且有明顯感知。但瞭解背後原因後,你應該能理解一下 React 團隊的痛苦吧,因為已有 API 確實沒有辦法描述是否 passive 這個行為,所以這是個暫時無法解決的問題。

討論地址是:精讀《深入瞭解現代瀏覽器四》· Issue #381 · dt-fe/weekly

如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公眾號

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

相關文章