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。
- 第一種方案是堅持 Chrome 效能優化的精神,委託時依然 pasive 處理。這樣處理至少和 React 16 一樣,
preventDefault()
都是失效的,雖然不正確,但至少不是 BreakChange。 - 第二種方案即什麼都不做,這導致原本預設
passive
的因為繫結到非 document 節點上而non-passive
了,這樣做不僅有效能問題,而且 API 會存在 BreackChange,雖然這種做法更 “原生”。 - 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 許可證)