IntersectionObserver 交叉检测

1,291 阅读2分钟

原文地址:《IntersectionObserver》

MDN

IntersectionObserver

IntersectionObserver Entry

IntersectionObserver 用于异步观察目标元素与父元素可视状态。

polyfill: tnpm install intersection-observer

base

const observer = new IntersectionObserver(callback, options);

observer.observe();  //开始监听目标元素
observer.unobserve();  //取消监听目标元素
observer.takeRecords();  //返回所以观察目标的entries对象数组
observer.disconnect();  //停止所有监听

callback

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => { 
    console.log(entry.isIntersecting);  //目标元素进入可见区域或离开可见区域
  });
});
  • isIntersecting: 目标元素进入可视区域或离开可见区域
  • intersectionRatio: 目标元素在可视区域的比例
  • intersectionRect: 目标元素与根元素的相交区域
  • boundingClientRect: 目标元素的边界区域,同 getBoundingClientRect()
  • rootBounds: 返回交叉区域观察者中的根
  • target: 触发时的目标元素
  • time: 触发时的时间戳

options

const observer = new IntersectionObserver(callback, {
  root: document.getElementById("container"),  //父容器为container
  rootMargin: "10px, 10px, 10px, 10px",  //父容器内缩10px为计算边界
  threshold: 1  //较差比例为100%时触发
});
  • root: 监听对象的父元素,未指定则默认为根元素(root)
  • rootMargin: 计算时的边界偏移量,可以放大/缩小计算容器
  • threshold: 监听对象与父元素交叉比例触发阈值(0~1)

吸顶吸底

吸顶效果

最近的一个业务需求中需要实现 TabBar 吸顶能力。对于简单的吸顶,我们直接用 position: sticky; 即可。但是视觉同学要求在吸顶的时候 TabBar 的样式也做相应的变化,这就需要我们去监听吸顶状态。通过监听滚动事件,计算父子元素的相对距离,从而判读是否吸顶,代码如下:

export default function Tabbar() {
  const [isFixed, setIsFixed] = useState(false);

  useEffect(() => {
    window.addEventListener(
      "scroll", () => {
        const rect = document.getElementById("tabbar") && document.getElementById("tabbar").getBoundingClientRect();  //获取元素高度
        setIsFixed(rect.top <= 0 ? true : false);  //吸顶状态更新
      }, true  //Rax中屏蔽了冒泡事件,需要在捕获阶段监听事件
    );
  }, []);  

  return (
    <View className="container">
      <Tabbar className=`tabbar ${isFixed ? "tabbar--fixed" : null}` id="tabbar" />
    </View>
  )
}
.tabbar {
  position: sticky;  /* 粘性定位 */
  left: 0;
  top: 0;
  ...  /* 未吸顶样式 */
}

.abbar--fixed {
  ...  /* 吸顶样式 */
}

虽然完美还原了视觉效果,但是一个简单的吸附判断需要添加滚动监听和状态机,对页面性能影响不小。并且还遇到了小坑:沉浸式 TitleBar 通过监听页面滚动切换效果,给了页面一个滚动层级,导致无法监听到元素的 scrollTop 属性,需要 Hack 处理。复盘的时候使用 IntersectionObserver 重构了一下,通过监听 Tabbar 元素与容器元素之间的交叉状态判断是否吸顶。看起来逼格提升了一个档次,代码如下:

export default function Tabbar() {
  useEffect(() => {
    if (containerRef.current && tabbarRef.current) {
      handleFixed(containerRef.current, tabbarRef.current);
    }
  }, []);

  const handleFixed = (containerEle, tabbarEle) => {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          tabbarEle.setAttribute('class', 'tabbar--fixed');
        } else {
          tabbarEle.setAttribute('class', 'tabbar--unfixed');
        }
      })
    }, {
      threshold: 1, //元素完全可见时触发回调函数
    });
    observer.observe(containerEle); //开始观察
  }

  return (
    <View ref={containerRef} className="container" >
      <Tabbar ref={tabbarRef} />
    </View>
  )
}
.container {
  position: fixed;
  top: 0;
  left: 0;
}

.tabbar--fixed {
  position: fixed;
  top: 0;
  left: 0;
  ...  /* 吸顶样式 */
}

.tabbar--unfixed {
  position: relative;
  ...  /* 未吸顶样式 */
}

看起来代码更烦了,但实际上可操作性更好。不用通过命令式地计算父子元素间的相对距离判断是否相交,而是声明式地把判断依据交给底层 API 实现。从 scroll 到 IntersectionObserver,性能也提升了不少。由于监听 scroll 事件密集发生,计算量很大,容易造成性能问题(套个节流函数 →_→);而 IntersectionObserver 则是通过回调实现,只在临界计算一次,所以性能比较好。

电梯导航

电梯导航

