IntersectionObserver

IntersectionObserver API 用于监听元素与父元素或视口的可视状态,分享一些日常开发中的实际应用案例。

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>
)
}

滚动动画

滚动效果

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

查看评论