IntersectionObserver 实战:懒加载、无限滚动与视口检测

性能优化(更新于 2026年6月2日)

IntersectionObserver:高性能视口检测 API

IntersectionObserver 是浏览器提供的异步视口交叉检测 API,替代传统的 scroll 事件监听:

对比项 scroll 监听 IntersectionObserver
执行线程 主线程同步 浏览器内部异步
触发频率 每帧都可能触发 仅交叉状态变化时
性能开销 高(需手动 throttle) 低(浏览器优化)
计算方式 getBoundingClientRect() 合成线程计算
兼容性 全浏览器 98%+ 全球覆盖

核心 API 详解

基本用法

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      console.log(entry.isIntersecting);     // 是否可见
      console.log(entry.intersectionRatio);  // 可见比例 0~1
      console.log(entry.target);             // 观察的目标元素
      console.log(entry.boundingClientRect); // 边界矩形
      console.log(entry.rootBounds);         // 根元素边界
      console.log(entry.time);               // 变化时间戳
    });
  },
  {
    root: null,         // 视口作为根(默认)
    rootMargin: '0px',  // 根元素边距扩展
    threshold: [0, 0.5, 1.0]  // 触发阈值
  }
);

observer.observe(document.querySelector('.target'));
observer.unobserve(element);
observer.disconnect();

rootMargin:提前/延迟触发

// 提前 200px 触发(用于懒加载预加载)
const lazyObserver = new IntersectionObserver(callback, {
  rootMargin: '200px 0px'  // 上下扩展 200px
});

// 仅在完全进入视口时触发
const fullObserver = new IntersectionObserver(callback, {
  rootMargin: '-100px 0px'  // 缩小根范围
});

threshold:精确控制触发时机

// 元素每进入 10% 就触发一次
const observer = new IntersectionObserver(callback, {
  threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
});

// 仅关心完全可见
const observer = new IntersectionObserver(callback, {
  threshold: 1.0
});

实战一:图片懒加载

基础实现

<img data-src="photo.webp" alt="描述" class="lazy-img" />
class LazyImageLoader {
  private observer: IntersectionObserver;

  constructor() {
    this.observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            const img = entry.target as HTMLImageElement;
            img.src = img.dataset.src!;
            img.removeAttribute('data-src');
            this.observer.unobserve(img);
          }
        });
      },
      { rootMargin: '200px 0px' }
    );
  }

  observe(container: HTMLElement) {
    container.querySelectorAll<HTMLImageElement>('.lazy-img')
      .forEach((img) => this.observer.observe(img));
  }
}

渐进式加载(模糊 → 清晰)

class ProgressiveImageLoader {
  private observer: IntersectionObserver;

  constructor() {
    this.observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (!entry.isIntersecting) return;
          const img = entry.target as HTMLImageElement;
          // 1. 先加载缩略图(20px 宽,模糊)
          const thumb = img.dataset.thumb!;
          img.src = thumb;
          img.classList.add('blurred');
          // 2. 再加载高清图
          const fullSrc = img.dataset.full!;
          const fullImg = new Image();
          fullImg.onload = () => {
            img.src = fullSrc;
            img.classList.remove('blurred');
          };
          fullImg.src = fullSrc;
          this.observer.unobserve(img);
        });
      },
      { rootMargin: '300px 0px' }
    );
  }
}
.blurred {
  filter: blur(20px);
  transition: filter 0.3s ease;
}

实战二:无限滚动

class InfiniteScroll {
  private observer: IntersectionObserver;
  private loading = false;
  private page = 1;

  constructor(
    private container: HTMLElement,
    private loadMore: (page: number) => Promise<HTMLElement[]>
  ) {
    const sentinel = document.createElement('div');
    sentinel.className = 'scroll-sentinel';
    container.appendChild(sentinel);

    this.observer = new IntersectionObserver(
      async (entries) => {
        if (!entries[0].isIntersecting || this.loading) return;
        this.loading = true;
        this.page++;
        const items = await this.loadMore(this.page);
        items.forEach((item) => container.insertBefore(item, sentinel));
        this.loading = false;
      },
      { rootMargin: '500px' }
    );

    this.observer.observe(sentinel);
  }

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

实战三:虚拟滚动

class VirtualScroller<T> {
  private observer: IntersectionObserver;
  private visibleRange = { start: 0, end: 0 };
  private itemHeight = 48;

