Resize Observer 介紹及原理淺析

背景?
響應(yīng)式設(shè)計(jì)指的是根據(jù)屏幕視口尺寸的不同,對(duì) Web 頁(yè)面的布局、外觀進(jìn)行調(diào)整,以便更加有效地進(jìn)行信息的展示。我們?nèi)粘I钪薪佑|的很多應(yīng)用都遵循響應(yīng)式的設(shè)計(jì)。
響應(yīng)式設(shè)計(jì)如今也成為 web 應(yīng)用的基本需求,而現(xiàn)在很多 web 應(yīng)用都已經(jīng)組件化,這意味著我們?nèi)绻胍獙?shí)現(xiàn)響應(yīng)式的應(yīng)用,那么我們也需要有某種方式監(jiān)聽 「組件/元素」 大小的變化,以便讓 「組件/元素」 也做到響應(yīng)式。
在 ResizeObserver 出現(xiàn)之前,我們也有一些實(shí)現(xiàn)響應(yīng)式布局的方案,包括:
- JS 方案——window.onresize /window.matchMedia。
- CSS 方案——媒體查詢。
但它們都各自有一些問(wèn)題。
media query 媒體查詢 - CSS 方案
在 CSS 中可以通過(guò)媒體查詢實(shí)現(xiàn)響應(yīng)式,但 CSS 的媒體查詢只能監(jiān)聽全局屬性,比如 viewport 的大小、screen 的大小等,并不能監(jiān)聽元素級(jí)別的尺寸變化。
而即使 CSS 能夠?qū)υ丶?jí)別進(jìn)行監(jiān)聽,也會(huì)遇到循環(huán)引用問(wèn)題,舉個(gè)例子,假設(shè)我們能夠?qū)δ硞€(gè)具體元素的寬度進(jìn)行監(jiān)聽,并寫出了以下代碼: (注意現(xiàn)在并不支持 :min-width 這樣的偽類寫法,下面只是偽代碼)
.father {
float: left;
}
.child {
width: 500px;
}
.father:min-width(450px) > .child {
width: 400px;
}
- 因?yàn)?father 設(shè)置了float: left ,所以它的寬度由 子元素 child 的寬度來(lái)決定,即一開始時(shí)為 500px;
- 如果.father 的寬度為 500px (大于 450px ),那么按照最后一個(gè)選擇器的寫法,子元素寬度應(yīng)該變?yōu)?400px;但當(dāng)子元素寬度為 400px 時(shí),也會(huì)使得外層 father 的寬度變?yōu)?400px;
- 因此子元素寬度又會(huì)變?yōu)?500px,此時(shí)循環(huán)引用便開始了....
window.resize - JS 方案
resize 事件只有當(dāng) viewport 的大小發(fā)生變化時(shí)會(huì)被觸發(fā),元素大小的變化不會(huì)觸發(fā) resize 事件;并且也只有注冊(cè)在 window 對(duì)象上的回調(diào)會(huì)在 resize 事件發(fā)生時(shí)被調(diào)用,其他元素上的回調(diào)不會(huì)被調(diào)用。
當(dāng) 「resize」 事件發(fā)生后,我們往往需要通過(guò)調(diào)用 getBoundingClientRect? 或者 getComputedStyle? 來(lái)獲取此時(shí)我們關(guān)心的元素大小,以此判斷元素是否發(fā)生了變化。頻繁調(diào)用 getBoundingClientRect? 、 getComputedStyle等 API 會(huì)導(dǎo)致 「瀏覽器重排(reflow)」,導(dǎo)致頁(yè)面性能變差,舉個(gè)例子:https://codesandbox.io/s/resize-event-5qn3q0?file=/index.html。
調(diào)用 getBoundingClientRect 等函數(shù)時(shí),瀏覽器為了保證我們拿到的元素參數(shù)是準(zhǔn)確的,會(huì)觸發(fā)一次 reflow 來(lái)重新布局。頻繁地調(diào)用以上函數(shù)就會(huì)導(dǎo)致瀏覽器頻繁重排、重繪,進(jìn)而導(dǎo)致性能問(wèn)題的出現(xiàn)。
雖然我們可以通過(guò)合并讀/寫操作,或是采用節(jié)流防抖,來(lái)減少重繪的次數(shù),但不可避免的,我們至少需要額外調(diào)用至少一次 getBoundingClientRect 操作。
而且當(dāng) viewport 大小不變,元素大小變化時(shí),此時(shí)我們不能通過(guò)監(jiān)聽 resize 事件來(lái)得知這一變化。比如在元素下 append 了一個(gè)新的 children,或者將元素的 display? 設(shè)為 none,亦或是改變?cè)撛馗讣?jí)節(jié)點(diǎn)或是相鄰節(jié)點(diǎn)的大小,以上這些都有可能在 viewport 大小不發(fā)生變化的情況下,導(dǎo)致元素大小改變,而此時(shí)通過(guò)監(jiān)聽 「resize」 事件我們就沒辦法感知到這些變化。
window.matchMedia - JS 方案
可以把 matchMedia 理解為 CSS 中媒體查詢的JS方案。
和 window.resize 類似,window.matchMedia 也只能監(jiān)聽 viewport 大小的變化;但和 window.resize 會(huì)在每次 viewport 大小變化時(shí)都觸發(fā)事件不同,matchMedia 關(guān)心的是某些特殊的斷點(diǎn),這往往更符合我們實(shí)現(xiàn)響應(yīng)式網(wǎng)頁(yè)的實(shí)際場(chǎng)景。
舉個(gè)例子,我們想實(shí)現(xiàn)在屏幕寬度小于 1080px 時(shí)將三列布局改為兩列布局,我們并不希望每次 window 大小變化時(shí)通知我們 ,而只希望屏幕在大于或小于某個(gè)特定的大小時(shí)通知我們即可。這種場(chǎng)景下使用 matchMedia 會(huì)比監(jiān)聽 window.resize 要性能更高。
const m = matchMedia('(max-width: 600px)')
m.addEventListener('change',(event)=>{console.log('macth onChange', event)})
小結(jié)
方案 | 相同問(wèn)題 | 特殊問(wèn)題 |
Media query-CSS | 只能監(jiān)聽viewport變化,不能監(jiān)聽某個(gè) 「組件/元素」 大小變化 | 循環(huán)引用問(wèn)題 |
window.resize-JS | 需要在 viewport 大小變化時(shí)手動(dòng)獲取元素的大小,可能導(dǎo)致性能問(wèn)題 | |
window-matchMedia-JS |
以上提到的三種瀏覽器原生方案都存在著只能監(jiān)聽 viewport 大小變化,而不能監(jiān)聽 「組件/元素」 大小變化的問(wèn)題。此外,CSS 的媒體查詢存在著循環(huán)引用的問(wèn)題,window.onresize? 和 window.matchMedia 則都需要在 viewport 大小變化時(shí)手動(dòng)獲取元素的大小,一旦操作過(guò)于頻繁則可能導(dǎo)致瀏覽器多次 reflow。
ResizeObserver 就是為了解決以上問(wèn)題而出現(xiàn)的,可以將其理解為 window.onresize? 的「組件/元素級(jí)別」 的替代方案。使用 ResizeObserver 可以讓我們監(jiān)聽到元素大小的變化,無(wú)需再手動(dòng)調(diào)用 getBoundingClientRect 來(lái)獲取元素的尺寸大小,同時(shí)也解決了無(wú)限回調(diào)和循環(huán)依賴的問(wèn)題。
ResizeObserver的使用?
API
- ResizeObserver.disconnect:取消和結(jié)束目標(biāo)對(duì)象上所有對(duì) Element 或 SVGElement 觀察。
- ResizeObserver.observe:開始觀察指定的 Element 或 SVGElement。
- 第一個(gè)參數(shù)為觀察的元素。
- 第二個(gè)參數(shù)為可選參數(shù) BoxOptions,用來(lái)指定將以哪種盒子模型來(lái)觀察變動(dòng),如content-box (默認(rèn)值),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 的兩個(gè)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。
什么時(shí)候觸發(fā)通知
與我們關(guān)注的盒模型有關(guān),ResizeObserver 會(huì)根據(jù)調(diào)用 observe 函數(shù)時(shí)傳遞的第二個(gè)可選參數(shù) BoxOptions 傳入的盒模型參數(shù)進(jìn)行監(jiān)聽,當(dāng)元素該盒模型變化時(shí)觸發(fā)通知。默認(rèn)監(jiān)聽 content-box變化以觸發(fā)監(jiān)聽。
通知內(nèi)容包括什么
通知的內(nèi)容包含了足夠的信息,以便開發(fā)者能夠根據(jù)當(dāng)前元素的具體大小信息來(lái)作出變化,而不是要開發(fā)者重新調(diào)用 getComputedStyle、 getBoundingClientRect 來(lái)獲取。
- 監(jiān)聽元素:target。
- contentRect。
- contentBoxSize。
- borderBoxSize。
- devicePixelContentBoxSize。
需要注意的是,雖然只有當(dāng) BoxOptions 關(guān)心的盒模型變化時(shí)才會(huì)觸發(fā)通知,但實(shí)際上通知時(shí)會(huì)將三種不同盒模型下的具體大小都返回給回調(diào)函數(shù),用戶無(wú)需再次手動(dòng)獲取。
在 React 中使用
為了避免在 React render中多次聲明 ResizeObserver 實(shí)例,我們可以把實(shí)例化過(guò)程放在 useLayoutEffect 或 useEffect 中。并且在非 SSR 場(chǎng)景中,我們應(yīng)該盡量使用 useLayoutEffect 而不是 useEffect。
useLayoutEffect 和 useEffect 的最大差別在于執(zhí)行時(shí)機(jī)的不同,useEffect 會(huì)在瀏覽器繪制完成之后調(diào)用,而 useLayoutEffect 則會(huì)在 React 更新 dom 之后,瀏覽器繪制之前執(zhí)行,并且會(huì)阻塞后面的繪制過(guò)程,因此適合在 useLayoutEffect 中進(jìn)行更改布局、及時(shí)獲取最新布局信息等操作。
ResizeObserver 原理?
執(zhí)行時(shí)機(jī)

