Ôå+Intersection Observer API 使用筆記 | Alex Liu

Intersection Observer API 使用筆記

• 15 min read

剛進入業界時,為了æé«˜ç¶²é çš„æ•ˆèƒ½ä»¥åŠæ›´è±å¯Œçš„ç¶²é äº’動效果,會利用 bLazy.js 實作圖片延é²è¼‰å…¥æˆ–是用 waypoints.js 執行間單的進場效果。ä¸éŽå¾Œä¾†ç™¼ç¾ Intersection Observer 這個ç€è¦½å™¨åŽŸç”Ÿçš„ Web API 讓這一切變得更簡單使用而且效能也更好。

在本文當種會æåˆ°é€™äº›å…§å®¹ï¼š

  • 生活中的 Intersection Observer
  • IntersectionObserver 建構函å¼
  • IntersectionObserver 實例方法
  • Gridsome çš„ IntersectionObserver 應用

å‰è¨€

Intersection Observer API 被廣泛應用在ç¾åœ¨å‰ç«¯çš„å„é …å·¥å…·ç¨®ã€‚åƒæ˜¯ Grisdome 中的內建組件 <g-image> ä»¥åŠ <g-link> 分別利用了這個 API 實è¸äº† å»¶é²è¼‰å…¥ï¼ˆLazy Load) åŠ è·¯ç”±é è¼‰ï¼ˆRoute Prefetching);Nuxt.js çš„ <nuxt-link> 也用它來判斷使å¦è·¯ç”±é è¼‰ï¼›Vuetify 也æä¾›äº† v-intersect directive 讓使用者å¯ä»¥éˆæ´»æ‡‰ç”¨ã€‚

ç¶œåˆéŽåŽ»çœ‹åˆ°çš„ Intersection Observer 大多應用在:

  • å»¶é²è¼‰å…¥ï¼ˆLazy Load)
  • 路由é è¼‰ï¼ˆRoute Prefetching)
  • ç„¡é™æ²å‹•(Infinite Scroll)

而這篇會èšç„¦åœ¨ Intersection Observer API ä¸Šï¼Œæœ€å¾Œæœƒç°¡å–®çš„çœ‹éŽ Gridsome 的內建組件如何應用 IntersectionObserver 來實作 Lazy Load å’Œ Route Prefetching 的功能。

Vuetify çš„ v-intersect directive


生活中的 Intersection Observer

Intersection Observerï¼Œå­—é¢æ„æ€ï¼šäº¤é»žè§€å¯Ÿè€…ã€‚ç”¨ä¸€å€‹çœŸå¯¦ä¾‹å­æ¯”å–» Intersection Observer é‹ä½œæ¦‚念:等公車 APP

æ—©æœŸå…¬è»Šç«™åªæœ‰ä¸€æ ¹ç«™ç‰Œç«‹åœ¨å“ªè£¡ï¼Œæƒ³è¦æ­å…¬è»Šï¼Œå°±å¾—åœ¨è»Šç«™ä¸€ç›´æœ›è‘—é æ–¹ï¼Œåªè¦ä¸€æœ‰å…¬è»Šå‡ºç¾éƒ½è¦ç¢ºèªæ˜¯ä¸æ˜¯è‡ªå·±è¦æ­å¾—那一ç­ï¼Œæƒ³è¦åŽ»ä¾¿åˆ©å•†åº—è²·å€‹é£²æ–™ä¹Ÿè¦ä¸æ–·ç·Šå¼µè»Šæ˜¯å­æœƒä¸æœƒçªç„¶å‡ºç¾ã€‚