  constructor(
    private container: HTMLElement,
    private items: T[],
    private renderItem: (item: T, index: number) => HTMLElement
  ) {
    this.container.style.height = `${this.items.length * this.itemHeight}px`;
    this.container.style.position = 'relative';
    this.setupObserver();
  }

  private setupObserver() {
    const sentinelTop = document.createElement('div');
    const sentinelBottom = document.createElement('div');
    this.container.prepend(sentinelTop);
    this.container.appendChild(sentinelBottom);

    this.observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (!entry.isIntersecting) return;
        this.updateVisibleItems();
      });
    });

    this.observer.observe(sentinelTop);
    this.observer.observe(sentinelBottom);
  }

  private updateVisibleItems() {
    const scrollTop = this.container.scrollTop;
    const viewHeight = this.container.clientHeight;
    const start = Math.floor(scrollTop / this.itemHeight);
    const end = Math.min(
      start + Math.ceil(viewHeight / this.itemHeight) + 2,
      this.items.length
    );

    if (start === this.visibleRange.start && end === this.visibleRange.end) return;
    this.visibleRange = { start, end };
    this.render();
  }

  private render() {
    const fragment = document.createDocumentFragment();
    for (let i = this.visibleRange.start; i < this.visibleRange.end; i++) {
      const el = this.renderItem(this.items[i], i);
      el.style.position = 'absolute';
      el.style.top = `${i * this.itemHeight}px`;
      fragment.appendChild(el);
    }
    this.container.innerHTML = '';
    this.container.appendChild(fragment);
  }
}

实战四:曝光打点

class ExposureTracker {
  private observer: IntersectionObserver;
  private tracked = new WeakSet<Element>();

  constructor(private onExpose: (element: Element) => void) {
    this.observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (!entry.isIntersecting) return;
          if (this.tracked.has(entry.target)) return;
          this.tracked.add(entry.target);
          this.onExpose(entry.target);
        });
      },
      { threshold: 0.5 }
    );
  }

  track(elements: Element[]) {
    elements.forEach((el) => this.observer.observe(el));
  }
}

const tracker = new ExposureTracker((el) => {
  const id = el.getAttribute('data-track-id');
  analytics.track('element_exposed', { id });
});

性能对比

场景 scroll 监听 IntersectionObserver 提升
1000 图片懒加载 ~60fps(throttle 200ms) ~60fps(无抖动) 流畅度 ↑
滚动事件触发次数 ~300 次/秒 ~5 次/秒 60× ↓
getBoundingClientRect 1000 次/触发 0(浏览器计算) 100% ↓
主线程占用 15-30ms/帧 <1ms/回调 30× ↓

浏览器兼容性

浏览器 版本 备注
Chrome 51+ 完整支持
Firefox 55+ 完整支持
Safari 12.1+ 完整支持
Edge 15+ 完整支持
IE 不支持 需 polyfill
// 降级方案
if ('IntersectionObserver' in window) {
  // 使用 IntersectionObserver
} else {
  // 降级:立即加载所有图片
  document.querySelectorAll('.lazy-img').forEach((img) => {
    (img as HTMLImageElement).src = (img as HTMLImageElement).dataset.src!;
  });
}

最佳实践

  1. 及时 unobserve:元素加载后立即取消观察,减少回调触发
  2. 合理 rootMargin:懒加载用 200-500px,曝光打点用 0px
  3. 批量观察:创建一个 Observer 实例观察多个元素,而非每个元素一个
  4. threshold 选择:懒加载 [0],进度条 [0, 0.25, 0.5, 0.75, 1]
  5. disconnect 清理:组件卸载时调用 disconnect() 避免内存泄漏

总结

IntersectionObserver 是现代前端视口检测的标准方案,相比 scroll 监听有数量级的性能优势。懒加载、无限滚动、曝光打点、动画触发是其四大核心场景,配合 rootMargin 和 threshold 可实现精细控制。

推荐使用 图片压缩 优化懒加载图片体积,图片调整 统一尺寸,格式转换 转为 WebP 格式。

本站提供浏览器本地工具,免注册即可试用 →

#IntersectionObserver#懒加载#虚拟滚动#性能#视口检测