文章出處

溫馨提示:本文目前僅適用于在 Chrome 51 及以上中瀏覽。

2016.11.1 追加,Firefox 52 也已經實現。

2016.11.29 追加,Firefox 的人擔心目前規范不夠穩定,未來很難保證向后兼容,所以禁用了這個 API,需要手動打開 dom.IntersectionObserver.enabled 才行。

2017.5.1 追加,Firefox 也默認開啟了。

IntersectionObserver API 是用來監視某個元素是否滾動進了瀏覽器窗口的可視區域(視口)或者滾動進了它的某個祖先元素的可視區域內。它的主要功能是用來實現延遲加載和展現量統計。先來看一段視頻簡介:

再來看看名字,名字里第一個單詞 intersection 是交集的意思,小時候數學里面就學過:

不過在網頁里,元素都是矩形的:

第二個單詞 observer 是觀察者的意思,和 MutationObserver 以及已死的 Object.observe 中的 observe(r) 一個意思。

下面列出了這個 API 中所有的參數、屬性、方法:

// 用構造函數生成觀察者實例
let observer = new IntersectionObserver((entries, observer) => {
  // 回調函數中可以拿到每次相交發生時所產生的交集的信息
  for (let entry of entries) {
    console.log(entry.time)
    console.log(entry.target)
    console.log(entry.rootBounds)
    console.log(entry.boundingClientRect
    console.log(entry.intersectionRect)
    console.log(entry.intersectionRatio)
  }
}, { // 構造函數的選項
  root: null,
  threshold: [0, 0.5, 1],
  rootMargin: "50px, 0px"
})

// 實例屬性
observer.root
observer.rootMargin
observer.thresholds

// 實例方法
observer.observe()
observer.unobserve()
observer.disconnect()
observer.takeRecords()

然后分三小節詳細介紹它們:

構造函數

new IntersectionObserver(callback, options)

callback 是個必選參數,當有相交發生時,瀏覽器便會調用它,后面會詳細介紹;options 整個參數對象以及它的三個屬性都是可選的:

root

IntersectionObserver API 的適用場景主要是這樣的:一個可以滾動的元素,我們叫它根元素,它有很多后代元素,想要做的就是判斷它的某個后代元素是否滾動進了自己的可視區域范圍。這個 root 參數就是用來指定根元素的,默認值是 null。

如果它的值是 null,根元素就不是個真正意義上的元素了,而是這個瀏覽器窗口了,可以理解成 window,但 window 也不是元素(甚至不是節點)。這時當前窗口里的所有元素,都可以理解成是 null 根元素的后代元素,都是可以被觀察的。

下面這個 demo 演示了根元素為 null 的用法:

<div id="info">我藏在頁面底部,請向下滾動</div>
<div id="target"></div>

<style>
  #info {
    position: fixed;
  }

  #target {
    position: absolute;
    top: calc(100vh + 500px);
    width: 100px;
    height: 100px;
    background: red;
  }
</style>

<script>
  let observer = new IntersectionObserver(() => {
    if (!target.isIntersecting) {
      info.textContent = "我出來了"
      target.isIntersecting = true
    } else {
      info.textContent = "我藏在頁面底部,請向下滾動"
      target.isIntersecting = false
    }
  }, {
    root: null // null 的時候可以省略
  })

  observer.observe(target)
</script>

需要注意的是,這里我通過在 target 上添加了個叫 isIntersecting 的屬性來判斷它是進來還是離開了,為什么這么做?先忽略掉,下面會有一小節專門解釋。

根元素除了是 null,還可以是目標元素任意的祖先元素:

<div id="root">
  <div id="info">向下滾動就能看到我</div>
  <div id="target"></div>
</div>

<style>
  #root {
    position: relative;
    width: 200px;
    height: 100vh;
    margin: 0 auto;
    overflow: scroll;
    border: 1px solid #ccc;
  }
  
  #info {
    position: fixed;
  }
  
  #target {
    position: absolute;
    top: calc(100vh + 500px);
    width: 100px;
    height: 100px;
    background: red;
  }
</style>

<script>
  let observer = new IntersectionObserver(() => {
    if (!target.isIntersecting) {
      info.textContent = "我出來了"
      target.isIntersecting = true
    } else {
      info.textContent = "向下滾動就能看到我"
      target.isIntersecting = false
    }
  }, {
    root: root
  })

  observer.observe(target)
</script>

需要注意的一點是,如果 root 不是 null,那么相交區域就不一定在視口內了,因為 root 和 target 的相交也可能發生在視口下方,像下面這個 demo 所演示的:

<div id="root">
  <div id="info">慢慢向下滾動</div>
  <div id="target"></div>
</div>

<style>
  #root {
    position: relative;
    width: 200px;
    height: calc(100vh + 500px);
    margin: 0 auto;
    overflow: scroll;
    border: 1px solid #ccc;
  }
  
  #info {
    position: fixed;
  }
  
  #target {
    position: absolute;
    top: calc(100vh + 1000px);
    width: 100px;
    height: 100px;
    background: red;
  }