後來,出ç¾äº†è·‘馬燈告訴你車å­ç¾åœ¨åœ¨å“ªå€‹è·¯å£ï¼Œå¤§ç´„é‚„è¦å¤šä¹…。眼å‰å‡ºç¾çš„éƒ½ä¸æ˜¯æˆ‘們è¦çš„,讓我們å¯ä»¥æœ‰æ•ˆçš„æŽŒæ¡æ™‚間,åšé»žå…¶ä»–事;å†å¾Œä¾†å‡ºç¾äº†æ‰‹æ©Ÿ APP,我們甚至å¯ä»¥è¨­å®šå…¬è»Šåœ¨é€²å…¥æŒ‡å®š " è§€å¯Ÿå€ " 時會發出通知,告訴我們車å­å¿«åˆ°äº†ï¼Œé€™æ¨£åœ¨æ‰‹æ©Ÿæ‹Žéˆ´éŸ¿å‰æˆ‘們就å¯ä»¥å¾žå®¹åœ°åšå…¶ä»–事了。

䏿˜¯æœ‰å…¬è»Šæ™‚刻表?呵,有準éŽå—Žï¼

é–‹é ­æåˆ°çš„ bLazy.js 或 waypoints.js 本質上都是é€éŽç›£è½ scroll äº‹ä»¶ï¼Œä¸æ–·é‡è¤‡ç¢ºèªæŒ‡å®šå…ƒç´ ç•¶å‰çš„ä½ç½®ï¼Œç›´åˆ°å‡ºç¾åœ¨æŒ‡å®šä½ç½®å¾ŒåŸ·è¡Œåœ–片載入或指定的 function。這éŽç¨‹ä¹‹é–“å°±æ˜¯ä¸æ–·ã€ä¸æ–·çš„去å ç”¨ JavaScript 的執行效能。

就跟我們需è¦ä¸æ–·çš„確èªå…¬è»Šä¾†äº†æ²’ï¼Œä¸æ–·ç¢ºèªçœ¼å‰çš„è»Šæ˜¯ä¸æ˜¯è‡ªå·±è¦æ­çš„一樣。

Intersection Observer API çš„å‡ºç¾æ”¹å–„了這個å•題,他類似等公車的手機 APP。當目標與我們指定的「觀察å€ã€ç”¢ç”Ÿäº¤é›†æˆ‘們在去å°å…¶åšè™•ç†ï¼Œä»–䏿œƒå ç”¨æˆ‘們的心æ€ï¼Œçœä¸‹ä¾†çš„精力就å¯ä»¥æ‹¿åŽ»åŸ·è¡Œå…¶ä»–ä»»å‹™ï¼Œæˆ–æ˜¯ ... 放空。


IntersectionObserver 建構函å¼

Intersection Observer API æä¾›äº†ä¸€å€‹ éžåŒæ­¥ çš„ç›®æ¨™å…ƒç´ èˆ‡è§€å¯Ÿå€æ˜¯å¦ç›¸äº¤çš„æ–¹æ³•,我們å¯ä»¥é€éŽå»ºæ§‹å‡½å¼ä¾†ç”¢ç”Ÿä¸€å€‹ observer 的實例使用它。

建構函å¼

IntersectionObserver 建構函å¼å®šç¾©å¦‚下:

declare var IntersectionObserver: {
  prototype: IntersectionObserver;
  new (
    callback: IntersectionObserverCallback,
    options?: IntersectionObserverInit
  ): IntersectionObserver;
};

IntersectionObserver å»ºæ§‹å‡½å¼æŽ¥æ”¶å…©å€‹åƒæ•¸ï¼Œå…ˆä¾†äº†è§£ options 的設定內容,他是是一個定義觀察å€çš„設定物件;後é¢å†ä¾†çœ‹çœ‹å¦‚何使用 callback,他是當被觀察元素與觀察å€ç”¢ç”Ÿäº¤é›†å¾ŒåŸ·è¡Œçš„ callback function。

