Skip to content

Intersection Observer

概要

IntersectionObserver 用于异步监听目标元素与视口(或指定祖先元素)的交叉状态。它是实现懒加载、无限滚动、曝光埋点的标准方案。

一、基本用法

js
// 1. 创建观察器
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach(entry => {
      // entry.isIntersecting → 元素是否进入视口
      // entry.intersectionRatio → 可见比例 (0~1)
      if (entry.isIntersecting) {
        console.log('元素进入视口:', entry.target)
        // 执行你的逻辑...
      }
    })
  },
  {
    root: null,           // null = 视口;可指定祖先元素
    rootMargin: '0px',    // 提前/延迟触发(类似 CSS margin)
    threshold: 0.1,       // 可见比例达到 10% 时触发
  }
)

// 2. 观察目标元素
observer.observe(document.querySelector('.target'))

// 3. 停止观察
observer.unobserve(element)   // 停止单个
observer.disconnect()         // 停止全部

二、实战场景

1. 图片懒加载

html
<!-- 占位 -->
<img data-src="real-photo.jpg" src="placeholder.jpg" class="lazy" />
js
const imageObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target as HTMLImageElement
      img.src = img.dataset.src!        // 替换为真实图片
      img.onload = () => img.classList.add('loaded')
      imageObserver.unobserve(img)       // 加载后停止观察
    }
  })
}, {
  rootMargin: '200px',  // 提前 200px 开始加载
})

document.querySelectorAll('img[data-src]').forEach(img => {
  imageObserver.observe(img)
})

2. 无限滚动加载

js
const sentinel = document.getElementById('scroll-sentinel')

const scrollObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      loadMoreItems()  // 加载下一页
    }
  })
}, {
  rootMargin: '100px',  // 距底部 100px 时触发
})

scrollObserver.observe(sentinel)
html
<!-- 放在列表末尾作为"哨兵"元素 -->
<div id="scroll-sentinel" style="height: 1px;"></div>

3. 曝光埋点

js
function trackImpression(element: Element, data: { id: string; type: string }) {
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        // 元素至少 50% 可见、持续 1 秒才计为有效曝光
        const timer = setTimeout(() => {
          sendAnalytics({
            event: 'impression',
            ...data,
            time: Date.now(),
          })
          observer.unobserve(entry.target)
        }, 1000)

        // 如果元素离开了,取消上报
        if (!entry.isIntersecting) {
          clearTimeout(timer)
        }
      }
    })
  }, {
    threshold: 0.5,  // 50% 可见
  })

  observer.observe(element)
}

4. 滚动动画(元素出现时触发动画)

js
const animationObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('animate-in')
      animationObserver.unobserve(entry.target)
    }
  })
}, {
  threshold: 0.15,
})

document.querySelectorAll('.reveal').forEach(el => {
  animationObserver.observe(el)
})
css
.reveal {
  opacity: 0;
  transform: translateY(30px);
  transition: opacity 0.6s ease, transform 0.6s ease;
}
.reveal.animate-in {
  opacity: 1;
  transform: translateY(0);
}

5. 吸顶导航(Sticky Nav)

js
const nav = document.querySelector('.nav')
const navSentinel = document.querySelector('.nav-sentinel')

const stickyObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    // sentinel 不再可见 → 导航已经滚过,需要吸顶
    nav.classList.toggle('is-sticky', !entry.isIntersecting)
  })
})

stickyObserver.observe(navSentinel)

6. 视频自动播放/暂停

js
const videoObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    const video = entry.target as HTMLVideoElement
    if (entry.isIntersecting) {
      video.play()
    } else {
      video.pause()
    }
  })
}, { threshold: 0.5 })

document.querySelectorAll('video').forEach(v => videoObserver.observe(v))

三、Vue 组合式函数封装

ts
// composables/useIntersectionObserver.ts
import { ref, onMounted, onUnmounted, type Ref } from 'vue'

export function useIntersectionObserver(
  target: Ref<Element | null>,
  options: IntersectionObserverInit = {},
  callback?: (entry: IntersectionObserverEntry) => void
) {
  const isIntersecting = ref(false)
  let observer: IntersectionObserver | null = null

  onMounted(() => {
    if (!target.value) return

    observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        isIntersecting.value = entry.isIntersecting
        callback?.(entry)
      })
    }, options)

    observer.observe(target.value)
  })

  onUnmounted(() => {
    observer?.disconnect()
  })

  return { isIntersecting }
}
vue
<script setup>
import { ref } from 'vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'

const target = ref<HTMLDivElement | null>(null)
const { isIntersecting } = useIntersectionObserver(target, {
  threshold: 0.3,
})

watch(isIntersecting, (val) => {
  if (val) console.log('元素可见了')
})
</script>

<template>
  <div ref="target">被观察的元素</div>
  <span>{{ isIntersecting ? '可见' : '不可见' }}</span>
</template>

四、React Hook 封装

tsx
// hooks/useIntersectionObserver.ts
import { useEffect, useRef, useState } from 'react'

export function useIntersectionObserver(
  options: IntersectionObserverInit = {}
) {
  const ref = useRef<HTMLDivElement>(null)
  const [isIntersecting, setIsIntersecting] = useState(false)

  useEffect(() => {
    const element = ref.current
    if (!element) return

    const observer = new IntersectionObserver(([entry]) => {
      setIsIntersecting(entry.isIntersecting)
    }, options)

    observer.observe(element)
    return () => observer.disconnect()
  }, [options.threshold, options.rootMargin])

  return { ref, isIntersecting }
}

五、与 scroll 事件对比

对比维度IntersectionObserverscroll 事件
性能✅ 异步,浏览器原生优化❌ 必须节流/防抖,仍会触发重排
代码量少,语义清晰多,需手动计算位置
离屏 iframe✅ 仍能检测❌ 无法触发
浏览器支持95%+100%
js
// ❌ 传统方式:scroll 事件监听
window.addEventListener('scroll', () => {
  const rect = element.getBoundingClientRect()
  if (rect.top < window.innerHeight) {
    // 元素可见
  }
  // ↑ 每次滚动都执行,且 getBoundingClientRect 触发重排
})

// ✅ IntersectionObserver
const observer = new IntersectionObserver(entries => {
  // 只在交叉状态变化时回调
})

小结

IntersectionObserver懒加载无限滚动曝光埋点 等场景的标准方案,对比传统的 scroll 事件具有显著的性能优势。核心配置参数 threshold(可见比例)和 rootMargin(提前量)覆盖了大多数实战场景,结合 Vue/React Hook 封装可以进一步提升开发效率。

CC BY-NC-SA 4.0 协议