</style>

<script>
  let observer = new IntersectionObserver(() => {
    if (!target.isIntersecting) {
      info.textContent = "我和 root 相交了,但你還是看不見"
      target.isIntersecting = true
    } else {
      info.textContent = "慢慢向下滾動"
      target.isIntersecting = false
    }
  }, {
    root: root
  })

  observer.observe(target)
</script>

總結一下:這一小節我們講了根元素的兩種類型,null 和任意的祖先元素,其中 null 值表示根元素為當前窗口(的視口)。 

threshold

當目標元素和根元素相交時,用相交的面積除以目標元素的面積會得到一個 0 到 1(0% 到 100%)的數值:

下面這句話很重要,IntersectionObserver API 的基本工作原理就是:當目標元素和根元素相交的面積占目標元素面積的百分比到達或跨過某些指定的臨界值時就會觸發回調函數。threshold 參數就是用來指定那個臨界值的,默認值是 0,表示倆元素剛剛挨上就觸發回調。有效的臨界值可以是在 0 到 1 閉區間內的任意數值,比如 0.5 表示當相交面積占目標元素面積的一半時觸發回調。而且可以指定多個臨界值,用數組形式,比如 [0, 0.5, 1],表示在兩個矩形開始相交,相交一半,完全相交這三個時刻都要觸發一次回調函數。如果你傳了個空數組,它會給你自動插入 0,變成 [0],也等效于默認值 0。

下面的動畫演示了當 threshold 參數為 [0, 0.5, 1] 時,向下滾動頁面時回調函數是在何時觸發的: 

 

不僅當目標元素從視口外移動到視口內時會觸發回調,從視口內移動到視口外也會:

你可以在這個 demo 里驗證上面的兩個動畫:

<div id="info">
慢慢向下滾動,相交次數: <span id="times">0</span> </div> <div id="target"></div> <style> #info { position: fixed; } #target { position: absolute; top: 200%; width: 100px; height: 100px; background: red; margin-bottom: 100px; } </style> <script> let observer = new IntersectionObserver(() => { times.textContent = +times.textContent + 1 }, { threshold: [0, 0.5, 1] }) observer.observe(target) </script>

threshold 數組里的數字的順序沒有強硬要求,為了可讀性,最好從小到大書寫。如果指定的某個臨界值小于 0 或者大于 1,瀏覽器會報錯:

<script>
new IntersectionObserver(() => {}, {
  threshold: 2 // SyntaxError: Failed to construct 'Intersection': Threshold values must be between 0 and 1.
})
</script> 

rootMagin

本文一開始就說了,這個 API 的主要用途之一就是用來實現延遲加載,那么真正的延遲加載會等 img 標簽或者其它類型的目標區塊進入視口才執行加載動作嗎?顯然,那就太遲了。我們通常都會提前幾百像素預先加載,rootMargin 就是用來干這個的。rootMargin 可以給根元素添加一個假想的 margin,從而對真實的根元素區域進行縮放。比如當 root 為 null 時設置 rootMargin: "100px",實際的根元素矩形四條邊都會被放大 100px,像這樣:

效果可以想象到,如果 threshold 為 0,那么當目標元素距離視口 100px 的時候(無論哪個方向),回調函數就提前觸發了。考慮到常見的頁面都沒有橫向滾動的需求,rootMargin 參數的值一般都是 "100px 0px",這種形式,也就是左右 margin 一般都是 0px. 下面是一個用 IntersectionObserver 實現圖片在距視口 500px 的時候延遲加載的 demo:

<div id="info">圖片在頁面底部,仍未加載,請向下滾動</div>
<img id="img" src="data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA="
              data-src="https://img.alicdn.com/bao/uploaded/i7/TB1BUK4MpXXXXa1XpXXYXGcGpXX_M2.SS2">

<style>
  #info {
    position: fixed;
  }

  #img {
    position: absolute;
    top: 300%;
  }
</style>

<script>
  let observer = new IntersectionObserver(() => {
    observer.unobserve(img)
    info.textContent = "開始加載圖片!"
    img.src = img.dataset.src
  }, {
    rootMargin: "500px 0px"
  })

  observer.observe(img)
</script>

注意 rootMargin 的值雖然和 CSS 里 margin 的值的格式一樣,但存在一些限制,rootMargin 只能用 px 和百分比兩種單位,用其它的單位會報錯,比如用 em:

<script>
new IntersectionObserver(() => {}, {
  rootMargin: "10em" // SyntaxError: Failed to construct 'Intersection': rootMargin must be specified in pixels or percent.
})
</script>

rootMargin 用百分比的話就是相對根元素的真實尺寸的百分比了,比如 rootMargin: "0px 0px 50% 0px",表示根元素的尺寸向下擴大了 50%。