不久,视觉同学提议将导航改成电梯导航,没问题……

电梯导航可以拆成两块逻辑:1、点击导航项滑动至对应模块顶部。2、页面滚动时判断视口内的模块并切换导航。

滑动模块:点击事件发生时调用 window.scrollTo() 方法平滑页面,平滑高度通过 getElementById(id).offsetTop 获取。

切换导航:切入正题,使用 IntersectionObserver 监听模块元素和根元素(root)的可视状态,组合 isIntersecting(是否进入视口)和intersectionRatio(进入视口的比例)判断模块全部进入视口时切换 Tab。

const tabMap = ['raider', 'show', 'poi'];

export default function Main() {
  const [tab, setTab] = useState('raider');

  useEffect(() => {
    const tabEle = tabMap.map(item => document.getElementById(item));  //获取DOM
    handleElevator(tabEle);
  }, [])

  const handleElevator = (tabEle) => {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting && entry.intersectionRatio === 1) {  //当模块全部滑入页面时
          setTab(entry.target.id);  //切换Tab
        }
      });
    })
    tabEle.forEach(item => observer.observe(item));  //遍历Tab设置监听
  }

  const handleClick = (id) => {
    setTab(id)
    window.scrollTo({
      top: document.getElementById(id).offsetTop,  //点击Tab项滑动至对应模块顶部
      behavior: 'smooth'  //平滑效果
    });
  }

  return (
    <View className="container">
      <Tabbar tab={tab} onClick={() => handleClick(id)} />
      <RaiderGroup id="raider" />
      <ShowGroup id="show" />
      <PoiGroup id="poi" />
    </View>
  );
}

图片懒加载

同理,判断图片进入可视区后将 src 中的占位图替换为 data-src 中的真实地址即可。

export default function Lazyload() {
  useEffect(() => {
    const imagesEle = document.getElementsByClassName("image-lazyload");
    const containerEle = getElementById("container");
    handleLazyload(imagesEle, containerEle);
  }, [])

  const handleLazyload = (imagesEle, containerEle) => {
    const observer = new IntersectionObserver((entries) =>{
      entries.forEach(item => {
        if (item.isIntersecting) {
          item.target.src = item.target.getAttribute("data-src");  //替换真实图片
          observer.unobserve(item.target);  //取消监听
        }
      })
    }, {
      root: containerEle
    });
    imagesEle.forEach(item => observer.observe(item));  //遍历图片设置监听
  }

  return (
    <View id="container">
      <Image className="image-lazyload" data-src="..." />
      <Image className="image-lazyload" data-src="..." />
      <Image className="image-lazyload" data-src="..." />
      ...
    </View>
  )
}

无限加载

同理,判断页面划到底部时(Loading 进入视口时),获取数据并插入,可以设置预加载高度提前加载。

export default function LoadMore() {
  const [data, setData] = useState([])

  useEffect(() => {
    const loadingEle = document.getElementById("loading");
    handleLoadMore(loadingEle);
  }, [])

  const handleLoadMore = (loadingEle, preload) => {
    const observer = new IntersectionObserver((entries) =>{
      entries.forEach((entry) => {
        if (entry.isIntersecting) {  //Loading进入视口时,即划到底部时
          fetchData();  //获取数据
        }
      })
    }, {
      rootMargin: `0px 0px ${preload}px 0px`, // 提前加载高度
    });
    observer.observe(loadingEle);  //设置监听
  }

  const fetchData = async () => {
    const res = await get(...);
    if (res.isSuccess) {
      setData(data.concat(res.data));  //插入数据
    }
  }

  return (
    <View id="container">
      {data && data.length > 0 && data.map(item => <View />)}  {/* 渲染元素 */}
      <Loading className="loading" />
    </View>
  )
}

曝光埋点

同理,需要判断元素全部进入可视区域时触发。

export default function Exposure() {
  useEffect(() => {
    const expEle = document.getElementsByClassName("need-exp");
    handleExposure(expEle);
  }, [])

  const handleExposure = (expEle) => {
    const observer = new IntersectionObserver((entries) =>{
      entries.forEach(item => {
        if (item.intersectionRatio === 1) {  //元素全部进入可视区域时
          ...  //曝光逻辑
          observer.unobserve(item.target);  //取消监听
        }
      })
    }, {
      threshold: 1  //当元素全部进入可视区时触发
    });
    expEle.forEach(item => observer.observe(item));  //遍历元素设置监听
  }

  return (
    <View id="container">
      <View className="need-exp" />
      <View className="need-exp" />
      <View className="need-exp" />
      ...
    </View>
  )
}

滚动动画

自适应轮播图

之前业务需要轮播图自适应图片高度,勉强还原了视觉效果。但是可以看到效果并不是最佳,自适应动画是在图片切换完成后才触发的,并没有实现跟手。

写不动了,插个眼...