“怎么判断一个元素在不在可视区域?”这个问题很像“怎么判断你老板在不在工位?”——你当然可以一直探头看(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!