IntersectionObserver API

舒適提示:本文目前僅適用於在 Chrome 51 及以上中瀏覽。html

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

2016.11.29 追加,Firefox 的人擔憂目前規範不夠穩定,將來很難保證向後兼容,因此禁用了這個 API,須要手動打開 dom.IntersectionObserver.enabled 才行。github

2017.5.1 追加,Firefox 也默認開啓了。web

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

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

不過在網頁裏,元素都是矩形的:瀏覽器

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

下面列出了這個 API 中全部的參數、屬性、方法:dom

// 用構造函數生成觀察者實例
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)

不須要你進行任何數學計算,真是簡單到爆,固然,由於兼容性問題,這個代碼不能被採用。

相關文章
相關標籤/搜索