一個神奇的交叉觀察 API Intersection Observer

前言

前端開發肯定離不開判斷一個元素是否能被用戶看見,然後再基於此進行一些交互。

過去,要檢測一個元素是否可見或者兩個元素是否相交併不容易,很多解決辦法不可靠或性能很差。然而,隨着互聯網的發展,這種需求卻與日俱增,比如,下面這些情況都需要用到相交檢測:

  • 圖片懶加載——當圖片滾動到可見時才進行加載

  • 內容無限滾動——也就是用戶滾動到接近內容底部時直接加載更多,而無需用戶操作翻頁,給用戶一種網頁可以無限滾動的錯覺

  • 檢測廣告的曝光情況——爲了計算廣告收益,需要知道廣告元素的曝光情況

  • 在用戶看見某個區域時執行任務或播放動畫

過去,相交檢測通常要用到事件監聽,並且需要頻繁調用 Element.getBoundingClientRect() 方法以獲取相關元素的邊界信息。事件監聽和調用 Element.getBoundingClientRect()  都是在主線程上運行,因此頻繁觸發、調用可能會造成性能問題。這種檢測方法極其怪異且不優雅。

上面這一段話來自 MDN ,中心思想就是現在判斷一個元素是否能被用戶看見的使用場景越來越多,監聽 scroll 事件以及通過 Element.getBoundingClientRect() 獲取節點位置的方式,又麻煩又不好用,那麼怎麼辦呢。於是就有了今天的內容 Intersection Observer API

Intersection Observer API 是什麼

我們需要觀察的元素被稱爲 目標元素 (target),設備視窗或者其他指定的元素視口的邊界框我們稱它爲 根元素 (root),或者簡稱爲  。

Intersection Observer API 翻譯過來叫做 “交叉觀察器”,因爲判斷元素是否可見(通常情況下)的本質就是判斷目標元素和根元素是不是產生了 交叉區域 。

爲什麼是通常情況下,因爲當我們 css 設置了 opacity: 0visibility: hidden 或者 用其他的元素覆蓋目標元素 的時候,對於視圖來說是不可見的,但對於交叉觀察器來說是可見的。這裏可能有點抽象,大家只需記住,交叉觀察器只關心 目標元素 和 根元素 是否有 交叉區域, 而不管視覺上能不能看見這個元素。當然如果設置了 display:none,那麼交叉觀察器就不會生效了,其實也很好理解,因爲元素已經不存在了,那麼也就監測不到了。

一句話總結:Intersection Observer API 提供了一種異步檢測目標元素與祖先元素或 viewport 相交情況變化的方法。 -- MDN

現在不懂沒關係,咱們接着往下看,看完自然就明白了。

Intersection Observer API 怎麼玩

生成觀察器

// 調用構造函數 IntersectionObserver 生成觀察器
const myObserver = new IntersectionObserver(callback, options);  
複製代碼

首先調用瀏覽器原生構造函數 IntersectionObserver ,構造函數的返回值是一個 觀察器實例 。

構造函數 IntersectionObserver 接收兩個參數

構造函數接收的參數 options

爲了方便理解,我們先看第二個參數 options 。一個可以用來配置觀察器實例的對象,那麼這個配置對象都包含哪些屬性呢?

構造函數接收的參數 callback

當元素可見比例超過指定閾值後,會調用一個回調函數,此回調函數接受兩個參數:存放 IntersectionObserverEntry 對象的數組和觀察器實例 (可選)。

((doc) ={
  //回調函數
  const callback = (entries, observer) ={
    console.log('🚀🚀~ 執行了一次callback');
    console.log('🚀🚀~ entries:', entries);
    console.log('🚀🚀~ observer:', observer);
  };
  //配置對象
  const options = {};
  //創建觀察器
  const myObserver = new IntersectionObserver(callback, options);
  //獲取目標元素
  const target = doc.querySelector(".target")
  //開始監聽目標元素
  myObserver.observe(target);
})(document)

我們把這兩個參數打印出來看一下,可以看到,第一個參數是一個數組,每一項都是一個目標元素對應的 IntersectionObserverEntry對象,第二個參數是觀察器實例對象 IntersectionObserver 。

什麼是 IntersectionObserverEntry 對象

展開 IntersectionObserverEntry 看一下都有什麼。

這裏再看一下 boundingClientRect ,intersectionRatio , rootBounds 三個屬性展開的內容都有什麼。

用一張圖來展示一下這幾個屬性,特別需要注意的是 right 和 bottom ,跟我們平時寫 css 的 position 那個不一樣 。

