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