判断dom元素是否在可视区域的常规方式

1. Intersection Observer API(推荐)

这是现代浏览器推荐的方法,性能最好,异步执行,不会阻塞主线程。

基础用法

javascript 复制代码
// 创建观察器
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      console.log('元素进入可视区域', entry.target);
      // 可以在这里执行懒加载等操作
      entry.target.classList.add('visible');
    } else {
      console.log('元素离开可视区域', entry.target);
      entry.target.classList.remove('visible');
    }
  });
});

// 观察元素
const elements = document.querySelectorAll('.watch-element');
elements.forEach(el => observer.observe(el));

高级配置

javascript 复制代码
const options = {
  // root: 指定根元素,默认为浏览器视窗
  root: null, // 或者指定特定元素,如 document.querySelector('.container')
  
  // rootMargin: 根的外边距,可以扩大或缩小根的边界框
  rootMargin: '10px 0px -100px 0px', // 上右下左,类似CSS margin
  
  // threshold: 触发回调的可见比例
  threshold: [0, 0.25, 0.5, 0.75, 1] // 在0%, 25%, 50%, 75%, 100%可见时触发
};

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    const visiblePercentage = Math.round(entry.intersectionRatio * 100);
    console.log(`元素可见 ${visiblePercentage}%`);
    
    // 根据可见比例执行不同操作
    if (entry.intersectionRatio > 0.5) {
      // 超过50%可见
      entry.target.classList.add('mostly-visible');
    }
  });
}, options);

实用工具函数

ini 复制代码
// 封装的工具函数
function createVisibilityObserver(options = {}) {
  const defaultOptions = {
    root: null,
    rootMargin: '0px',
    threshold: 0.1
  };
  
  const finalOptions = { ...defaultOptions, ...options };
  
  return new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      const element = entry.target;
      const isVisible = entry.isIntersecting;
      
      // 触发自定义事件
      element.dispatchEvent(new CustomEvent('visibilityChange', {
        detail: { isVisible, entry }
      }));
    });
  }, finalOptions);
}

// 使用示例
const observer = createVisibilityObserver({ threshold: 0.5 });

document.querySelectorAll('.lazy-load').forEach(element => {
  observer.observe(element);
  
  element.addEventListener('visibilityChange', (e) => {
    if (e.detail.isVisible) {
      // 执行懒加载
      const img = element.querySelector('img[data-src]');
      if (img) {
        img.src = img.dataset.src;
        img.removeAttribute('data-src');
      }
    }
  });
});

2. getBoundingClientRect() 方法

传统方法,同步执行,需要手动调用。

基础用法

javascript 复制代码
function isInViewport(element) {
  const rect = element.getBoundingClientRect();
  const windowHeight = window.innerHeight || document.documentElement.clientHeight;
  const windowWidth = window.innerWidth || document.documentElement.clientWidth;
  
  return (
    rect.top >= 0 &&
    rect.left >= 0 &&
    rect.bottom <= windowHeight &&
    rect.right <= windowWidth
  );
}

// 使用示例
const element = document.querySelector('.target');
if (isInViewport(element)) {
  console.log('元素完全在视窗内');
}

部分可见判断

ini 复制代码
function isPartiallyInViewport(element) {
  const rect = element.getBoundingClientRect();
  const windowHeight = window.innerHeight || document.documentElement.clientHeight;
  const windowWidth = window.innerWidth || document.documentElement.clientWidth;
  
  return (
    rect.bottom > 0 &&
    rect.top < windowHeight &&
    rect.right > 0 &&
    rect.left < windowWidth
  );
}

