阅读 4635

JS怎么监听div元素的resize

  在实现一个自定义滚动条需求的时候,需要监听到某个div元素的宽高变化,第一时间想到的是resize事件,但是很不幸运的是,resize事件只能加在window对象上,并不能监听具体某个DOM元素。

  多方查阅之后,了解到MutationObserverResize Observer,可以用来监听整个DOM中任何变化的东西,可以把它理解为一个类,实例化之后调用类实例的几个简单接口即可完成监听,以下具体介绍:

MutationObserver介绍

构造函数为window.MutationObserver,参数为一个回调函数。

  监控到DOM中的改变并且等待一系列改变结束后就会触发回调函数。它与事件的不同之处在于,它在DOM变化时,会记录每一个DOM的变化(为一个MutationRecord对象),但是到DOM变化结束时触发回调。DOM变化可能是一系列的(比如元素的宽和高同时改变),那么这一系列的变化就会产生一个队列,这个队列会作为参数传递给回调函数。

  由于浏览器差异的原因,一些版本的浏览器各自支持了构造函数,但是用法都是一样的,实例化一个观察者的代码如下:

let MutationObserver = window.MutationObserver ||
                      window.WebKitMutationObserver || 
                      window.MozMutationObserver
                      
let observer = new MutationObserver(callback)    
复制代码

调用接口开始监控DOM。

常用的接口有三个:

  • observe(element, options) 配置MutationObserver在DOM更改匹配给定选项时,通过其回调函数开始接收通知。

    element即要监听的DOM元素,options为监听选项对象,可选的选项如下:

 所以监听元素宽高变化,就是监听其style属性变化:

    observer.observe(element, { 
            attributes: true, 
            attributeFilter: ['style'], 
            attributeOldValue: true
        })
复制代码

这样当元素的style发生改变的时候,就会触发构造函数中传入的callback函数。

  • disconnect() 阻止 MutationObserver 实例继续接收的通知,直到再次调用其observe方法,该观察者对象包含的回调函数都不会再被调用。

  • takeRecords() 从MutationObserver的通知队列中删除所有待处理的通知,并将它们返回到一个MutationRecord对象构成的新数组中。

示例

这里以Vue中的一个组件作为实例,了解了以上所述内容后其实非常简单,代码如下:

<!DOCTYPE html>
<html>

	<head>
		<meta charset="UTF-8">
		<title></title>
		<script src="js/vue.js" type="text/javascript" charset="utf-8"></script>
		<style type="text/css">
			html,body{
				width: 100%;
				height: 100%;
			}
			.container {
				width: 100%;
				height: 100%;
				position: relative
			}
			
			.resize-element {
				position: absolute;
				top: 50%;
				left: 50%;
				height: 10rem;
				width: 10rem;
				transform: translate(-50%,-50%);
				overflow: hidden;
				resize: both;   /*用户可以调节元素的宽度和高度*/
				display: block;
				box-shadow: 0 0 1px 1px #3361D8;
				border-radius: 2px;
			}
		</style>
	</head>

	<body>
		<div class="container" id="main">
			<div class="resize-element">
				改变大小试试
			</div>
			<div class="resize-record">
				触发了{{firedNum}}次resize事件。
			</div>
		</div>
		<script type="text/javascript">
			new Vue({
				el: "#main",
				data: {
					observer: null,
					firedNum: 0,
					recordOldValue: { // 记录下旧的宽高数据,避免重复触发回调函数
						width: '0',
						height: '0'
					}
				},
				mounted() {
					let MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver
					let element = document.querySelector('.resize-element')
					this.observer = new MutationObserver((mutationList) => {
						for(let mutation of mutationList) {
							console.log(mutation)
						}
						let width = getComputedStyle(element).getPropertyValue('width')
						let height = getComputedStyle(element).getPropertyValue('height')
						if(width === this.recordOldValue.width && height === this.recordOldValue.height) return
						this.recordOldValue = {
							width,
							height
						}
						this.firedNum += 1
					})
					this.observer.observe(element, {
						attributes: true,
						attributeFilter: ['style'],
						attributeOldValue: true
					})
				},
				beforeDestroyed() {
					if(this.observer) {
						this.observer.disconnect()
						this.observer.takeRecords()
						this.observer = null
					}
				}

			})
		</script>
	</body>

</html>
复制代码

