我在真实项目中用VueUse解决的10个高频开发问题
又踩坑了,useScroll 在 iOS 上疯狂触发 scroll 事件
今天上线前测兼容性,发现一个诡异问题:在 iPhone Safari 上,只要页面稍微滑一下,useScroll 就像抽风一样每秒 emit 几十次回调,导致我写的“滚动到某位置自动高亮导航”的逻辑卡顿、错乱,甚至把 IntersectionObserver 都挤崩了。安卓和桌面端完全没事,就 iOS 这个老冤家又来搞事情。
第一反应是「是不是我监听太狠了?」赶紧翻 VueUse 文档——哦,它默认没防抖,useScroll 是原生 scroll 事件的响应式封装,iOS WebKit 的 scroll 行为本来就和 Chrome 不一样:它用的是“弹性滚动+异步合成帧”,事件不是在滚动中实时触发,而是在每次帧刷新时批量抛出,而且频率高得离谱(尤其在快速 flick 后惯性滚动阶段)。这就导致 useScroll 的 y 值像坐过山车,isScrolling 也忽真忽假,根本没法做稳定判断。
我先试了最朴素的办法:自己加 throttle 包一层:
import { useScroll } from '@vueuse/core'
import { throttle } from 'lodash'
const { y } = useScroll(window)
const throttledY = ref(0)
watch(y, (val) => {
throttledY.value = val
}, { immediate: true })
// 然后用 throttledY 而不是 y —— 结果?没用。
// 因为 y 本身还在高频更新,watch 的回调照样被疯狂触发,只是赋值慢了点而已。
折腾了半天发现,这根本不是“回调太多”的问题,而是“数据源本身在抖”。VueUse 的 useScroll 底层用的是 window.addEventListener('scroll', ...),iOS 上这个事件就是这么不讲武德。文档里倒是提了一句 debounce 选项,但只对 isScrolling 生效,y/x 还是原样吐出来。这里我踩了个坑:以为开了 { debounce: 100 } 就万事大吉,结果一测,y 还是狂跳。
后来试了下发现,真正能治它的,是换掉事件源本身 —— 改用 requestIdleCallback + getBoundingClientRect() 模拟滚动状态,或者更干脆点:不用 useScroll,改用 useWindowScroll?不对,它底层还是一样的……等等,VueUse 其实早就埋了个彩蛋:useScroll 支持传入一个 target,而如果你传的是一个 DOM 元素,它会尝试用 IntersectionObserver 或 ResizeObserver 来 fallback?不,不是。查源码发现,它其实支持一个叫 smooth 的配置,但那是给动画用的……
算了,不绕弯子了。最后我抄了 VueUse 官方 demo 里一个没写进文档的 trick:把 useScroll 和 useThrottleFn 组合起来,但不是包 watch,是直接包整个响应式对象的 getter。核心思路是:不让 y 值本身高频变化,而是让它的读取变“懒”——你什么时候要,我才给你算一次,且带节流。
最终方案长这样(亲测 iOS Safari 17.5 下丝滑):
import { useScroll, useThrottleFn } from '@vueuse/core'
import { ref, computed, onMounted, onUnmounted } from 'vue'
export function useStableScroll(target = window, options = {}) {
const { y, x, isScrolling } = useScroll(target, options)
// 关键:用 useThrottleFn 包住 getter,而不是监听 y 变化
const stableY = ref(0)
const stableX = ref(0)
const updateStable = useThrottleFn(() => {
stableY.value = y.value
stableX.value = x.value
}, 60) // 60ms ≈ 16fps,刚好匹配屏幕刷新率
// 手动绑定 scroll 事件,避免 useScroll 默认的 addEventListener 被 iOS 搞疯
let scrollListener
onMounted(() => {
scrollListener = () => updateStable()
target.addEventListener('scroll', scrollListener, { passive: true })
})
onUnmounted(() => {
if (scrollListener && target?.removeEventListener) {
target.removeEventListener('scroll', scrollListener)
}
})
return {
y: computed(() => stableY.value),
x: computed(() => stableX.value),
isScrolling: computed(() => isScrolling.value), // isScrolling 本身已经带 debounce,默认 150ms,够用
}
}
然后在组件里这么用:
<script setup>
import { useStableScroll } from './composables/useStableScroll'
const { y, isScrolling } = useStableScroll()
// 现在 y.value 就是稳稳的,不会一秒蹦几百次
watch(y, (val) => {
if (val > 100 && !isScrolling.value) {
// 滚动停止后才触发高亮逻辑
highlightActiveNav()
}
})
</script>
原理其实挺简单:iOS 的 scroll 事件还是在狂发,但我们不接它的“原始脉冲”,而是用 useThrottleFn 把它压成“每 60ms 最多执行一次更新”。配合 passive: true,还能让浏览器知道“我不调 preventDefault”,进一步提升滚动流畅度。至于为什么不用 debounce?因为 debounce 是“最后一次触发后等一段时间再执行”,而我们想要的是“匀速采样”,所以 throttle 更合适。
顺带一提,这个方案还有个小尾巴:如果用户滚动非常慢(比如手指拖拽),stableY 更新会有轻微延迟(最多 60ms),但实际体验中几乎感知不到,而且比原来疯狂抖动强太多了。另外,isScrolling 我没动它,因为它本来就有自己的 debounce,且行为符合直觉(滚动中为 true,停 150ms 后变 false),没必要再套一层。
还有个细节差点忘了说:如果你用的是 Vue 3.4+,可以试试 watchEffect + onInvalidate 自动清理,但我懒得改了,上面那个 onMounted/onUnmounted 已经足够健壮,也更容易 debug。
最后吐槽一句:VueUse 的 useScroll 真的很好用,文档也写得清楚,但它没替你处理平台差异,尤其是 iOS 这种“祖传 bug 集大成者”。作为业务开发者,不能只 copy-paste 示例代码,得知道它背后绑的是哪个事件、在什么浏览器里会翻车。
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案(比如纯 CSS 方案规避 JS 滚动监听,或者用 scrollend 事件),欢迎评论区交流。对了,这个 useStableScroll 我已经丢进公司内部的 @jztheme/composables 里了,调用方式和原版一致,无缝替换。

暂无评论