From 33058f2b292b3a581333bdfb21b8f671898c5060 Mon Sep 17 00:00:00 2001 From: Peter Bengtsson Date: Tue, 8 Dec 2020 14:40:17 -0500 Subject: initial commit --- .../web/api/intersection_observer_api/index.html | 167 ++++++ .../index.html" | 562 +++++++++++++++++++++ 2 files changed, 729 insertions(+) create mode 100644 files/zh-cn/web/api/intersection_observer_api/index.html create mode 100644 "files/zh-cn/web/api/intersection_observer_api/\347\202\271\350\247\202\345\257\237\350\200\205api\347\232\204\346\227\266\345\272\217\345\205\203\347\264\240\345\217\257\350\247\201\346\200\247/index.html" (limited to 'files/zh-cn/web/api/intersection_observer_api') diff --git a/files/zh-cn/web/api/intersection_observer_api/index.html b/files/zh-cn/web/api/intersection_observer_api/index.html new file mode 100644 index 0000000000..16ec1fcc7f --- /dev/null +++ b/files/zh-cn/web/api/intersection_observer_api/index.html @@ -0,0 +1,167 @@ +--- +title: Intersection Observer API +slug: Web/API/Intersection_Observer_API +tags: + - Intersection Observer API + - IntersectionObserver +translation_of: Web/API/Intersection_Observer_API +--- +
+

{{DefaultAPISidebar("Intersection Observer API")}}

+ +

Intersection Observer API提供了一种异步检测目标元素与祖先元素或 {{Glossary("viewport")}} 相交情况变化的方法。

+
+ +

过去,要检测一个元素是否可见或者两个元素是否相交并不容易,很多解决办法不可靠或性能很差。然而,随着互联网的发展,这种需求却与日俱增,比如,下面这些情况都需要用到相交检测:

+ + + +

过去,相交检测通常要用到事件监听,并且需要频繁调用{{domxref("Element.getBoundingClientRect()")}} 方法以获取相关元素的边界信息。事件监听和调用 {{domxref("Element.getBoundingClientRect()")}}  都是在主线程上运行,因此频繁触发、调用可能会造成性能问题。这种检测方法极其怪异且不优雅。

+ +

假如有一个无限滚动的网页,开发者使用了一个第三方库来管理整个页面的广告,又用了另外一个库来实现消息盒子和点赞,并且页面有很多动画(译注:动画往往意味着较高的性能消耗)。两个库都有自己的相交检测程序,都运行在主线程里,而网站的开发者对这些库的内部实现知之甚少,所以并未意识到有什么问题。但当用户滚动页面时,这些相交检测程序就会在页面滚动回调函数里不停触发调用,造成性能问题,体验效果让人失望。

+ +

Intersection Observer API 会注册一个回调函数,每当被监视的元素进入或者退出另外一个元素时(或者 {{Glossary("viewport")}} ),或者两个元素的相交部分大小发生变化时,该回调方法会被触发执行。这样,我们网站的主线程不需要再为了监听元素相交而辛苦劳作,浏览器会自行优化元素相交管理。

+ +

注意 Intersection Observer API 无法提供重叠的像素个数或者具体哪个像素重叠,他的更常见的使用方式是——当两个元素相交比例在 N% 左右时,触发回调,以执行某些逻辑。

+ +

Intersection observer 的概念和用法

+ +

Intersection Observer API 允许你配置一个回调函数,当以下情况发生时会被调用

+ + + +

通常,您需要关注文档最接近的可滚动祖先元素的交集更改,如果元素不是可滚动元素的后代,则默认为设备视窗。如果要观察相对于根(root)元素的交集,请指定根(root)元素为null

+ +

无论您是使用视口还是其他元素作为根,API都以相同的方式工作,只要目标元素的可见性发生变化,就会执行您提供的回调函数,以便它与所需的交叉点交叉。

+ +

目标(target)元素与根(root)元素之间的交叉度是交叉比(intersection ratio)。这是目标(target)元素相对于根(root)的交集百分比的表示,它的取值在0.0和1.0之间。

+ +

创建一个 intersection observer

+ +

创建一个 IntersectionObserver对象,并传入相应参数和回调用函数,该回调函数将会在目标(target)元素和根(root)元素的交集大小超过阈值(threshold)规定的大小时候被执行。

+ +
let options = {
+    root: document.querySelector('#scrollArea'),
+    rootMargin: '0px',
+    threshold: 1.0
+}
+
+let observer = new IntersectionObserver(callback, options);
+
+ +

阈值为1.0意味着目标元素完全出现在root选项指定的元素中可见时,回调函数将会被执行。

+ +

Intersection observer options

+ +

传递到{{domxref("IntersectionObserver.IntersectionObserver", "IntersectionObserver()")}}构造函数的 options 对象,允许您控制观察者的回调函数的被调用时的环境。它有以下字段:

+ +
+
root
+
指定根(root)元素,用于检查目标的可见性。必须是目标元素的父级元素。如果未指定或者为null,则默认为浏览器视窗。
+
rootMargin  
+
根(root)元素的外边距。类似于 CSS 中的  {{cssxref("margin")}} 属性,比如 "10px 20px 30px 40px" (top, right, bottom, left)。如果有指定root参数,则rootMargin也可以使用百分比来取值。该属性值是用作root元素和target发生交集时候的计算交集的区域范围,使用该属性可以控制root元素每一边的收缩或者扩张。默认值为0。
+
threshold
+
可以是单一的number也可以是number数组,target元素和root元素相交程度达到该值的时候IntersectionObserver注册的回调函数将会被执行。如果你只是想要探测当target元素的在root元素中的可见性超过50%的时候,你可以指定该属性值为0.5。如果你想要target元素在root元素的可见程度每多25%就执行一次回调,那么你可以指定一个数组[0, 0.25, 0.5, 0.75, 1]。默认值是0(意味着只要有一个target像素出现在root元素中,回调函数将会被执行)。该值为1.0含义是当target完全出现在root元素中时候 回调才会被执行。
+
+

Targeting an element to be observed

+
+
+ +

为每个观察者配置一个目标

+ +
let target = document.querySelector('#listItem');
+observer.observe(target);
+
+ +

每当目标满足该IntersectionObserver指定的threshold值,回调被调用。

+ +

只要目标满足为IntersectionObserver指定的阈值,就会调用回调。回调接收 {{domxref("IntersectionObserverEntry")}} 对象和观察者的列表:

+ +
let callback =(entries, observer) => {
+  entries.forEach(entry => {
+    // Each entry describes an intersection change for one observed
+    // target element:
+    //   entry.boundingClientRect
+    //   entry.intersectionRatio
+    //   entry.intersectionRect
+    //   entry.isIntersecting
+    //   entry.rootBounds
+    //   entry.target
+    //   entry.time
+  });
+};
+ +

请留意,你注册的回调函数将会在主线程中被执行。所以该函数执行速度要尽可能的快。如果有一些耗时的操作需要执行,建议使用 {{domxref("Window.requestIdleCallback()")}} 方法。

+ +

How intersection is calculated -- 交集的计算

+ +

所有区域均被Intersection Observer API 当做一个矩形看待。如果元素是不规则的图形也将会被看成一个包含元素所有区域的最小矩形,相似的,如果元素发生的交集部分不是一个矩形,那么也会被看作是一个包含他所有交集区域的最小矩形。

+ +

这个有助于理解IntersectionObserverEntry 的属性,IntersectionObserverEntry用于描述target和root的交集。

+ +

The intersection root and root margin

+ +

在我们开始跟踪target元素和容器元素之前,我们要先知道什么是容器(root)元素。容器元素又称为intersection root, 或 root element.这个既可以是target元素祖先元素也可以是指定null则使用浏览器视口做为容器(root)。

+ +

用作描述intersection root 元素边界的矩形可以使用root margin来调整矩形大小,即rootMargin属性,在我们创建IntersectionObserver对象的时候使用。rootMargin的属性值将会做为margin偏移值添加到intersection root 元素的对应的margin位置,并最终形成root 元素的矩形边界。

+ +

Thresholds

+ +

IntersectionObserver API并不会每次在元素的交集发生变化的时候都会执行回调。相反它使用了thresholds参数。当你创建一个observer的时候,你可以提供一个或者多个number类型的数值用来表示target元素在root元素的可见程序的百分比,然后,API的回调函数只会在元素达到thresholds规定的阈值时才会执行。

+ +

例如,当你想要在target在root元素中中的可见性每超过25%或者减少25%的时候都通知一次。你可以在创建observer的时候指定thresholds属性值为[0, 0.25, 0.5, 0.75, 1],你可以通过检测在每次交集发生变化的时候的都会传递回调函数的参数"IntersectionObserverEntry.isIntersecting"的属性值来判断target元素在root元素中的可见性是否发生变化。如果isIntersecting 是 true,target元素的至少已经达到thresholds属性值当中规定的其中一个阈值,如果是false,target元素不在给定的阈值范围内可见。

+ +

为了让我们感受下thresholds是如何工作的,尝试滚动以下的例子,每一个colored box 的四个边角都会展示自身在root元素中的可见程度百分比,所以在你滚动root的时候你将会看到四个边角的数值一直在发生变化。每一个box都有不同的thresholds:

+ + + +

Interfaces

+ +
+
{{domxref("IntersectionObserver")}}
+
Provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's {{Glossary('viewport')}}. The ancestor or viewport is referred to as the root.
+
{{domxref("IntersectionObserverEntry")}}
+
Provides information about the intersection of a particular target with the observers root element at a particular time. Instances of this interface cannot be created, but a list of them is returned by {{domxref("IntersectionObserver.takeRecords", "IntersectionObserver.takeRecords()")}}.
+
+ +

Specifications

+ + + + + + + + + + + + + + +
SpecificationStatusComment
{{SpecName('IntersectionObserver')}}{{Spec2('IntersectionObserver')}}Initial definition.
+ +

浏览器兼容性

+ + + +

{{Compat("api.IntersectionObserver")}}

+ +