// 更详细的可见性信息
function getVisibilityInfo(element) {
  const rect = element.getBoundingClientRect();
  const windowHeight = window.innerHeight;
  const windowWidth = window.innerWidth;
  
  // 计算可见区域
  const visibleTop = Math.max(0, rect.top);
  const visibleLeft = Math.max(0, rect.left);
  const visibleBottom = Math.min(windowHeight, rect.bottom);
  const visibleRight = Math.min(windowWidth, rect.right);
  
  const visibleWidth = Math.max(0, visibleRight - visibleLeft);
  const visibleHeight = Math.max(0, visibleBottom - visibleTop);
  const visibleArea = visibleWidth * visibleHeight;
  const totalArea = rect.width * rect.height;
  
  return {
    isVisible: visibleArea > 0,
    isFullyVisible: isInViewport(element),
    visibilityRatio: totalArea > 0 ? visibleArea / totalArea : 0,
    rect: rect,
    visibleArea: { width: visibleWidth, height: visibleHeight }
  };
}

// 使用示例
const element = document.querySelector('.target');
const info = getVisibilityInfo(element);
console.log(`可见比例: ${(info.visibilityRatio * 100).toFixed(2)}%`);

滚动监听版本

kotlin 复制代码
class ScrollVisibilityTracker {
  constructor(options = {}) {
    this.elements = new Map();
    this.threshold = options.threshold || 0.1;
    this.throttleDelay = options.throttleDelay || 100;
    
    this.checkVisibility = this.throttle(this.checkVisibility.bind(this), this.throttleDelay);
    this.bindEvents();
  }
  
  observe(element, callback) {
    this.elements.set(element, {
      callback,
      wasVisible: false
    });
    
    // 初始检查
    this.checkElement(element);
  }
  
  unobserve(element) {
    this.elements.delete(element);
  }
  
  checkVisibility() {
    this.elements.forEach((data, element) => {
      this.checkElement(element);
    });
  }
  
  checkElement(element) {
    const data = this.elements.get(element);
    if (!data) return;
    
    const info = getVisibilityInfo(element);
    const isVisible = info.visibilityRatio >= this.threshold;
    
    if (isVisible !== data.wasVisible) {
      data.wasVisible = isVisible;
      data.callback(isVisible, info);
    }
  }
  
  bindEvents() {
    window.addEventListener('scroll', this.checkVisibility, { passive: true });
    window.addEventListener('resize', this.checkVisibility);
  }
  
  destroy() {
    window.removeEventListener('scroll', this.checkVisibility);
    window.removeEventListener('resize', this.checkVisibility);
    this.elements.clear();
  }
  
  throttle(func, delay) {
    let timeoutId;
    let lastExecTime = 0;
    
    return function (...args) {
      const currentTime = Date.now();
      
      if (currentTime - lastExecTime > delay) {
        func.apply(this, args);
        lastExecTime = currentTime;
      } else {
        clearTimeout(timeoutId);
        timeoutId = setTimeout(() => {
          func.apply(this, args);
          lastExecTime = Date.now();
        }, delay - (currentTime - lastExecTime));
      }
    };
  }
}

// 使用示例
const tracker = new ScrollVisibilityTracker({ threshold: 0.5 });

document.querySelectorAll('.track-element').forEach(element => {
  tracker.observe(element, (isVisible, info) => {
    if (isVisible) {
      element.classList.add('in-view');
      console.log('元素进入视窗', info);
    } else {
      element.classList.remove('in-view');
    }
  });
});

3. 特殊场景的解决方案

在滚动容器中的元素

javascript 复制代码
function isInScrollContainer(element, container) {
  const elementRect = element.getBoundingClientRect();
  const containerRect = container.getBoundingClientRect();
  
  return (
    elementRect.top >= containerRect.top &&
    elementRect.left >= containerRect.left &&
    elementRect.bottom <= containerRect.bottom &&
    elementRect.right <= containerRect.right
  );
}

// 使用Intersection Observer观察滚动容器
function createContainerObserver(container) {
  return new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      console.log('容器内元素可见性变化', entry.isIntersecting);
    });
  }, {
    root: container, // 指定容器为根元素
    threshold: 0.1
  });
}

考虑CSS Transform的情况

arduino 复制代码
function getTransformedBounds(element) {
  const rect = element.getBoundingClientRect();
  
  // 如果元素有CSS transform,getBoundingClientRect已经包含了变换后的位置
  // 不需要额外计算
  return rect;
}