那麼第二個參數 IntersectionObserver 觀察器實例對象都有什麼呢

彆着急,接着往下看,實例屬性部分。

觀察器實例屬性

上面留了一個坑,回調函數的第二個參數 IntersectionObserver 觀察器實例對象都有什麼呢?我們把實例對象打印出來看一下

((doc) ={
  //回調函數
  const callback = () ={};
  //配置對象
  const options = {};
  //創建觀察器
  const myObserver = new IntersectionObserver(callback, options);
  //獲取目標元素
  const target = doc.querySelector(".target")
  //開始監聽目標元素
  myObserver.observe(target);
  console.log('🚀🚀~ myObserver:', myObserver);
})(document)

可以看到,我們的觀察器實例上面包含如下屬性

是不是特別眼熟,沒錯,就是我們創建觀察者實例的時候,傳入的 options 對象,只不過 options 對象是可選的,觀察器實例的屬性就使用我們傳入的 options 對象,如果沒傳就使用默認值,唯一不同的是,options 中 的屬性 threshold 是單數,而我們實例獲取到的 thresholds 是複數。

值得注意的是,這裏的所有屬性都是 只讀 的,也就是說一旦觀察器被創建,則 無法 更改其配置,所以一個給定的觀察者對象只能用來監聽可見區域的特定變化值。

接下來我們就通過代碼結合動圖演示一下這些屬性

((doc) ={
  let n = 0
  //獲取目標元素
  const target = doc.querySelector(".target")
  //獲取根元素
  const root = doc.querySelector(".out-container")
  //回調函數
  const callback = (entries, observer) ={
    n++
    console.log(`🚀🚀~ 執行了 ${n} 次callback`);
    console.log('🚀🚀~ entries:', entries);
    console.log('🚀🚀~ observer:', observer);
  };
  //配置對象
  const options = {
    root: root,
    rootMargin: '0px 0px 0px 0px',
    threshold: [0.5],
    trackVisibility: true,
    delay: 100
  };
  //創建觀察器
  const myObserver = new IntersectionObserver(callback, options);
  //開始監聽目標元素
  myObserver.observe(target);
  console.log('🚀🚀~ myObserver:', myObserver);
})(document)

root 這個沒什麼說的,就是設置指定節點爲根元素 rootMargin 我們把 rootMargin 修改爲 '50px 50px 50px 50px',可以看到,我們的目標元素還沒有露出來的時候回調函數就已經執行了,也就是說目標元素距離根元素還有 50px 的 margin 時,觀察器就認爲是發生了交叉。thresholds 我們把 threshold 修改爲 [0.1, 0.3, 0.5, 0.8, 1], 可以看到,回調函數觸發了多次,也就是說當交叉區域的百分比,每達到指定的閾值時都會觸發一次回調函數。

注意 Intersection Observer API 無法提供重疊的像素個數或者具體哪個像素重疊,他的更常見的使用方式是——當兩個元素相交比例在 N% 左右時,觸發回調,以執行某些邏輯。 -- MDN

trackVisibility 修改 trackVisibility 爲 true ,可以看到, isVisible 屬性值爲 true 。修改 css 屬性 爲 opacity: 0,可以看到,雖然我們藍色小方塊並沒有出現在視圖中,但是回調函數已經執行了,並且 isVisible 屬性值爲 false 而 isIntersecting 值爲 true 。delay 回調函數延遲觸發,我們修改 delay 爲 3000,可以看到 log 是 3000ms 以後才輸出的。

觀察器實例方法

通過此段代碼來演示觀察器實例方法,爲了方便演示,我添加了幾個對應的按鈕。

((doc) ={
  let n = 0
  //獲取目標元素
  const target1 = doc.querySelector(".target1")
  const target2 = doc.querySelector(".target2")
  //添加幾個按鈕方便操作
  const observe = doc.querySelector(".observe")
  const unobserve = doc.querySelector(".unobserve")
  const disconnect = doc.querySelector(".disconnect")
  observe.addEventListener('click'() => myObserver.observe(target1))
  unobserve.addEventListener('click'() => myObserver.unobserve(target1))
  disconnect.addEventListener('click'() => myObserver.disconnect())
  //獲取根元素
  const root = doc.querySelector(".out-container")
  //回調函數
  const callback = (entries, observer) ={
    n++
    console.log(`🚀🚀~ 執行了 ${n} 次callback`);
    console.log('🚀🚀~ entries:', entries);
    console.log('🚀🚀~ observer:', observer);
  };
  //配置對象
  const options = {
    root: root,
    rootMargin: '0px 0px 0px 0px',
    threshold: [0.1, 0.2, 0.3, 0.5],
    trackVisibility: true,
    delay: 100
  };
  //創建觀察器
  const myObserver = new IntersectionObserver(callback, options);
  //開始監聽目標元素
  myObserver.observe(target2);
  console.log('🚀🚀~ myObserver:', myObserver);
})(document)