如果使用了負 margin,真實的根元素區域會被縮小,對應的延遲加載就會延后,比如用了 rootMargin: "-100px" 的話,目標元素滾動進根元素可視區域內部 100px 的時候才有可能觸發回調。

實例

實例屬性

root

該觀察者實例的根元素(默認值為 null):

new IntersectionObserver(() => {}).root // null
new IntersectionObserver(() => {}, {root: document.body}).root // document.body

rootMargin

rootMargin 參數(默認值為 "0px")經過序列化后的值:

new IntersectionObserver(() => {}).rootMargin // "0px 0px 0px 0px"
new IntersectionObserver(() => {}, {rootMargin: "50px"}).rootMargin // "50px 50px 50px 50px"
new IntersectionObserver(() => {}, {rootMargin: "50% 0px"}).rootMargin // "50% 0px 50% 0px"
new IntersectionObserver(() => {}, {rootMargin: "50% 0px 50px"}).rootMargin // 50% 0px 50px 0px" 
new IntersectionObserver(() => {}, {rootMargin: "1px 2px 3px 4px"}).rootMargin  // "1px 2px 3px 4px"

thresholds

threshold 參數(默認值為 0)經過序列化后的值,即便你傳入的是一個數字,序列化后也是個數組,目前 Chrome 的實現里數字的精度會有丟失,但無礙:

new IntersectionObserver(() => {}).thresholds // [0]
new IntersectionObserver(() => {}, {threshold: 1}).thresholds // [1]
new IntersectionObserver(() => {}, {threshold: [0.3, 0.6]}).thresholds // [[0.30000001192092896, 0.6000000238418579]]
Object.isFrozen(new IntersectionObserver(() => {}).thresholds) // true, 是個被 freeze 過的數組

這三個實例屬性都是用來標識一個觀察者實例的,都是讓人來讀的,在代碼中沒有太大用途。

實例方法

observe()

觀察某個目標元素,一個觀察者實例可以觀察任意多個目標元素。注意,這里可能有同學會問:能不能 delegate?能不能只調用一次 observe 方法就能觀察一個頁面里的所有 img 元素,甚至那些未產生的?答案是不能,這不是事件,沒有冒泡。

unobserve()

取消對某個目標元素的觀察,延遲加載通常都是一次性的,observe 的回調里應該直接調用 unobserve() 那個元素.

disconnect()

取消觀察所有已觀察的目標元素

takeRecords()

理解這個方法需要講點底層的東西:在瀏覽器內部,當一個觀察者實例在某一時刻觀察到了若干個相交動作時,它不會立即執行回調,它會調用 window.requestIdleCallback() (目前只有 Chrome 支持)來異步的執行我們指定的回調函數,而且還規定了最大的延遲時間是 100 毫秒,相當于瀏覽器會執行:

requestIdleCallback(() => {
  if (entries.length > 0) {
    callback(entries, observer)
  }
}, {
  timeout: 100
})

你的回調可能在隨后 1 毫秒內就執行,也可能在第 100 毫秒才執行,這是不確定的。在這不確定的 100 毫秒之間的某一刻,假如你迫切需要知道這個觀察者實例有沒有觀察到相交動作,你就得調用 takeRecords() 方法,它會同步返回包含若干個 IntersectionObserverEntry 對象的數組(IntersectionObserverEntry 對象包含每次相交的信息,在下節講),如果該觀察者實例此刻并沒有觀察到相交動作,那它就返回個空數組。

注意,對于同一個相交信息來說,同步的 takeRecords() 和異步的回調函數是互斥的,如果回調先執行了,那么你手動調用 takeRecords() 就必然會拿到空數組,如果你已經通過 takeRecords() 拿到那個相交信息了,那么你指定的回調就不會被執行了(entries.length > 0 是 false)。

這個方法的真實使用場景很少,我舉不出來,我只能寫出一個驗證上面兩段話(時序無規律)的測試代碼:

<script>
  setInterval(() => {
    let observer = new IntersectionObserver(entries => {
      if (entries.length) {
        document.body.innerHTML += "<p>異步的 requestIdleCallback() 回調先執行了"
      }
    })

    requestAnimationFrame(() => {
      setTimeout(() => {
        if (observer.takeRecords().length) {
          document.body.innerHTML += "<p>同步的 takeRecords() 先執行了"
        }
      }, 0)
    })

    observer.observe(document.body)

    scrollTo(0, 1e10)
  }, 100)
</script>

回調函數

