vue3 实现一个监听元素是否在可视区内的 Hook

一、核心场景与需求背景

在现代前端开发中,监听元素是否处于视口(Viewport)内是一个高频需求,典型场景包括:

  1. 元素吸顶/吸底:导航栏或操作按钮在滚动到特定位置时固定显示
  2. 懒加载:图片或组件在进入视口时才加载资源
  3. 曝光统计:统计广告或内容的实际展示次数
  4. 无限滚动:滚动到底部时自动加载更多内容

这些场景都依赖于一个基础能力:实时判断元素是否可见。本文将深入解析如何在 Vue 中实现这一功能。

二、基础实现方案:传统滚动监听

2.1 核心原理

通过监听容器的 scroll 事件,结合元素的位置信息(如 getBoundingClientRect)来判断元素是否在视口内。

2.2 基础代码实现

以下是一个简化版的元素可见性监听实现:

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

// 监听窗口滚动事件
window.addEventListener('scroll', function() {
  const element = document.getElementById('target-element');
  if (isElementInViewport(element)) {
    console.log('元素在视口中');
  } else {
    console.log('元素不在视口中');
  }
});

2.3 存在的问题

  1. 性能问题:滚动事件触发频率极高,频繁计算元素位置会导致页面卡顿
  2. 兼容性问题:不同浏览器对滚动事件的处理存在差异
  3. 边界处理复杂:需要考虑元素部分可见、完全可见等多种情况
  4. 动态容器支持不足:难以处理滚动容器动态变化的场景

三、Vue 中的高级实现:自定义 Hook 方案

3.1 完整代码实现

以下是一个基于 Vue 3 Composition API 的元素可见性监听 Hook,解决了上述问题:

typescript 复制代码
import { ref, watch, onUnmounted, type Ref } from 'vue';

// 滚动容器类型
type ScrollContainer = HTMLElement | Window;

/**
 * 监听元素是否在可视区内的 Hook
 * @param targetRef - 目标元素的 Ref(DOM 元素)
 * @param scrollContainerRef - 滚动容器的 Ref(默认 window,可指定其他DOM元素)
 * @param throttleDelay - 节流延迟时间(默认 100ms,最小 0ms)
 * @returns 元素是否可见的响应式状态
 */
export function useElementVisibility(
  targetRef: Ref<HTMLElement | null>,
  scrollContainerRef: Ref<ScrollContainer | null> = ref(window),
  throttleDelay: number = 100
): Ref<boolean> {

  const isVisible = ref(false);
  // 存储当前绑定事件的容器(用于卸载时清理)
  let currentContainer: ScrollContainer | null = null;

  const getContainerInfo = (container: ScrollContainer) => {
    if (container instanceof Window) {
      return {
        height: window.innerHeight,
        scrollTop: window.scrollY,
        rect: new DOMRectReadOnly(0, 0, window.innerWidth, window.innerHeight)
      };
    }
    return {
      height: container.clientHeight,
      scrollTop: container.scrollTop,
      rect: container.getBoundingClientRect()
    };
  };
  
  // 优化后的节流函数:保证最后一次滚动可以执行
  const throttle = (fn: (...rest: any) => void, time = 500) => {
    let timer: NodeJS.Timeout | null = null;
    let lastArgs: any[] | null = null;
    
    return (...rest: any) => {
      lastArgs = rest;
      
      if (!timer) {
        fn(...rest);
        timer = setTimeout(() => {
          if (lastArgs) fn(...lastArgs);
          timer = null;
          lastArgs = null;
        }, time);
      }
    };
  };
  
  const checkVisibility = () => {
    const target = targetRef.value;
    const container = scrollContainerRef.value;

    if (!target || !container) {
      isVisible.value = false;
      return;
    }

    // 获取容器和目标元素信息
    const { height: containerHeight, rect: containerRect } = getContainerInfo(container);
    const targetRect = target.getBoundingClientRect();

    // 计算相对位置(目标元素相对于容器的坐标)
    const targetTopRelative = targetRect.top - containerRect.top;
    const targetBottomRelative = targetTopRelative + targetRect.height;

    // 可见性判断(只要有部分在容器内即视为可见)
    isVisible.value = targetBottomRelative > 0 && targetTopRelative < containerHeight;
  };

  const throttledCheck = throttle(checkVisibility, throttleDelay);

  // 抽离事件绑定/解绑逻辑
  const setupListeners = (container: ScrollContainer) => {
    if (container instanceof Window) {
      window.addEventListener('scroll', throttledCheck);
      window.addEventListener('resize', throttledCheck);
    } else {
      container.addEventListener('scroll', throttledCheck);
      window.addEventListener('resize', throttledCheck);
    }
    currentContainer = container;
  };

  const cleanupListeners = (container: ScrollContainer) => {
    if (container instanceof Window) {
      window.removeEventListener('scroll', throttledCheck);
      window.removeEventListener('resize', throttledCheck);
    } else {
      container.removeEventListener('scroll', throttledCheck);
      window.removeEventListener('resize', throttledCheck);
    }
  };

  // 监听滚动容器变化(滚动容器有可能从父组件取(异步获取)需要watch更新)
  watch(
    scrollContainerRef,
    (newContainer, oldContainer) => {
      // 解绑旧容器事件
      if (oldContainer) cleanupListeners(oldContainer);
      // 绑定新容器事件(并立即检查一次)
      if (newContainer) {
        setupListeners(newContainer);
        checkVisibility();
      }
    },
    { immediate: true }
  );

  // 组件卸载时清理所有事件
  onUnmounted(() => {
    if (currentContainer) {
      cleanupListeners(currentContainer);
      currentContainer = null;
    }
  });

  return isVisible;
}