options(é¸å¡«ï¼‰

options 為é¸å¡«ç‰©ä»¶ï¼Œä¸€å…±æœ‰ä¸‰å€‹é¸å¡«å±¬æ€§å¯ä»¥è¨­å®šï¼š

interface IntersectionObserverInit {
  root?: Element | null;
  rootMargin?: string;
  threshold?: number | number[];
}
  • root
    • Type: Element | null
    • Default: null
    • è¦è§€å¯Ÿçš„ Element 的上層 Element。如果沒有設定則為 null,é è¨­æƒ…框下觀察的範åœç‚º top-level document's viewport,我解釋為螢幕的框框。
  • rootMargin
    • Type: string
    • Default: 0px 0px 0px 0px
    • rootMargin 定義的是 root Element 的邊界。é€éŽé€™å€‹å±¬æ€§æ”¾å¤§æˆ–ç¸®å°æˆ‘們觀測的範åœï¼Œå¡«å¯«æ–¹å¼è·Ÿ CSS çš„ margin 屬性類似,但單ä½åªæŽ¥å— åƒç´ ï¼ˆpxï¼‰åŠ ç™¾åˆ†æ¯”ï¼ˆ%)。
      例如:
      '5px'               // all margins set to 5px
      '5px 10%'           // top & bottom = 5px, right & left = 10%
      '-10px 5px 8%'      // top = -10px, right & left = 5px, bottom = 8%
      '-10px -5px 5px 8%' // top = -10px, right = -5px, bottom = 5px, left = 8%
      

      注æ„
      • 坿ޥå—的單ä½ç‚ºåƒç´ ï¼ˆpx)åŠç™¾åˆ†æ¯”(%),如果使用無單ä½ã€remã€em 會噴出以下錯誤:

      Uncaught DOMException: Failed to construct 'IntersectionObserver': rootMargin must be specified in pixels or percent.
  • threshold
    • Type: number | number[]
    • Default:0
    • 閾?(google ç¿»è­¯ï¼‰ï¼Œè¡¨ç¤ºç›¸äº¤å¤šå°‘æ¯”ä¾‹æœƒå‘¼å« callback function。它å¯ä»¥æ˜¯ä¸€å€‹æ•¸å­—,或是數字陣列。例如:0, 0.5, 1.0,分別表示:在觀察å€çš„邊界外ã€èˆ‡è§€å¯Ÿå€äº¤é›† 50%ã€å®Œå…¨å‡ºç¾åœ¨è§€å¯Ÿå€çš„邊界內。注æ„ï¼šé€™æ²’æœ‰æ–¹å‘æ€§ï¼Œä¸è«–是由上往下æ²å‹•,或是由下往上æ²å‹•都é©ç”¨ã€‚數字å€é–“åªèƒ½åœ¨ 0 - 1.0 之間。

callback function

interface IntersectionObserverCallback {
  (entries: IntersectionObserverEntry[], observer: IntersectionObserver): void;
}

設定觀察元素與觀察å€é”到 threshold 這定的比例時è¦åŸ·è¡Œçš„ function,這個 callback function å¸¶äº†å…©å€‹åƒæ•¸ï¼š

  • entries
    • Type:IntersectionObserverEntry[]
      interface IntersectionObserverEntry {
        readonly boundingClientRect: DOMRectReadOnly;
        readonly intersectionRatio: number;
        readonly intersectionRect: DOMRectReadOnly;
        readonly isIntersecting: boolean;
        readonly rootBounds: DOMRectReadOnly | null;
        readonly target: Element;
        readonly time: number;
      }
      
    • entries 為一個陣列,è£é¢åŒ…å«äº†è¦åŸ·è¡Œ callback çš„ DOM 資料,分別來說明一下這包資料裡é¢çš„屬性。
      boundingClientRectã€intersectionRectã€rootBounds 引用一張圖片來解釋
      Rectangles of IntersectionObserverEntry{> loading=lazy width=794 }
      • rootBounds: 「觀察å€ã€çš„矩形資料 DOMRectReadOnly。
      • boundingClientRect: 當下觸發 callback function 的「整個被觀察元素ã€çŸ©å½¢è³‡æ–™ï¼Œåœ¨åœ–上指的是整個紅色å€åŸŸã€‚
      • intersectionRect: 表示已出ç¾åœ¨è§€å¯Ÿå€å…§çš„被觀察元素矩形資料,或是說相交ã€é‡ç–Šå€åŸŸçš„部分。

      ä¸Šé¢æåˆ°çš„çŸ©å½¢è³‡æ–™ï¼Œè©³ç´°å…§å®¹å¯ä»¥æŸ¥è©¢ Element.getBoundingClientRect() 這個 API
      以下的部分比較好ç†è§£ï¼Œç°¡å–®å¸¶éŽã€‚
      • intersectionRatio: 表示與 root é‡ç–Šçš„æ¯”例。
      • isIntersecting: 被觀察元素 是å¦èˆ‡ root 相交,å¯ä»¥ç”¨ä¾†åˆ¤æ–·ç•¶ callback function è§¸ç™¼æ™‚ï¼Œè§€å¯Ÿå…ƒç´ èˆ‡è§€å¯Ÿå€æ˜¯å¦æœ‰é‡ç–Šã€‚
      • target: 這整包相交資料是屬於哪一個「被觀察元素ã€ã€‚
      • time: 建立 IntersectionObserver 實例後到處發 callback ç¶“éŽçš„æ™‚間。
  • observer
    • Type:IntersectionObserver
    • IntersectionObserver 的實例。

callback function 的執行時機

根據 MDN 上的說明,Intersection Observer 是一個 éžåŒæ­¥ çš„ Web APIï¼é‚£ callback function 是何時會執行呢?

其實在 Intersection Observer 的底層,callback function 會在 window.requestIdleCallback() 中被觸發,這個 API æœƒåœ¨ç•«é¢æ›´æ–°çš„æ¯ä¸€å¹€ï¼ˆframe)的最後執行。而é è¨­ timeout(超時強制執行時間)約為 100ms,表示在強制執行時間到以å‰ï¼Œåªè¦ JavaScript çš„åŸ·è¡Œç·šç¨‹æ²’æœ‰è¢«ç©ºä¸‹ä¾†ï¼Œä»–éƒ½ä¸æœƒåŸ·è¡Œã€‚

