现在你看到我了:如何延迟、延迟加载和使用 IntersectionObserver 采取行动
已发表: 2022-03-10曾几何时,有一位 Web 开发人员成功地说服了他的客户,网站不应该在所有浏览器中看起来都一样,关心可访问性,并且是 CSS 网格的早期采用者。 但在他内心深处,表现才是他真正的热情所在:他不断优化、缩小、监控,甚至在他的项目中使用心理技巧。
然后,有一天,他了解到延迟加载图像和其他资产,这些资产不会立即对用户可见,并且对于在屏幕上呈现有意义的内容不是必不可少的。 这是黎明的开始:开发人员进入了延迟加载 jQuery 插件的邪恶世界(或者可能是async
和defer
属性的不那么邪恶的世界)。 甚至有人说他直接进入了所有邪恶的核心: scroll
事件侦听器的世界。 我们永远无法确定他最终去了哪里,但话说回来,这个开发者绝对是虚构的,与任何开发者的任何相似之处都只是巧合。
好吧,你现在可以说潘多拉的盒子已经打开了,我们虚构的开发者并没有让这个问题变得不那么真实。 如今,从速度和页面权重的角度来看,优先考虑首屏内容对于我们的 Web 项目的性能变得非常重要。
在这篇文章中,我们将走出scroll
的黑暗,谈论现代的延迟加载资源的方式。 不仅仅是延迟加载图像,而是加载任何资产。 更重要的是,我们今天要讨论的技术不仅仅是延迟加载资产:我们将能够根据元素对用户的可见性提供任何类型的延迟功能。
女士们先生们,让我们来谈谈 Intersection Observer API。 但在我们开始之前,让我们看一下将我们IntersectionObserver
的现代工具的前景。
2017 年对于我们的浏览器内置工具来说是非常好的一年,帮助我们提高代码库的质量和风格,而无需付出太多努力。 如今,Web 似乎正在从基于非常不同的零星解决方案转向非常典型的解决方案,转向更明确定义的 Observer 接口方法(或只是“观察者”):得到良好支持的 MutationObserver 很快就获得了新的家庭成员在现代浏览器中采用:
- IntersectionObserver 和
- PerformanceObserver(作为 Performance Timeline Level 2 规范的一部分)。
另一个潜在的家庭成员 FetchObserver 正在进行中,它将引导我们更多地进入网络代理领域,但今天我想更多地谈谈前端。
PerformanceObserver
和IntersectionObserver
旨在帮助前端开发人员在不同点提高项目的性能。 前者为我们提供了真实用户监控的工具,而后者是工具,为我们提供了切实的性能改进。 如前所述,本文将详细介绍后者: IntersectionObserver 。 为了特别了解IntersectionObserver
的机制,我们应该看看通用的 Observer 应该如何在现代网络中工作。
专业提示:您可以跳过理论并立即深入了解 IntersectionObserver 的机制,或者更进一步,直接了解IntersectionObserver
的可能应用。
观察者与事件
顾名思义,“观察者”旨在观察页面上下文中发生的事情。 观察者可以观察页面上发生的事情,比如 DOM 变化。 他们还可以监视页面的生命周期事件。 观察者也可以运行一些回调函数。 现在细心的读者可能会立即发现这里的问题并问:“那么,重点是什么? 我们不是已经为此目的举办了活动吗? 是什么让观察者与众不同?” 很好的一点! 让我们仔细看看并整理一下。
常规 Event 和 Observer 之间的关键区别在于,默认情况下,前者对 Event 的每次发生都进行同步响应,影响主线程的响应能力,而后者应该异步响应,不会对性能造成太大影响。 至少,对于当前呈现的观察者来说是这样的:它们都是异步的,我认为这在未来不会改变。
这导致了处理 Observer 回调的主要区别,这可能会使初学者感到困惑:Observer 的异步性质可能导致多个 observables 被同时传递给回调函数。 正因为如此,回调函数不应该期待一个单一的条目,而是一个条目Array
(即使有时数组中只包含一个条目)。
此外,一些观察者(尤其是我们今天讨论的那个)提供了非常方便的预计算属性,否则,我们在使用常规事件时使用昂贵的(从性能角度)方法和属性来计算自己。 为了澄清这一点,我们将在本文稍后的示例中找到一个示例。
因此,如果有人很难摆脱事件范式,我会说观察者是类固醇上的事件。 另一种描述是:观察者是事件之上的一个新的近似水平。 但无论您喜欢哪种定义,不用说,观察者并不是要取代事件(至少现在还没有); 两者都有足够的用例,他们可以愉快地并肩生活。
通用观察者的结构
观察者的通用结构(在撰写本文时可用的任何结构)看起来类似于以下内容:
/** * Typical Observer's registration */ let observer = new YOUR-TYPE-OF-OBSERVER(function (entries) { // entries: Array of observed elements entries.forEach(entry => { // Here we can do something with each particular entry }); }); // Now we should tell our Observer what to observe observer.observe(WHAT-TO-OBSERVE);
再次注意, entries
是一个值Array
,而不是单个条目。
这是通用结构:特定观察者的实现在传递给它的observe()
的参数和传递给它的回调的参数方面有所不同。 例如MutationObserver
还应该获取一个配置对象,以了解更多关于要观察的 DOM 中的哪些变化。 PerformanceObserver
不观察 DOM 中的节点,而是具有可以观察的专用条目类型集。
到这里,让我们结束本次讨论的“通用”部分,深入探讨今天文章的主题IntersectionObserver
。
解构 IntersectionObserver
首先我们来梳理一下IntersectionObserver
是什么。
根据 MDN:
Intersection Observer API 提供了一种异步观察目标元素与祖先元素或顶级文档视口的交集变化的方法。
简单地说, IntersectionObserver
异步观察一个元素与另一个元素的重叠。 让我们谈谈IntersectionObserver
中这些元素的用途。
IntersectionObserver 初始化
在前面的一段中,我们已经看到了通用 Observer 的结构。 IntersectionObserver
稍微扩展了这个结构。 首先,这种类型的 Observer 需要具有三个主要元素的配置:
-
root
:这是用于观察的根元素。 它定义了可观察元素的基本“捕获框架”。 默认情况下,root
是浏览器的视口,但实际上可以是 DOM 中的任何元素(然后将root
设置为document.getElementById('your-element')
类的东西)。 请记住,在这种情况下,您要观察的元素必须“存在”在root
的 DOM 树中。
-
rootMargin
:定义root
元素周围的边距,当您的root
元素的尺寸不能提供足够的灵活性时,它会扩展或缩小“捕获框架”。 该配置的取值选项与 CSS 中的margin
选项类似,例如rootMargin: '50px 20px 10px 40px'
(top, right bottom, left)。 这些值可以简写(如rootMargin: '50px'
)并且可以用px
或%
表示。 默认情况下,rootMargin: '0px'
。
-
threshold
:当观察到的元素与“捕获框架”(定义为root
和rootMargin
的组合)的边界相交时,并不总是希望立即做出反应。threshold
定义了观察者应该做出反应的交叉点的百分比。 它可以定义为单个值或值数组。 为了更好地理解threshold
的影响(我知道它有时可能会令人困惑),这里有一些例子:-
threshold: 0
:默认值IntersectionObserver
应在观察元素的第一个或最后一个像素与“捕获帧”的边界之一相交时做出反应。 请记住,IntersectionObserver
与方向无关,这意味着它会在两种情况下做出反应:a)当元素进入时和 b)当它离开“捕获框架”时。 -
threshold: 0.5
:当观察到的元素的 50% 与“捕获框架”相交时,应触发观察者; -
threshold: [0, 0.2, 0.5, 1]
:观察者应该在 4 种情况下做出反应:- 观察到的元素的第一个像素进入“捕获框”:元素仍然不在该框内,或者观察到的元素的最后一个像素离开“捕获框”:元素不再在框内;
- 20% 的元素在“捕获框架”内(同样,方向对于
IntersectionObserver
并不重要); - 50% 的元素在“捕获框”内;
- 100% 的元素都在“捕获框架”内。 这与
threshold: 0
严格相反。
-
为了通知我们的IntersectionObserver
我们需要的配置,我们只需将config
对象与回调函数一起传递给 Observer 的构造函数,如下所示:
const config = { root: null, // avoiding 'root' or setting it to 'null' sets it to default value: viewport rootMargin: '0px', threshold: 0.5 }; let observer = new IntersectionObserver(function(entries) { … }, config);
现在,我们应该给IntersectionObserver
实际要观察的元素。 这只需将元素传递给observe()
函数即可完成:
… const img = document.getElementById('image-to-observe'); observer.observe(image);
关于这个观察到的元素有几点需要注意:
- 前面已经提到过,但值得再次提及:如果您将
root
设置为 DOM 中的元素,则观察到的元素应该位于root
的 DOM 树中。 -
IntersectionObserver
一次只能接受一个元素进行观察,不支持批量供应观察。 这意味着如果您需要观察多个元素(比如说页面上的多个图像),您必须遍历所有元素并分别观察它们中的每一个:
… const images = document.querySelectorAll('img'); images.forEach(image => { observer.observe(image); });
- 当加载带有 Observer 的页面时,您可能会注意到
IntersectionObserver
的回调已同时针对所有观察到的元素触发。 即使是那些与提供的配置不匹配的。 “嗯……不是我所期望的,”这是第一次遇到这种情况时通常的想法。 但是不要在这里混淆:这并不一定意味着这些观察到的元素在页面加载时会以某种方式与“捕获框架”相交。
但这意味着,该元素的条目已初始化,现在由您的IntersectionObserver
控制。 不过,这可能会给您的回调函数添加不必要的噪音,并且您有责任检测哪些元素确实与“捕获框架”相交,而我们仍然不需要考虑哪些元素。 要了解如何进行检测,让我们更深入地了解回调函数的结构,并看看这些条目是由什么组成的。
IntersectionObserver 回调
首先, IntersectionObserver
的回调函数有两个参数,我们将从第二个参数开始以相反的顺序讨论它们。 除了前面提到的观察条目Array
,与我们的“捕获帧”相交,回调函数将观察者本身作为第二个参数。
参考观察者本身
new IntersectionObserver(function(entries, SELF) {…});
当您想在IntersectionObserver
第一次检测到某个元素后停止观察某个元素时,获取对 Observer 本身的引用在很多情况下很有用。 图像的延迟加载、其他资产的延迟获取等场景属于这种情况。 当你想停止观察一个元素时, IntersectionObserver
提供了一个unobserve(element-to-stop-observing)
方法,该方法可以在对观察到的元素执行一些操作后在回调函数中运行(例如实际延迟加载图像,例如)。
其中一些场景将在本文中进一步回顾,但我们不再讨论第二个论点,让我们来看看这个回调剧的主要参与者。
IntersectionObserverEntry
new IntersectionObserver(function(ENTRIES, self) {…});
我们在回调函数中作为Array
获取的entries
属于特殊类型: IntersectionObserverEntry
。 这个接口为我们提供了一组关于每个特定观察元素的预定义和预计算的属性。 让我们来看看最有趣的。
首先, IntersectionObserverEntry
类型的条目带有关于三个不同矩形的信息——定义过程中涉及的元素的坐标和边界:
-
rootBounds
:“捕获帧”的矩形(root
+rootMargin
); -
boundingClientRect
:被观察元素本身的矩形; -
intersectionRect
:被观察元素相交的“捕获框”区域。
异步计算这些矩形的真正酷之处在于,它为我们提供了与元素定位相关的重要信息,而无需我们调用getBoundingClientRect()
、 offsetTop
、 offsetLeft
和其他昂贵的定位属性和方法来触发布局抖动。 纯粹以性能取胜!
我们感兴趣的IntersectionObserverEntry
接口的另一个属性是isIntersecting
。 这是一个方便属性,指示观察到的元素当前是否与“捕获框架”相交。 当然,我们可以通过查看intersectionRect
来获取此信息(如果此矩形不是 0×0,则该元素与“捕获框”相交),但是为我们预先计算此值非常方便。
isIntersecting
可用于找出观察到的元素是刚刚进入“捕获框架”还是已经离开它。 要找出这一点,将此属性的值保存为全局标志,当此元素的新条目到达您的回调函数时,将其新的isIntersecting
与该全局标志进行比较:
- 如果它是
false
现在是true
,那么元素正在进入“捕获框”; - 如果它是相反的并且它现在是
false
的而之前它是true
,那么这个元素正在离开“捕获框架”。
isIntersecting
正是帮助我们解决前面讨论的问题的属性,即将真正与“捕获框架”相交的元素的条目与那些仅仅是条目初始化的噪音分开。
let isLeaving = false; let observer = new IntersectionObserver(function(entries) { entries.forEach(entry => { if (entry.isIntersecting) { // we are ENTERING the "capturing frame". Set the flag. isLeaving = true; // Do something with entering entry } else if (isLeaving) { // we are EXITING the "capturing frame" isLeaving = false; // Do something with exiting entry } }); }, config);
注意:在 Microsoft Edge 15 中,未实现isIntersecting
属性,尽管完全支持IntersectionObserver
,但仍返回undefined
。 不过,这已在 2017 年 7 月得到修复,并且自 Edge 16 起可用。
IntersectionObserverEntry
接口提供了另外一个预先计算的便利属性: intersectionRatio
。 此参数可用于与isIntersecting
相同的目的,但由于它是浮点数而不是布尔值,因此提供了更精细的控制。 intersectionRatio
的值表示被观察元素的区域有多少与“捕获框”相交( intersectionRect
区域与boundingClientRect
区域的比率)。 同样,我们可以使用来自这些矩形的信息自己进行此计算,但为我们完成它是件好事。
target
是IntersectionObserverEntry
接口的另一个属性,您可能需要经常访问它。 但这里绝对没有魔法——它只是传递给 Observer 的observe()
函数的原始元素。 就像你在处理事件时已经习惯的event.target
一样。
要获取IntersectionObserverEntry
接口的完整属性列表,请检查规范。
可能的应用
我意识到您很可能正是因为这一章才阅读这篇文章:毕竟,当我们有用于复制和粘贴的代码片段时,谁会关心机制? 所以现在不会再用更多的讨论来打扰您了:我们正在进入代码和示例的领域。 我希望代码中包含的注释能让事情更清楚。
延迟功能
首先,让我们回顾一个示例,该示例揭示了IntersectionObserver
思想的基本原理。 假设您有一个元素,一旦它出现在屏幕上,就必须进行大量计算。 例如,您的广告应仅在实际向用户展示时才注册一次浏览。 但是现在,让我们假设您在页面第一个屏幕下方的某处有一个自动播放的轮播元素。
一般来说,运行轮播是一项繁重的任务。 通常,它涉及 JavaScript 计时器、自动滚动元素的计算等。所有这些任务都加载主线程,当它在自动播放模式下完成时,我们很难知道我们的主线程什么时候受到这个打击。 当我们谈论在我们的第一个屏幕上优先考虑内容并希望尽快点击 First Meaningful Paint 和 Time To Interactive 时,阻塞的主线程成为我们性能的瓶颈。
为了解决这个问题,我们可能会推迟播放这样的轮播,直到它进入浏览器的视口。 对于这种情况,我们将把我们的知识和示例用于IntersectionObserverEntry
接口的isIntersecting
参数。
const carousel = document.getElementById('carousel'); let isLeaving = false; let observer = new IntersectionObserver(function(entries) { entries.forEach(entry => { if (entry.isIntersecting) { isLeaving = true; entry.target.startCarousel(); } else if (isLeaving) { isLeaving = false; entry.target.stopCarousel(); } }); } observer.observe(carousel);
在这里,我们仅在轮播进入我们的视口时才播放它。 注意没有传递给IntersectionObserver
初始化的config
对象:这意味着我们依赖默认配置选项。 当轮播离开我们的视口时,我们应该停止播放它,以免将资源花在不再重要的元素上。
延迟加载资产
这可能是IntersectionObserver
最明显的用例:我们不想花费资源来下载用户现在不需要的东西。 这将为您的用户带来巨大的好处:用户无需下载,他们的移动设备也无需解析和编译大量他们目前不需要的无用信息。 毫不奇怪,它还将有助于提高应用程序的性能。
以前,为了推迟下载和处理资源,直到用户可能在屏幕上看到它们,我们正在处理像scroll
这样的事件的事件侦听器。 问题很明显:这太频繁地触发了听众。 所以我们必须想出限制或去抖动回调执行的想法。 但是所有这些都给我们的主线程增加了很大的压力,在我们最需要的时候阻塞它。
那么,回到延迟加载场景中的IntersectionObserver
,我们应该注意什么? 让我们看一个延迟加载图像的简单示例。
尝试将该页面缓慢滚动到“第三屏”,并观察右上角的监控窗口:它会告诉您到目前为止已经下载了多少张图片。
在这个任务的 HTML 标记的核心中放置了一个简单的图像序列:
… <img data-src="https://blah-blah.com/foo.jpg"> …
如您所见,图像应该没有src
标签:一旦浏览器看到src
属性,它将立即开始下载该图像,这与我们的意图相反。 因此,我们不应该将该属性放在 HTML 中的图像上,相反,我们可能会依赖一些data-
属性,例如data-src
。
这个解决方案的另一部分当然是 JavaScript。 让我们关注这里的主要部分:
const images = document.querySelectorAll('[data-src]'); const config = { … }; let observer = new IntersectionObserver(function (entries, self) { entries.forEach(entry => { if (entry.isIntersecting) { … } }); }, config); images.forEach(image => { observer.observe(image); });
在结构方面,这里没有什么新东西:我们之前已经介绍过所有这些:
- 我们使用
data-src
属性获取所有消息; - 设置
config
:对于这种情况,您希望扩展“捕获框架”以检测低于视口底部的元素; - 使用该配置注册
IntersectionObserver
; - 遍历我们的图像并添加所有这些图像以供此
IntersectionObserver
观察;
有趣的部分发生在对条目调用的回调函数中。 涉及三个基本步骤。
首先,我们只处理真正与我们的“捕获框架”相交的项目。 这段代码你现在应该很熟悉了。
entries.forEach(entry => { if (entry.isIntersecting) { … } });
然后,我们通过将带有
data-src
的图像转换为真实的<img src="…">
以某种方式处理条目。if (entry.isIntersecting) { preloadImage(entry.target); … }
preloadImage()
是一个非常简单的函数,这里不值一提。 只需阅读源代码。下一步也是最后一步:由于延迟加载是一次性操作,我们不需要每次元素进入我们的“捕获框架”时都下载图像,我们应该不
unobserve
已经处理的图像。 当我们不再需要常规事件以防止代码中的内存泄漏时,我们应该使用element.removeEventListener()
来处理常规事件。if (entry.isIntersecting) { preloadImage(entry.target); // Observer has been passed as
self
to our callback self.unobserve(entry.target); }
笔记。 除了unobserve(event.target)
我们还可以调用disconnect()
:它完全断开我们的IntersectionObserver
并且不再观察图像。 如果您唯一关心的是观察者的第一次命中,这将很有用。 在我们的例子中,我们需要 Observer 来持续监控图像,所以我们现在不应该断开连接。
随意分叉示例并使用不同的设置和选项。 当您特别想延迟加载图像时,有一件有趣的事情要提。 您应该始终牢记由观察到的元素生成的框! 如果你检查这个例子,你会注意到第 41-47 行图像的 CSS 包含所谓的冗余样式,包括。 min-height: 100px
。 这样做是为了给图像占位符( <img>
没有src
属性)一些垂直尺寸。 做什么的?
- 如果没有垂直尺寸,所有
<img>
标签都会生成 0×0 框; - 由于
<img>
标签默认生成某种inline-block
框,所有这些 0×0 框将在同一行并排对齐; - 这意味着您的
IntersectionObserver
将一次注册所有(或者,取决于您滚动的速度,几乎所有)图像 - 可能不是您想要实现的。
当前部分的突出显示
当然, IntersectionObserver
不仅仅是延迟加载。 这是用这种技术替换scroll
事件的另一个例子。 在这个例子中,我们有一个非常常见的场景:在固定导航栏上,我们应该根据文档的滚动位置突出显示当前部分。
在结构上,它类似于延迟加载图像的示例,并且具有相同的基本结构,但有以下例外:
- 现在我们要观察的不是图像,而是页面上的部分;
- 显然,我们还有一个不同的函数来处理回调中的条目(
intersectionHandler(entry)
)。 但是这个并不有趣:它所做的只是切换 CSS 类。
这里有趣的是config
对象:
const config = { rootMargin: '-50px 0px -55% 0px' };
你问,为什么rootMargin
的默认值不是0px
? 好吧,仅仅因为突出显示当前部分和延迟加载图像在我们试图实现的目标上完全不同。 使用延迟加载,我们希望在图像进入视图之前开始加载。 因此,为了这个目的,我们在底部将“捕获帧”扩展了 50 像素。 相反,当我们想要突出显示当前部分时,我们必须确保该部分实际上在屏幕上可见。 不仅如此:我们必须确保用户实际上正在阅读或将要阅读本节。 因此,我们希望一个部分在我们可以将其声明为活动部分之前从底部开始超过视口的一半。 此外,我们要考虑导航栏的高度,因此我们将导航栏的高度从“捕获框架”中移除。
另外,请注意,在突出显示当前导航项的情况下,我们不想停止观察任何内容。 在这里,我们应该始终让IntersectionObserver
负责,因此您不会在这里找到disconnect()
和unobserve()
。
概括
IntersectionObserver
是一种非常直接的技术。 它在现代浏览器中有很好的支持,如果你想为仍然(或根本不)支持它的浏览器实现它,当然有一个 polyfill。 但总而言之,这是一项很棒的技术,它允许我们做各种与检测视口中的元素相关的事情,同时帮助实现非常好的性能提升。
为什么 IntersectionObserver 对您有好处?
-
IntersectionObserver
是一个异步非阻塞 API! -
IntersectionObserver
在scroll
或resize
事件时替换了我们昂贵的侦听器。 -
IntersectionObserver
会为您完成所有昂贵的计算,例如getClientBoundingRect()
,这样您就不需要了。 -
IntersectionObserver
遵循其他观察者的结构模式,因此理论上,如果您熟悉其他观察者的工作方式,应该很容易理解。
要记住的事情
如果我们将 IntersectionObserver 的功能与所有来自它的window.addEventListener('scroll')
的世界进行比较,很难看出这个 Observer 的任何缺点。 所以,让我们只注意一些要记住的事情:
- 是的,
IntersectionObserver
是一个异步非阻塞 API。 很高兴知道这一点! 但更重要的是要了解,即使 API 本身是异步的,您在回调中运行的代码默认也不会异步运行。 因此,如果您的回调函数的计算使主线程无响应,仍然有机会消除您从IntersectionObserver
获得的所有好处。 但这是一个不同的故事。 - 如果您使用
IntersectionObserver
延迟加载资产(例如图像),请在加载资产后运行.unobserve(asset)
。 IntersectionObserver
只能检测出现在文档格式结构中的元素的交集。 明确一点:可观察元素应该生成一个盒子并以某种方式影响布局。 以下只是一些示例,可以让您更好地理解:- 有
display: none
是不可能的; -
opacity: 0
或visibility:hidden
确实创建了盒子(即使是不可见的),所以它们会被检测到; -
width:0px; height:0px
width:0px; height:0px
很好。 但是,必须注意绝对定位的元素完全定位在父边界之外(具有负边距或负top
、left
等)并被父级的overflow: hidden
不会被检测到:它们的盒子不在格式化结构的范围。
- 有
I know it was a long article, but if you're still around, here are some links for you to get an even better understanding and different perspectives on the Intersection Observer API:
- Intersection Observer API on MDN;
- IntersectionObserver polyfill;
- IntersectionObserver polyfill as
npm
module; - Lazy-Loading Images with IntersectionObserver [video] by amazing Paul Lewis;
- Basic and short (just 01:39), but very informative introduction to IntersectionObserver [video] by Surma.
With this, I would like to make a pause in our discussion to give you an opportunity to play with this technology and realize all of its convenience. So, go play with it. The article is finally over. This time I really mean it.