observe

 const myObserver = new IntersectionObserver(callback, options);
 myObserver.observe(target);

接受一個目標元素作爲參數。很好理解,當我們創建完觀察器實例後,要手動的調用 observe 方法來通知它開始監測目標元素。

可以在同一個觀察者對象中配置監聽多個目標元素

target2 元素是通過代碼自動監測的,而 target1 則是我們在點擊了 observe 按鈕之後開始監測的。通過動圖可以看到,當我單擊 observe 按鈕後,我們的 entries 數組裏面就包含了兩條數據,前文中說到,可以通過 target 屬性來判斷是哪個目標元素。

unobserve

 const myObserver = new IntersectionObserver(callback, options);
 myObserver.observe(target);
 myObserver.unobserve(target)
複製代碼

接收一個目標元素作爲參數,當我們不想監聽某個元素的時候,需要手動調用 unobserve 方法來停止監聽指定目標元素。通過動圖可以發現,當我們點擊 unobserve 按鈕後,由兩條數據變成了一條數據,說明 target1 已經不再接受監測了。

disconnect

 const myObserver = new IntersectionObserver(callback, options);
 myObserver.disconnect()

當我們不想監測任何一個目標元素時,我們需要手動調用 disconnect 方法停止監聽工作。通過動圖可以看到,當我們點擊 disconnect 按鈕後,控制檯不再輸出 log ,說明監聽工作已經停止,可以通過 observe 再次開啓監聽工作。

takeRecords

返回所有觀察目標的 IntersectionObserverEntry 對象數組,應用場景較少。

當觀察到交互動作發生時,回調函數並不會立即執行,而是在空閒時期使用 requestIdleCallback 來異步執行回調函數,但是也提供了同步調用的 takeRecords 方法。

如果異步的回調先執行了,那麼當我們調用同步的 takeRecords 方法時會返回空數組。同理,如果已經通過 takeRecords 獲取了所有的觀察者實例,那麼回調函數就不會被執行了。

注意事項

構造函數 IntersectionObserver 配置的回調函數都在哪些情況下被調用?

構造函數 IntersectionObserver 配置的回調函數,在以下情況發生時可能會被調用

((doc) ={
  //回調函數
  const callback = () ={
    console.log('🚀🚀~ 執行了一次callback');
  };
  //配置對象
  const options = {};
  //觀察器實例
  const myObserver = new IntersectionObserver(callback, options);
  //目標元素
  const target = doc.querySelector("#target")
  //開始觀察
  myObserver.observe(target);
})(document)

可以看到,無論目標元素是否與根元素相交,當我們第一次監聽目標元素的時候,回調函數都會觸發一次,所以不要直接在回調函數里寫邏輯代碼,儘量通過 isIntersecting 或者 intersectionRect 進行判斷之後再執行邏輯代碼。

頁面的可見性如何監測

頁面的可見性可以通過document.visibilityState或者document.hidden獲得。頁面可見性的變化可以通過document.visibilitychange來監聽。

可見性和交叉觀察

當 css 設置了opacity: 0visibility: hidden 以及 用其他的元素覆蓋目標元素 ,都不會影響交叉觀察器的監測,也就是都不會影響 isIntersecting 屬性的結果,但是會影響 isVisible 屬性的結果, 如果元素設置了 display:none 就不會被檢測了。當然影響元素可視性的屬性不止上述這些,還包括positionmarginclip 等等等等... 就靠小夥伴們自行發掘了

交集的計算

所有區域均被 Intersection Observer API 當做一個 矩形 看待。如果元素是不規則的圖形也將會被看成一個包含元素所有區域的最小矩形,相似的,如果元素髮生的交集部分不是一個矩形,那麼也會被看作是一個包含他所有交集區域的最小矩形。

我怎麼知道目標元素來自視口的上方還是下方

