如何動(dòng)態(tài)監(jiān)聽 DOM 元素高度變化?
以下文章來源于騰訊IMWeb前端團(tuán)隊(duì) ,作者h(yuǎn)ope
大家好,我是零一,考慮這樣一種情況,產(chǎn)品同學(xué)希望達(dá)到以下功能:
在我們的網(wǎng)頁中有一個(gè)固定區(qū)域,這個(gè)區(qū)域會(huì)用于渲染從后端拉取的含有圖片等資源的富文本字符串。
他需要在內(nèi)容不超過一個(gè)最大高度的時(shí)候完全顯示所有內(nèi)容,超過最大內(nèi)容后僅展示最大高度范圍內(nèi)的內(nèi)容,超出部分隱藏,并通過一個(gè)按鈕 “展示更多” 來給用戶展示更多的選擇。
在這看似簡單的需求當(dāng)中,其實(shí)涉及到了一個(gè)難點(diǎn),那就是怎樣動(dòng)態(tài)的監(jiān)聽到內(nèi)容區(qū)域的高度變化?
因?yàn)樵谶@里面會(huì)含有圖片資源,他們在渲染的時(shí)候會(huì)發(fā)起網(wǎng)絡(luò)請求,等待圖片加載完成后觸發(fā)瀏覽器重排,該區(qū)域的高度被撐開。
因此,內(nèi)容區(qū)域的高度是動(dòng)態(tài)變化,且變化的時(shí)間點(diǎn)是未知的,那么怎樣知道我們的內(nèi)容區(qū)高度發(fā)生了變化呢?
為此我做了以下幾種嘗試:
MutationObserver
IntersectionObserver
ResizeObserver
監(jiān)聽所有資源的 onload 事件
iframe
2、MutationObserver
MutationObserver 接口提供了監(jiān)視對 DOM 樹所做更改的能力。它被設(shè)計(jì)為舊的 Mutation Events 功能的替代品,該功能是 DOM3 Events 規(guī)范的一部分。
observe(target, options)
這個(gè)方法會(huì)根據(jù)傳入的 options 配置,觀察 DOM 樹中的單個(gè) Node 或者所有的子孫節(jié)點(diǎn)的變化。
他一共有七個(gè)屬性,這里就不一一介紹了,可以通過 MutationObserverInit 來獲取相應(yīng)的介紹.
那么我們要怎么使用這個(gè) API 來監(jiān)聽目標(biāo)區(qū)域的高度變化呢?
首先我們要?jiǎng)?chuàng)建對該區(qū)域的 dom 根結(jié)點(diǎn)引用:
// useRef創(chuàng)建引用
const contentRef = useRef();
// 綁定ref
<div
className="content"
dangerouslySetInnerHTML={{ __html: details }}
style={{ maxHeight }}
ref={contentRef}
/>;
然后我們需要?jiǎng)?chuàng)建一個(gè) MutationObserver 實(shí)例:
const [height, setHeight] = useState(-1);
const [observer, setObserver] = useState<MutationObserver>(null!);
useEffect(() => {
const observer = new MutationObserver((mutationList) => {
if (height !== contentRef.current?.clientHeight) {
console.log("高度變化了!");
setHeight(contentRef.current.clientHeight);
}
});
setObserver(observer);
}, []);
當(dāng)我們的 ref 或者 observer 發(fā)生變化的時(shí)候,對 ref 節(jié)點(diǎn)進(jìn)行觀察:
useEffect(() => {
if (!observer || !contentRef.current) return;
observer.observe(contentRef.current, {
childList: true, // 子節(jié)點(diǎn)的變動(dòng)(新增、刪除或者更改)
attributes: true, // 屬性的變動(dòng)
characterData: true, // 節(jié)點(diǎn)內(nèi)容或節(jié)點(diǎn)文本的變動(dòng)
subtree: true, // 是否將觀察器應(yīng)用于該節(jié)點(diǎn)的所有后代節(jié)點(diǎn)
});
}, [contentRef.current, observer]);
完整代碼:
const Details = () => {
// useRef創(chuàng)建引用
const contentRef = useRef();
const [height, setHeight] = useState(-1);
const [observer, setObserver] = useState<MutationObserver>(null!);
useEffect(() => {
const observer = new MutationObserver((mutationList) => {
if (height !== contentRef.current?.clientHeight) {
console.log('高度變化了!');
setHeight(contentRef.current.clientHeight);
}
});
setObserver(observer);
}, []);
useEffect(() => {
if (!observer || !contentRef.current) return
observer.observe(contentRef, {
childList: true, // 子節(jié)點(diǎn)的變動(dòng)(新增、刪除或者更改)
attributes: true, // 屬性的變動(dòng)
characterData: true, // 節(jié)點(diǎn)內(nèi)容或節(jié)點(diǎn)文本的變動(dòng)
subtree: true// 是否將觀察器應(yīng)用于該節(jié)點(diǎn)的所有后代節(jié)點(diǎn)
});
}, [contentRef.current, observer]);
// 綁定ref
return<div className="content" dangerouslySetInnerHTML={{ __html: details }} style={{ maxHeight }} ref={contentRef} />
}
經(jīng)過上面的一番操作之后,發(fā)現(xiàn)根本達(dá)不到效果,因?yàn)槲覀兊?css 屬性根本沒有發(fā)生變化(我們是通過 maxHeight 來約束容器的高度的), 但是資源加載完畢之后,瀏覽器重排根本沒有產(chǎn)生 css 屬性的變化,它的高度是自動(dòng)計(jì)算的
因此這個(gè)方案無濟(jì)于事!但是它確實(shí)可以監(jiān)聽到認(rèn)為修改容器的高度產(chǎn)生的變化,比如:contentRef.current.style.height = '1000px',這個(gè) api 是可以監(jiān)聽到這一操作的,但是并不符合我們的場景
此外,它的瀏覽器兼容性也還行:
3、IntersectionObserver
經(jīng)過激情編碼,最后發(fā)現(xiàn) MutationObserver 根本達(dá)不到我們想要的效果之后,其實(shí)我的心態(tài)已經(jīng)產(chǎn)生了一些變化,不過不要緊!
我們可以換一種思路,既然我們無法通過監(jiān)聽容器的高度變化來展示相應(yīng)的 “展開更多” 操作,那么我們可不可以將這個(gè) “展開更多” 固定到一個(gè)位置上,然后超出部分隱藏,
當(dāng)我們的內(nèi)容自動(dòng)撐開,達(dá)到指定高度后,我們這個(gè) “展開更多” 的操作的按鈕就顯示出來了,聽上去不錯(cuò),能達(dá)到要求!廢話不多說,開擼!
因?yàn)檫@里只涉及到相應(yīng)的 css 樣式的書寫,就不做展示了。
經(jīng)過處理之后,確實(shí)在容器高度小于指定高度的時(shí)候,“展示更多” 按鈕不會(huì)展示,超過最大值之后,會(huì)將該按鈕展示出來,
但是也遇到了一個(gè)問題,操作按鈕是有高度的,如果我們的內(nèi)容高度介于最大高度 - 按鈕高度 到 容器的最大高度之間, 按鈕會(huì)產(chǎn)生顯示一部分,同時(shí)又隱藏一部分的效果,這可不是我們想要的!
如圖所示:
顯然這種效果是不符合要求的,我們的 “展示更多” 按鈕,只有兩種狀態(tài),要么全部展示,要么不展示,沒有這種部分展示的效果
因此我查閱了相關(guān)資料,了解到了 IntersectionObserver 這個(gè) API,它可以監(jiān)聽一個(gè)元素是否進(jìn)入用戶視野,它的相關(guān)使用方法可以參考這篇文章:IntersectionObserver API 使用教程
它使用起來和 MutationObserver 幾乎一樣,只是名字不一樣而已
它監(jiān)聽的值里面有一個(gè)比較重要的屬性:intersectionRatio
借助這個(gè) API,我的設(shè)計(jì)思路是這樣的:
當(dāng)用戶滾動(dòng)網(wǎng)頁的時(shí)候(或者不滾動(dòng),此時(shí)目標(biāo)區(qū)域已經(jīng)出現(xiàn)在屏幕中),可以得到 intersectionRatio 的值,通過判斷這個(gè)值是否等于 1 來決定要不要展示 “展示更多” 按鈕
但經(jīng)過我的編碼實(shí)現(xiàn)后,發(fā)現(xiàn)滾動(dòng)事件發(fā)生的時(shí)候,intersectionRatio 的變化是不可靠的,有時(shí)候完全可見了,但是它并不等于 1。經(jīng)過多輪實(shí)驗(yàn),結(jié)果依然如此。但是它確實(shí)可以用來判斷一個(gè)元素是否進(jìn)入用戶視野
由于使用上結(jié)果的不可靠,我放棄這個(gè)方案(可能是我使用方式上出了問題)
它的各瀏覽器兼容性如下:
4、ResizeObserver
顧名思義,這個(gè) API 就是專門監(jiān)聽 DOM 尺寸變化的,只不過它還處于試驗(yàn)階段,各瀏覽器的兼容性很差,所以基本不考慮
具體使用方法可以參考這篇文章:檢測 DOM 尺寸變化 JS API ResizeObserver 簡介
它現(xiàn)階段各瀏覽器的兼容性情況:
5、監(jiān)聽所有資源的 onload 事件
既然上述方法都不行,那么我絞盡腦汁,又想出了另外一種方法:監(jiān)聽所有帶有 src 屬性的 DOM 元素的 onload 事件,通過他的回調(diào)來判斷當(dāng)前容器的高度情況
這種實(shí)現(xiàn)方式,在思路上是完全符合目的的,具體做法參考如下:
const [height, setHeight] = useState(-1);
const [showMore, setShowMore] = useState(false);
// contentRef 的定義見 MutationObserver 一節(jié)
useEffect(() => {
const sources = contentRef.current.querySelectorAll("[src]");
sources.onload = () => {
const height = contentRef?.current?.clientHeight ?? 0;
const show = height >= parseInt(MAX_HEIGHT, 10);
setHeight(height);
setShowMore(show);
};
}, []);
通過這種方式可以實(shí)現(xiàn)對富文本中的圖片進(jìn)行加載后,對容器高度進(jìn)行相應(yīng)的判斷。
但是這種方式,存在不確定性,即無法判斷是否找齊了所有高度由內(nèi)容撐開的資源。
6、Iframe
這是終極方案,也是在此背景中所采用的方案。
既然 window 可以監(jiān)聽到 resize 事件,那么我們就可以利用 iframe 來達(dá)到同樣的效果,具體做法就是在容器里面嵌套一個(gè)隱藏的高度為 100% 的 iframe,通過監(jiān)聽他的 resize 事件,來判斷當(dāng)前容器的高度。
話不多說,具體實(shí)現(xiàn)方式如下:
const Detail: FC<{}> = () => {
const ref = useRef<HTMLDivElement>(null);
const ifr = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(-1);
const [showMore, setShowMore] = useState(false);
const [maxHeight, setMaxHeight] = useState(MAX_HEIGHT);
const introduceInfo = useAppSelect(
(state) => state.courseInfo?.data?.introduce_info ?? {}
);
const details = introduceInfo.details ?? "";
const isFolded = maxHeight === MAX_HEIGHT;
const onresize = useCallback(() => {
const height = ref?.current?.clientHeight ?? 0;
const show = height >= parseInt(MAX_HEIGHT, 10);
setHeight(height);
setShowMore(show);
if (ifr.current && show) {
ifr.current.remove();
}
}, []);
useEffect(() => {
if (!ref.current || !ifr.current?.contentWindow) return;
ifr.current.contentWindow.onresize = onresize;
onresize();
}, [details]);
if (!details) returnnull;
return (
<section className="section detail-content">
<div className="content-wrapper">
<div
className="content"
dangerouslySetInnerHTML={{ __html: details }}
style={{ maxHeight }}
ref={ref}
/>
{/* 這個(gè)iframe是用來動(dòng)態(tài)監(jiān)聽content高度的變化的 */}
<iframe title={IFRAME_ID} id={IFRAME_ID} ref={ifr} />
</div>
{isFolded && showMore && (
<>
<div
className="show-more"
onClick={() => {
setMaxHeight(isFolded ? "none" : MAX_HEIGHT);
}}
>
查看全部
<IconArrowDown className="icon" />
</div>
<div className="mask" />
</>
)}
</section>
);
};
這種方式實(shí)際上就是對 ResizeObserver 的一種 hack,經(jīng)過多次實(shí)踐,符合功能要求。
7、總結(jié)
解決問題要盡可能的考慮多種情況,對比多種方案,采取最為可靠的一種方案。
不斷學(xué)習(xí),多查詢資料,你所遇到的問題基本上前人都已經(jīng)踩過坑了。
監(jiān)聽 DOM 元素的高度變化,可以采用內(nèi)嵌 iframe 的方式來解決。
作者:hope
歡迎關(guān)注微信公眾號 :前端印象