“怎么判断一个元素在不在可视区域?”这个问题很像“怎么判断你老板在不在工位?”——你当然可以一直探头看(scroll 事件里狂算),但你也可以请前台帮你盯着(IntersectionObserver)。
区别就是:前者你累、页面也累;后者你轻松,浏览器也更愿意配合。
先把需求说清楚:你到底要判断哪一种“可见”?
很多 bug 的根源不是 API,用错了,而是你们根本没对齐“可见”的定义。常见有三种:
- 露个头就算可见:元素只要和视口有交集就算(曝光埋点经常这么干)。
- 至少可见一半:比如图片懒加载、卡片动画,想更“稳”一点。
- 完全可见:通常用于“必须完整展示后才算到达”的场景。
后面所有方案,都能支持这三种,只是配置/判断方式不同。
推荐方案:IntersectionObserver(省心 + 省电)
如果你能用 IntersectionObserver,就优先用它。
它不是让你在滚动时疯狂算,而是浏览器在合适的时机告诉你:现在它交不交叉、交叉比例是多少。
1) 最小可用示例:露头就算
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| function observeVisible(target, onChange) { const observer = new IntersectionObserver((entries) => { for (const entry of entries) { onChange({ isVisible: entry.isIntersecting, ratio: entry.intersectionRatio, entry, }); } });
observer.observe(target);
return () => observer.disconnect(); }
const el = document.querySelector('#banner'); const stop = observeVisible(el, ({ isVisible }) => { if (isVisible) { console.log('看到了'); } });
|
2) “至少可见一半”:用 threshold 控制
1 2 3 4 5 6 7 8 9 10
| const observer = new IntersectionObserver( ([entry]) => { if (entry.intersectionRatio >= 0.5) { console.log('至少可见一半'); } }, { threshold: [0, 0.5, 1] } );
observer.observe(document.querySelector('#card'));
|
3) “提前一点触发”:rootMargin
典型场景:图片懒加载。你希望“快滚到可见区域前”就开始加载,不然用户滚到了图片还在转圈。
1 2 3 4 5 6 7 8
| const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { console.log('该加载图片了'); } }, { rootMargin: '200px 0px', threshold: 0 } );
|
rootMargin: '200px 0px' 的意思就是:把视口上下各“扩容” 200px。元素还没进视口,但进了扩容后的范围,就会触发。
4) 监听滚动容器:root 指向容器
很多人踩坑在这:页面不是 window 滚动,而是一个 div 自己滚。
1 2 3 4 5 6 7 8 9 10 11
| const container = document.querySelector('.list'); const item = document.querySelector('.list-item');
const observer = new IntersectionObserver( ([entry]) => { console.log('容器内可见:', entry.isIntersecting); }, { root: container, threshold: 0 } );
observer.observe(item);
|
什么时候它也会“坑”你?
- 兼容性:老浏览器/某些 WebView 可能不支持,要做降级。
- 你需要一次性扫描一堆元素:它能扛,但也别把几万条都扔进去;列表场景还是要配合虚拟列表。
- 你想要“精确到像素”的实时值:IntersectionObserver 是高层语义,不是每一帧都回调。
兜底方案:getBoundingClientRect(简单粗暴,但别高频用)
getBoundingClientRect() 会给你元素相对视口的位置:top/left/bottom/right/width/height。
1) 露头就算可见
1 2 3 4 5 6 7 8
| function isInViewport(el) { if (!el) return false; const rect = el.getBoundingClientRect(); const vw = window.innerWidth || document.documentElement.clientWidth; const vh = window.innerHeight || document.documentElement.clientHeight;
return rect.bottom > 0 && rect.right > 0 && rect.top < vh && rect.left < vw; }
|
这段判断的逻辑就是:只要矩形和视口矩形有交集,就算可见。
2) 完全可见
1 2 3 4 5 6 7 8
| function isFullyInViewport(el) { if (!el) return false; const rect = el.getBoundingClientRect(); const vw = window.innerWidth || document.documentElement.clientWidth; const vh = window.innerHeight || document.documentElement.clientHeight;
return rect.top >= 0 && rect.left >= 0 && rect.bottom <= vh && rect.right <= vw; }
|
3) 容器内可见(不是 window)
1 2 3 4 5 6 7
| function isInContainerViewport(el, container) { if (!el || !container) return false; const rect = el.getBoundingClientRect(); const crect = container.getBoundingClientRect();
return rect.bottom > crect.top && rect.top < crect.bottom && rect.right > crect.left && rect.left < crect.right; }
|
这个写法的好处是:你不用自己算 scrollTop/offsetTop 那堆容易错的相对坐标,直接比较两个 rect 就行。
如果你真的要“监听滚动来算”:请至少做到这几件事
有些场景你可能就是没法用 IntersectionObserver(比如特殊环境、极老设备、某些 WebView),那用 scroll/resize 也不是不行,但别写成“滚一下算一万次”。
1) 只在 rAF 里算一次(最常用的节流方式)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| function watchVisibilityByScroll(el, onChange) { let ticking = false; let last = null;
const check = () => { ticking = false; const visible = isInViewport(el); if (visible !== last) { last = visible; onChange(visible); } };
const onScroll = () => { if (ticking) return; ticking = true; requestAnimationFrame(check); };
window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('resize', onScroll);
onScroll();
return () => { window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onScroll); }; }
|
你会发现我做了两件很“抠门”的事:
- 用
requestAnimationFrame 保证每帧最多算一次
- 只有可见性变化时才回调,别每次都触发你的业务逻辑
2) 别在回调里做重活
你判断可见是为了触发某件事,但那件事别一触发就把页面打挂,比如:
- 曝光埋点:做批量上报/合并上报,别可见一次发一个请求
- 懒加载:别同时开几十个下载,注意并发控制
- 动画:别一口气给几百个元素加 class
选型建议:我平时怎么选?
- 懒加载 / 曝光埋点 / 进入视口触发动画:IntersectionObserver
- 只想“点一下按钮时判断一下”:getBoundingClientRect
- 老设备/奇葩 WebView:scroll + rAF(并且写得克制点)
结尾:别把“判断可见”写成性能问题
这类需求最容易从“一个 if”膨胀成“全站卡顿”。我的经验是:
- 能交给浏览器就交给浏览器(IntersectionObserver)
- 真要自己算,就降低频率、降低范围、降低副作用
判断一个元素在不在可视区域,本质上是“你愿不愿意让主线程喘口气”的选择题。你让它喘,它就不容易在你最需要的时候突然躺平。
Happy Coding!