最近,在项目中某个任务中需要获取DOM的高度,初始我采用ref来获取DOM的高度,发现获取到的高度并不是最终的DOM高度,说到这里可能大家都会开始疑惑,ref不就是获取DOM的吗?既然拿到了DOM为什么DOM高度不对呢?下面举个实际场景:
image.png

父组件是一个纯函数组件,含有三个子组件,每个子组件依赖的数据来自于store中接口调用(不是在父组件通过props注入,而是在子组件内部直接取store数据)。现在需要获取父组件的DOM高度,我们在父组件最外层元素绑定ref来获取,假设接口还没返回时,数据未填充时父组件高度为200px,当接口陆续返回后获取到的DOM高度任然是200px。

这是为什么呢?我们看看ref更新更新机制就可以找出答案。
image.png
只有在组件重新渲染,才会更新ref。上面例子中,ref是绑定在父组件最外层元素上,子组件依赖的store数据发生变化子组件会重新渲染,但是父组件没有依赖这些store数据,并不会重新渲染,因此ref也不会更新,拿到的DOM高度还是200px。

MutationObserver

Mutation Observer API 用来监视 DOM 变动。DOM 的任何变动,比如节点的增减、属性的变动、文本内容的变动,这个 API 都可以得到通知。

MutationObserver

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 方法用来执行监听,接受两个参数:

  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 中,必须有 childListattributescharacterData中一种或多种,否则会报错。其中各个属性意思如下:

  • 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, // 是否将观察器应用于该节点的所有后代节点
})

characterData节点内容或节点文本的变动

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); // 监听不到

前面还有两个属性 attributeOldValuecharacterDataOldValue 没有说,其实是影响 takeRecords() 方法返回 MutationRecord 实例。如果设置了这两个属性,就会对应返回对象中 oldValue 为记录之前旧的 attributedata值。

比如将原来的 className 的值 aaa 替换成 taroldValue 记录为 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;
}

MutationObserver监听不到动画改变高宽

可以看出,没有打印输出,是监听不到动画改变高宽的。所以,在这还需对这条“漏网之鱼”进行处理。处理很简单,只需在动画(transitionendanimationend)停止事件触发时监听高宽变化即可。在这里用 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
  }
})

animationend事件监听动画改变高宽

ResizeObserver

既然对容器区域宽高监听有硬性需求,那么是否有相关规范呢?答案是有的,ResizeObserver 接口可以监听到 Element 的内容区域或 SVGElement 的边界框改变。内容区域则需要减去内边距 padding。目前还是实验性的一个接口,各大浏览器对ResizeObserver兼容性不够,实际应用需谨慎。

ResizeObserver

ResizeObserver Polyfill

实验性的 API 不足,总有 Polyfill 来弥补。

  1. ResizeObserver Polyfill 利用事件冒泡,在顶层 document 上监听动画 transitionend
  2. 监听 windowresize 事件;
  3. 其次用 MutationObserver 监听 document 元素;
  4. 兼容IE11以下 通过 DOMSubtreeModified 监听 document 元素。

利用MapShim (类似ES6中 Map) 数据结构,key 为被监听元素,valueResizeObserver 实例,映射监听关系,顶层 documentwindow 监听到触发事件,通过绑定元素即可监听元素尺寸变化。部分源码如下:

/**
 * 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

windowresize 没有兼容性问题,按照这个思路,可以用隐藏的 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-resizerresize-sensor等。不过这种方案不是特别优雅,需要插入 iframe 元素,还需将父元素定位,可能在页面上会有其他意想不到的问题,仅作为供参考方案吧。

总结

最后,要优雅地监听元素的宽高变化,不要去根据交互行为而是从元素本身去监听,了解 MutationObserver 接口是重点,其次要考虑到元素动画可能造成宽高变化,兼容IE11以下,通过 DOMSubtreeModified 监听。用 iframe 模拟 windowresize 属于一种供参考方案。


记得要微笑
1.9k 声望4.5k 粉丝

知不足而奋进,望远山而前行,卯足劲,不减热爱。


引用和评论

0 条评论