JS怎麼監聽div元素的resize

草榴社群發表於2018-12-29

  在實現一個自定義滾動條需求的時候,需要監聽到某個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為監聽選項物件,可選的選項如下:

JS怎麼監聽div元素的resize

 所以監聽元素寬高變化,就是監聽其style屬性變化:

    observer.observe(element, { 
            attributes: true, 
            attributeFilter: ['style'], 
            attributeOldValue: true
        })
複製程式碼

這樣當元素的style發生改變的時候,就會觸發建構函式中傳入的callback函式。

  • disconnect() 阻止 MutationObserver 例項繼續接收的通知,直到再次呼叫其observe方法,該觀察者物件包含的回撥函式都不會再被呼叫。

  • takeRecords() 從MutationObserver的通知佇列中刪除所有待處理的通知,並將它們返回到一個MutationRecord物件構成的新陣列中。

示例

這裡以Vue中的一個元件作為例項,瞭解了以上所述內容後其實非常簡單,程式碼如下:

JS怎麼監聽div元素的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">
			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 不過你要是不小心處理所有的讀和寫操作,就會導致佈局混亂。比如下面這個小示例:

當你改變瀏覽器視窗大小的時候,就可以看到相應的變化:

JS怎麼監聽div元素的resize

這也就是為什麼 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的實際應用。 通過調整瀏覽器視窗的大小來嘗試一下,注意漸變角度和文字內容僅在元素的大小受到影響時才發生變化:

JS怎麼監聽div元素的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%;
			}
			
			.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
複製程式碼

JS怎麼監聽div元素的resize

使用

  • 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);
複製程式碼
  • 實現方式:

JS怎麼監聽div元素的resize

模擬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>
複製程式碼

相關文章