除éžåœ¨é€™æœŸé–“å‘¼å« IntersectionObserver.takeRecords()ï¼Œé€™å€‹åœ¨å¯¦ä¾‹æ–¹æ³•è£¡é¢æœƒæåˆ°ã€‚

å¦å¤–一點是,callback function 第一次被呼å«çš„æ™‚間點其實是在將被觀察元素指定給 Intersection Observer 實力的那一刻,也是就是等等會æåˆ°çš„實例方法 IntersectionObserver.observe(),這時ä¸è«–被觀察元素是å¦èˆ‡è§€å¯Ÿå€æœ‰äº¤é›†ï¼Œéƒ½æœƒåŸ·è¡Œã€‚所以在實作上建議è¦åŽ»åˆ¤æ–· intersectionRatio 的值來決定是å¦åŸ·è¡Œ callback function,ä¸ç„¶ä»¥ç‚ºåšäº† Lazy Load,實則åˆå§‹åŒ–當下就全部都載入了。

IntersectionObserver 實例方法

看看 IntersectionObserver çš„ interface 定義å§ï¼

interface IntersectionObserver {
  readonly root: Element | null;
  readonly rootMargin: string;
  readonly thresholds: ReadonlyArray<number>;
  disconnect(): void;
  observe(target: Element): void;
  takeRecords(): IntersectionObserverEntry[];
  unobserve(target: Element): void;
}