// 对于复杂的3D变换,可能需要更精确的计算
function isTransformedElementVisible(element) {
  const rect = element.getBoundingClientRect();
  
  // 检查元素是否因为transform: scale(0)等而不可见
  const computedStyle = getComputedStyle(element);
  const transform = computedStyle.transform;
  
  if (transform === 'none') {
    return isPartiallyInViewport(element);
  }
  
  // 检查是否有scale(0)或类似的变换
  if (rect.width === 0 || rect.height === 0) {
    return false;
  }
  
  return isPartiallyInViewport(element);
}

4. 性能优化技巧

虚拟滚动场景

kotlin 复制代码
class VirtualScrollObserver {
  constructor(container, options = {}) {
    this.container = container;
    this.itemHeight = options.itemHeight || 100;
    this.buffer = options.buffer || 5; // 缓冲区项目数量
    this.items = [];
    this.visibleRange = { start: 0, end: 0 };
    
    this.handleScroll = this.throttle(this.calculateVisibleRange.bind(this), 16);
    this.container.addEventListener('scroll', this.handleScroll);
  }
  
  calculateVisibleRange() {
    const scrollTop = this.container.scrollTop;
    const containerHeight = this.container.clientHeight;
    
    const start = Math.max(0, Math.floor(scrollTop / this.itemHeight) - this.buffer);
    const end = Math.min(
      this.items.length - 1,
      Math.ceil((scrollTop + containerHeight) / this.itemHeight) + this.buffer
    );
    
    if (start !== this.visibleRange.start || end !== this.visibleRange.end) {
      this.visibleRange = { start, end };
      this.onVisibleRangeChange(this.visibleRange);
    }
  }
  
  onVisibleRangeChange(range) {
    // 子类实现或通过回调处理
    console.log('可见范围变化:', range);
  }
  
  throttle(func, delay) {
    let lastTime = 0;
    return function(...args) {
      const now = Date.now();
      if (now - lastTime >= delay) {
        func.apply(this, args);
        lastTime = now;
      }
    };
  }
}

懒加载图片完整实现

kotlin 复制代码
class LazyImageLoader {
  constructor(options = {}) {
    this.options = {
      rootMargin: '50px',
      threshold: 0.1,
      loadingClass: 'lazy-loading',
      loadedClass: 'lazy-loaded',
      errorClass: 'lazy-error',
      ...options
    };
    
    this.observer = new IntersectionObserver(
      this.handleIntersection.bind(this),
      {
        rootMargin: this.options.rootMargin,
        threshold: this.options.threshold
      }
    );
    
    this.loadingImages = new Set();
  }
  
  observe(img) {
    if (!(img instanceof HTMLImageElement)) {
      console.warn('LazyImageLoader: 只能观察img元素');
      return;
    }
    
    if (!img.dataset.src && !img.dataset.srcset) {
      console.warn('LazyImageLoader: 图片缺少data-src或data-srcset属性');
      return;
    }
    
    this.observer.observe(img);
  }
  
  handleIntersection(entries) {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        this.loadImage(entry.target);
        this.observer.unobserve(entry.target);
      }
    });
  }
  
  loadImage(img) {
    if (this.loadingImages.has(img)) return;
    
    this.loadingImages.add(img);
    img.classList.add(this.options.loadingClass);
    
    const tempImg = new Image();
    
    tempImg.onload = () => {
      this.applyImage(img, tempImg);
      img.classList.remove(this.options.loadingClass);
      img.classList.add(this.options.loadedClass);
      this.loadingImages.delete(img);
    };
    
    tempImg.onerror = () => {
      img.classList.remove(this.options.loadingClass);
      img.classList.add(this.options.errorClass);
      this.loadingImages.delete(img);
    };
    
    // 支持srcset
    if (img.dataset.srcset) {
      tempImg.srcset = img.dataset.srcset;
    }
    tempImg.src = img.dataset.src;
  }
  
  applyImage(img, tempImg) {
    if (img.dataset.srcset) {
      img.srcset = img.dataset.srcset;
      delete img.dataset.srcset;
    }
    img.src = tempImg.src;
    delete img.dataset.src;
  }
  
  destroy() {
    this.observer.disconnect();
    this.loadingImages.clear();
  }
}