new IntersectionObserver(function(entries, observer) {
  for (let entry of entries) {
    console.log(entry.time)
    console.log(entry.target)
    console.log(entry.rootBounds)
    console.log(entry.boundingClientRect
    console.log(entry.intersectionRect)
    console.log(entry.intersectionRatio)
  }
})

回調函數共有兩個參數,第二個參數就是觀察者實例本身,一般沒用,因為實例通常我們已經賦值給一個變量了,而且回調函數里的 this 也是那個實例。第一個參數是個包含有若干個 IntersectionObserverEntry 對象的數組,也就是和 takeRecords() 方法的返回值一樣。每個 IntersectionObserverEntry 對象都代表一次相交,它的屬性們就包含了那次相交的各種信息。entries 數組中 IntersectionObserverEntry 對象的排列順序是按照它所屬的目標元素當初被 observe() 的順序排列的。

time

相交發生時距離頁面打開時的毫秒數(有小數),也就是相交發生時 performance.now() 的返回值,比如 60000.560000000005,表示是在頁面打開后大概 1 分鐘發生的相交。在回調函數里用 performance.now() 減去這個值,就能算出回調函數被 requestIdleCallback 延遲了多少毫秒:

<script>
  let observer = new IntersectionObserver(([entry]) => {
    document.body.textContent += `相交發生在 ${performance.now() - entry.time} 毫秒前`
  })

  observer.observe(document.documentElement)
</script>

你可以不停刷新上面這個 demo,那個毫秒數最多 100 出頭,因為瀏覽器內部設置的最大延遲就是 100。

target

相交發生時的目標元素,因為一個根元素可以觀察多個目標元素,所以這個 target 不一定是哪個元素。

rootBounds

一個對象值,表示發生相交時根元素可見區域的矩形信息,像這樣:

{
  "top": 0,
  "bottom": 600,
  "left": 0,
  "right": 1280,
  "width": 1280,
  "height": 600
}

boundingClientRect

發生相交時目標元素的矩形信息,等價于 target.getBoundingClientRect()。

intersectionRect

根元素和目標元素相交區域的矩形信息。

intersectionRatio

0 到 1 的數值,表示相交區域占目標元素區域的百分比,也就是 intersectionRect 的面積除以 boundingClientRect 的面積得到的值。

貼邊的情況是特例

上面已經說過,IntersectionObserver API 的基本工作原理就是檢測相交率的變化。每個觀察者實例為所有的目標元素都維護著一個上次相交率(previousThreshold)的字段,在執行 observe() 的時候會給 previousThreshold 賦初始值 0,然后每次檢測到新的相交率滿足(到達或跨過)了 thresholds 中某個指定的臨界值,且那個臨界值和當前的 previousThreshold 值不同,就會觸發回調,并把滿足的那個新的臨界值賦值給 previousThreshold,依此反復,很簡單,對吧。

但是不知道你有沒有注意到,前面講過,當目標元素從距離根元素很遠到和根元素貼邊,這時也會觸發回調(假如 thresholds 里有 0),但這和工作原理相矛盾啊,離的很遠相交率是 0,就算貼邊,相交率還是 0,值并沒有變,不應該觸發回調啊。的確,這和基本工作原理矛盾,但這種情況是特例,目標元素從根元素外部很遠的地方移動到和根元素貼邊,也會當做是滿足了臨界值 0,即便 0 等于 0。

還有一個反過來的特例,就是目標元素從根元素內部的某個地方(相交率已經是 1)移動到和根元素貼邊(還是 1),也會觸發回調(假如 thresholds 里有 1)。

目標元素寬度或高度為 0 的情況也是特例

很多時候我們的目標元素是個空的 img 標簽或者是一個空的 div 容器,如果沒有設置 CSS,這些元素的寬和高都是 0px,那渲染出的矩形面積就是 0px2,那算相交率的時候就會遇到除以 0 這種在數學上是非法操作的問題,即便在 JavaScript 里除以 0 并不會拋異常還是會得到 Infinity,但相交率一直是 Infinity 也就意味著回調永遠不會觸發,所以這種情況必須特殊對待。

特殊對待的方式就是:0 面積的目標元素的相交率要么是 0 要么是 1。無論是貼邊還是移動到根元素內部,相交率都是 1,其它情況都是 0。1 到 0 會觸發回調,0 到 1也會觸發回調,就這兩種情況:

由于這個特性,所以為 0 面積的目標元素設置臨界值是沒有意義的,設置什么值、設置幾個,都是一個效果。 

但是注意,相交信息里的 intersectionRatio 屬性永遠是 0,很燒腦,我知道:

<div id="target"></div>

<script>
  let observer = new IntersectionObserver(([entry]) => {
    alert(entry.intersectionRatio)
  })

  observer.observe(target)
</script>

observe() 之前就已經相交了的情況是特例嗎?

不知道你們有沒有這個疑問,反正我有過。observe() 一個已經和根元素相交的目標元素之后,再也不滾動頁面,意味著之后相交率再也不會變化,回調不應該發生,但還是發生了。這是因為:在執行 observe() 的時候,瀏覽器會將 previousThreshold 初始化成 0,而不是初始化成當前真正的相交率,然后在下次相交檢測的時候就檢測到相交率變化了,所以這種情況不是特殊處理。

瀏覽器何時進行相交檢測,多久檢測一次?

我們常見的顯示器都是 60hz 的,就意味著瀏覽器每秒需要繪制 60 次(60fps),大概每 16.667ms 繪制一次。如果你使用 200hz 的顯示器,那么瀏覽器每 5ms 就要繪制一次。我們把 16.667ms 和 5ms 這種每次繪制間隔的時間段,稱之為 frame(幀,和 html 里的 frame 不是一個東西)。瀏覽器的渲染工作都是以這個幀為單位的,下圖是 Chrome 中每幀里瀏覽器要干的事情(我在原圖的基礎上加了 Intersection Observations 階段):

Intersection Observations In A Frame

可以看到,相交檢測(Intersection Observations)發生在 Paint 之后 Composite 之前,多久檢測一次是根據顯示設備的刷新率而定的。但可以肯定的是,每次繪制不同的畫面之前,都會進行相交檢測,不會有漏網之魚。

一次性到達或跨過的多個臨界值中選一個最近的

如果一個觀察者實例設置了 11 個臨界值:[0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1],那么當目標元素和根元素從完全不相交狀態滾動到相交率為 1 這一段時間里,回調函數會觸發幾次?答案是:不確定。要看滾動速度,如果滾動速度足夠慢,每次相交率到達下一個臨界值的時間點都發生在了不同的幀里(瀏覽器至少繪制了 11 次),那么就會有 11 次相交被檢測到,回調函數就會被執行 11 次;如果滾動速度足夠快,從不相交到完全相交是發生在同一個幀里的,瀏覽器只繪制了一次,瀏覽器雖然知道這一次滾動操作就滿足了 11 個指定的臨界值(從不相交到 0,從 0 到 0.1,從 0.1 到 0.2 ··· ),但它只會考慮最近的那個臨界值,那就是 1,回調函數只觸發一次:

<div id="info">相交次數:
  <span id="times">0</span>
  <button onclick="document.scrollingElement.scrollTop = 10000">一下滾動到最低部</button>
</div>
<div id="target"></div>

<style>
  #info {
    position: fixed;
  }

  #target {
    position: absolute;
    top: 200%;
    width: 100px;
    height: 100px;
    background: red;
    margin-bottom: 100px;
  }