除了å‰é¢è¨­å®šçš„ options 三個屬性的唯讀資料外,還實例æä¾›äº†å››å€‹ method

  • observe()
    告訴 IntersectionObserver 實例è¦è§€å¯Ÿå“ªäº›å…ƒç´ ã€‚
  • unobserve()
    告訴 IntersectionObserver 實例è¦å–消觀察哪一個元素。
  • disconnect()
    åœç”¨æ•´å€‹ IntersectionObserver。
  • takeRecords()
    呼å«ä»–會回傳一個 已觀察到進入觀察å€ï¼Œä½†æ˜¯å°šæœªå‘¼å« callback function çš„å…ƒç´ é™£åˆ—ï¼Œä¸¦æ¸…ç©ºå¾…å‘¼å« callback function 的元素陣列
    Intersection Observer è¦ç­‰ JavaScript 執行續空閒下來æ‰å›žåŸ·è¡Œï¼Œæ˜¯éžåŒæ­¥å‘¼å« callback function。å‡å¦‚需è¦ç«‹åˆ»çŸ¥é“當下有沒有任何元素進入觀察å€ï¼Œå¯ä»¥ä½¿ç”¨ä»–ï¼Œä½†åŒæ™‚也會清空å³å°‡å‘¼å« callback 的陣列,callback function 就䏿œƒåŸ·è¡Œã€‚
    然後真的,目å‰ä¹Ÿæ²’看éŽå“ªè£¡ç”¨åˆ°å®ƒã€‚然後我花了最多篇幅解釋

Gridsome 的 IntersectionObserver 應用

最後看點範例。

這裡拿 Gridsome 0.7.17 的組件:<g-image> ä»¥åŠ <g-link> 來說明他如何利用 Intersection Observer API 來設計 Lazy Load è·Ÿ Route Prefetching 這兩個功能å§ï¼

Gridsome 在設計組件時,分別將 DOM 渲染相關的 code 集中在 Component 裡é¢ï¼Œè€Œç¨‹å¼é‚輯的部分則集中在 vue directives 裡é¢ã€‚而關於 Intersection Observer API 的應用è—在 gridsome/app/directives 裡é¢ã€‚

å¦å¤–å…ˆæå…©å€‹éƒ¨åˆ†ï¼ŒæŽ¥ä¸‹ä¾†æœƒçœ‹åˆ°çš„ caniuse.IntersectionObserver 為 boolean å€¼ï¼Œç”¨ä»¥ç¢ºèª global ä¸‹æ˜¯å¦æ”¯æ´é€™å€‹ API,å¦å¤– createObserver 這個 function 他原始碼如下:

export function createObserver (handler, options = {}) {
  const observer = new IntersectionObserver(entries => {
    entries.forEach(handler)
  }, {
    rootMargin: '20px',
    threshold: 0.1,
    ...options
  })

  return observer
}

å‘¼å« createObserver() 他會 new 一個 IntersectionObserver 並將其實例 return 給我們使用,é è¨­çš„ options 為 { rootMargin: '20px', threshold: 0.1 }。

g-image çš„ Lazy Load

原始碼(經éŽç°¡åŒ–)

import caniuse from '../utils/caniuse'
import { addClass, removeClass } from '../utils/class'
import { createObserver } from '../utils/intersectionObserver'

const observer = caniuse.IntersectionObserver
  ? createObserver(intersectionHandler)
  : null

export default {
  inserted (el) {
    observe(el)
  },
  update (el) {
    observe(el)
  },
  unbind (el) {
    unobserve(el)
  }
}

function intersectionHandler ({ intersectionRatio, target }) {
  if (intersectionRatio > 0) {
    observer.unobserve(target)
    loadImage(target)
  }
}

function observe (el) {
  if (!observer) loadImage(el)
  else observer.observe(el)
}

function unobserve (el) {
  if (observer) {
    observer.unobserve(el)
  }
}

function loadImage (el) {
  const src = el.getAttribute('data-src')

  if (!src || el.src.endsWith(src)) {
    return // src is already switched
  }

  el.onload = () => {
    removeClass(el, 'g-image--loading')
    addClass(el, 'g-image--loaded')
  }

  el.src = src
}

當圖片元素與畫é¢ç”¢ç”Ÿäº¤é›†å¾Œæœƒå‘¼å« intersectionHandler()ï¼Œä¸¦ç¢ºèª intersectionRatio(é‡ç–Šæ¯”例)是å¦å¤§æ–¼ 0,如果大於則表示元素出ç¾åœ¨è§€å¯Ÿå€å…§ï¼Œå‰‡ç«‹å³å°‡è©²å…ƒç´ è§£é™¤è§€å¯Ÿï¼ŒæŽ¥è‘—執行載入圖片的 function loadImage(el)。在 loadImage(el) è£¡é¢æœƒå°‡ <g-image> 組件準備好的 data-src,替æ›åˆ°åœ–片的 src ä¸Šï¼Œå®Œæˆ Lazy Load 動作。