3.2 核心优化点解析

3.2.1 节流函数优化

原节流函数存在内存泄漏风险,优化后的版本使用 timer 替代布尔标志,确保定时器能被正确清除:

typescript 复制代码
const throttle = (fn: (...rest: any) => void, time = 500) => {
  let timer: NodeJS.Timeout | null = null;
  let lastArgs: any[] | null = null;
  
  return (...rest: any) => {
    lastArgs = rest;
    
    if (!timer) {
      fn(...rest);
      timer = setTimeout(() => {
        if (lastArgs) fn(...lastArgs);
        timer = null;
        lastArgs = null;
      }, time);
    }
  };
};

3.2.2 动态容器处理

通过 watch 监听 scrollContainerRef 的变化,实现容器动态切换时的事件重绑定:

typescript 复制代码
watch(
  scrollContainerRef,
  (newContainer, oldContainer) => {
    // 解绑旧容器事件
    if (oldContainer) cleanupListeners(oldContainer);
    // 绑定新容器事件
    if (newContainer) {
      setupListeners(newContainer);
      checkVisibility();
    }
  },
  { immediate: true }
);

3.2.3 响应式状态管理

返回一个响应式 ref 对象,确保可见性状态变化能自动触发视图更新:

typescript 复制代码
const isVisible = ref(false);
// ...
return isVisible;

四、使用示例:实现元素吸底功能

4.1 基本用法

vue 复制代码
<template>
  <div class="page-container">
    <div ref="contentRef" class="content">
      <!-- 长内容区域 -->
      <p v-for="i in 100" :key="i">内容行 {{ i }}</p>
    </div>
    
    <div ref="targetRef" class="floating-button">
      固定按钮
    </div>
    
    <!-- 当目标元素不可见时,显示底部固定按钮 -->
    <div v-if="!isVisible" class="fixed-bottom-button">
      吸底按钮
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { useElementVisibility } from './useElementVisibility';

const targetRef = ref(null);
const contentRef = ref(null);

const isVisible = useElementVisibility(targetRef, contentRef);
</script>

4.2 进阶用法:自定义可见性阈值

如果你需要更精确的控制(如元素完全可见才触发),可以修改 checkVisibility 函数:

typescript 复制代码
// 修改可见性判断逻辑
const checkVisibility = () => {
  const target = targetRef.value;
  const container = scrollContainerRef.value;

  if (!target || !container) {
    isVisible.value = false;
    return;
  }

  const { height: containerHeight, rect: containerRect } = getContainerInfo(container);
  const targetRect = target.getBoundingClientRect();

  // 完全可见判断
  isVisible.value = (
    targetRect.top >= containerRect.top &&
    targetRect.bottom <= containerRect.bottom
  );
};

五、常见问题与解决方案

5.1 为什么需要 watch 监听 scrollContainerRef?

Vue 中 ref 的值有一个从 null 到实际 DOM 元素的变化过程:

  • 初始化时:组件挂载前,scrollContainerRef.value 是 null
  • 挂载后:scrollContainerRef.value 才会更新为对应的 DOM 元素

如果没有 watch 监听,当 ref 从 null 变为实际元素时,钩子无法感知,导致事件未绑定。watch 的核心作用是:

  1. 处理 ref 从 null 到元素的初始化过程
  2. 支持滚动容器动态变化(如父组件异步传递 ref)
  3. 确保事件正确绑定和解绑,避免内存泄漏

