在使用 iscroll 或 better-scroll 滾動插件,如果容器內部元素有高度變化要去及時更新外部包裹容器,即調用 refresh() 方法。不然就會造成滾動誤差(滾動不到底部或滾動脫離底部)。
可能我們一般處理思路:
- 在每次
DOM節點有更新(刪除或插入)后就去調用refresh(),更新外部容器。 - 對異步資源(如圖片)加載,使用
onload監聽每次加載完成,再去調用refresh(),更新外部容器。
這樣我們會發現,如果容器內部元素比較復雜,調用會越來越繁瑣,甚至還要考慮到用戶使用的每一個操作都可能導致內部元素寬高變化,進而要去調整外部容器,調用 refresh()。
實際上,不管是對元素的哪種操作,都會造成它的屬性、子孫節點、文本節點發生了變化,如果能能監聽得到這種變化,這時只需比較容器寬高變化,即可實現對容器寬高的監聽,而無需關系它外部行為。DOM3 Events 規范為我們提供了 MutationObserver 接口監視對 DOM 樹所做更改的能力。
MutationObserver
Mutation Observer API 用來監視 DOM 變動。DOM 的任何變動,比如節點的增減、屬性的變動、文本內容的變動,這個 API 都可以得到通知。

PS Mutation Observer API 已經有很不錯的瀏覽器兼容性,如果對IE10及以下沒有要求的話。
MutationObserver 特點
DOM 發生變動都會觸發 Mutation Observer 事件。但是,它跟事件還是有不用點:事件是同步觸發,DOM 變化立即觸發相應事件;Mutation Observer 是異步觸發,DOM 變化不會馬上觸發,而是等當前所有 DOM 操作都結束后才觸發。總的來說,特點如下:
- 它等待所有腳本任務完成后,才會運行(即異步觸發方式)。
- 它把
DOM變動記錄封裝成一個數組進行處理,而不是一條條個別處理DOM變動。 - 它既可以觀察
DOM的所有類型變動,也可以指定只觀察某一類變動。
MutationObserver 構造函數
MutationObserver 構造函數的實例傳的是一個回調函數,該函數接受兩個參數,第一個是變動的數組,第二個是觀察器的實例。
var observer = new MutationObserver(function (mutations, observer){ mutations.forEach(function (mutaion) { console.log(mutation); }) })
MutationObserver 實例的 observe() 方法
observe 方法用來執行監聽,接受兩個參數:
- 第一個參數,被觀察的
DOM節點; - 第二個參數,一個配置對象,指定所要觀察特征。
var $tar = document.getElementById('tar'); var option = { childList: true, // 子節點的變動(新增、刪除或者更改) attributes: true, // 屬性的變動 characterData: true, // 節點內容或節點文本的變動 subtree: true, // 是否將觀察器應用於該節點的所有后代節點 attributeFilter: ['class', 'style'], // 觀察特定屬性 attributeOldValue: true, // 觀察 attributes 變動時,是否需要記錄變動前的屬性值 characterDataOldValue: true // 觀察 characterData 變動,是否需要記錄變動前的值 } mutationObserver.observe($tar, option);
option 中,必須有 childList、attributes和characterData中一種或多種,否則會報錯。其中各個屬性意思如下:
childList布爾值,表示是否應用到子節點的變動(新增、刪除或者更改);attributes布爾值,表示是否應用到屬性的變動;characterData布爾值,表示是否應用到節點內容或節點文本的變動;subtree布爾值,表示是否應用到是否將觀察器應用於該節點的所有后代節點;attributeFilter數組,表示觀察特定屬性;attributeOldValue布爾值,表示觀察attributes變動時,是否需要記錄變動前的屬性值;characterDataOldValue布爾值,表示觀察characterData變動,是否需要記錄變動前的值;
childList 和 subtree 屬性
childList 屬性表示是否應用到子節點的變動(新增、刪除或者更改),監聽不到子節點后代節點變動。
var mutationObserver = new MutationObserver(function (mutations) { console.log(mutations); }) mutationObserver.observe($tar, { childList: true, // 子節點的變動(新增、刪除或者更改) }) var $div1 = document.createElement('div'); $div1.innerText = 'div1'; // 新增子節點 $tar.appendChild($div1); // 能監聽到 // 刪除子節點 $tar.childNodes[0].remove(); // 能監聽到 var $div2 = document.createElement('div'); $div2.innerText = 'div2'; var $div3 = document.createElement('div'); $div3.innerText = 'div3'; // 新增子節點 $tar.appendChild($div2); // 能監聽到 // 替換子節點 $tar.replaceChild($div3, $div2); // 能監聽到 // 新增孫節點 $tar.childNodes[0].appendChild(document.createTextNode('新增孫文本節點')); // 監聽不到
attributes 和 attributeFilter 屬性
attributes 屬性表示是否應用到 DOM 節點屬性的值變動的監聽。而 attributeFilter 屬性是用來過濾要監聽的屬性 key。
// ... mutationObserver.observe($tar, { attributes: true, // 屬性的變動 attributeFilter: ['class', 'style'], // 觀察特定屬性 }) // ... // 改變 style 屬性 $tar.style.height = '100px'; // 能監聽到 // 改變 className $tar.className = 'tar'; // 能監聽到 // 改變 dataset $tar.dataset = 'abc'; // 監聽不到
characterData 和 subtree 屬性
characterData 屬性表示是否應用到節點內容或節點文本的變動。subtree 是否將觀察器應用於該節點的所有后代節點。為了更好觀察節點文本變化,將兩者結合應用到富文本監聽上是不錯的選擇。
簡單的富文本,比如
<div id="tar" contentEditable>A simple editor</div>
var $tar = document.getElementById('tar'); var MutationObserver = window.MutationObserver || window.webkitMutationObserver || window.MozMutationObserver; var mutationObserver = new MutationObserver(function (mutations) { console.log(mutations); }) mutationObserver.observe($tar, { characterData: true, // 節點內容或節點文本的變動 subtree: true, // 是否將觀察器應用於該節點的所有后代節點 })

takeRecords()、disconnect() 方法
MutationObserver 實例上還有兩個方法,takeRecords() 用來清空記錄隊列並返回變動記錄的數組。disconnect() 用來停止觀察。調用該方法后,DOM 再發生變動,也不會觸發觀察器。
var $text5 = document.createTextNode('新增文本節點5'); var $text6 = document.createTextNode('新增文本節點6'); // 新增文本節點 $tar.appendChild($text5); var record = mutationObserver.takeRecords(); console.log('record: ', record); // 返回 記錄新增文本節點操作,並清空監聽隊列 // 替換文本節點 $tar.replaceChild($text6, $text5); mutationObserver.disconnect(); // 此處以后的不再監聽 // 刪除文本節點 $tar.removeChild($text6); // 監聽不到
前面還有兩個屬性 attributeOldValue 和 characterDataOldValue 沒有說,其實是影響 takeRecords() 方法返回 MutationRecord 實例。如果設置了這兩個屬性,就會對應返回對象中 oldValue 為記錄之前舊的 attribute 和 data值。
比如將原來的 className 的值 aaa 替換成 tar,oldValue 記錄為 aaa。
record: [{
addedNodes: NodeList [] attributeName: "class" attributeNamespace: null nextSibling: null oldValue: "aaa" previousSibling: null removedNodes: NodeList [] target: div#tar.tar type: "attributes" }]
MutationObserver 的應用
一個容器本身以及內部元素的屬性變化,節點變化和文本變化是影響該容器高寬的重要因素(當然還有其他因素),以上了解了 MutationObserver API 的一些細節,可以實現監聽容器寬高的變化。
var $tar = document.getElementById('tar'); var MutationObserver = window.MutationObserver || window.webkitMutationObserver || window.MozMutationObserver; var recordHeight = 0; var mutationObserver = new MutationObserver(function (mutations) { console.log(mutations); let height = window.getComputedStyle($tar).getPropertyValue('height'); if (height === recordHeight) { return; } recordHeight = height; console.log('高度變化了'); // 之后更新外部容器等操作 }) mutationObserver.observe($tar, { childList: true, // 子節點的變動(新增、刪除或者更改) attributes: true, // 屬性的變動 characterData: true, // 節點內容或節點文本的變動 subtree: true // 是否將觀察器應用於該節點的所有后代節點 })
漏網之魚:動畫(animation、transform)改變容器高(寬)
除了容器內部元素節點、屬性變化,還有 css3 動畫會影響容器高寬,由於動畫並不會造成元素屬性的變化,所以 MutationObserver API 是監聽不到的。
將 #tar 容器加入以下 css 動畫
@keyframes changeHeight { to { height: 300px; } } #tar { background-color: aqua; border: 1px solid #ccc; animation: changeHeight 2s ease-in 1s; }

可以看出,沒有打印輸出,是監聽不到動畫改變高寬的。所以,在這還需對這條“漏網之魚”進行處理。處理很簡單,只需在動畫(transitionend、animationend)停止事件觸發時監聽高寬變化即可。在這里用 Vue 自定義指令處理如下:
/** * 監聽元素高度變化,更新滾動容器 */ Vue.directive('observe-element-height', { insert (el, binding) { const MutationObserver = window.MutationObserver || window.webkitMutationObserver || window.MozMutationObserver let recordHeight = 0 const onHeightChange = _.throttle(function () { // _.throttle 節流函數 let height = window.getComputedStyle(el).getPropertyValue('height'); if (height === recordHeight) { return } recordHeight = height console.log('高度變化了') // 之后更新外部容器等操作 }, 500) el.__onHeightChange__ = onHeightChange el.addEventListener('animationend', onHeightChange) el.addEventListener('transitionend', onHeightChange) el.__observer__ = new MutationObserver((mutations) => { onHeightChange() }); el.__observer__.observe(el, { childList: true, subtree: true, characterData: true, attributes: true }) }, unbind (el) { if (el.__observer__) { el.__observer__.disconnect() el.__observer__ = null } el.removeEventListener('animationend', el.__onHeightChange__) el.removeEventListener('transitionend', el.__onHeightChange__) el.__onHeightChange__ = null } })

ResizeObserver
既然對容器區域寬高監聽有硬性需求,那么是否有相關規范呢?答案是有的,ResizeObserver 接口可以監聽到 Element 的內容區域或 SVGElement 的邊界框改變。內容區域則需要減去內邊距 padding。目前還是實驗性的一個接口,各大瀏覽器對ResizeObserver兼容性不夠,實際應用需謹慎。

ResizeObserver Polyfill
實驗性的 API 不足,總有 Polyfill 來彌補。
ResizeObserver Polyfill利用事件冒泡,在頂層document上監聽動畫transitionend;- 監聽
window的resize事件; - 其次用
MutationObserver監聽document元素; - 兼容IE11以下 通過
DOMSubtreeModified監聽document元素。
利用MapShim (類似ES6中 Map) 數據結構,key 為被監聽元素,value 為 ResizeObserver 實例,映射監聽關系,頂層 document 或 window 監聽到觸發事件,通過綁定元素即可監聽元素尺寸變化。部分源碼如下:
/** * Initializes DOM listeners. * * @private * @returns {void} */ ResizeObserverController.prototype.connect_ = function () { // Do nothing if running in a non-browser environment or if listeners // have been already added. if (!isBrowser || this.connected_) { return; } // Subscription to the "Transitionend" event is used as a workaround for // delayed transitions. This way it's possible to capture at least the // final state of an element. document.addEventListener('transitionend', this.onTransitionEnd_); window.addEventListener('resize', this.refresh); if (mutationObserverSupported) { this.mutationsObserver_ = new MutationObserver(this.refresh); this.mutationsObserver_.observe(document, { attributes: true, childList: true, characterData: true, subtree: true }); } else { document.addEventListener('DOMSubtreeModified', this.refresh); this.mutationEventsAdded_ = true; } this.connected_ = true; };
PS:不過,這里貌似作者沒有對 animation 做處理,也就是 animation 改變元素尺寸還是監聽不到。不知道是不是我沒有全面的考慮,這點已向作者提了issue。
用 iframe 模擬 window 的 resize
window 的 resize 沒有兼容性問題,按照這個思路,可以用隱藏的 iframe 模擬 window 撐滿要監聽得容器元素,當容器尺寸變化時,自然會 iframe 尺寸也會改變,通過contentWindow.onresize() 就能監聽得到。
function observeResize(element, handler) { let frame = document.createElement('iframe'); const CSS = 'position:absolute;left:0;top:-100%;width:100%;height:100%;margin:1px 0 0;border:none;opacity:0;visibility:hidden;pointer-events:none;'; frame.style.cssText = CSS; frame.onload = () => { frame.contentWindow.onresize = () => { handler(element); }; }; element.appendChild(frame); return frame; } let element = document.getElementById('main'); // listen for resize observeResize(element, () => { console.log('new size: ', { width: element.clientWidth, height: element.clientHeight }); });
采用這種方案常用插件有 iframe-resizer、resize-sensor等。不過這種方案不是特別優雅,需要插入 iframe 元素,還需將父元素定位,可能在頁面上會有其他意想不到的問題,僅作為供參考方案吧。
總結
最后,要優雅地監聽元素的寬高變化,不要去根據交互行為而是從元素本身去監聽,了解 MutationObserver 接口是重點,其次要考慮到元素動畫可能造成寬高變化,兼容IE11以下,通過 DOMSubtreeModified 監聽。用 iframe 模擬 window 的 resize 屬於一種供參考方案。
轉載自 :https://segmentfault.com/a/1190000019877595?utm_source=tag-newest
