Resize Observer 介紹及原理淺析
背景?
響應式設計指的是根據屏幕視口尺寸的不同,對 Web 頁面的布局、外觀進行調整,以便更加有效地進行信息的展示。我們日常生活中接觸的很多應用都遵循響應式的設計。
響應式設計如今也成為 web 應用的基本需求,而現在很多 web 應用都已經組件化,這意味著我們如果想要實現響應式的應用,那么我們也需要有某種方式監聽 「組件/元素」 大小的變化,以便讓 「組件/元素」 也做到響應式。
在 ResizeObserver 出現之前,我們也有一些實現響應式布局的方案,包括:
- JS 方案——window.onresize /window.matchMedia。
- CSS 方案——媒體查詢。
但它們都各自有一些問題。
media query 媒體查詢 - CSS 方案
在 CSS 中可以通過媒體查詢實現響應式,但 CSS 的媒體查詢只能監聽全局屬性,比如 viewport 的大小、screen 的大小等,并不能監聽元素級別的尺寸變化。
而即使 CSS 能夠對元素級別進行監聽,也會遇到循環引用問題,舉個例子,假設我們能夠對某個具體元素的寬度進行監聽,并寫出了以下代碼: (注意現在并不支持 :min-width 這樣的偽類寫法,下面只是偽代碼)
.father {
float: left;
}
.child {
width: 500px;
}
.father:min-width(450px) > .child {
width: 400px;
}
- 因為.father 設置了float: left ,所以它的寬度由 子元素 child 的寬度來決定,即一開始時為 500px;
- 如果.father 的寬度為 500px (大于 450px ),那么按照最后一個選擇器的寫法,子元素寬度應該變為 400px;但當子元素寬度為 400px 時,也會使得外層 father 的寬度變為 400px;
- 因此子元素寬度又會變為 500px,此時循環引用便開始了....
window.resize - JS 方案
resize 事件只有當 viewport 的大小發生變化時會被觸發,元素大小的變化不會觸發 resize 事件;并且也只有注冊在 window 對象上的回調會在 resize 事件發生時被調用,其他元素上的回調不會被調用。
當 「resize」 事件發生后,我們往往需要通過調用 getBoundingClientRect? 或者 getComputedStyle? 來獲取此時我們關心的元素大小,以此判斷元素是否發生了變化。頻繁調用 getBoundingClientRect? 、 getComputedStyle等 API 會導致 「瀏覽器重排(reflow)」,導致頁面性能變差,舉個例子:https://codesandbox.io/s/resize-event-5qn3q0?file=/index.html。
調用 getBoundingClientRect 等函數時,瀏覽器為了保證我們拿到的元素參數是準確的,會觸發一次 reflow 來重新布局。頻繁地調用以上函數就會導致瀏覽器頻繁重排、重繪,進而導致性能問題的出現。
雖然我們可以通過合并讀/寫操作,或是采用節流防抖,來減少重繪的次數,但不可避免的,我們至少需要額外調用至少一次 getBoundingClientRect 操作。
而且當 viewport 大小不變,元素大小變化時,此時我們不能通過監聽 resize 事件來得知這一變化。比如在元素下 append 了一個新的 children,或者將元素的 display? 設為 none,亦或是改變該元素父級節點或是相鄰節點的大小,以上這些都有可能在 viewport 大小不發生變化的情況下,導致元素大小改變,而此時通過監聽 「resize」 事件我們就沒辦法感知到這些變化。
window.matchMedia - JS 方案
可以把 matchMedia 理解為 CSS 中媒體查詢的JS方案。
和 window.resize 類似,window.matchMedia 也只能監聽 viewport 大小的變化;但和 window.resize 會在每次 viewport 大小變化時都觸發事件不同,matchMedia 關心的是某些特殊的斷點,這往往更符合我們實現響應式網頁的實際場景。
舉個例子,我們想實現在屏幕寬度小于 1080px 時將三列布局改為兩列布局,我們并不希望每次 window 大小變化時通知我們 ,而只希望屏幕在大于或小于某個特定的大小時通知我們即可。這種場景下使用 matchMedia 會比監聽 window.resize 要性能更高。
const m = matchMedia('(max-width: 600px)')
m.addEventListener('change',(event)=>{console.log('macth onChange', event)})
小結
方案 | 相同問題 | 特殊問題 |
Media query-CSS | 只能監聽viewport變化,不能監聽某個 「組件/元素」 大小變化 | 循環引用問題 |
window.resize-JS | 需要在 viewport 大小變化時手動獲取元素的大小,可能導致性能問題 | |
window-matchMedia-JS |
以上提到的三種瀏覽器原生方案都存在著只能監聽 viewport 大小變化,而不能監聽 「組件/元素」 大小變化的問題。此外,CSS 的媒體查詢存在著循環引用的問題,window.onresize? 和 window.matchMedia 則都需要在 viewport 大小變化時手動獲取元素的大小,一旦操作過于頻繁則可能導致瀏覽器多次 reflow。
ResizeObserver 就是為了解決以上問題而出現的,可以將其理解為 window.onresize? 的「組件/元素級別」 的替代方案。使用 ResizeObserver 可以讓我們監聽到元素大小的變化,無需再手動調用 getBoundingClientRect 來獲取元素的尺寸大小,同時也解決了無限回調和循環依賴的問題。
ResizeObserver的使用?
API
- ResizeObserver.disconnect:取消和結束目標對象上所有對 Element 或 SVGElement 觀察。
- ResizeObserver.observe:開始觀察指定的 Element 或 SVGElement。
- 第一個參數為觀察的元素。
- 第二個參數為可選參數 BoxOptions,用來指定將以哪種盒子模型來觀察變動,如content-box (默認值),border-box和device-pixel-content-box。
- ResizeObserver.unobserve:結束觀察指定的 Element 或 SVGElement。
const ro = new ResizeObserver(entries => {
for (let entry of entries) {
const cr = entry.contentRect;
console.log('Element:', entry.target);
console.log(`Element size: ${cr.width}px x ${cr.height}px`);
console.log(`Element padding: ${cr.top}px ; ${cr.left}px`);
}
});
// Observe one or multiple elements
ro.observe(someElement);
附上 MDN 的兩個demo:
- Resize observer border-radius test - CodeSandbox:https://codesandbox.io/s/resize-observer-border-radius-test-ztwuyg。
- Resize observer text test - CodeSandbox:https://codesandbox.io/s/resize-observer-text-test-dktwk1。
什么時候觸發通知
與我們關注的盒模型有關,ResizeObserver 會根據調用 observe 函數時傳遞的第二個可選參數 BoxOptions 傳入的盒模型參數進行監聽,當元素該盒模型變化時觸發通知。默認監聽 content-box變化以觸發監聽。
通知內容包括什么
通知的內容包含了足夠的信息,以便開發者能夠根據當前元素的具體大小信息來作出變化,而不是要開發者重新調用 getComputedStyle、 getBoundingClientRect 來獲取。
- 監聽元素:target。
- contentRect。
- contentBoxSize。
- borderBoxSize。
- devicePixelContentBoxSize。
需要注意的是,雖然只有當 BoxOptions 關心的盒模型變化時才會觸發通知,但實際上通知時會將三種不同盒模型下的具體大小都返回給回調函數,用戶無需再次手動獲取。
在 React 中使用
為了避免在 React render中多次聲明 ResizeObserver 實例,我們可以把實例化過程放在 useLayoutEffect 或 useEffect 中。并且在非 SSR 場景中,我們應該盡量使用 useLayoutEffect 而不是 useEffect。
useLayoutEffect 和 useEffect 的最大差別在于執行時機的不同,useEffect 會在瀏覽器繪制完成之后調用,而 useLayoutEffect 則會在 React 更新 dom 之后,瀏覽器繪制之前執行,并且會阻塞后面的繪制過程,因此適合在 useLayoutEffect 中進行更改布局、及時獲取最新布局信息等操作。
ResizeObserver 原理?
執行時機
先從瀏覽器渲染流程開始說起,網頁渲染會經歷以下幾個主要過程:
- 解析 HTML,構建 DOM 樹。
- 解析 CSS,生成 CSS 規則樹。
- 布局 Layout——合并 DOM 樹和 CSS 規則,生成 Render 樹。
- 繪制 Paint——繪制 Render 樹(paint),繪制頁面像素信息。
「如果是由我們來設計,我們應該在以上渲染流程中的哪個環境來執行 ResizeObserver 的監聽通知會比較合理?」
因為我們在 ResizeObserver 的回調函數中可以(也經常會)根據當前元素的大小來改變 style 或者 dom 樹,而這些操作往往都會觸發 layout/reflow;因此,應該是在 「布局Layout 和 繪制Paint 之間」來執行回調函數會更加合理。
而如果有多個 ResizeObserver 實例都在回調中進行了改變布局的操作,那么最好的方式就是在所有回調都執行完重新布局,確保得到一個最終準確的布局之后,再來進行繪制 Paint,避免繪制的內容是無效內容。
因此如上圖所示,ResizeObserver 的通知會在 Layout 和 Paint 之間進行(圖中的 4 Notify),當回調中改變了 Layout 時,則會重新 loop 執行 Animate、RAF、Layout、Notify,直到所有需要被通知的元素都通知完(也可以理解為 loop循環 會在 layout 不再被改變時結束)。
如何判斷是否需要通知
每個 ResizeObserver 實例內都有一個 ResizeObservation 對象,ResizeObservation 對象表達了一種訂閱監聽的關系,并在其中記錄了監聽的元素(target)、監聽的盒模型(即observe函數的第二個參數)、上次通知的值(lastReportedSizes,即上次通知時元素的大小尺寸)
每次 layout 過后,對于監聽的每個元素,都會重新計算元素的大小,并與上次通知的大小(lastReportedSizes)進行比較,一旦大小發生變化才會被設置為 active,意味著 「可能」 會被通知。為什么這里提的是 「可能」 ,下面會進行解釋。
需要注意的是,內部獲取元素的大小是通過調用 getComputedStyle 實現的,那么多次調用 getComputedStyle 會不會導致瀏覽器頻繁 layout/reflow ?
- 在瀏覽器觸發 reflow 后,所有已有元素位置都會記錄快照,只要不再觸發位置等變化導致快照失效,那么第二次開始訪問位置就不會觸發 reflow。
- 當前面的通知回調改變了 Layout 時,下一個 ResizeObserver 實例調用 getComputedStyle 時就有可能導致瀏覽器 reflow。
- 但此時為了獲取準確的元素信息, reflow 是無法避免的;因為不涉及到 繪制paint,所以開銷還是可接受的。
無限循環
結合上圖,我們假設這樣的場景,在監聽到 「節點1」 寬度變化時,設置 「子孫節點2」 的寬度;而在 「節點2」 寬度改變時,我們對 「節點1」 的寬度進行改變,此時可能又會觸發 「節點1」 的監聽回調,從而出現無限循環的監關系。
在 ResizeObserver 的回調中對 dom 進行操作,比如改變另外一個元素的大小,或是隱藏/展示某個元素,這些操作可能會導致新的回調調用,引發無限循環,最終導致界面 UI 卡死。上面我們只舉三個層級節點的例子作為說明,如果節點監聽關系的數量越多、層級越深,那么情況就會更糟。
還有另外一種場景是,在監聽函數中創建新的 ResizeObserver 實例,導致循環的每一次迭代都有新的元素需要通知,那么最終循環就會因為內存溢出而終止,這里不作過多討論。
如果避免無限循環
無限循環的場景是真實存在的,想要避免無限循環的出現,我們需要給循環過程加上一些限制,以此來解除循環。有三種限制策略可以考慮:
- 執行次數限制。
- 允許執行最多次數 N 次循環,當超過次數 N 時,循環終止。
- 優點是實現簡單,并且具有一致性,當這個算法在不同的機器上運行時都能有相同的表現。
- 缺點是 N 的定義太過隨意,缺乏比較可靠的結論定義。
- 執行時間限制。
- 循環最多執行 N ms 時長,當超過這個時間時循環終止。
- 雖然聽起來實現很簡單,但我們無法保證具體會執行多少次調度,在不同性能的機器上,每次執行的時間是不同的,意味著不同的機器執行次數會不同,也可能因此導致不同機器上最終展示的內容不一致。
- 執行深度限制。
執行深度限制
設定一次渲染流程中需要通知的元素(指的是和上次通知時的大小 lastReportedSize 相比發生了變化)為集合 N,設定上次迭代的元素最小深度 Depth 為 ∞
當 N 不為空時,開始循環。
- 在一次迭代中,對集合 N 中的所有元素進行通知(并在通知中可能觸發重新布局流程),并將 Depth 更新為本次迭代中元素的最小深度 d。
- 將所有小于等于深度 d 的元素移除,更新集合 N——即下次迭代只會對比上次迭代的最淺元素更深的元素進行通知。
直到 N 為空時,循環終止,通知結束,開始瀏覽器繪制 Paint。
通過以上說明,我們也可以意識到在一次循環中,只有滿足以下兩個條件的元素才會被通知:
- 上次迭代/Layout過后,元素的大小被改變了。
- 元素的深度比上次迭代的最淺深度更低。
「那么深度限制就不存在問題了嗎?」
深度限制可能會使得頁面展示不是完全準確的,但是相比于頁面UI卡死,這個問題對于用戶而言是更好接受的。