打开网易新闻 查看精彩图片

去年有个数据挺有意思:npm上带"scroll"关键词的动画库,周下载量加起来超过1.5亿次。但你去问前端工程师,十个里有八个会吐槽——"我就想要个进度条,它给我塞了200KB的贝塞尔曲线"。

这事怪不了开发者。滚动监听在React里是个典型的"看起来简单,做起来漏"的活儿。节流、方向判断、边界检测、被动事件监听,随便一个需求都能让你写出一堆 imperative 代码(命令式代码)。然后你发现问题还没完:服务端渲染时 `window` 不存在,iOS 的橡皮筋回弹会误判方向,容器的 `overflow` 层级搞不清该监听谁。

ReactUse 这个库的存在感一直很奇怪。GitHub 标星不多,但你去翻 Ant Design、Arco Design 的源码,会发现它们内部都在用。它不做动画,只做一件事:把浏览器原生能力包装成可组合的 Hooks。100 多个钩子,平均每个不到 50 行代码。

这篇我们拿 6 个最常见的滚动场景开刀。每个场景先给你看手写实现的问题,再换 ReactUse 的解法。看完你会明白为什么有些团队宁愿自己造轮子,也不碰那些"全能"动画库

场景一:滚动进度条,但用户到底看完没有

最简单的进度条实现,上面代码已经给了。`scrollTop` 除以可滚动高度,绑个 `fixed` 定位的 div,完事。但产品经理第二天就会来找你:"能不能加个'已读完'的标记?"

这意味着你要检测用户是否到达底部。 naive 的做法是判断 `scrollTop + clientHeight === scrollHeight`,但不同浏览器的取整策略不一样,Chrome 可能差 1px,Safari 可能差 3px。于是你加个阈值,比如距离底部 50px 算到达。然后发现阈值在移动端和桌面端表现不一致,再改成动态计算。

ReactUse 的 `useScroll` 把这些都包了。它返回的 `arrivedState` 用 IntersectionObserver 检测边界,比数学计算更可靠。代码变成这样:

const { y, arrivedState } = useScroll(elRef); // arrivedState.bottom 直接告诉你是否触底

方向检测也换了实现。不是比较前后两次 `scrollTop`,而是维护一个滑动窗口,过滤掉橡皮筋回弹导致的微小抖动。你在 iPhone 上快速滑动后松手,进度条不会抽风。

场景二:吸顶导航,但高度会变的场景

场景二:吸顶导航,但高度会变的场景

吸顶效果(sticky header)的坑在于:你的导航栏高度可能变化。用户往下滚,导航栏缩小并固定;往上滚,又展开恢复原状。这个"缩小"的动画如果直接用 CSS `transform: scale`,会导致内部文字模糊,因为 GPU 渲染的纹理被压缩了。

打开网易新闻 查看精彩图片

正确的做法是用 `height` 动画,但这就涉及动态计算占位高度。导航栏从 80px 缩到 48px,下面的内容要同步 `paddingTop` 变化,否则会出现跳跃。手写代码里,你需要用 `ResizeObserver` 监听导航栏高度,再用 `useScroll` 监听位置,两个 effect 之间还要同步状态。

ReactUse 的 `useElementBounding` 把尺寸和位置监听合成一个钩子。它内部用 `ResizeObserver` + `IntersectionObserver` 双保险,返回的 `height` 是响应式的,直接拿来算 `paddingTop` 就行。SSR 场景下它返回 `{ width: 0, height: 0 }`,不会报错。

有个细节:很多团队吸顶时会加个阴影或背景色变化。`useElementBounding` 返回的 `top` 值在吸顶瞬间会突变,直接拿来做渐变动画会闪。ReactUse 的做法是内部维护一个 `isStuck` 状态,用阈值滞后避免抖动。

场景三:模态框锁屏,但滚动条宽度要处理

场景三:模态框锁屏,但滚动条宽度要处理

打开模态框时锁定背景滚动,这个需求看起来就是 `overflow: hidden`。但 Windows 上滚动条占据 17px 宽度,隐藏后页面会右移,内容产生"抖动"。macOS 的滚动条是覆盖式的,没有这个问题。

标准的修复方案是计算滚动条宽度,给 `body` 加等宽的 `paddingRight`。但计算宽度需要创建一个不可见的滚动容器,测量后销毁,这段代码没人想写第二遍。更麻烦的是:如果页面本来就没有滚动条(内容没撑满),你加了 `paddingRight` 反而会出现白边。