更多参考

+ + diff --git "a/files/zh-cn/web/api/intersection_observer_api/\347\202\271\350\247\202\345\257\237\350\200\205api\347\232\204\346\227\266\345\272\217\345\205\203\347\264\240\345\217\257\350\247\201\346\200\247/index.html" "b/files/zh-cn/web/api/intersection_observer_api/\347\202\271\350\247\202\345\257\237\350\200\205api\347\232\204\346\227\266\345\272\217\345\205\203\347\264\240\345\217\257\350\247\201\346\200\247/index.html" new file mode 100644 index 0000000000..24446a0141 --- /dev/null +++ "b/files/zh-cn/web/api/intersection_observer_api/\347\202\271\350\247\202\345\257\237\350\200\205api\347\232\204\346\227\266\345\272\217\345\205\203\347\264\240\345\217\257\350\247\201\346\200\247/index.html" @@ -0,0 +1,562 @@ +--- +title: Timing element visibility with the Intersection Observer API +slug: Web/API/Intersection_Observer_API/点观察者API的时序元素可见性 +translation_of: Web/API/Intersection_Observer_API/Timing_element_visibility +--- +
{{APIRef("Intersection Observer API")}}
+ +
交叉点观察者API使得当感兴趣的元素或多或少被共享祖先节点或元素遮蔽时,可以方便地异步通知,包括 {{domxref("Document")}}本身。
+ +
 
+ +

The Intersection Observer API makes it easy to be asynchronously notified when elements of interest become more or less obscured by a shared ancestor node or element, including the {{domxref("Document")}} itself. In this article, we'll build a mock blog which has a number of ads interspersed among the contents of the page, then use the Intersection Observer API to track how much time each ad is visible to the user. When an ad exceeds one minute of visible time, it will be replaced with a new one.

+ +