目標元素滾動的方向也是可以判斷的,原理是根元素的 entry.rootBounds.y 是固定不變的 ,所以我們只需要計算 entry.boundingClientRect.y 與 entry.rootBounds.y 的大小,當回調函數觸發的時候,我們記錄下當時的位置,如果 entry.boundingClientRect.y < entry.rootBounds.y,說明是在視口下方,那麼當下一次目標元素可見的時候,我們就知道目標元素時來自視口下方的,反之亦然。

let wasAbove = false;
function callback(entries, observer) {
    entries.forEach(entry ={
        const isAbove = entry.boundingClientRect.y < entry.rootBounds.y;
        if (entry.isIntersecting) {
            if (wasAbove) {
                // Comes from top
            }
        }
        wasAbove = isAbove;
    });
}

應用場景

介紹完基礎知識,總得來幾個實例 (演示代碼採用 VUE3.0),當然實際場景要比這複雜的多,如何在自己的工作學習中應用,還是要靠小夥伴們多多開動聰明的大腦~

數據列表無限滾動

<template>
  <div class="box">
    <div class="vbody"
         v-for='item in list'
         :key='item'>內容區域{{item}}</div>
    <div class="reference"
         ref='reference'></div>
  </div>
</template>

<script lang='ts'>
import { defineComponent, onMounted, reactive, ref } from 'vue'