这里记录了旧的宽高数据来避免重复触发回调函数,这样做的原因在于宽高数据改变时,不一定是整数,而MutationRecord.recordOldValue中记录的是取整后的数据,这样就会导致在拖动改变DOM元素的宽高时,数值一直在整数和小数之间跳动,会多次触发。

MutationObserver实现Vue nextTick

Vue 倡导开发者尽量不直接操作DOM,但有的时候由于各种需求让开发者不得不这样做,于是 nextTick 的实现就是让开发者在修改数据后,能够在数据更新到DOM后才执行对应的函数,从而获取最新的 DON 数据。

那么如何实现 nextTick呢,我们首先可以想到的是利用 setTimeout 的异步回调来实现,不过由于各个浏览器的不同,setTimeout 的延迟很高,因此在 nextTick 中只作为最后的备胎,首选的方案则是 MutationObserver(在后面的内容中 MO 代表 MutationObserver)

nextTick 的源码实现

export const nextTick = (function () {
  var callbacks = []
  var pending = false
  var timerFunc
  function nextTickHandler () {
    pending = false
    var copies = callbacks.slice(0)
    callbacks = []
    for (var i = 0; i < copies.length; i++) {
      copies[i]()
    }
  }
  /* istanbul ignore if */
  if (typeof MutationObserver !== 'undefined') { // 首选 MutationObserver 
    var counter = 1
    var observer = new MutationObserver(nextTickHandler) // 声明 MO 和回调函数
    var textNode = document.createTextNode(counter)
    observer.observe(textNode, { // 监听 textNode 这个文本节点
      characterData: true // 一旦文本改变则触发回调函数 nextTickHandler
    })
    timerFunc = function () {
      counter = (counter + 1) % 2 // 每次执行 timeFunc 都会让文本在 1 和 0 间切换
      textNode.data = counter
    }
  } else {
    timerFunc = setTimeout // 如果不支持 MutationObserver, 退选 setTimeout
  }
  return function (cb, ctx) {
    var func = ctx
      ? function () { cb.call(ctx) }
      : cb
    callbacks.push(func)
    if (pending) return
    pending = true
    timerFunc(nextTickHandler, 0)
  }
})()
复制代码

MutationObserver 的功能和作用

MO 给开发者提供了一种能在某个范围内的DOM数发生变化时作出适当反应的能力

用人话说是开发者能通过它创建一个观察者对象,这个对象会监听某个DOM元素,并在它的DOM树发生变化时执行我们提供的回调函数。

具体参考这个 DEMO点击预览

比较特别的是实例化的时候需要先传入回调函数:

    var observer = new MutationObserver(function(mutations) {
      mutations.forEach(function(mutation) {
        console.log(mutation.type);
      })
    })
复制代码

然后才配置观察选项,包括观察节点和观察的属性:

// 选择目标节点
var target = document.querySelector('#some-id');
 
// 配置观察选项:
var config = { attributes: true, childList: true, characterData: true }
 
// 传入目标节点和观察选项
observer.observe(target, config);
 
// 随后,你还可以停止观察
observer.disconnect();
复制代码

对于老版本的谷歌和火狐,则需要使用带前缀的 MO:

var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver
复制代码

MutationObserver 和 microtask

那么为什么优选使用 MutationObserver呢?

一开始以为是 MO 就是用来监听 DOM 变化的,那么使用 textnode 模拟 DOM 变化再利用 MO 来监听触发从而实现 nextTick 不就很适合,直到了解看到了知乎上的问答才知道是因为 MO 会比 setTimeout 早执行的缘故,

这里需要了解JS的运行运行机制(重新刷新了我的三观), JS 的事件运行机制执行的时候会区分 taskmicrotask, 引擎在每个 task 执行完毕,并在从队列里取下一个task来执行之前, 执行完所有的 microtask 队列中的 microtask.

** setTimeout** 回调会被分配到一个新的task中等待执行,而 Promise 的 resolver、MO 的 回调都会被分配到 microtask 的队列中,所以会比 setTimout 先执行.

除了比 setTimout 快之外,还有 渲染性能 的问题,根据HTML Standard, 每个 task 运行完以后, UI 都会重新渲染,那么在 microtask 中就完成数据更新, 当前 task 结束就可以得到最新的 UI, 反之如果新建一个 task 来做数据更新,那么渲染就会进行两次。

所以性价比如此高的 MO 自然成为了首选

关于 microtask,具体可以阅读 Jake 写的 Tasks, microtasks, queues and schedules

Vue nextTick的版本迭代