// 使用示例
const lazyLoader = new LazyImageLoader({
  rootMargin: '100px',
  threshold: 0.1
});

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

5. 兼容性处理

ini 复制代码
// Intersection Observer polyfill检查
function createCompatibleObserver(callback, options) {
  if ('IntersectionObserver' in window) {
    return new IntersectionObserver(callback, options);
  } else {
    console.warn('IntersectionObserver not supported, falling back to scroll listener');
    return createScrollBasedObserver(callback, options);
  }
}

function createScrollBasedObserver(callback, options = {}) {
  const elements = new Set();
  const threshold = options.threshold || 0;
  
  function checkElements() {
    const entries = [];
    
    elements.forEach(element => {
      const info = getVisibilityInfo(element);
      const isIntersecting = info.visibilityRatio >= threshold;
      
      entries.push({
        target: element,
        isIntersecting,
        intersectionRatio: info.visibilityRatio,
        boundingClientRect: info.rect
      });
    });
    
    if (entries.length > 0) {
      callback(entries);
    }
  }
  
  const throttledCheck = throttle(checkElements, 100);
  
  window.addEventListener('scroll', throttledCheck, { passive: true });
  window.addEventListener('resize', throttledCheck);
  
  return {
    observe(element) {
      elements.add(element);
      // 立即检查一次
      setTimeout(() => {
        const info = getVisibilityInfo(element);
        const isIntersecting = info.visibilityRatio >= threshold;
        callback([{
          target: element,
          isIntersecting,
          intersectionRatio: info.visibilityRatio,
          boundingClientRect: info.rect
        }]);
      }, 0);
    },
    
    unobserve(element) {
      elements.delete(element);
    },
    
    disconnect() {
      window.removeEventListener('scroll', throttledCheck);
      window.removeEventListener('resize', throttledCheck);
      elements.clear();
    }
  };
}

总结

选择合适的方法:

  1. Intersection Observer API - 现代浏览器首选,性能最佳
  2. getBoundingClientRect + 滚动监听 - 需要兼容老浏览器时使用
  3. 虚拟滚动 - 处理大量元素时的特殊优化

关键考虑因素:

  • 性能:Intersection Observer > 节流的滚动监听 > 频繁的滚动监听
  • 精确度:getBoundingClientRect更精确,但需要手动触发
  • 兼容性:getBoundingClientRect支持更老的浏览器
  • 功能需求:是否需要部分可见、可见比例等详细信息
相关推荐
掘金安东尼1 小时前
Astro 十一月更新:新特性与生态亮点(2025)
前端
小兵张健1 小时前
腾讯云智面试
面试
Hilaku1 小时前
如何用隐形字符给公司内部文档加盲水印?(抓内鬼神器🤣)
前端·javascript·面试
guxuehua1 小时前
Monorepo Beta 版本发布问题排查与解决方案
前端
猫头虎-前端技术1 小时前
小白也能做AI产品?我用 MateChat 给学生做了一个会“拍照解题 + 分步教学”的AI智能老师
前端·javascript·vue.js·前端框架·ecmascript·devui·matechat
b***66611 小时前
前端的dist包放到后端springboot项目下一起打包
前端·spring boot·后端
栀秋6661 小时前
ES6+新增语法特性:重塑JavaScript的开发范式
前端·javascript
爱分享的鱼鱼1 小时前
Vue动态路由详解:从基础到实践
前端
未来之窗软件服务1 小时前
幽冥大陆(三十七)文件系统路径格式化——东方仙盟筑基期
前端·javascript·文件系统·仙盟创梦ide·东方仙盟