export default defineComponent({
  name: '',
  setup() {
    const reference = ref(null)
    const list = reactive([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
    onMounted(() ={
      let n = 10
      //回調函數
      const callback = (entries) ={
        const myEntry = entries[0]
        if (myEntry.isIntersecting) {
          console.log(`🚀🚀~ 觸發了無線滾動,開始模擬請求數據 ${n}`)
          n++
          list.push(n)
        }
      }
      //配置對象
      const options = {
        root: null,
        rootMargin: '0px 0px 0px 0px',
        threshold: [0, 1],
        trackVisibility: true,
        delay: 100,
      }
      //觀察器實例
      const myObserver = new IntersectionObserver(callback, options)
      //開始觀察
      myObserver.observe(reference.value)
    })

    return { reference, list }
  },
})
</script>

<style>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
.reference {
  width: 100%;
  visibility: hidden;
}
.vbody {
  width: 100%;
  height: 200px;
  background-color: red;
  color: aliceblue;
  font-size: 40px;
  text-align: center;
  line-height: 200px;
  margin: 10px 0;
}
</style>

我們只需要在底部添加一個參考元素,當參考元素可見時,就向後臺請求數據,就可以實現無線滾動的效果了。

圖片預加載

<template>
  <div class="box">
    <div class="vbody">內容區域</div>
    <div class="vbody">內容區域</div>
    <div class="header"
         ref='header'>
      <img :src="url">
    </div>
    <div class="vbody">內容區域</div>
  </div>
</template>

<script lang='ts'>
import { defineComponent, onMounted, ref } from 'vue'

export default defineComponent({
  name: '',
  setup() {
    const header = ref(null)
    const url = ref('')
    onMounted(() ={
      //回調函數
      const callback = (entries) ={
        const myEntry = entries[0]
        if (myEntry.isIntersecting) {
          console.log('🚀🚀~ 預加載圖片開始')
          url.value =
            '//img10.360buyimg.com/imgzone/jfs/t1/197235/15/2956/67824/6115e076Ede17a418/d1350d4d5e52ef50.jpg'
        }
      }
      //配置對象
      const options = {
        root: null,
        rootMargin: '200px 200px 200px 200px',
        threshold: [0],
        trackVisibility: true,
        delay: 100,
      }
      //觀察器實例
      const myObserver = new IntersectionObserver(callback, options)
      //開始觀察
      myObserver.observe(header.value)
    })

    return { header, url }
  },
})
</script>

<style>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
.box {
}
.header {
  width: 100%;
  height: 400px;
  background-color: blue;
  color: aliceblue;
  font-size: 40px;
  text-align: center;
  line-height: 400px;
}
.header img {
  width: 100%;
  height: 100%;
}
.reference {
  width: 100%;
  visibility: hidden;
}
.vbody {
  width: 100%;
  height: 800px;
  background-color: red;
  color: aliceblue;
  font-size: 40px;
  text-align: center;
  line-height: 800px;
  margin: 10px 0;
}
</style>

利用 options 的 rootMargin屬性,可以在圖片即將進入可視區域的時間進行圖片的加載,即避免了提前請求大量圖片造成的性能問題,也避免了圖片進入窗口才加載已經來不及的問題。

吸頂

<template>
  <div class="box">
    <div class="reference"
         ref='reference'></div>
    <div class="header"
         ref='header'>吸頂區域</div>
    <div class="vbody">內容區域</div>
    <div class="vbody">內容區域</div>
    <div class="vbody">內容區域</div>
  </div>
</template>

<script lang='ts'>
import { defineComponent, onMounted, ref } from 'vue'

export default defineComponent({
  name: '',
  setup() {
    const header = ref(null)
    const reference = ref(null)

    onMounted(() ={
      //回調函數
      const callback = (entries) ={
        const myEntry = entries[0]
        if (!myEntry.isIntersecting) {
          console.log('🚀🚀~ 觸發了吸頂')
          header.value.style.position = 'fixed'
          header.value.style.top = '0px'
        } else {
          console.log('🚀🚀~ 取消吸頂')
          header.value.style.position = 'relative'
        }
      }
      //配置對象
      const options = {
        root: null,
        rootMargin: '0px 0px 0px 0px',
        threshold: [0, 1],
        trackVisibility: true,
        delay: 100,
      }
      //觀察器實例
      const myObserver = new IntersectionObserver(callback, options)
      //開始觀察
      myObserver.observe(reference.value)
    })

    return { reference, header }
  },
})
</script>

<style>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
.header {
  width: 100%;
  height: 100px;
  background-color: blue;
  color: aliceblue;
  font-size: 40px;
  text-align: center;
  line-height: 100px;
}
.reference {
  width: 100%;
  visibility: hidden;
}
.vbody {
  width: 100%;
  height: 800px;
  background-color: red;
  color: aliceblue;
  font-size: 40px;
  text-align: center;
  line-height: 800px;
  margin: 10px 0;
}
</style>

思路就是利用一個參考元素作爲交叉觀察器觀察的對象,當參考元素可見的時候,取消吸頂區域的 fixed 屬性,否則添加 fixed 屬性,吸底稍微複雜一點,但是道理差不多,留給小夥伴們自行研究吧 ~ ~。

埋點上報

<template>
  <div class="box">
    <div class="vbody">內容區域</div>
    <div class="vbody">內容區域</div>
    <div class="header"
         ref='header'>埋點區域</div>
    <div class="vbody">內容區域</div>

  </div>
</template>

<script lang='ts'>
import { defineComponent, onMounted, ref } from 'vue'

export default defineComponent({
  name: '',
  setup() {
    const header = ref(null)

    onMounted(() ={
      //回調函數
      const callback = (entries) ={
        const myEntry = entries[0]
        if (myEntry.isIntersecting) {
          console.log('🚀🚀~ 觸發了埋點')
        }
      }
      //配置對象
      const options = {
        root: null,
        rootMargin: '0px 0px 0px 0px',
        threshold: [0.5],
        trackVisibility: true,
        delay: 100,
      }
      //觀察器實例
      const myObserver = new IntersectionObserver(callback, options)
      //開始觀察
      myObserver.observe(header.value)
    })

    return { header }
  },
})
</script>

<style>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
.header {
  width: 100%;
  height: 400px;
  background-color: blue;
  color: aliceblue;
  font-size: 40px;
  text-align: center;
  line-height: 400px;
}
.vbody {
  width: 100%;
  height: 800px;
  background-color: red;
  color: aliceblue;
  font-size: 40px;
  text-align: center;
  line-height: 800px;
  margin: 10px 0;
}
</style>

通常情況下,我們統計一個元素是否被用戶有效的看到,並不是元素剛出現就觸發埋點,而是元素進入可是區域一定比例纔可以,我們可以配置 options 的 threshold 爲 0.5

等等等等。。。。

這個 api 可以說是非常強大了,可玩性也是極高,大家自由發揮 ~ ~

兼容性

爲什麼有兩張兼容性的圖呢?因爲 trackVisibility 和 delay 兩個屬性是屬於 IntersectionObserver V2 的。所以小夥伴們在用的時候一定要注意兼容性。當然也有兼容解決方案,那就是 intersection-observer-polyfill

參考資料

[1] Can I Use:

https://caniuse.com/?search=IntersectionObserver%20

[2] MDN Intersection Observer:

https://developer.mozilla.org/zh-CN/docs/Web/API/IntersectionObserver

[3] IntersectionObserver API 使用教程:

https://www.ruanyifeng.com/blog/2016/11/intersectionobserver_api.html

[4] intersection-observer-polyfill:

https://www.npmjs.com/package/intersection-observer-polyfill

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/fu6VqPfWn7mB7evopnDeLA