</style>

<script>
  let observer = new IntersectionObserver(() => {
    times.textContent = +times.textContent + 1
  }, {
    threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1] // 11 個臨界值
  })

  observer.observe(target)
</script>

離開視口的時候也一個道理,假如根元素和目標元素的相交率先從完全相交變成了 0.45,然后又從 0.45 變成了完全不相交,那么回調函數只會觸發兩次。 

如何判斷當前是否相交?

我上面有幾個 demo 都用了幾行看起來挺麻煩的代碼來判斷目標元素是不是在視口內:

if (!target.isIntersecting) {
  // 相交
  target.isIntersecting = true
} else {
  // 不想交
  target.isIntersecting = false
}

為什么?難道用 entry.intersectionRatio > 0 判斷不可以嗎:

<div id="info">不可見,請非常慢的向下滾動</div>
<div id="target"></div>

<style>
  #info {
    position: fixed;
  }

  #target {
    position: absolute;
    top: 200%;
    width: 100px;
    height: 100px;
    background: red;
  }
</style>

<script>
  let observer = new IntersectionObserver(([entry]) => {
    if (entry.intersectionRatio > 0) {
      // 快速滾動會執行到這里
      info.textContent = "可見了"
    } else {
      // 慢速滾動會執行到這里
      info.textContent = "不可見,請非常慢的向下滾動"
    }
  })

  observer.observe(target)
</script>

粗略一看,貌似可行,但你別忘了上面講的貼邊的情況,如果你滾動頁面速度很慢,當目標元素的頂部和視口底部剛好挨上時,瀏覽器檢測到相交了,回調函數觸發了,但這時 entry.intersectionRatio 等于 0,會進入 else 分支,繼續向下滾,回調函數再不會觸發了,提示文字一直停留在不可見狀態;但如果你滾動速度很快,當瀏覽器檢測到相交時,已經越過了 0 那個臨界值,存在了實際的相交面積,entry.intersectionRatio > 0 也就為 true 了。所以這樣寫會導致代碼執行不穩定,不可行。

除了通過在元素身上添加新屬性來記錄上次回調觸發時是進還是出外,我還想到另外一個辦法,那就是給 threshold 選項設置一個很小的接近 0 的臨界值,比如 0.000001(或者干脆用 Number.MIN_VALUE),然后再用 entry.intersectionRatio > 0 判斷,這樣就不會受貼邊的情況影響了,也就不會受滾動速度影響了:

<div id="info">不可見,以任意速度向下滾動</div>
<div id="target"></div>

<style>
  #info {
    position: fixed;
  }

  #target {
    position: absolute;
    top: 200%;
    width: 100px;
    height: 100px;
    background: red;
  }
</style>

