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(); 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"), rootMargin: "10px, 10px, 10px, 10px", threshold: 1 });
|
- 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 ); }, []);
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)); handleElevator(tabEle); }, [])
const handleElevator = (tabEle) => { const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting && entry.intersectionRatio === 1) { setTab(entry.target.id); } }); }) tabEle.forEach(item => observer.observe(item)); }
const handleClick = (id) => { setTab(id) window.scrollTo({ top: document.getElementById(id).offsetTop, 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) { 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> ) }
|
滚动动画
滚动效果
之前业务需要轮播图自适应图片高度,勉强还原了视觉效果。但是可以看到效果并不是最佳,自适应动画是在图片切换完成后才触发的,并没有实现跟手。