先從瀏覽器渲染流程開始說(shuō)起,網(wǎng)頁(yè)渲染會(huì)經(jīng)歷以下幾個(gè)主要過(guò)程:
- 解析 HTML,構(gòu)建 DOM 樹。
- 解析 CSS,生成 CSS 規(guī)則樹。
- 布局 Layout——合并 DOM 樹和 CSS 規(guī)則,生成 Render 樹。
- 繪制 Paint——繪制 Render 樹(paint),繪制頁(yè)面像素信息。
「如果是由我們來(lái)設(shè)計(jì),我們應(yīng)該在以上渲染流程中的哪個(gè)環(huán)境來(lái)執(zhí)行 ResizeObserver 的監(jiān)聽通知會(huì)比較合理?」
因?yàn)槲覀冊(cè)?ResizeObserver 的回調(diào)函數(shù)中可以(也經(jīng)常會(huì))根據(jù)當(dāng)前元素的大小來(lái)改變 style 或者 dom 樹,而這些操作往往都會(huì)觸發(fā) layout/reflow;因此,應(yīng)該是在 「布局Layout 和 繪制Paint 之間」來(lái)執(zhí)行回調(diào)函數(shù)會(huì)更加合理。
而如果有多個(gè) ResizeObserver 實(shí)例都在回調(diào)中進(jìn)行了改變布局的操作,那么最好的方式就是在所有回調(diào)都執(zhí)行完重新布局,確保得到一個(gè)最終準(zhǔn)確的布局之后,再來(lái)進(jìn)行繪制 Paint,避免繪制的內(nèi)容是無(wú)效內(nèi)容。
因此如上圖所示,ResizeObserver 的通知會(huì)在 Layout 和 Paint 之間進(jìn)行(圖中的 4 Notify),當(dāng)回調(diào)中改變了 Layout 時(shí),則會(huì)重新 loop 執(zhí)行 Animate、RAF、Layout、Notify,直到所有需要被通知的元素都通知完(也可以理解為 loop循環(huán) 會(huì)在 layout 不再被改變時(shí)結(jié)束)。
如何判斷是否需要通知
每個(gè) ResizeObserver 實(shí)例內(nèi)都有一個(gè) ResizeObservation 對(duì)象,ResizeObservation 對(duì)象表達(dá)了一種訂閱監(jiān)聽的關(guān)系,并在其中記錄了監(jiān)聽的元素(target)、監(jiān)聽的盒模型(即observe函數(shù)的第二個(gè)參數(shù))、上次通知的值(lastReportedSizes,即上次通知時(shí)元素的大小尺寸)
每次 layout 過(guò)后,對(duì)于監(jiān)聽的每個(gè)元素,都會(huì)重新計(jì)算元素的大小,并與上次通知的大?。╨astReportedSizes)進(jìn)行比較,一旦大小發(fā)生變化才會(huì)被設(shè)置為 active,意味著 「可能」 會(huì)被通知。為什么這里提的是 「可能」 ,下面會(huì)進(jìn)行解釋。
需要注意的是,內(nèi)部獲取元素的大小是通過(guò)調(diào)用 getComputedStyle 實(shí)現(xiàn)的,那么多次調(diào)用 getComputedStyle 會(huì)不會(huì)導(dǎo)致瀏覽器頻繁 layout/reflow ?
- 在瀏覽器觸發(fā) reflow 后,所有已有元素位置都會(huì)記錄快照,只要不再觸發(fā)位置等變化導(dǎo)致快照失效,那么第二次開始訪問(wèn)位置就不會(huì)觸發(fā) reflow。
- 當(dāng)前面的通知回調(diào)改變了 Layout 時(shí),下一個(gè) ResizeObserver 實(shí)例調(diào)用 getComputedStyle 時(shí)就有可能導(dǎo)致瀏覽器 reflow。
- 但此時(shí)為了獲取準(zhǔn)確的元素信息, reflow 是無(wú)法避免的;因?yàn)椴簧婕暗?繪制paint,所以開銷還是可接受的。
無(wú)限循環(huán)