<script>
  let observer = new IntersectionObserver(([entry]) => {
    if (entry.intersectionRatio > 0) {
      info.textContent = "可見了"
    } else {
      info.textContent = "不可見,以任意速度向下滾動"
    }
  }, {
    threshold: [0.000001]
  })

  observer.observe(target)
</script>

目標元素不是根元素的后代元素的話會怎樣?

如果在執行 observe() 時,目標元素不是根元素的后代元素,瀏覽器也并不會報錯,Chrome 從 53 開始會對這種用法發出警告(是我提議的),從而提醒開發者這種用法有可能是不對的。為什么不更嚴格點,直接報錯?因為元素的層級關系是可以變化的,可能有人會寫出這樣的代碼:

<div id="root"></div>
<div id="target"></div>

<style>
  #target {
    width: 100px;
    height: 100px;
    background: red;
  }
</style>

<script>
  let observer = new IntersectionObserver(() => alert("看見我了"), {root: root})
  observer.observe(target) // target 此時并不是 root 的后代元素,Chrome 控制臺會發出警告:target element is not a descendant of root.
  root.appendChild(target) // 現在是了,觸發回調
</script>

又或者被 observe 的元素此時還未添加到 DOM 樹里:

<div id="root"></div>

<style>
  #target {
    width: 100px;
    height: 100px;
    background: red;
  }
</style>

<script>
  let observer = new IntersectionObserver(() => alert("看見我了"), {root: root})
  let target = document.createElement("div") // 還不在 DOM 樹里
  observer.observe(target) // target 此時并不是 root 的后代元素,Chrome 控制臺會發出警告:target element is not a descendant of root.
  root.appendChild(target) // 現在是了,觸發回調
</script>

也就是說,只要在相交發生時,目標元素是根元素的后代元素,就可以了,執行 observe() 的時候可以不是。

是后代元素還不夠,根元素必須是目標元素的祖先包含塊

要求目標元素是根元素的后代元素只是從 DOM 結構上說的,一個較容易理解的限制,另外一個不那么容易理解的限制是從 CSS 上面說的,那就是:根元素矩形必須是目標元素矩形的祖先包含塊(包含塊也是鏈式的,就像原型鏈)。比如下面這個 demo 所演示的,兩個做隨機移動的元素 a 和 b,a 是 b 的父元素,但它倆的 position 都是 fixed,導致 a 不是 b 的包含塊,所以這是個無效的觀察操作,嘗試把 fixed 改成 relative 就發現回調觸發了:

<div id="a">
  <div id="b"></div>
</div>
<div id="info">0%</div>

<style>
  #a, #b {
    position: fixed; /* 嘗試改成 relative */
    width: 200px;
    height: 200px;
    opacity: 0.8;
  }

  #a {
    background: red
  }

  #b {
    background: blue
  }

  #info {
    width: 200px;
    margin: 0 auto;
  }

  #info::before {
    content: "Intersection Ratio: ";
  }
</style>

<script>
  let animate = (element, oldCoordinate = {x: 0, y: 0}) => {
    let newCoordinate = {
      x: Math.random() * (innerWidth - element.clientWidth),
      y: Math.random() * (innerHeight - element.clientHeight)
    }
    let keyframes = [oldCoordinate, newCoordinate].map(coordinateToLeftTop)
    let duration = calcDuration(oldCoordinate, newCoordinate)

    element.animate(keyframes, duration).onfinish = () => animate(element, newCoordinate)
  }

  let coordinateToLeftTop = coordinate => ({
    left: coordinate.x + "px",
    top: coordinate.y + "px"
  })

  let calcDuration = (oldCoordinate, newCoordinate) => {
    // 移動速度為 0.3 px/ms
    return Math.hypot(oldCoordinate.x - newCoordinate.x, oldCoordinate.y - newCoordinate.y) / 0.3
  }

  animate(a)
  animate(b)
</script>


<script>
  let thresholds = Array.from({
    length: 200
  }, (k, v) => v / 200) // 200 個臨界值對應 200px

  new IntersectionObserver(([entry]) => {
    info.textContent = (entry.intersectionRatio * 100).toFixed(2) + "%"
  }, {
    root: a,
    threshold: thresholds
  }).observe(b)
</script>

從 DOM 樹中刪除目標元素會怎么樣?

假設現在根元素和目標元素已經是相交狀態,這時假如把目標元素甚至是根元素從 DOM 樹中刪除,或者通過 DOM 操作讓目標元素不在是根元素的后代元素,再或者通過改變 CSS 屬性導致根元素不再是目標元素的包含塊,又或者通過 display:none 隱藏某個元素,這些操作都會讓兩者的相交率突然變成 0,回調函數就有可能被觸發:

<div id="info"> 刪除目標元素也會觸發回調
  <button onclick="document.body.removeChild(target)">刪除 target</button>
</div>
<div id="target"></div>


<style>
  #info {
    position: fixed;
  }
  
  #target {
    position: absolute;
    top: 100px;
    width: 100px;
    height: 100px;
    background: red;
  }
