Intersection Observer API

深入理解 Intersection Observer API 的工作机制、性能优势、与传统 scroll 事件对比、以及在懒加载、无限滚动、广告可见性等场景的应用。

Intersection Observer API(Intersection Observer API)

一、为什么需要 Intersection Observer

1.1 传统滚动检测的问题

在 Intersection Observer 出现之前,检测元素是否进入视口需要使用 scroll 事件或定时轮询。这些方法有几个根本性的问题:

性能问题:scroll 事件在滚动时高频触发,即使使用节流(throttle)也会带来性能开销。更糟糕的是,每次事件处理中调用 getBoundingClientRect() 会触发强制同步布局(layout thrashing),进一步加剧性能问题。

边缘情况复杂:要准确判断元素是否"进入视口",需要考虑多种边界情况——滚动方向、元素大小变化、父元素滚动、iframe 嵌套等。

// 传统方式的性能问题
let lastKnownScrollY = 0;

function checkElementVisibility() {
  const element = document.getElementById('target');
  const rect = element.getBoundingClientRect();

  // getBoundingClientRect() 触发强制同步布局
  const isVisible = rect.top < window.innerHeight && rect.bottom > 0;

  if (isVisible) {
    loadContent();
  }
}

window.addEventListener('scroll', () => {
  lastKnownScrollY = window.scrollY;
  checkElementVisibility();  // 滚动时持续执行,性能差
});

1.2 Intersection Observer 的优势

Intersection Observer API 是浏览器原生提供的观察者模式实现,它的优势在于:

  1. 异步执行:回调在事件循环的微任务阶段执行,不阻塞主线程
  2. 自动批量处理:多个观察目标的变化在同一次回调中处理
  3. 无需手动计算:浏览器自动处理所有边界情况
  4. 低开销:不需要在滚动时执行任何计算
// Intersection Observer 的优雅实现
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      loadContent();
      observer.unobserve(entry.target);  // 加载后停止观察
    }
  });
}, {
  root: null,  // 使用视口作为根
  threshold: 0.1  // 10% 可见时触发
});

observer.observe(document.getElementById('target'));

二、API 详解

2.1 基本用法

const observer = new IntersectionObserver(callback, options);
observer.observe(targetElement);
observer.unobserve(targetElement);
observer.disconnect();

callback 接收一个 IntersectionObserverEntry 数组:

const observer = new IntersectionObserver((entries, observer) => {
  entries.forEach(entry => {
    console.log('Target:', entry.target);
    console.log('isIntersecting:', entry.isIntersecting);
    console.log('intersectionRatio:', entry.intersectionRatio);
    console.log('boundingClientRect:', entry.boundingClientRect);
    console.log('rootBounds:', entry.rootBounds);
    console.log('intersectionRect:', entry.intersectionRect);
  });
}, options);

2.2 IntersectionObserverEntry 属性

interface IntersectionObserverEntry {
  // 目标元素
  target: Element;

  // 目标元素边界矩形(相对于根元素)
  boundingClientRect: DOMRectReadOnly;

  // 根元素边界矩形(null 表示视口)
  rootBounds: DOMRectReadOnly | null;

  // 相交矩形(目标元素与根元素的交集)
  intersectionRect: DOMRectReadOnly;

  // 相交比例(0 到 1)
  intersectionRatio: number;

  // 是否正在与根相交
  isIntersecting: boolean;

  // 目标进入/离开根的时间戳
  time: DOMHighResTimeStamp;
}

2.3 options 配置

const options = {
  // 观察目标元素的父元素(或祖先元素)
  // null 表示使用视口作为根
  root: document.querySelector('.scroll-container'),

  // root 元素的外边距
  // 类似于 CSS 的 margin,可以扩大/缩小观察范围
  rootMargin: '0px 0px -100px 0px',  // 下边距 -100px

  // 相交比例阈值数组
  // 可以设置多个阈值,在不同相交比例时触发回调
  threshold: [0, 0.25, 0.5, 0.75, 1],

  // 或单个阈值
  threshold: 0.5  // 50% 可见时触发
};

rootMargin 的语法

// 与 CSS margin 类似的语法
rootMargin: '10px'              // 所有方向 10px
rootMargin: '-10px 20px'        // 上下 -10px,左右 20px
rootMargin: '0px 0px -50% 0px'  // 底部向上收缩 50%

三、懒加载实现

3.1 图片懒加载

这是 Intersection Observer 最常见的应用场景:

class LazyImageLoader {
  constructor(options = {}) {
    this.options = {
      root: null,
      rootMargin: '50px',  // 提前 50px 开始加载
      threshold: 0.1,
      ...options
    };

    this.observer = new IntersectionObserver(
      this.handleIntersection.bind(this),
      this.options
    );

    this.loadedCount = 0;
  }

