掃二維碼與項目經(jīng)理溝通
我們在微信上24小時期待你的聲音
解答本文疑問/技術咨詢/運營咨詢/技術建議/互聯(lián)網(wǎng)交流
響應式設計指的是根據(jù)屏幕視口尺寸的不同,對 Web 頁面的布局、外觀進行調整,以便更加有效地進行信息的展示。我們?nèi)粘I钪薪佑|的很多應用都遵循響應式的設計。

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

我們在微信上24小時期待你的聲音
解答本文疑問/技術咨詢/運營咨詢/技術建議/互聯(lián)網(wǎng)交流