我在真实项目中用VueUse解决的10个高频开发问题

码农晓芳 框架 阅读 1,197
赞 31 收藏
二维码
手机扫码查看
反馈

又踩坑了,useScroll 在 iOS 上疯狂触发 scroll 事件

今天上线前测兼容性,发现一个诡异问题:在 iPhone Safari 上,只要页面稍微滑一下,useScroll 就像抽风一样每秒 emit 几十次回调,导致我写的“滚动到某位置自动高亮导航”的逻辑卡顿、错乱,甚至把 IntersectionObserver 都挤崩了。安卓和桌面端完全没事,就 iOS 这个老冤家又来搞事情。

我在真实项目中用VueUse解决的10个高频开发问题

第一反应是「是不是我监听太狠了?」赶紧翻 VueUse 文档——哦,它默认没防抖,useScroll 是原生 scroll 事件的响应式封装,iOS WebKit 的 scroll 行为本来就和 Chrome 不一样:它用的是“弹性滚动+异步合成帧”,事件不是在滚动中实时触发,而是在每次帧刷新时批量抛出,而且频率高得离谱(尤其在快速 flick 后惯性滚动阶段)。这就导致 useScrolly 值像坐过山车,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 元素,它会尝试用 IntersectionObserverResizeObserver 来 fallback?不,不是。查源码发现,它其实支持一个叫 smooth 的配置,但那是给动画用的……

算了,不绕弯子了。最后我抄了 VueUse 官方 demo 里一个没写进文档的 trick:useScrolluseThrottleFn 组合起来,但不是包 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 里了,调用方式和原版一致,无缝替换。

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

暂无评论