  observe(element) {
    // 存储原始 src
    element.dataset.src = element.src;
    element.src = '';  // 先清除,placeholder 可以在 CSS 中设置
    element.dataset.loaded = 'false';

    this.observer.observe(element);
  }

  handleIntersection(entries) {
    entries.forEach(entry => {
      if (entry.isIntersecting && entry.target.dataset.loaded === 'false') {
        this.loadImage(entry.target);
      }
    });
  }

  loadImage(element) {
    const src = element.dataset.src;

    const img = new Image();
    img.onload = () => {
      element.src = src;
      element.dataset.loaded = 'true';
      this.loadedCount++;
      this.observer.unobserve(element);
    };
    img.onerror = () => {
      console.error('Failed to load image:', src);
      element.dataset.loaded = 'error';
    };
    img.src = src;
  }

  disconnect() {
    this.observer.disconnect();
  }
}

// HTML
// <img data-src="real-image.jpg" class="lazy" alt="description">

// 使用
const loader = new LazyImageLoader();
document.querySelectorAll('.lazy').forEach(img => loader.observe(img));

3.2 背景图片懒加载

class LazyBackgroundLoader {
  constructor(options = {}) {
    this.observer = new IntersectionObserver(
      this.handleIntersection.bind(this),
      { threshold: 0.1, ...options }
    );
  }

  observe(element) {
    element.dataset.bgSrc = element.style.backgroundImage;
    element.style.backgroundImage = 'none';
    this.observer.observe(element);
  }

  handleIntersection(entries) {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const element = entry.target;
        const bgSrc = element.dataset.bgSrc;
        element.style.backgroundImage = bgSrc;
        this.observer.unobserve(element);
      }
    });
  }
}

3.3 视频懒加载

// 对于 <video> 元素,懒加载 poster 和预加载元数据
const videoObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    const video = entry.target;

    if (entry.isIntersecting) {
      // 进入视口:开始加载
      video.poster = video.dataset.poster;
      video.preload = 'metadata';

      // 如果需要自动播放
      if (video.dataset.autoplay) {
        video.play().catch(() => {});
      }
    } else {
      // 离开视口:暂停播放
      video.pause();
    }
  });
}, { threshold: 0.25 });

document.querySelectorAll('video[data-lazy]').forEach(v => videoObserver.observe(v));

四、无限滚动

4.1 基础实现

class InfiniteScroller {
  constructor(fetcher, options = {}) {
    this.fetcher = fetcher;  // async function that returns next page
    this.options = {
      rootMargin: '200px',  // 距离底部 200px 时触发加载
      threshold: 0,
      ...options
    };

    this.page = 1;
    this.loading = false;
    this.hasMore = true;
    this.container = document.getElementById('content');

    this.observer = new IntersectionObserver(
      this.handleIntersection.bind(this),
      this.options
    );
  }

  async handleIntersection(entries) {
    const entry = entries[0];

    if (entry.isIntersecting && !this.loading && this.hasMore) {
      await this.loadMore();
    }
  }

  async loadMore() {
    this.loading = true;
    this.showLoader();

    try {
      const items = await this.fetcher(this.page);

      if (items.length === 0) {
        this.hasMore = false;
        this.showEndMessage();
      } else {
        this.appendItems(items);
        this.page++;
      }
    } catch (error) {
      this.showError(error);
    } finally {
      this.loading = false;
      this.hideLoader();
    }
  }

  appendItems(items) {
    items.forEach(item => {
      const el = document.createElement('div');
      el.className = 'item';
      el.textContent = JSON.stringify(item);
      this.container.appendChild(el);
    });
  }

  showLoader() {
    const loader = document.getElementById('loader');
    if (loader) loader.style.display = 'block';
  }

  hideLoader() {
    const loader = document.getElementById('loader');
    if (loader) loader.style.display = 'none';
  }

  showEndMessage() {
    const end = document.getElementById('end-message');
    if (end) end.style.display = 'block';
    this.observer.disconnect();
  }

  showError(error) {
    console.error('Failed to load:', error);
  }

  observe(triggerElement) {
    this.observer.observe(triggerElement);
  }
}

4.2 带分页的无限滚动

// 使用 sentinel 元素触发加载
const scroller = new InfiniteScroller(async (page) => {
  const response = await fetch(`/api/posts?page=${page}&limit=20`);
  return response.json();
});

// 观察 sentinel 元素
scroller.observe(document.getElementById('sentinel'));

五、其他应用场景

5.1 广告可见性追踪

class AdVisibilityTracker {
  constructor() {
    this.observer = new IntersectionObserver(
      this.handleIntersection.bind(this),
      { threshold: [0, 0.25, 0.5, 0.75, 1] }
    );
    this.impressions = new Map();
  }

  track(adElement, adId) {
    adElement.dataset.adId = adId;
    adElement.dataset.firstVisible = '';
    adElement.dataset.totalVisibleTime = '0';
    adElement.dataset.lastVisibleTime = '';

    this.observer.observe(adElement);
  }