5.2 是否需要在 onMounted 中调用 useElementVisibility?

不需要。因为 useElementVisibility 内部已经通过 watch 和 immediate: true 处理了 ref 的动态变化,直接在 setup 中调用即可:

vue 复制代码
<script setup>
import { ref } from 'vue';
import { useElementVisibility } from './useElementVisibility';

const targetRef = ref(null);
const scrollContainerRef = ref(null);

// 直接调用,无需等待 onMounted
const isVisible = useElementVisibility(targetRef, scrollContainerRef);
</script>

5.3 如何优化性能?

  1. 合理设置节流时间:根据业务需求调整 throttleDelay,避免过于频繁的计算
  2. 避免不必要的计算:在 checkVisibility 中添加早期返回条件
  3. 使用 Intersection Observer API(高级优化):对于复杂场景,可改用 Intersection Observer API 实现更高效的监听

六、性能优化:Intersection Observer 方案

6.1 原理简介

Intersection Observer API 提供了一种异步观察目标元素与其祖先元素或视口交叉状态的方法,避免了频繁的 scroll 事件监听,性能更优。

6.2 基于 Intersection Observer 的实现

以下是使用 Intersection Observer 重写的元素可见性监听 Hook:

typescript 复制代码
import { ref, watch, onUnmounted, type Ref } from 'vue';

export function useElementVisibility(
  targetRef: Ref<HTMLElement | null>,
  scrollContainerRef: Ref<HTMLElement | Window | null> = ref(window),
  threshold: number | number[] = 0
): Ref<boolean> {
  const isVisible = ref(false);
  let observer: IntersectionObserver | null = null;

  const createObserver = () => {
    const target = targetRef.value;
    const container = scrollContainerRef.value;
    
    if (!target || !container) return;

    // 创建 Intersection Observer 实例
    observer = new IntersectionObserver(
      (entries) => {
        entries.forEach(entry => {
          isVisible.value = entry.isIntersecting;
        });
      },
      {
        root: container instanceof Window ? null : container,
        threshold
      }
    );

    observer.observe(target);
  };

  const destroyObserver = () => {
    if (observer) {
      observer.disconnect();
      observer = null;
    }
  };

  // 监听目标元素和容器变化
  watch([targetRef, scrollContainerRef], () => {
    destroyObserver();
    createObserver();
  }, { immediate: true });

  // 组件卸载时清理
  onUnmounted(() => {
    destroyObserver();
  });

  return isVisible;
}

6.3 性能对比

方案 优点 缺点
滚动事件监听 兼容性好 性能较差,频繁触发回调
Intersection Observer 性能优异,浏览器原生优化 兼容性略差(需考虑 polyfill)

七、总结

本文深入解析了在 Vue 中实现元素可见性监听的完整方案,包括:

  1. 基础原理:通过 scroll 事件和 getBoundingClientRect 判断元素位置
  2. Vue 实现:封装为自定义 Hook,处理动态容器和响应式状态
  3. 性能优化:使用节流和 Intersection Observer API 提升性能
  4. 常见问题:解答了 watch 必要性、调用时机等关键问题

在实际项目中,建议优先使用 Intersection Observer 方案,在兼容性要求较高的场景下可使用优化后的滚动监听方案。通过合理的封装和使用,能够高效解决各种元素可见性相关的业务需求。

相关推荐
宇寒风暖3 小时前
@(AJAX)
前端·javascript·笔记·学习·ajax
Giser探索家7 小时前
低空智航平台技术架构深度解析:如何用AI +空域网格破解黑飞与安全管控难题
大数据·服务器·前端·数据库·人工智能·安全·架构
gnip8 小时前
前端实现自动检测项目部署更新
前端
John_ToDebug9 小时前
JS 与 C++ 双向通信实战:基于 WebHostViewListener 的消息处理机制
前端·c++·chrome
gnip9 小时前
监听设备网络状态
前端·javascript
As331001010 小时前
Chrome 插件开发实战:打造高效浏览器扩展
前端·chrome
xrkhy10 小时前
nvm安装详细教程(卸载旧的nodejs,安装nvm、node、npm、cnpm、yarn及环境变量配置)
前端·npm·node.js
IT毕设实战小研11 小时前
基于SpringBoot的救援物资管理系统 受灾应急物资管理系统 物资管理小程序
java·开发语言·vue.js·spring boot·小程序·毕业设计·课程设计
德育处主任12 小时前
p5.js 3D盒子的基础用法
前端·数据可视化·canvas
前端的阶梯12 小时前
为何我的figma-developer-mcp不可用?
前端