ReactUse 的 `useScrollLock` 处理了所有这些边缘情况。它内部用 `window.innerWidth - document.documentElement.clientWidth` 算宽度,比创建临时 DOM 更快。还会检查 `getComputedStyle` 确认当前是否有滚动条,避免误伤。

一个容易忽略的点:锁屏时要记住之前的 `overflow` 值,关闭时恢复。如果用户打开模态框前,页面已经是 `overflow: hidden`(比如另一个模态框),你不能粗暴地改成 `auto`。`useScrollLock` 用栈结构管理多个锁屏请求,后开的先关,不会互相覆盖。

场景四:锚点滚动,但要考虑用户偏好

场景四:锚点滚动,但要考虑用户偏好

"点击按钮平滑滚动到对应章节"这个功能,`scrollIntoView({ behavior: 'smooth' })` 就能做。但 `prefers-reduced-motion` 媒体查询你要不要支持?有些用户因为前庭功能障碍,看到平滑滚动会头晕。

ReactUse 的 `useSmoothScroll` 内部检测了这个媒体查询,如果用户设置了减少动画,自动降级为即时跳转。它还封装了 `scrollTo` 和 `scrollBy` 两种模式,支持相对偏移(比如"往上滚 200px")。

另一个细节:如果目标元素在可滚动容器内部,而不是 `document`,`scrollIntoView` 会同时滚动容器和页面,产生双重动画。`useSmoothScroll` 允许你指定滚动容器,只操作那一层。

打开网易新闻 查看精彩图片

性能方面,它用 `requestAnimationFrame` 做缓动计算,而不是 CSS 的 `scroll-behavior`。原因是后者无法中断,用户中途点击其他锚点,动画会排队执行,感觉很卡。`requestAnimationFrame` 可以随时取消,响应更快。

场景五:元素可见性检测,但阈值要动态

场景五:元素可见性检测,但阈值要动态

IntersectionObserver 是检测元素是否进入视口的原生 API,但直接用起来很啰嗦。你要创建 observer 实例,管理回调引用,还要记得在元素卸载时 `unobserve`。如果多个组件都需要监听,代码重复得一塌糊涂。

ReactUse 的 `useIntersectionObserver` 把它压缩成一行。默认配置是元素出现 10% 就触发,你可以改成"完全可见"或自定义阈值数组。一个常见的需求是:图片懒加载时,希望在距离视口 200px 时就开始加载,`rootMargin` 参数直接支持。

动态阈值是个高级用法。比如你做无限滚动列表,希望用户快滑到底部时加载更多,但"快滑到"的定义随列表长度变化。短列表剩 3 个元素就加载,长列表剩 10 个。`useIntersectionObserver` 的 `threshold` 可以是响应式的,配合 `useMemo` 动态计算。

SSR 兼容性处理得也干净。服务端没有 `IntersectionObserver`,钩子直接返回 `false`,不会尝试访问 `window`。

场景六:滚动联动动画,但不用动画库

场景六:滚动联动动画,但不用动画库

最复杂的场景:滚动位置驱动动画,比如视差滚动、文字颜色渐变、图片缩放。传统做法是用 GSAP ScrollTrigger 或 Framer Motion,但它们都带了完整的动画引擎,体积不小。

ReactUse 提供了 `useScrollProgress`,把滚动位置映射到 0-1 的进度值。你可以拿这个值直接驱动 CSS 变量,或者配合 `useRafFn` 做逐帧更新。没有缓动函数库,没有关键帧系统,就是纯粹的数据流。

一个实战技巧:配合 `useSpring`(另一个 ReactUse 钩子)可以给进度值加物理效果。用户停止滚动后,动画不会戛然而止,而是带一点惯性衰减。这个"弹簧"效果完全基于 `requestAnimationFrame`,没有依赖任何动画库。

性能上,`useScrollProgress` 内部做了节流,默认 16ms(约 60fps)触发一次。你可以调得更粗,比如 100ms,用于不那么敏感的联动效果。

ReactUse 的作者 Anthony Fu 在文档里写过一句话:「这些钩子不是让你少写代码,是让你写对代码。」看完这六个场景的对比,你可能会重新评估自己的技术选型——那些下载量过亿的动画库,你真的需要吗?