1. 程式人生 > 實用技巧 >如何優雅監聽容器高度變化

如何優雅監聽容器高度變化

前言

老鳥:怎樣去監聽DOM元素的高度變化呢?
菜鳥:哈哈哈哈哈,這都不知道哦,用onresize事件鴨!
老鳥扶了扶眼睛,空氣安靜幾秒鐘,菜鳥才晃過神來。對鴨,普通DOM元素沒有onresize事件,只有在window物件下有此事件,該死,又雙叒叕糗大了。

哈哈哈哈,以上純屬虛構,不過在最近專案中還真遇到過對容器監聽高(寬)變化:在使用iscroll或better-scroll滾動外掛,如果容器內部元素有高度變化要去及時更新外部包裹容器,即呼叫refresh()方法。不然就會造成滾動誤差(滾動不到底部或滾動脫離底部)。

可能我們一般處理思路:

  • 在每次DOM節點有更新(刪除或插入)後就去呼叫refresh(),更新外部容器。
  • 對非同步資源(如圖片)載入,使用onload監聽每次載入完成,再去呼叫refresh(),更新外部容器。

這樣我們會發現,如果容器內部元素比較複雜,呼叫會越來越繁瑣,甚至還要考慮到使用者使用的每一個操作都可能導致內部元素寬高變化,進而要去調整外部容器,呼叫refresh()。

實際上,不管是對元素的哪種操作,都會造成它的屬性、子孫節點、文字節點發生了變化,如果能能監聽得到這種變化,這時只需比較容器寬高變化,即可實現對容器寬高的監聽,而無需關係它外部行為。DOM3 Events規範為我們提供了MutationObserver介面監視對DOM樹所做更改的能力。

MutationObserver

Mutation Observer API用來監視DOM變動。DOM的任何變動,比如節點的增減、屬性的變動、文字內容的變動,這個API都可以得到通知。

PSMutation 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方法用來執行監聽,接受兩個引數:

  1. 第一個引數,被觀察的DOM節點;
  2. 第二個引數,一個配置物件,指定所要觀察特徵。
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是否將觀察器應用於該節點的所有後代節點。為了更好觀察節點文字變化,將兩者結合應用到富文字監聽上是不錯的選擇。

簡單的富文字,比如

<divid="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 的應用

一個容器本身以及內部元素的屬性變化,節點變化和文字變化是影響該容器高寬的重要因素(當然還有其他因素),以上了解了MutationObserverAPI 的一些細節,可以實現監聽容器寬高的變化。

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 動畫會影響容器高寬,由於動畫並不會造成元素屬性的變化,所以MutationObserverAPI 是監聽不到的。

將#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來彌補。

  1. ResizeObserver Polyfill利用事件冒泡,在頂層document上監聽動畫transitionend;
  2. 簡體window的resize事件;
  3. 其次用MutationObserver監聽document元素;
  4. 相容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。

廣州品牌設計公司https://www.houdianzi.com

用 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屬於一種供參考方案。