  handleIntersection(entries) {
    entries.forEach(entry => {
      const adId = entry.target.dataset.adId;
      const now = Date.now();

      if (entry.isIntersecting) {
        // 广告开始可见
        if (!entry.target.dataset.firstVisible) {
          entry.target.dataset.firstVisible = now;
        }
        entry.target.dataset.lastVisibleTime = now;
      } else {
        // 广告不再可见,计算可见时间
        if (entry.target.dataset.lastVisibleTime) {
          const lastTime = parseInt(entry.target.dataset.lastVisibleTime);
          const currentTotal = parseInt(entry.target.dataset.totalVisibleTime) || 0;
          entry.target.dataset.totalVisibleTime = currentTotal + (now - lastTime);

          // 发送可见性数据到广告服务器
          this.reportImpression(adId, entry.target.dataset.totalVisibleTime);
        }
      }
    });
  }

  reportImpression(adId, visibleTime) {
    // 发送可见性报告
    console.log(`Ad ${adId} visible for ${visibleTime}ms`);
  }
}

5.2 动画触发

// 当元素进入视口时触发动画
const animationObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('animate');
      // 或者使用 Web Animations API
      entry.target.animate([
        { opacity: 0, transform: 'translateY(20px)' },
        { opacity: 1, transform: 'translateY(0)' }
      ], {
        duration: 500,
        easing: 'ease-out'
      });
      animationObserver.unobserve(entry.target);
    }
  });
}, { threshold: 0.2 });

document.querySelectorAll('.animate-on-scroll').forEach(el => {
  animationObserver.observe(el);
});

5.3 "阅读更多"功能

// 检测用户是否阅读完一篇文章
class ReadingProgress {
  constructor() {
    this.progress = 0;
    this.article = document.querySelector('article');
    this.progressBar = document.getElementById('reading-progress');

    if (!this.article) return;

    this.observer = new IntersectionObserver(
      this.handleIntersection.bind(this),
      {
        threshold: Array.from({ length: 101 }, (_, i) => i / 100)  // 每 1% 触发
      }
    );

    this.observer.observe(this.article);
  }

  handleIntersection(entries) {
    const entry = entries[0];
    const rect = entry.boundingClientRect;
    const windowHeight = window.innerHeight;

    // 计算阅读进度
    // 元素顶部在视口顶部时为 0,底部在视口底部时为 1
    if (entry.isIntersecting) {
      const elementTop = rect.top;
      const elementHeight = rect.height;

      // 顶部不可见时(向下滚动超过元素顶部)= 0%
      // 底部不可见时(向上滚动超过元素底部)= 100%
      const visibleTop = Math.max(0, elementTop);
      const visibleBottom = Math.min(windowHeight, elementTop + elementHeight);

      this.progress = Math.min(1, Math.max(0,
        (visibleBottom - visibleTop) / windowHeight +
        (windowHeight - visibleBottom) / elementHeight
      ));

      this.progressBar.style.width = `${this.progress * 100}%`;
    } else {
      // 完全不可见
      if (rect.top > windowHeight) {
        this.progress = 1;  // 已经滚过了
      }
    }
  }
}

六、注意事项与最佳实践

6.1 内存管理

class MemorySafeObserver {
  constructor() {
    this.observers = new Map();
  }

  observe(element, callback) {
    const observer = new IntersectionObserver((entries) => {
      callback(entries);
    });

    observer.observe(element);
    this.observers.set(element, observer);
  }

  unobserve(element) {
    const observer = this.observers.get(element);
    if (observer) {
      observer.disconnect();
      this.observers.delete(element);
    }
  }

  disconnectAll() {
    this.observers.forEach(observer => observer.disconnect());
    this.observers.clear();
  }
}

6.2 与 ResizeObserver 配合

// 同时观察交叉和尺寸变化
class VisibilityTracker {
  constructor(element) {
    this.element = element;
    this.intersectionObserver = null;
    this.resizeObserver = null;

    this.setupIntersectionObserver();
    this.setupResizeObserver();
  }

  setupIntersectionObserver() {
    this.intersectionObserver = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          this.onVisible();
        }
      });
    });

    this.intersectionObserver.observe(this.element);
  }

  setupResizeObserver() {
    this.resizeObserver = new ResizeObserver((entries) => {
      entries.forEach(entry => {
        const width = entry.contentRect.width;
        const height = entry.contentRect.height;
        this.onResize(width, height);
      });
    });

    this.resizeObserver.observe(this.element);
  }

  onVisible() {
    console.log('Element is visible');
  }

  onResize(width, height) {
    console.log(`Size changed: ${width}x${height}`);
  }

  destroy() {
    this.intersectionObserver?.disconnect();
    this.resizeObserver?.disconnect();
  }
}

参考资料

延展阅读