我在真实项目中高频使用的工具技巧与避坑指南

Good“胜捷 优化 阅读 697
赞 31 收藏
二维码
手机扫码查看
反馈

谁更灵活?谁更省事?

最近给一个老项目加个「滚动定位高亮导航」功能,结果在工具链选型上卡了两天。不是逻辑难,是工具太乱:有直接用原生 IntersectionObserver 的,有用 React-Intersection-Observer 封装的,还有人硬上 getBoundingClientRect() + scroll 事件监听的……我本来想抄个现成的 npm 包完事,结果试了三个,两个在 iOS Safari 下失效,一个在 SSR 场景里直接报错 Cannot access 'window' before initialization —— 又是熟悉的配方,又是熟悉的味道。

我在真实项目中高频使用的工具技巧与避坑指南

所以干脆拉出来对线一次:就比三样东西——兼容性、SSR 友好度、调试成本。不聊虚的“设计理念”,就讲我实际改 bug 的时候,哪个让我骂得少、修得快、上线后不半夜被钉钉叫醒。

方案一:纯原生 IntersectionObserver(我目前主力用)

我比较喜欢用这个,前提是项目没强依赖老旧浏览器。它写起来干净,逻辑清晰,而且——关键点来了——iOS Safari 15.4+ 之后终于修好了 rootMarginposition: 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=&quot;#${id}&quot;])?.classList.add('active');
    } else {
      document.querySelector(nav a[href=&quot;#${id}&quot;])?.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 实现滚动定位回溯、或者用它驱动动画入场,后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论