跟蹤元素可視?試試 Intersection Observer
本文將講解 Intersection Observer 的用法及其 polyfill 的原理,我們一起來看下。
背景
現在有以下幾種場景。
-
頁面滾動時懶加載圖片
-
實現無線滾動頁面(Infinite scrolling)
-
根據某個元素是否出現在視窗從而執行某些邏輯
對於這些傳統的實現方法是,監聽到 scroll 事件後,調用目標元素的 getBoundingClientRect() 方法,得到它對應於視口左上角的座標,再判斷是否在視口之內。這種方法的缺點是,由於 scroll 事件是同步事件,在滾動時密集發生,計算量很大,容易造成性能問題。經常需要配合節流一起使用。
這時候 Intersection Observer 就可以優秀的解決我們上述問題。
getBoundingClientRect()(地址:https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect)
Intersection Observer 概念及用法
Intersection Observer 是 w3c 提出的一種 Observer API,屬於瀏覽器中全局可訪問對象,Intersection Observer 能夠更好地支持上述場景,因爲 Observer 並不在主線程中執行,降低了資源消耗,優化了網頁性能。
Intersection Observer 爲 web 開發者提供了一種異步查詢元素相對於其他元素或窗口位置的能力。它常應用於解決追蹤一個元素在窗口的可視問題。
注:一旦 IntersectionObserver 被創建,則無法更改其配置,所以一個給定的觀察者對象只能用來監聽可見區域的特定變化值;但是,你可以在同一個觀察者對象中配置監聽多個目標元素。
API
const observer = new IntersectionObserver(callback[, options]);
// 方法
// 開始觀察某個目標元素
observer.observe(target)
// 停止觀察某個目標元素
observer.unobserve(target)
// 關閉監視器
observer.disconnect()
// 獲取所有 IntersectionObserver 觀察的 targets
observer.takeRecords()
// 注:該方法是同步獲取所有targets,一旦調用,callback回調將不再執行
options 爲可選參數。
未指定時,observer 實例默認使用文檔視口作爲 root,margin 爲 0,閾值爲 0%。(即一像素的改變都會觸發回調函數)
可以配置的參數有三個:
callback:當元素可見比例超過指定閾值(threshold)後,會調用回調函數,此回調函數接受兩個參數:
entries:一個 IntersectionObserverEntry 對象組成的數組。intersectionObserverEntry 提供目標元素的信息,有以下六個屬性:
observer:被調用的 IntersectionObserver 實例。
IntersectionObserver 地址:https://developer.mozilla.org/zh-CN/docs/Web/API/IntersectionObserver
瀏覽器兼容性
我們在使用該 api 時,一定要判斷瀏覽器是否支持,如果不支持,需要我們引入 pollify 來解決, 我們本篇主要介紹:Intersection Observer polyfill 的原理,來了解一下其具體實現。
對 Intersection Observer 底層源碼感興趣的同學可以看:intersection observe 實現
intersection observe 實現地址:https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/intersection_observer/intersection_observer.cc
Observe 實現原理
observe 方法定義在 IntersectionObserver 原型上
IntersectionObserver.prototype.observe = function(target) {
var isTargetAlreadyObserved = this._observationTargets.some(function(item) {
return item.element == target;
});
if (isTargetAlreadyObserved) {
return;
}
if (!(target && target.nodeType == 1)) {
throw new Error('target must be an Element');
}
this._registerInstance();
this._observationTargets.push({element: target, entry: null});
this._monitorIntersections();
this._checkForIntersections();
}
該函數接收的參數就是我們需要監測的 dom 元素(目標元素)。
首先會遍歷this._observationTargets
數組,這步就是爲了判斷當前的元素是否已經通過 observe 方法監測過。如果已經監測過,(isTargetAlreadyObserved 爲 true)就直接 return,防止同一個 observer 實例對同一個 target 元素進行多次監測。
如果沒有監測過 target 元素,這裏會對 target 的類型進行判斷。如果不是一個 dom 結點(nodeType !== 1
),同樣會拋出一個錯誤。
_registerInstance 函數做了什麼呢?
IntersectionObserver.prototype._registerInstance = function() {
if (registry.indexOf(this) < 0) {
registry.push(this);
}
};
顧名思義,如果我們的 observe 實例不存在,即將該實例加入到全局registry
數組中,避免被垃圾回收機制回收。
_monitorIntersections 函數
該函數主要用來實現對目標元素的檢測,可以看下具體實現,摘除了一些邊界值判斷的邏輯,如判斷 dom 已經銷燬,判斷重複監聽等,直接看核心邏輯
IntersectionObserver.prototype._monitorIntersections = function() {
if (this.POLL_INTERVAL) {
this._monitoringInterval = setInterval(
this._checkForIntersections, this.POLL_INTERVAL);
}
else {
addEvent(window, 'resize', this._checkForIntersections, true);
addEvent(document, 'scroll', this._checkForIntersections, true);
if (this.USE_MUTATION_OBSERVER && 'MutationObserver' in window) {
this._domObserver = new MutationObserver(this._checkForIntersections);
this._domObserver.observe(document, {
attributes: true,
childList: true,
characterData: true,
subtree: true
});
}
}
}
實現監聽的方式有兩種
-
poll_interval
:如果設置了輪詢時間,則按每隔 n 秒進行輪詢,觀察 dom 變化,這種方式簡單粗暴且輪詢較耗費性能,因而默認是關閉的。 -
MutationObserver
:這種監聽方式是監聽窗口的 resize 和頁面的 scroll 事件,當然,這兩種監聽滿足不了所有的場景,比如:某一個元素的顯隱,因而,它使用了是MutationObserve
這個 api,監聽document
元素下所有節點的attributes
,childList
和characterData
的變化,每當有 children 節點發生變化時都會去檢測 target 元素和 root 元素的交集狀態。
_checkForIntersections 函數
上述的_monitorIntersections
中有四個地方調用了_checkForIntersection
-
setInterval 輪詢監聽 dom 變化時
-
window 的 resize
-
document 的 scroll
-
MutationObserver api 監聽 dom 變化時作爲回調觸發
還有就是第一個講解的 observe 函數中,作爲 callback 回調觸發。
該函數的作用是,判斷 root 和 target 的交集是否發生變化,發生變化則觸發 observe 的回調。
IntersectionObserver.prototype._checkForIntersections = function() {
if (!this.root && crossOriginUpdater && !crossOriginRect) {
// Cross origin monitoring, but no initial data available yet.
return;
}
// 判斷root是否在dom結構中,傳入的root一定要是target的祖先元素
var rootIsInDom = this._rootIsInDom();
var rootRect = rootIsInDom ? this._getRootRect() : getEmptyRect();
this._observationTargets.forEach(function(item) {
var target = item.element;
var targetRect = getBoundingClientRect(target);
var rootContainsTarget = this._rootContainsTarget(target);
var oldEntry = item.entry;
var intersectionRect = rootIsInDom && rootContainsTarget &&
this._computeTargetAndRootIntersection(target, targetRect, rootRect);
var rootBounds = null;
if (!this._rootContainsTarget(target)) {
rootBounds = getEmptyRect();
} else if (!crossOriginUpdater || this.root) {
rootBounds = rootRect;
}
var newEntry = item.entry = new IntersectionObserverEntry({
time: now(),
target: target,
boundingClientRect: targetRect,
rootBounds: rootBounds,
intersectionRect: intersectionRect
});
if (!oldEntry) {
this._queuedEntries.push(newEntry);
} else if (rootIsInDom && rootContainsTarget) {
// If the new entry intersection ratio has crossed any of the
// thresholds, add a new entry.
if (this._hasCrossedThreshold(oldEntry, newEntry)) {
this._queuedEntries.push(newEntry);
}
} else {
// If the root is not in the DOM or target is not contained within
// root but the previous entry for this target had an intersection,
// add a new record indicating removal.
if (oldEntry && oldEntry.isIntersecting) {
this._queuedEntries.push(newEntry);
}
}
}, this);
if (this._queuedEntries.length) {
this._callback(this.takeRecords(), this);
}
};
this._observationTargets
這個屬性用來保存被 observer 所監聽的所有的 target 元素。
_getRootRect
是獲取 root 元素的區域,這個區域是rootRect
和rootMargin
結合計算出新的 rootRect 區域的大小。
接着遍歷this._observationTargets
。
在這個forEach
遍歷中,主要動作就是:蒐集 root 元素和 target 元素的交集狀態,並把他們存入到_queuedEntries
數組中。
而計算目標元素和 root 元素相交區域的核心就是 _computeTargetAndRootIntersection 函數
_computeTargetAndRootIntersection 函數
IntersectionObserver.prototype._computeTargetAndRootIntersection =
function(target, rootRect) {
// If the element isn't displayed, an intersection can't happen.
if (window.getComputedStyle(target).display == 'none') return;
var targetRect = getBoundingClientRect(target);
var intersectionRect = targetRect;
var parent = getParentNode(target);
// 標誌位
var atRoot = false;
while (!atRoot) {
var parentRect = null;
var parentComputedStyle = parent.nodeType == 1 ?
window.getComputedStyle(parent) : {};
// 如果parentRect display爲none,target和root元素同樣是不可能存在交集的
if (parentComputedStyle.display == 'none') return;
if (parent == this.root || parent == document) {
atRoot = true;
parentRect = rootRect;
} else {
// If the element has a non-visible overflow, and it's not the <body>
// or <html> element, update the intersection rect.
// Note: <body> and <html> cannot be clipped to a rect that's not also
// the document rect, so no need to compute a new intersection.
if (parent != document.body &&
parent != document.documentElement &&
parentComputedStyle.overflow != 'visible') {
parentRect = getBoundingClientRect(parent);
}
}
// If either of the above conditionals set a new parentRect
// calculate new intersection data.
if (parentRect) {
intersectionRect = computeRectIntersection(parentRect, intersectionRect);
if (!intersectionRect) break;
}
parent = getParentNode(parent);
}
return intersectionRect;
}
function getParentNode(node) {
var parent = node.parentNode;
if (parent && parent.nodeType == 11 && parent.host) {
// If the parent is a shadow root, return the host element.
return parent.host;
}
if (parent && parent.assignedSlot) {
// If the parent is distributed in a <slot>, return the parent of a slot.
return parent.assignedSlot.parentNode;
}
return parent;
}
這裏判斷如果元素是隱藏的,則不可能會相交,直接 return。
通過atRoot
標誌位,判斷 while 循環是否循環到了this.root
或者是document
。
如果我們採用默認的 root 即 document,而且parentNode
就是document
,那麼循環將會進入 if 分支,並將parentRect
被賦值爲rootRect
,atRoot
設置爲 true。接着執行第 44 行代碼邏輯。
computeRectIntersection 函數
function computeRectIntersection(rect1, rect2) {
var top = Math.max(rect1.top, rect2.top);
var bottom = Math.min(rect1.bottom, rect2.bottom);
var left = Math.max(rect1.left, rect2.left);
var right = Math.min(rect1.right, rect2.right);
var width = right - left;
var height = bottom - top;
return (width >= 0 && height >= 0) && {
top: top,
bottom: bottom,
left: left,
right: right,
width: width,
height: height
};
}
這裏就是在計算兩個區域 rect1 和 rect2 的交集
紅框部分即相交部分的區域~
如果 target.parentNode 不是 document,那麼 while 循環會執行 else 分支。其中執行 else 分支有一個條件parentComputedStyle.overflow != 'visible'
。如果parentComputedStyle.overflow
的值爲visible
,那麼 target 和 root 最大的交叉面積就是 target 的大小。
交叉面積算出來之後,使用 IntersectionObserverEntry 函數計算出各個屬性值
function IntersectionObserverEntry(entry) {
this.time = entry.time;
this.target = entry.target;
this.rootBounds = entry.rootBounds;
this.boundingClientRect = entry.boundingClientRect;
this.intersectionRect = entry.intersectionRect || getEmptyRect();
this.isIntersecting = !!entry.intersectionRect;
// Calculates the intersection ratio.
var targetRect = this.boundingClientRect;
var targetArea = targetRect.width * targetRect.height;
var intersectionRect = this.intersectionRect;
var intersectionArea = intersectionRect.width * intersectionRect.height;
// Sets intersection ratio.
if (targetArea) {
// Round the intersection ratio to avoid floating point math issues:
// https://github.com/w3c/IntersectionObserver/issues/324
this.intersectionRatio = Number((intersectionArea / targetArea).toFixed(4));
} else {
// If area is zero and is intersecting, sets to 1, otherwise to 0
this.intersectionRatio = this.isIntersecting ? 1 : 0;
}
}
然後計算出intersectionRatio
和isIntersecting
的值。
總結
到這裏,_checkForIntersections函數
第 11 行的遍歷完成啦
遍歷完成,後面還有兩行邏輯~
if (this._queuedEntries.length) {
this._callback(this.takeRecords(), this);
}
this._queuedEntries
是一個數組,其中每一個元素都是 IntersectionObserverEntry 實例對象。只有當這個屬性的長度大於 0 的時候,纔會觸發回調函數。
回調函數第一個參數是this.takeRecords()
獲取到的值,回憶一下上面講解 intersection observe 概念的時候,我們說過 callback 回調的第一個參數entrys
,是由 IntersectionObserverEntry 對象組成的數組,他就是takeRecords
方法的返回值,那麼takeRecords
方法做了什麼~
IntersectionObserver.prototype.takeRecords = function() {
var records = this._queuedEntries.slice();
this._queuedEntries = [];
return records;
}
方法實現很簡單,使用數組的 slice 方法對this._queuedEntries
進行了一個拷貝,然後清空了this._queuedEntries
。我們知道,intersection observe 的回調觸發和takeRecords
的調用都可以用來獲取 entries(IntersectionObserverEntry 對象數組),每個對象的目標元素都包含每次相交的信息,可以顯式通過調用takeRecords
方法或隱式地通過觀察者的回調 (oberve 的 callback 第一個參數) 自動調用。當我們調用takeRecords
後,有一步清空操作,可以看出如果顯示調用takeRecords
,則callback
不會再被調用。
IntersectionObserverEntry 地址:https://developer.mozilla.org/zh-CN/docs/Web/API/IntersectionObserverEntry
引用官網的一句話就是:
調用此方法會清除掛起的相交狀態列表,因此不會運行回調
以上是處理所有 Observer 的主體邏輯啦。
展望
Intersection Observer, version 2
目前兼容性還不是很好,期待未來征服各主流瀏覽器
我們不禁要思考,v1 版有哪些不足?
Intersection Observer v1 API 可以告訴您元素何時滾動到窗口的視口中,但它不會告訴您該元素是否被任何其他頁面內容覆蓋(即元素何時被遮擋)或該元素的可視顯示已被 transform,opacity 有效 filter 等 css 屬性修改地使其不可見。
Intersection Observer v2 引入了跟蹤目標元素的實際 “可見性” 的概念,就像人類定義的那樣。IntersectionObserver 通過在構造函數中設置一個選項,相交的 IntersectionObserverEntry 實例將包含一個名爲 isVisible 的新布爾字段,isVisible 是 true,即目標元素完全不被其他內容遮擋,並且沒有應用會改變或扭曲其在屏幕上的顯示的視覺效果。相反,一個 false 意味着不能保證。
最後
附上 pollify 完整源碼的 github 地址:intersection observe pollify 源碼(地址:https://github.com/GoogleChromeLabs/intersection-observer/blob/main/intersection-observer.js)
作者 | 賈璐伊(若笙)
編輯 | 橙子君
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/wMNO5NDcdElb1xr0urw-XQ