Gridsome 的路由é å–(Route Prefetching)主è¦å¯¦ä½œæ˜¯åˆ©ç”¨ Vue Router 去å–得需è¦çš„組件,詳細的 code 在 fetch 裡é¢ï¼Œé€™é‚Šå°±ä¸æŒ–開來看了。至於他è¦å¦‚何判定è¦é å–哪些部分,ä¾é çš„也是 intersectionHandler。

原始碼

import fetch from '../fetch'
import router from '../router'
import caniuse from '../utils/caniuse'
import { stripPathPrefix } from '../utils/helpers'
import { createObserver } from '../utils/intersectionObserver'

const isPreloaded = {}

const observer = caniuse.IntersectionObserver
  ? createObserver(intersectionHandler)
  : null

export default {
  inserted (el) {
    observer && observer.observe(el)
  },
  unbind (el) {
    observer && observer.unobserve(el)
  }
}

function intersectionHandler ({ intersectionRatio, target }) {
  if (process.isClient) {
    if (intersectionRatio > 0) {
      observer.unobserve(target)

      if (document.location.hostname === target.hostname) {
        if (isPreloaded[target.pathname]) return
        else isPreloaded[target.pathname] = true

        const path = stripPathPrefix(target.pathname)
        const { route } = router.resolve({ path })

        setTimeout(() => fetch(route, { shouldPrefetch: true }), 250)
      }
    }
  }
}

åªçœ‹ intersectionHandler() 的部分。

一樣一旦連çµå…ƒç´ å‡ºç¾åœ¨è§€å¯Ÿå€å…§ä¾¿ç«‹å³è§£é™¤è©²è§€å¯Ÿå…ƒç´ ï¼Œä¸¦åœ¨å»¶é² 250ms 後 fetch() å–得該路由é é¢çš„資料與組件該é é¢çš„組件,這樣一來當使用者點擊路由時,因為相關資料與組件已經先é å–完æˆï¼Œåœ¨åˆ‡æ›ç•«é¢ä¸Šå°±æœƒæ›´ç‚ºé †æš¢ã€‚類似的作法在 Nuxt.js çš„ <nuxt-link> 裡é¢ä¹Ÿæœ‰ä½¿ç”¨åˆ°ã€‚

çµèªž

Intersection Observer API 推出很長一段時間了,儘管 IE 11 é è¨­æƒ…æ³ä¸‹ä¸æ”¯æ´ä½†é‚„是å¯ä»¥ä½¿ç”¨ polyfill 來實ç¾ã€‚

å¦å¤–在 Chrome ç›®å‰å¯¦ä½œçš„為 Intersection Observer v2,在建構函å¼çš„ options é¸åƒå¤šäº†å…©å€‹åƒæ•¸ trackVisibility è·Ÿ delay,é è¨­åˆ†åˆ¥ç‚º falst è·Ÿ 0ï¼Œä½†å› ç‚ºåªæœ‰ Chromium 核心ç€è¦½å™¨æœ‰å¯¦ç¾ï¼ŒåŠ ä¸Š google 官方還是比較建議以 v1 ç‚ºä¸»ï¼Œæ‰€ä»¥æœ‰èˆˆè¶£çš„æ§æ²¹å¯ä»¥åˆ°åƒè€ƒé€£çµçœ‹çœ‹ã€‚

åƒè€ƒé€£çµ

關於 Intersection Observer v2 的部分

è«‹æˆ‘å–æ¯å’–å•¡

如果這裡的內容有幫助到你的話,一æ¯å’–å•¡å°±æ˜¯å°æˆ‘最大的鼓勵。

è«‹æˆ‘å–æ¯å’–å•¡
ÿÿÿÿ