上面关于 nextTick 的源码实现属于 vue 最早的版本 v1.0.9,在深挖 mutationObserver 的时候发现 nextTick 在vue的版本迭代中也在不断的进化,同事也发生过退化,非常有趣:

先说说退化的事件,尤大(vue的作者)曾经使用 window.postMessage 来替代 MO 实现 nextTick,结果开发者使用后发现了问题,可以看看这两个 JSFiddle:jsfiddle1点击预览 和 jsfiddle2点击预览, 两个例子用了不同版本来实现元素的绝对定位,第一个使用的是 2.0.0-rc6,这个版本采用的是 MO,而后来因为 IOS 9.3 的 WebView 里 MO 有 bug,尤大便换成 window.postMessage来实现,即第二个实例版本为 2.0.0-rc7, 但是由于 postMessage 会将回调放到 macrotask 其实也就是 task 里面,导致可能执行了多次 UI 的task都没有执行 window.postMessage 的 task,也就延迟了更新DOM操作的时间。尤大在后续版本撤回了这一次修改,具体的讨论可以看issue

关于进化,在后续的版本里,由于 es6 的新语法,nextTick 开始使用 Promise.then 和 MO 来做首选和次选,在前面的讨论中已经提到,Promise.then 也属于 microtask。

Resize Observer

Resize Observer是一个新的JavaScript API,与Intersection Observer API、Mutation Observer等其他观察者API非常相似。 它允许在尺寸发生变化时通知元素。

ResizeObserver的解释:开发过程当中经常遇到的一个问题就是如何监听一个 div 的尺寸变化。但众所周知,为了监听 div 的尺寸变化,都将侦听器附加到 window 中的 resize 事件。但这很容易导致性能问题,因为大量的触发事件。换句话说,使用 window.resize 通常是浪费的,因为它告诉我们每个视窗大小的变化,而不仅仅是当一个元素的大小发生变化。

使用 ResizeObserver 的API的另一个用例就是视窗 resize 事件不能帮助我们:当元素被动态地添加或删除时,会影响父元素的大小。这也是现代单页应用程序越来越频繁使用 ResizeObserver 原因之一。 通过 window.resize 事件的监听,可以调用一个回调函数。在这个回调函数中做我们需要做的事情。

// define a callback
function callback() {
    // something cool here
}
// add resize listener to window object
window.addEventListener('resize', callback)
复制代码

比如说,你要调整一个元素的大小,那就需要在 resize 的回调函数 callback() 中调用 getBoundingClientRect 或 getComputerStyle 不过你要是不小心处理所有的读和写操作,就会导致布局混乱。比如下面这个小示例:

当你改变浏览器视窗大小的时候,就可以看到相应的变化:

这也就是为什么 ResizeObserver 是一个有用的API。它对所观察到的任何元素的大小的变化做出反应,而不依赖于所引起的变化。它还提供了对所观察元素的新大小的访问。那接下来让我们直接切入正题。

简单总结一下:

ResizeObserver 允许我们观察DOM元素的内容矩形大小(宽度、高度)的变化,并做出相应的响应。它就像给元素添加 document.onresize() 或 window.resize() 事件(但在JavaScript中,只有 window 才有 resize 事件)。当元素改变大小而不调整视窗大小时,它是有用的。 下面描述一些调整观察者的行为:

  • 当观察到的元素被插入或从DOM中删除时,观察将会触发
  • 当观察到的元素 display 值为 none 时,观察都会触发
  • 观察不会对未替换的内联元素(non-replaced inline element)触发
  • 观察不会由CSS的 transform 触发
  • 如果元素有显示,而且元素大小不是 0,0 ,观察将会触发

基本用法 使用Resize Observer非常简单,只需实例化一个新的ResizeObserver对象并传入一个回调函数,该函数接收观察到的条目

const myObserver = new ResizeObserver(entries => {
  // 遍历条目,做一些事情
});
复制代码

然后,我们可以在实例上调用observe并传入一个元素来观察

const someEl = document.querySelector('.some-element');
const someOtherEl = document.querySelector('.some-other-element');

myObserver.observe(someEl);
myObserver.observe(someOtherEl);
复制代码

对于每个entry,我们都会得到一个包含contentRect和一个target属性的对象。target是DOM元素本身,contentRect是具有以下属性的对象:width,height,x,y,top,right,bottom和left。

与元素的getBoundingClientRect不同,contentRect的width和height值不包含padding。contentRect.top是元素的顶部padding,contentRect.left是元素的左侧padding。

比如要打印出被监听元素寸尺变化时width和height的值,可以像下面这样做:

const myObserver = new ResizeObserver(entries => {
  entries.forEach(entry => {
    console.log('width', entry.contentRect.width);
    console.log('height', entry.contentRect.height);
  });
});

const someEl = document.querySelector('.some-element');
myObserver.observe(someEl);
复制代码

上面的示例中,使用了forEach 循环来遍历观察者的回调中的 entries ,其实在 entries 上使用 for ... of 可以得到相同的效果

Resize Observer API 示例

下面是一个简单的演示,以查看Resize Observer API的实际应用。 通过调整浏览器窗口的大小来尝试一下,注意渐变角度和文本内容仅在元素的大小受到影响时才发生变化:


<!DOCTYPE html>
<html>

	<head>
		<meta charset="UTF-8">
		<title></title>
		<script src="js/vue.js" type="text/javascript" charset="utf-8"></script>
		<style type="text/css">
			html,
			body {
				width: 100%;
				height: 100%;
			}
			
			.box {
				text-align: center;
				height: 20vh;
				border-radius: 8px;
				box-shadow: 0 0 4px var(--subtle);
				display: flex;
				justify-content: center;
				align-items: center;
			}
			
			.box h3 {
				color: #fff;
				margin: 0;
				font-size: 5vmin;
				text-shadow: 0 0 10px rgba(0, 0, 0, 0.4);
			}
			
			.box.small {
				max-width: 550px;
				margin: 1rem auto;
			}
		</style>
	</head>

	<body>
		<div class="box">
			<h3 class="info"></h3>
		</div>
		<div class="box small">
			<h3 class="info"></h3>
		</div>
		<script type="text/javascript">
			const boxes = document.querySelectorAll('.box');

			const myObserver = new ResizeObserver(entries => {
				for(let entry of entries) {
					const infoEl = entry.target.querySelector('.info');
					const width = Math.floor(entry.contentRect.width);
					const height = Math.floor(entry.contentRect.height);

					const angle = Math.floor(width / 360 * 100);
					const gradient = `linear-gradient(${ angle }deg, rgba(0,143,104,1) 50%, rgba(250,224,66,1) 50%)`;

					entry.target.style.background = gradient;

					infoEl.innerText = `I'm ${ width }px and ${ height }px tall`;
				}
			});

			boxes.forEach(box => {
				myObserver.observe(box);
			});
		</script>
	</body>

</html>
复制代码

常用npm包

  resize-detector
  size-sensor
复制代码

使用

  • Install
cnpm i --save size-sensor
复制代码
import { bind, clear } from 'size-sensor'
复制代码
  • bind&unbind
import { bind, clear } from 'size-sensor';
 
// bind the event on element, will get the `unbind` function
const unbind1 = bind(document.querySelector('.container'), element => {
  // do what you want to to.
});
 
const unbind2 = bind(document.querySelector('.container'), element => {
  // do what you want to to.
});
 
// if you want to cancel bind event.
unbind1();
复制代码
  • clear
import { bind, clear } from 'size-sensor';
 
/*
 * // bind the resize event.
 * const unbind1 = bind(...);
 * const unbind2 = bind(...);
 * ...
 */
 
// you can cancel all the event of element.
clear(element);
复制代码
  • 实现方式:

模拟windows的resize

<!DOCTYPE html>
<html>

	<head>
		<meta charset="UTF-8">
		<title></title>
		<script src="js/vue.js" type="text/javascript" charset="utf-8"></script>
		<style type="text/css">
			html,body{
				width: 100%;
				height: 100%;
			}
			.container {
				width: 100%;
				height: 100%;
				position: relative
			}
			
			.resize-element {
				position: absolute;
				top: 50%;
				left: 50%;
				height: 10rem;
				width: 10rem;
				transform: translate(-50%,-50%);
				overflow: hidden;
				resize: both;   /*用户可以调节元素的宽度和高度*/
				display: block;
				box-shadow: 0 0 1px 1px #3361D8;
				border-radius: 2px;
			}
		</style>
	</head>

	<body>
		<div class="container" id="main">
			<div class="resize-element">
				改变大小试试
			</div>
			<div class="resize-record">
				窗口触发了{{firedNum}}次resize事件。
			</div>
		</div>
		<script type="text/javascript">
			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;';
			function observeResize(element, handler) {
				let frame = document.createElement('iframe');
				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
				});
			});
		</script>
	</body>

</html>
复制代码
关注下面的标签,发现更多相似文章
评论