我在真实项目中高频使用的工具技巧与避坑指南
谁更灵活?谁更省事?
最近给一个老项目加个「滚动定位高亮导航」功能,结果在工具链选型上卡了两天。不是逻辑难,是工具太乱:有直接用原生 IntersectionObserver 的,有用 React-Intersection-Observer 封装的,还有人硬上 getBoundingClientRect() + scroll 事件监听的……我本来想抄个现成的 npm 包完事,结果试了三个,两个在 iOS Safari 下失效,一个在 SSR 场景里直接报错 Cannot access 'window' before initialization —— 又是熟悉的配方,又是熟悉的味道。
所以干脆拉出来对线一次:就比三样东西——兼容性、SSR 友好度、调试成本。不聊虚的“设计理念”,就讲我实际改 bug 的时候,哪个让我骂得少、修得快、上线后不半夜被钉钉叫醒。
方案一:纯原生 IntersectionObserver(我目前主力用)
我比较喜欢用这个,前提是项目没强依赖老旧浏览器。它写起来干净,逻辑清晰,而且——关键点来了——iOS Safari 15.4+ 之后终于修好了 rootMargin 在 position: sticky 容器下的计算 bug。以前踩过坑,现在补了 margin 后基本稳了。
代码就这几行,核心逻辑不到 10 行:
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
const id = entry.target.id;
const isActive = entry.isIntersecting && entry.intersectionRatio > 0.3;
document.querySelector(nav a[href="#${id}"])?.classList.toggle('active', isActive);
});
},
{
rootMargin: '0px 0px -40% 0px',
threshold: [0, 0.3, 0.6]
}
);
document.querySelectorAll('section[id]').forEach(el => observer.observe(el));
优点太实在:无依赖、体积零增加、调试时直接打断点看 entries 就行,不需要翻源码猜行为。缺点也直白:IE 全系拜拜,Android 4.x 基本凉;另外如果页面有动态插入 section,得手动调 observer.observe(),不能自动响应——但这种场景我一般加个 MutationObserver 联动,也就多 5 行。
方案二:React-Intersection-Observer(封装得漂亮,但坑在细节)
这个包我一开始真挺心动,Hook 写法丝滑,TypeScript 支持也好,还自带 debounce 和 unmount 自动 disconnect。但真正往项目里塞才发现,它默认把 threshold 设成 [0],导致滚动一点点就触发,高亮跳来跳去;改成 [0.3] 后,又发现它内部用了 useEffect,SSR 渲染时直接炸锅——服务端没 window,根本跑不起来。
解决方法是加一层判断:
import { useInView } from 'react-intersection-observer';
function NavItem({ id }) {
// 注意:必须加 typeof window !== 'undefined'
const [ref, inView] = typeof window !== 'undefined'
? useInView({ threshold: 0.3 })
: [null, false];
useEffect(() => {
if (inView) {
document.querySelectorAll('nav a').forEach(a =>
a.classList.toggle('active', a.getAttribute('href') === #${id})
);
}
}, [inView, id]);
return <section ref={ref} id={id}>...</section>;
}
这里注意我踩过好几次坑:不是所有组件都能这么写,比如你用了 Next.js 的 getStaticProps,那 useEffect 还是会闪一下;而且它内部做了一层 throttle,默认 100ms,有时候快速滚动会漏掉中间状态——我上次就因为这个,用户反馈“点导航跳过去,但高亮没跟上”,折腾了半天才发现是它自己 throttle 把回调吃掉了。
方案三:手撸 getBoundingClientRect + scroll(最原始,但也最可控)
这个方案我在一个微信内嵌 H5 页面里用过——客户要求必须支持 iOS 12,而那个版本的 IntersectionObserver 是半残废。代码不多,但全是体力活:
let ticking = false;
function updateActiveNav() {
const sections = document.querySelectorAll('section[id]');
const scrollY = window.scrollY + 100; // 补个偏移
for (let i = 0; i < sections.length; i++) {
const el = sections[i];
const rect = el.getBoundingClientRect();
const offsetTop = rect.top + scrollY;
const offsetBottom = offsetTop + rect.height;
if (scrollY >= offsetTop - 100 && scrollY < offsetBottom - 100) {
const id = el.id;
document.querySelector(nav a[href="#${id}"])?.classList.add('active');
} else {
document.querySelector(nav a[href="#${id}"])?.classList.remove('active');
}
}
}
function onScroll() {
if (!ticking) {
requestAnimationFrame(() => {
updateActiveNav();
ticking = false;
});
ticking = true;
}
}
window.addEventListener('scroll', onScroll);
优点是:全兼容、完全可控、不怕任何 SSR 或 hydrate 时机问题。缺点也很明显:容易卡顿、逻辑耦合重、维护成本高。比如你想加个“滚动到 70% 才算进入”,就得手动算比例;再比如页面有 sticky header,offset 就得反复调——我上次调了 4 次才让 iPhone 6s 上的表现和 Chrome 一致。
我的选型逻辑
看场景,我一般选这三个中的一个:
- 新项目、目标浏览器 ≥ iOS 15.4 / Android Chrome 90 → 直接上原生
IntersectionObserver,不加任何 wrapper; - React 项目但需要 SSR(比如 Next.js)→ 我会自己封装一个轻量 Hook,只在
useEffect里初始化 observer,不依赖任何第三方包; - 微信/钉钉/企业微信等内嵌 WebView,且明确要求 iOS 12~14 → 老老实实手撸
getBoundingClientRect,别想着偷懒。
至于 react-intersection-observer?我不会再主动引入了。它封装得是好看,但真实项目里,你永远不知道下一个客户会不会提“能不能去掉 debounce”“能不能支持自定义 root”“能不能在 SSR 时不报错”——每次都要 patch,不如一开始就写清楚。
另外提醒一句:所有方案都别忘了加防抖或 requestAnimationFrame,我见过太多人直接绑 scroll 导致页面卡成 PPT。还有,rootMargin 别写死像素值,最好用百分比或 CSS 自定义属性,方便后续适配暗色模式或缩放字体。
踩坑提醒:这三点一定注意
- iOS Safari 对
rootMargin的解析和 Chrome 不一样:比如'0px 0px -50% 0px'在 Safari 下可能被截断成-49%,建议测试时用 iOS 真机,别信模拟器; - IntersectionObserver 的
isIntersecting不等于intersectionRatio > 0:有些情况 ratio 是 0,但 isIntersecting 还是 true,别混着用; - 动态插入 DOM 后,observer 不会自动接管:比如用
innerHTML插入 section,记得手动observe(),或者用MutationObserver监听并补上。
以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如结合 history.pushState 实现滚动定位回溯、或者用它驱动动画入场,后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

暂无评论