</style>

<script>
  let observer = new IntersectionObserver(() => {
    if (!document.getElementById("target")) {
      info.textContent = "target 被刪除了"
    }
  })

  observer.observe(target)
</script>

關于 iframe

在 IntersectionObserver API 之前,你無法在一個跨域的 iframe 頁面里判斷這個 iframe 頁面或者頁面里的某個元素是否出現在了頂層窗口的視口里,這也是為什么要發明 IntersectionObserver API 的一個很重要的原因。請看下圖演示:

無論怎么動,無論多少層 iframe, IntersectionObserver 都能精確的判斷出目標元素是否出現在了頂層窗口的視口里,無論跨域不跨域。

前面講過根元素為 null 表示實際的根元素是當前窗口的視口,現在更明確點,應該是最頂層窗口的視口。

如果當前頁面是個 iframe 頁面,且和頂層頁面跨域,在根元素為 null 的前提下觸發回調后,你拿到的 IntersectionObserverEntry 對象的 rootBounds 屬性會是 null;即便兩個頁面沒有跨域,那么 rootBounds 屬性所拿到的矩形的坐標系統和 boundingClientRect 以及 intersectionRect 這兩個矩形也是不一樣的,前者坐標系統的原點是頂層窗口的左上角,后兩者是當前 iframe 窗口左上角。

鑒于互聯網上的廣告 90% 都是跨域的 iframe,我想 IntersectionObserver API 能夠大大簡化這些廣告的延遲加載和真實曝光量統計的實現。 

根元素不能是其它 frame 下的元素

如果沒有跨域的話,根元素可以是上層 frame 中的某個祖先元素嗎?比如像下面這樣:

<div id="root">
  <iframe id="iframe"></iframe>
</div>

<script>
  let iframeHTML = `
    <div id="target"></div>

    <style>
      #target {
        width: 100px;
        height: 100px;
        background: red;
      }
    </style>

    <script>
      let observer = new IntersectionObserver(() => {
        alert("intersecting")
      }, {
        root: top.root
      })

      observer.observe(target)
    <\/script>`

  iframe.src = URL.createObjectURL(new Blob([iframeHTML], {"type": "text/html"}))
</script>

我不清楚上面這個 demo 中 root 算不算 target 的祖先包含塊,但規范明確規定了這種觀察操作無效,根元素不能是來自別的 frame。總結一下就是:根元素要么是 null,要么是同 frame 里的某個祖先包含塊元素。

真的只是判斷兩個元素相交嗎?

實際情況永遠沒表面看起來那么簡單,瀏覽器真的只是判斷兩個矩形相交嗎?看下面的代碼:

<div id="parent">
  <div id="target"></div>
</div>

<style>
  #parent {
    width: 20px;
    height: 20px;
    background: red;
    overflow: hidden;
  }

  #target {
    width: 100px;
    height: 100px;
    background: blue;
  }
</style>

<script>
  let observer = new IntersectionObserver(([entry]) => {
    alert(`相交矩形為: ${entry.intersectionRect.width} x ${entry.intersectionRect.width}`)
  })

  observer.observe(target)
</script>

這個 demo 里根元素為當前視口,目標元素是個 100x100 的矩形,如果真的是判斷兩個矩形的交集那么簡單,那這個相交矩形就應該是 100 x 100,但彈出來的相交矩形是 20 x 20。因為其實在相交檢測之前,有個裁減目標元素矩形的步驟,裁減完才去和根元素判斷相交,裁減的基本思想就是,把目標元素被“目標元素和根元素之間存在的那些元素”遮擋的部分裁掉,具體裁減步驟是這樣的(用 rect 代表最終的目標元素矩形):

  1. 讓 rect 為目標元素矩形
  2. 讓 current 為目標元素的父元素
  3. 如果 current 不是根元素,則進行下面的循環:
    1. 如果 current 的 overflow 不是 visible(是 scroll 或 hidden 或 auto) 或者 current 是個 iframe 元素(iframe 天生自帶 overflow: auto),則:
      1. 讓 rect 等于 rect 和 current 的矩形(要排除滾動條區域)的交集
    2. 讓 current 為 current 的父元素(iframe 里的 html 元素的父元素就是父頁面里的 iframe 元素)

也就是說,實際上是順著目標元素的 DOM 樹一直向上循環求交集的過程。再看上面的 demo,目標元素矩形一開始是 100x100,然后和它的父元素相交成了 20x20,然后 body 元素和 html 元素沒有設置 overflow,所以最終和視口做交集的是 20x20 的矩形。 

關于雙指縮放

移動端設備和 OS X 系統上面,允許用戶使用兩根手指放大頁面中的某一部分:

如果頁面某一部分被放大了,那同時也就意味著頁面邊緣上某些區域顯示在了視口的外面:

這些情況下 IntersectionObserver API 都不會做專門處理,無論是根元素還是目標元素,它們的矩形都是縮放前的真實尺寸(就像 getBoundingClientRect() 方法所表現的一樣),而且即便相交真的發生在了那些因縮放導致用戶眼睛看不到的區域內,回調函數也照樣觸發。如果你用的 Mac 系統,你現在就可以測試一下上面的任意一個 demo。

關于垃圾回收

一個觀察者實例無論對根元素還是目標元素,都是弱引用的,就像 WeakMap 對自己的 key 是弱引用一樣。如果目標元素被垃圾回收了,關系不大,瀏覽器就不會再檢測它了;如果是根元素被垃圾回收了,那就有點問題了,根元素沒了,但觀察者實例還在,如果這時使用哪個觀察者實例會怎樣:

<div id="root"></div>
<div id="target"></div>

<script>
  let observer = new IntersectionObserver(() => {}, {root: root}) // root 元素一共有兩個引用,一個是 DOM 樹里的引用,一個是全局變量 root 的引用
  document.body.removeChild(root) // 從 DOM 樹里移除
  root = null // 全局變量置空
  setTimeout(() => {
    gc() // 手動 gc,需要在啟動 Chrome 時傳入 --js-flags='--expose-gc' 選項
    console.log(observer.root) // null,觀察者實例的根元素已經被垃圾回收了
    observer.observe(target) // Uncaught InvalidStateError: observe() called on an IntersectionObserver with an invalid root,執行 observer 的任意方法都會報錯。
  })
</script>

也就是說,那個觀察者實例也相當于死了。這個報錯是從 Chrome 53 開始的(我提議的),51 和 52 上只會靜默失敗。

后臺標簽頁

由于 Chrome 不會渲染后臺標簽頁,所以也就不會檢測相交了,當你切換到前后才會繼續。你可以通過 Command/Ctrl + 左鍵打開上面任意的 demo 試試。

吐槽命名

threshold 和 thresholds

構造函數的參數里叫 threshold,實例的屬性里叫 thresholds。道理我都懂,前者既能是一個單數形式的數字,也能是一個復數形式的數組,所以用了單數形式,而后者序列化出來只能是個數組,所以就用了復數了。但是統一更重要吧,我覺的都用復數形式沒什么問題,一開始研究這個 API 的時候我嘗試傳了 {thresholds: [1]},試了半天才發現多了個 s,坑死了。

2017-5-27 追記:https://github.com/WICG/IntersectionObserver/issues/215 有人和我一樣被坑了,他問能不能改一下 API,我回到:“太晚了”。

disconnect

什么?disconnect?什么意思?connect 什么了?我只知道 observe 和 unobserve,你他么的叫 unobserveAll 會死啊。這個命名很容易讓人不明覺厲,結果是個很簡單的東西。叫這個其實是為了和 MutationObserver 以及 PerformanceObserver 統一。

rootBounds & boundingClientRect & intersectionRect

這三者都是返回一個矩形信息的,本是同類,但是名字沒有一點規律,讓人無法記憶。我建議叫 rootRect & targetRect & intersectionRect,一遍就記住了,真不知道寫規范的人怎么想的。

Polyfil

寫規范的人會在 Github 倉庫上維護一個 polyfill,目前還未完成。但 polyfill 顯然無法支持 iframe 內元素的檢測,不少細節也無法模擬。

其它瀏覽器實現進度

Firefox:https://bugzilla.mozilla.org/show_bug.cgi?id=1243846

Safari:https://bugs.webkit.org/show_bug.cgi?id=159475

Edge:https://developer.microsoft.com/en-us/microsoft-edge/platform/status/intersectionobserver

總結

雖然目前該 API 的規范已經有一年歷史了,但仍非常不完善,大量的細節都沒有規定;Chrome 的實現也有半年了,但還是有不少 bug(大多是疑似 bug,畢竟規范不完善)。因此,本文中有些細節我故意略過,比如目標元素大于根元素,甚至根元素面積為 0,支不支持 svg 這些,因為我也不知道什么是正確的表現。 

2016-8-2 追記:今天被同事問了個真實需求,“統計淘寶搜索頁面在頁面打開兩秒后展現面積超過 50% 的寶貝”,我立刻想到了用 IntersectionObserver:

setTimeout(() => {
  let observer = new IntersectionObserver(entries => {
    entries.forEach(entry => {
      console.log(entry.target) // 拿到了想要的寶貝元素
    })
    observer.disconnect() // 統計到就不在需要繼續觀察了
  }, {
    threshold: 0.5 // 只要展現面積達到 50% 的寶貝元素 
  })

  // 觀察所有的寶貝元素
  Array.from(document.querySelectorAll("#mainsrp-itemlist .item")).forEach(item => observer.observe(item))
}, 2000)

不需要你進行任何數學計算,真是簡單到爆,當然,因為兼容性問題,這個代碼不能被采用。


文章列表




Avast logo

Avast 防毒軟體已檢查此封電子郵件的病毒。
www.avast.com


arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()