結(jié)合上圖,我們假設(shè)這樣的場(chǎng)景,在監(jiān)聽到 「節(jié)點(diǎn)1」 寬度變化時(shí),設(shè)置 「子孫節(jié)點(diǎn)2」 的寬度;而在 「節(jié)點(diǎn)2」 寬度改變時(shí),我們對(duì) 「節(jié)點(diǎn)1」 的寬度進(jìn)行改變,此時(shí)可能又會(huì)觸發(fā) 「節(jié)點(diǎn)1」 的監(jiān)聽回調(diào),從而出現(xiàn)無(wú)限循環(huán)的監(jiān)關(guān)系。
在 ResizeObserver 的回調(diào)中對(duì) dom 進(jìn)行操作,比如改變另外一個(gè)元素的大小,或是隱藏/展示某個(gè)元素,這些操作可能會(huì)導(dǎo)致新的回調(diào)調(diào)用,引發(fā)無(wú)限循環(huán),最終導(dǎo)致界面 UI 卡死。上面我們只舉三個(gè)層級(jí)節(jié)點(diǎn)的例子作為說(shuō)明,如果節(jié)點(diǎn)監(jiān)聽關(guān)系的數(shù)量越多、層級(jí)越深,那么情況就會(huì)更糟。
還有另外一種場(chǎng)景是,在監(jiān)聽函數(shù)中創(chuàng)建新的 ResizeObserver 實(shí)例,導(dǎo)致循環(huán)的每一次迭代都有新的元素需要通知,那么最終循環(huán)就會(huì)因?yàn)閮?nèi)存溢出而終止,這里不作過(guò)多討論。
如果避免無(wú)限循環(huán)
無(wú)限循環(huán)的場(chǎng)景是真實(shí)存在的,想要避免無(wú)限循環(huán)的出現(xiàn),我們需要給循環(huán)過(guò)程加上一些限制,以此來(lái)解除循環(huán)。有三種限制策略可以考慮:
- 執(zhí)行次數(shù)限制。
- 允許執(zhí)行最多次數(shù) N 次循環(huán),當(dāng)超過(guò)次數(shù) N 時(shí),循環(huán)終止。
- 優(yōu)點(diǎn)是實(shí)現(xiàn)簡(jiǎn)單,并且具有一致性,當(dāng)這個(gè)算法在不同的機(jī)器上運(yùn)行時(shí)都能有相同的表現(xiàn)。
- 缺點(diǎn)是 N 的定義太過(guò)隨意,缺乏比較可靠的結(jié)論定義。
- 執(zhí)行時(shí)間限制。
- 循環(huán)最多執(zhí)行 N ms 時(shí)長(zhǎng),當(dāng)超過(guò)這個(gè)時(shí)間時(shí)循環(huán)終止。
- 雖然聽起來(lái)實(shí)現(xiàn)很簡(jiǎn)單,但我們無(wú)法保證具體會(huì)執(zhí)行多少次調(diào)度,在不同性能的機(jī)器上,每次執(zhí)行的時(shí)間是不同的,意味著不同的機(jī)器執(zhí)行次數(shù)會(huì)不同,也可能因此導(dǎo)致不同機(jī)器上最終展示的內(nèi)容不一致。
- 執(zhí)行深度限制。
執(zhí)行深度限制
設(shè)定一次渲染流程中需要通知的元素(指的是和上次通知時(shí)的大小 lastReportedSize 相比發(fā)生了變化)為集合 N,設(shè)定上次迭代的元素最小深度 Depth 為 ∞
當(dāng) N 不為空時(shí),開始循環(huán)。
- 在一次迭代中,對(duì)集合 N 中的所有元素進(jìn)行通知(并在通知中可能觸發(fā)重新布局流程),并將 Depth 更新為本次迭代中元素的最小深度 d。
- 將所有小于等于深度 d 的元素移除,更新集合 N——即下次迭代只會(huì)對(duì)比上次迭代的最淺元素更深的元素進(jìn)行通知。
直到 N 為空時(shí),循環(huán)終止,通知結(jié)束,開始瀏覽器繪制 Paint。

通過(guò)以上說(shuō)明,我們也可以意識(shí)到在一次循環(huán)中,只有滿足以下兩個(gè)條件的元素才會(huì)被通知:
- 上次迭代/Layout過(guò)后,元素的大小被改變了。
- 元素的深度比上次迭代的最淺深度更低。
「那么深度限制就不存在問(wèn)題了嗎?」
深度限制可能會(huì)使得頁(yè)面展示不是完全準(zhǔn)確的,但是相比于頁(yè)面UI卡死,這個(gè)問(wèn)題對(duì)于用戶而言是更好接受的。