Although many aspects of this example will not match real world usage (in particular, the articles all have the same text and aren't loaded from a database, and there are just a handful of simple text-only ads that are selected from an array), this should provide enough understanding of the API to quickly learn how to apply the Intersection Observer API to your own site.

+ +

There's a good reason why the notion of tracking visibility of ads is being used in this example. It turns out that one of the most common uses of Flash or other script in advertising on the Web is to record how long each ad is visible, for the purpose of billing and payment of revenues. Without the Intersection Observer API, this winds up being done using intervals and timeouts for each individual ad, or other techniques that tend to slow the page down. Using this API lets everything get streamlined by the browser to reduce the impact on performance substantially.

+ +

Let's get started!

+ +
+

Site structure: The HTML

+ +

The site's structure is not too complicated. We'll be using CSS Grid to style and lay out the site, so we can be pretty straightforward here:

+ +
<div class="wrapper">
+  <header>
+    <h1>A Fake Blog</h1>
+    <h2>Showing Intersection Observer in action!</h2>
+  </header>
+
+  <aside>
+    <nav>
+      <ul>
+        <li><a href="#link1">A link</a></li>
+        <li><a href="#link2">Another link</a></li>
+        <li><a href="#link3">One more link</a></li>
+      </ul>
+    </nav>
+  </aside>
+
+  <main>
+  </main>
+</div>
+ +

This is the framework for the entire site. At the top is the site's header region, contained within a {{HTMLElement("header")}} block. Below that, we define the site's sidebar as a list of links within an {{HTMLElement("aside")}} block.

+ +

Finally comes the main body. We start with an empty {{HTMLElement("main")}} element here. This box will be populated using script later.

+ +

Styling the site with CSS

+ +

With the structure of the site defined, we turn to the styling for the site. Let's look at the style for each component of the page individually.

+ +

The basics

+ +

We provide styles for the {{HTMLElement("body")}} and {{HTMLElement("main")}} elements to define the site's background as well as the grid the various parts of the site will be placed in.

+ +
body {
+  font-family: "Open Sans", "Arial", "Helvetica", sans-serif;
+  background-color: aliceblue;
+}
+
+.wrapper {
+  display: grid;
+  grid-template-columns: auto minmax(min-content, 1fr);
+  grid-template-rows: auto minmax(min-content, 1fr);
+  max-width: 700px;
+  margin: 0 auto;
+  background-color: aliceblue;
+}
+ +

The site's {{HTMLElement("body")}} is configured here to use one of a number of common sans-serif fonts, and to use "aliceblue" as the background color. Then the "wrapper" class is defined; it wraps the entire blog, including the header, sidebar, and body content (articles and ads).

+ +

The wrapper establishes a CSS grid with two columns and two rows. The first column (sized automatically based on its content) is used for the sidebar and the second column (which will be used for body content) is sized to be at least the width of the contents of the column and at most all remaining available space.

+ +

The first row will be used specially for the site header. The rows are sized the same way as the columns: the first one is automatically sized and the one uses the remaining space, but at least enough space to provide room for all elements within it.

+ +

The wrapper's width is fixed at 700px so that it will fit in the available space when presented inline on MDN below.

+ +

The header

+ +

The header is fairly simple, since for this example all it contains is some text. Its style looks like this:

+ +
header {
+  grid-column: 1 / -1;
+  grid-row: 1;
+  background-color: aliceblue;
+}
+ +

{{cssxref("grid-row")}} is set to 1, since we want the header to be placed in the top row of the site's grid. More interesting is our use of {{cssxref("grid-column")}} here; here we specify that we want the column to start in the first column and ends in the first column past the last grid line—in other words, the header spans across all of the columns within the grid. Perfect for our needs.

+ +

The sidebar

+ +

Our sidebar is used to present links to other pages on the site. None of them work in our example here, but they exist to help with the presentation of a blog-like experience. The sidebar is represented using an {{HTMLElement("aside")}} element, and is styled as follows:

+ +
aside {
+  grid-column: 1;
+  grid-row: 2;
+  background-color: cornsilk;
+  padding: 5px 10px;
+}
+
+aside ul {
+  padding-left: 0;
+}
+
+aside ul li {
+  list-style: none;
+}
+
+aside ul li a {
+  text-decoration: none;
+}
+ +

The most important thing to note here is that the {{cssxref("grid-column")}} is set to 1, to place the sidebar on the left-hand side of the screen. If you change this to -1, it will appear on the right (although some other elements will need some adjustments made to their margins to get the spacing just right). The {{cssxref("grid-row")}} is set to 2, to place it alongside the site body.

+ +

The content body

+ +

Speaking of the site's body: the main content of the site is kept in a {{HTMLElement("main")}} element. The following style is applied to that:

+ +
main {
+  grid-column: 2;
+  grid-row: 2;
+  margin: 0;
+  margin-left: 16px;
+  font-size: 16px;
+}
+ +

The primary feature here is that the grid position is set to place the body content in column 2, row 2.

+ +

Articles

+ +

Each article is contained in an {{HTMLElement("article")}} element, styled like this:

+ +
article {
+  background-color: white;
+  padding: 6px;
+}
+
+article:not(:last-child) {
+  margin-bottom: 8px;
+}
+
+article h2 {
+  margin-top: 0;
+}
+ +

This creates article boxes with a white background which float atop the blue background, with a small margin around the article. Every article which isn't the last item in the container has an 8px bottom margin to space things apart.

+ +

Ads

+ +

Finally, the ads have the following initial styling. Individual ads may customize the style somewhat, as we'll see later.

+ +
.ad {
+  height: 96px;
+  padding: 6px;
+  border-color: #555;
+  border-style: solid;
+  border-width: 1px;
+}
+
+.ad:not(:last-child) {
+  margin-bottom: 8px;
+}
+
+.ad h2 {
+  margin-top: 0;
+}
+
+.ad div {
+  position: relative;
+  float: right;
+  padding: 0 4px;
+  height: 20px;
+  width: 120px;
+  font-size: 14px;
+  bottom: 30px;
+  border: 1px solid black;
+  background-color: rgba(255, 255, 255, 0.5);
+}
+ +

There's nothing magic in here. It's fairly basic CSS.

+ +

Tying it together with JavaScript

+ +

That brings us to the JavaScript code which makes everything work. Let's start with the global variables:

+ +
let contentBox;
+
+let nextArticleID = 1;
+let visibleAds = new Set();
+let previouslyVisibleAds = null;
+
+let adObserver;
+let refreshIntervalID = 0;
+ +

These are used as follows:

+ +
+
contentBox
+
A reference to the {{HTMLElement("main")}} element's {{domxref("HTMLElement")}} object in the DOM. This is where we'll insert the articles and ads.
+
nextArticleID
+
Each article is given a unique ID number; this variable tracks the next ID to use, starting with 1.
+
visibleAds
+
A {{jsxref("Set")}} which we'll use to track the ads currently visible on the screen.
+
previouslyVisibleAds
+
Used to temporarily store the list of visible ads while the document is not visible (for example, if the user has tabbed to another page).
+
adObserver
+
Will hold our {{domxref("IntersectionObserver")}} used to track the intersection between the ads and the <main> element's bounds.
+
refreshIntervalID
+
Used to store the interval ID returned by {{domxref("WindowOrWorkerGlobalScope.setInterval", "setInterval()")}}. This interval will be used to trigger our periodic refreshes of the ads' content.
+
+ +

Setting up

+ +

To set things up, we run the startup() function below when the page loads:

+ +
window.addEventListener("load", startup, false);
+
+function startup() {
+  contentBox = document.querySelector("main");
+
+  document.addEventListener("visibilitychange", handleVisibilityChange, false);
+
+  let observerOptions = {
+    root: null,
+    rootMargin: "0px",
+    threshold: [0.0, 0.75]
+  };
+
+  adObserver = new IntersectionObserver(intersectionCallback,
+                    observerOptions);
+
+  buildContents();
+  refreshIntervalID = window.setInterval(handleRefreshInterval, 1000);
+}
+ +

First, a reference to the content wrapping {{HTMLElement("main")}} element is obtained, so we can insert our content into it. Then we set up an event listener for the {{event("visibilitychange")}} event. This event is sent when the document becomes hidden or visible, such as when the user switches tabs in their browser. The Intersection Observer API doesn't take this into account when detecting intersection, since intersection isn't affected by page visibility. Therefore, we need to pause our timers while the page is tabbed out; hence this event listener.

+ +

Next we set up the options for the {{domxref("IntersectionObserver")}} which will monitor target elements (ads, in our case) for intersection changes relative to the document. The options are configured to watch for intersections with the document's viewport (by setting root to null). We have no margins to extend or contract the intersection root's rectangle; we want to match the boundaries of the document's viewport exactly for intersection purposes. And the threshold is set to an array containing the values 0.0 and 0.75; this will cause our callback to execute whenever a targeted element becomes completely obscured or first starts to become unobscured (intersection ratio 0.0) or passes through 75% visible in either direction (intersection ratio 0.75).

+ +

The observer, adObserver, is created by calling IntersectionObserver's constructor, passing in the callback function, intersectionCallback, and our options.

+ +

We then call a function buildContents(), which we'll define later to actually generate and insert into the document the articles and ads we want to present.

+ +

Finally, we set up an interval which triggers once a second to handle any necessary refreshing. We need a one second refresh since we're displaying timers in all visible ads for the purposes of this example. You may not need an interval at all, or you might do it differently or using a different time interval.

+ +

Handling document visibility changes

+ +

Let's take a look at the handler for the {{event("visibilitychange")}} event. Our script receives this event when the document itself becomes visible or invisible. The most important scenario here is when the user switches tabs. Since Intersection Observer only cares about the intersection between the targeted elements and the intersection root, and not the tab's visibility (which is a different issue entirely), we need to use the Page Visibility API to detect these tab switches and disable our timers for the duration.

+ +
function handleVisibilityChange() {
+  if (document.hidden) {
+    if (!previouslyVisibleAds) {
+      previouslyVisibleAds = visibleAds;
+      visibleAds = [];
+      previouslyVisibleAds.forEach(function(adBox) {
+        updateAdTimer(adBox);
+        adBox.dataset.lastViewStarted = 0;
+      });
+    }
+  } else {
+    previouslyVisibleAds.forEach(function(adBox) {
+      adBox.dataset.lastViewStarted = performance.now();
+    });
+    visibleAds = previouslyVisibleAds;
+    previouslyVisibleAds = null;
+  }
+}
+ +

Since the event itself doesn't state whether the document has switched from visible to invisible or vice-versa, the {{domxref("document.hidden")}} property is checked to see if the document is not currently visible. Since it's theoretically possible to get called multiple times, we only proceed if we haven't already paused the timers and saved the visibility states of the existing ads.

+ +

To pause the timers, all we need to do is remove the ads from the set of visible ads (visibleAds) and mark them as inactive. To do so, we begin by saving the set of visible ads into a variable known as previouslyVisibleAds to be sure we can restore them when the user tabs back into the document, and we then empty the visibleAds set so they won't be treated as visible. Then, for each of the ads that are being suspended, we call our updateAdTimer() function, which handles updating the ad's total visible time counter, then we set their dataset.lastViewStarted property to 0, which indicates that the tab's timer isn't running.

+ +

If the document has just become visible, we reverse this process: first we go through previouslyVisibleAds and set each one's dataset.lastViewStarted to the current document's time (in milliseconds since the document was created) using the {{domxref("Performance.now", "performance.now()")}} method. Then we set visibleAds back to previouslyVisibleAds and set the latter to null. Now the ads are all restarted, and configured to know that they became visible at the current time, so that they will not add up the duration of time the page was tabbed away the next time they're updated.

+ +

Handling intersection changes

+ +

Once per pass through the browser's event loop, each {{domxref("IntersectionObserver")}} checks to see if any of its target elements have passed through any of the observer's intersection ratio thresholds. For each observer, a list of targets that have done so is compiled, and sent to the observer's callback as an array of {{domxref("IntersectionObserverEntry")}} objects. Our callback, intersectionCallback(), looks like this:

+ +
function intersectionCallback(entries) {
+  entries.forEach(function(entry) {
+    let adBox = entry.target;
+
+    if (entry.isIntersecting) {
+      if (entry.intersectionRatio >= 0.75) {
+        adBox.dataset.lastViewStarted = entry.time;
+        visibleAds.add(adBox);
+      }
+    } else {
+      visibleAds.delete(adBox);
+      if ((entry.intersectionRatio === 0.0) && (adBox.dataset.totalViewTime >= 60000)) {
+        replaceAd(adBox);
+      }
+    }
+  });
+}
+ +

As previously mentioned, the {{domxref("IntersectionObserver")}} callback receives as input an array of all of the observer's targeted elements which have become either more or less visible than one of the intersection observer ratios. We iterate over each of those entries—which are of type {{domxref("IntersectionObserverEntry")}}. If the target element is intersecting with the root, we know it has just transitioned from the obscured state to the visible state. If it's become at least 75% visible, then we consider the ad visible and we start the timer by setting the ad's dataset.lastViewStarted attribute to the transition time in {{domxref("IntersectionObserverEntry.time", "entry.time")}}, then add the ad to the set visibleAds so we know to process it as time goes by.

+ +

If the ad has transitioned to the not-intersecting state, we remove the ad from the set of visible ads. Then we have one special behavior: we look to see if {{domxref("IntersectionObserverEntry.intersectionRatio", "entry.ratio")}} is 0.0; if it is, that means the element has become totally obscured. If that's the case, and the ad has been visible for at least a minute total, we call a function we'll create called replaceAd() to replace the existing ad with a new one. This way, the user sees a variety of ads over time, but the ads are only replaced while they can't be seen, resulting in a smooth experience.

+ +

Handling periodic actions

+ +

Our interval handler, handleRefreshInterval(), is called about once per second courtesy of the call to {{domxref("WindowOrGlobalScope.setInterval", "setInterval")}} made in the startup() function {{anch("Setting up", "described above")}}. Its main job is to update the timers every second and schedule a redraw to update the timers we'll be drawing within each ad.

+ +
function handleRefreshInterval() {
+  let redrawList = [];
+
+  visibleAds.forEach(function(adBox) {
+    let previousTime = adBox.dataset.totalViewTime;
+    updateAdTimer(adBox);
+
+    if (previousTime != adBox.dataset.totalViewTime) {
+      redrawList.push(adBox);
+    }
+  });
+
+  if (redrawList.length) {
+    window.requestAnimationFrame(function(time) {
+      redrawList.forEach(function(adBox) {
+        drawAdTimer(adBox);
+      });
+    });
+  }
+}
+ +

The array redrawList will be used to keep a list of all the ads which need to be redrawn during this refresh cycle, since it may not be exactly the same as the elapsed time due to system activity or because you've set the interval to something other than every 1000 milliseconds.

+ +

Then, for each of the visible ads, we save the value of dataset.totalViewTime (the total number of milliseconds the ad has currently been visible, as of the last time it was updated) and then call updateAdTimer() to update the time. If it's changed, then we push the ad onto the redrawList so we know it needs to be updated during the next animation frame.

+ +

Finally, if there's at least one element to redraw, we use {{domxref("window.requestAnimationFrame", "requestAnimationFrame()")}} to schedule a function that will redraw each element in the redrawList during the next animation frame.

+ +

Updating an ad's visibility timer

+ +

Previously (see {{anch("Handling document visibility changes")}} and {{anch("Handling periodic actions")}}), we've seen that when we need to update an ad's "total visible time" counter, we call a function named updateAdTimer() to do so. This function takes as an input an ad's {{domxref("HTMLDivElement")}} object. Here it is:

+ +
function updateAdTimer(adBox) {
+  let lastStarted = adBox.dataset.lastViewStarted;
+  let currentTime = performance.now();
+
+  if (lastStarted) {
+    let diff = currentTime - lastStarted;
+
+    adBox.dataset.totalViewTime = parseFloat(adBox.dataset.totalViewTime) + diff;
+  }
+
+  adBox.dataset.lastViewStarted = currentTime;
+}
+ +

To track an element's visible time, we use two custom data attributes (see {{htmlattrxref("data-*")}}) on every ad:

+ +
+
lastViewStarted
+
The time in milliseconds, relative to the time at which the document was created, at which the ad's visibility count was last updated, or the ad last became visible. 0 if the ad was not visible as of the last time it was checked.
+
totalViewTime
+
The total number of milliseconds the ad has been visible.
+
+ +

These are accessed through each ad's {{domxref("HTMLElement.dataset")}} attribute, which provides a {{domxref("DOMStringMap")}} mapping each custom attribute's name to its value. The values are strings, but we can convert those to numbers easily enough—in fact, JavaScript generally does it automatically, although we'll have one instance where we have to do it ourselves.

+ +

We start by fetching the time at which the ad's previous visibility status check time (adBox.dataset.lastViewStarted) into a local variable named lastStarted. We also get the current time-since-creation value using {{domxref("Performance.now", "performance.now()")}} into currentTime.

+ +

If lastStarted is non-zero—meaning the timer is currently running, we compute the difference between the current time and the start time to determine the number of milliseconds the timer has been visible since the last time it became visible. This is added to the current value of the ad's totalViewTime to bring the total up to date. Note the use of {{jsxref("parseFloat()")}} here; because these values are strings, JavaScript tries to do a string concatenation instead of addition without it.

+ +

Finally, the last-viewed time for the ad is updated to the current time. This is done whether the ad was running when this function was called or not; this causes the ad's timer to always be running when this function returns. This makes sense because this function is only called if the ad is visible, even if it's just now become visible.

+ +

Drawing an ad's timer

+ +

Inside each ad, for demonstration purposes, we draw the current value of its totalViewTime, converted into minutes and seconds. This is handled by passing the ad's element into the drawAdTimer() function:

+ +
function drawAdTimer(adBox) {
+  let timerBox = adBox.querySelector(".timer");
+  let totalSeconds = adBox.dataset.totalViewTime / 1000;
+  let sec = Math.floor(totalSeconds % 60);
+  let min = Math.floor(totalSeconds / 60);
+
+  timerBox.innerText = min + ":" + sec.toString().padStart(2, "0");
+}
+ +

This code finds the ad's timer using its ID, "timer", and computes the number of seconds elapsed by dividing the ad's totalViewTime by 1000. Then it calculates the number of minutes and seconds elapsed before setting the timer's {{domxref("Node.innerText", "innerText")}} to a string representing that time in the form m:ss. The {{jsxref("String.padStart()")}} method is used to ensure that the number of seconds is padded out to two digits if it's less than 10.

+ +

Building the page contents

+ +

The buildContents() function is called by the {{anch("Setting up", "startup code")}} to select and insert into the document the articles and ads to be presented:

+ +
let loremIpsum = "<p>Lorem ipsum dolor sit amet, consectetur adipiscing" +
+  " elit. Cras at sem diam. Vestibulum venenatis massa in tincidunt" +
+  " egestas. Morbi eu lorem vel est sodales auctor hendrerit placerat" +
+  " risus. Etiam rutrum faucibus sem, vitae mattis ipsum ullamcorper" +
+  " eu. Donec nec imperdiet nibh, nec vehicula libero. Phasellus vel" +
+  " malesuada nulla. Aliquam sed magna aliquam, vestibulum nisi at," +
+  " cursus nunc.</p>";
+
+function buildContents() {
+  for (let i=0; i<5; i++) {
+    contentBox.appendChild(createArticle(loremIpsum));
+
+    if (!(i % 2)) {
+      loadRandomAd();
+    }
+  }
+}
+
+ +

The variable loremIpsum contains the text we'll use for the body of all of our articles. Obviously in the real world, you'd have some code to pull articles from a database or the like, but this does the job for our purposes. Every article uses the same text; you could of course change that easily enough.

+ +

buildContents() creates a page with five articles. Following every odd-numbered article, an ad is "loaded" and inserted into the page. Articles are inserted into the content box (that is, the {{HTMLElement("main")}} element that contains all the site content) after being created using a method called createArticle(), which we'll look at next.

+ +

The ads are created using a function called loadRandomAd(), which both creates the ad and inserts it into the page. We'll see later that this same function can also replace an existing ad, but for now, we're simply appending ads to the existing content.

+ +

Creating an article

+ +

To create the {{HTMLElement("article")}} element for an article (as well as all of its contents), we use the createArticle() function, which takes as input a string which is the full text of the article to add to the page.

+ +
function createArticle(contents) {
+  let articleElem = document.createElement("article");
+  articleElem.id = nextArticleID;
+
+  let titleElem = document.createElement("h2");
+  titleElem.id = nextArticleID;
+  titleElem.innerText = "Article " + nextArticleID + " title";
+  articleElem.appendChild(titleElem);
+
+  articleElem.innerHTML += contents;
+  nextArticleID +=1 ;
+
+  return articleElem;
+}
+ +

First, the <article> element is created and its ID is set to the unique value nextArticleID (which starts at 1 and goes up for each article). Then we create and append an {{HTMLElement("h2")}} element for the article title and then we append the HTML from contents to that. Finally, nextArticleID is incremented (so that the next element gets a new unique ID) and we return the new <article> element to the caller.

+ +

Creating an ad

+ +

The loadRandomAd() function simulates loading an ad and adding it to the page. If you don't pass a value for replaceBox, a new element is created to contain the ad; the ad is then appended to the page. if you specify a replaceBox, that box is treated as an existing ad element; instead of creating a new one, the existing element is changed to contain the new ad's style, content, and other data. This avoids the risk of lengthy layout work being done when you update the ad, which could happen if you first delete the old element then insert a new one.

+ +
function loadRandomAd(replaceBox) {
+  let ads = [
+    {
+      bgcolor: "#cec",
+      title: "Eat Green Beans",
+      body: "Make your mother proud—they're good for you!"
+    },
+    {
+      bgcolor: "aquamarine",
+      title: "MillionsOfFreeBooks.whatever",
+      body: "Read classic literature online free!"
+    },
+    {
+      bgcolor: "lightgrey",
+      title: "3.14 Shades of Gray: A novel",
+      body: "Love really does make the world go round..."
+    },
+    {
+      bgcolor: "#fee",
+      title: "Flexbox Florist",
+      body: "When life's layout gets complicated, send flowers."
+    }
+  ];
+  let adBox, title, body, timerElem;
+
+  let ad = ads[Math.floor(Math.random()*ads.length)];
+
+  if (replaceBox) {
+    adObserver.unobserve(replaceBox);
+    adBox = replaceBox;
+    title = replaceBox.querySelector(".title");
+    body = replaceBox.querySelector(".body");
+    timerElem = replaceBox.querySelector(".timer");
+  } else {
+    adBox = document.createElement("div");
+    adBox.className = "ad";
+    title = document.createElement("h2");
+    body = document.createElement("p");
+    timerElem = document.createElement("div");
+    adBox.appendChild(title);
+    adBox.appendChild(body);
+    adBox.appendChild(timerElem);
+  }
+
+  adBox.style.backgroundColor = ad.bgcolor;
+
+  title.className = "title";
+  body.className = "body";
+  title.innerText = ad.title;
+  body.innerHTML = ad.body;
+
+  adBox.dataset.totalViewTime = 0;
+  adBox.dataset.lastViewStarted = 0;
+
+  timerElem.className="timer";
+  timerElem.innerText = "0:00";
+
+  if (!replaceBox) {
+    contentBox.appendChild(adBox);
+  }
+
+  adObserver.observe(adBox);
+}
+ +

First is the array ads. This array contains the data needed to create each ad. We have four here to choose from at random. In a real-world scenario, of course, the ads would come from a database or, more likely, an advertising service from which you fetch ads using an API. However, our needs are simple: each ad is represented by an object with three properties: a background color (bgcolor), a title (title), and a body text string (body).

+ +

Then we define several variables:

+ +
+
adBox
+
This will be set to the element that represents the ad. For new ads being appended to the page, this is created using {{domxref("Document.createElement()")}}. When replacing an existing ad, this is set to the specified ad element (replaceBox).
+
title
+
Will hold the {{HTMLElement("h2")}} element representing the ad's title.
+
body
+
Will hold the {{HTMLElement("p")}} representing the ad's body text.
+
timerElem
+
Will hold the {{HTMLElement("div")}} element which contains the time the ad has been visible so far.
+
+ +

A random ad is selected by computing Math.floor(Math.random() * ads.length); the result is a value between 0 and one less than the number of ads. The corresponding ad is now known as adBox.

+ +

If a value is specified for replaceBox, we use that as the ad element. To do so, we begin by ending observation of the element by calling {{domxref("IntersectionObserver.unobserve()")}}. Then the local variables for each of the elements that comprise an ad: the ad box itself, the title, the body, and the timer box, are all set to the corresponding elements in the existing ad.

+ +

If no value is specified for replaceBox, we create a new ad element. The ad's new {{HTMLElement("div")}} element is created and its properties established by setting its class name to "ad". Next, the ad title element is created, along with the body and the visibility timer; these are an {{HTMLElement("h2")}}, a {{HTMLElement("p")}}, and a {{HTMLElement("div")}} element, respectively. These elements are appended to the adBox element.

+ +

After that, the code paths converge once again. The ad's background color is set to the value specified in the new ad's record, and elements' classes and contents are set appropriately as well.

+ +

Next, it's time to set up the custom data properties to track the ad's visibility data by setting adBox.dataset.totalViewTime and adBox.dataset.lastViewStarted to 0.

+ +

Finally, we set the ID of the <div> which will show the timer we'll present in the ad to show how long it's been visible, giving it the class "timer". The initial text is set to "0:00", to represent the starting time of 0 minutes and 0 seconds, and it's appended to the ad.

+ +

If we're not replacing an existing ad, we need to append the element to the content area of the page using {{domxref("Node.appendChild", "Document.appendChild()")}}. If we're replacing an ad, it's already there, with its contents replaced with the new ad's. Then we call the {{domxref("IntersectionObserver.observe", "observe()")}} method on our Intersection Observer, adObserver, to start watching the ad for changes to its intersection with the viewport. From now on, any time the ad becomes 100% obscured or even a single pixel becomes visible, or the ad passes through 75% visible in one way or another, the {{anch("Handling intersection changes", "observer's callback")}} is executed.

+ +

Replacing an existing ad

+ +

Our {{anch("Handling intersection changes", "observer's callback")}} keeps an eye out for ads which become 100% obscured and have a total visible time of at least one minute. When that happens, the replaceAd() function is called with that ad's element as an input, so that the old ad can be replaced with a new one.

+ +
function replaceAd(adBox) {
+  let visibleTime;
+
+  updateAdTimer(adBox);
+
+  visibleTime = adBox.dataset.totalViewTime
+  console.log("  Replacing ad: " + adBox.querySelector("h2").innerText + " - visible for " + visibleTime)
+
+  loadRandomAd(adBox);
+}
+ +

replaceAd() begins by calling updateAdTimer() on the existing ad, to ensure that its timer is up-to-date. This ensures that when we read its totalViewTime, we see the exact final value for how long the ad was visible to the user. We then report that data; in this case, by logging it to console, but in the real world, you'd submit the information to an ad service's API or save it into a database.

+ +

Then we load a new ad by calling {{anch("Creating an ad", "loadRandomAd()")}}, specifying the ad to be replaced as an input parameter. As we saw previously, loadRandomAd() will replace an existing ad with content and data corresponding to a new ad, if you specify an existing ad's element as an input parameter.

+ +

The new ad's element object is returned to the caller in case it's needed.

+
+ +

Result

+ +

The resulting page looks like this. Try experimenting with scrolling around and watch how visibility changes affect the timers in each ad. Also note that each ad is replaced after one minute of visibility, and how the timers pause while the document is tabbed into the background.

+ +

{{EmbedLiveSample("fullpage_example", 750, 800)}}

+ +

See also

+ + -- cgit v1.2.3-54-g00ecf