Code前端首页关于Code前端联系我们

Vue3 怎么正确监听window resize?这几个坑踩过的人都说惨

terry 4小时前 阅读数 84 #Vue
文章标签 Vue3window resize

很多做Vue3响应式布局的朋友,一开始都会觉得“不就是监听window.onresize吗?Vue里用watch或者watchEffect套一下不就完了?”真这么简单的话,就不会有人因为这个写的页面在手机端频繁卡顿、在PC端调整浏览器窗口到全屏/退出全屏时不生效,甚至还出现内存泄漏的问题了,今天咱们就从基础写法、优化性能、避坑指南这几个维度,把Vue3监听window resize这件事说透,看完保证你下次写再也不会踩雷。

最基础的写法能写,但别直接用在项目里

先从刚接触Vue3监听窗口变化的朋友常用的写法讲起,算是给大家一个“入门参照系”——虽然这个参照系问题挺多的,但至少能跑通功能。

得把window对象变成Vue3响应式系统能“感知”到的东西吗?其实不用,因为watch/watchEffect本身就是用来监听响应式数据或副作用函数的,监听浏览器原生事件的话,我们可以把“窗口尺寸更新”这件事包装成一个响应式数据,或者直接在副作用函数里绑定事件。

先看直接用watchEffect绑定事件的错误入门版(虽然简单但bug爆炸):

import { watchEffect, onUnmounted } from 'vue'
// 错误写法示例
watchEffect(() => {
  // 每次watchEffect重新运行都会绑定一次新的onresize!
  window.onresize = () => {
    console.log('窗口宽高变了', window.innerWidth, window.innerHeight)
  }
})

这段代码最直观的问题就是重复绑定事件——watchEffect默认在组件挂载和依赖更新时都会运行,比如你页面上有个响应式按钮被点了,这个watchEffect可能就会重新跑一遍,绑一次新的onresize,旧的又没解绑,最后窗口动一下控制台会打印几十条甚至上百条日志,手机端卡得要死是必然的。

稍微改得能用一点的基础版,是手动管理事件绑定和解绑,结合onUnmounted钩子:

import { ref, onMounted, onUnmounted } from 'vue'
export default {
  setup() {
    const windowWidth = ref(window.innerWidth)
    const windowHeight = ref(window.innerHeight)
    // 定义单独的更新函数
    const updateWindowSize = () => {
      windowWidth.value = window.innerWidth
      windowHeight.value = window.innerHeight
    }
    onMounted(() => {
      // 组件挂载后绑定一次
      window.addEventListener('resize', updateWindowSize)
    })
    onUnmounted(() => {
      // 组件卸载时必须解绑!否则会内存泄漏
      window.removeEventListener('resize', updateWindowSize)
    })
    return { windowWidth, windowHeight }
  }
}

这段代码能跑通基础功能,也不会重复绑定、不会内存泄漏,但还是有两个大问题:一是PC端全屏切换(F11或点击浏览器全屏按钮)时,有的浏览器不会触发resize事件;二是防抖节流没做——用户拖动浏览器窗口边缘时,resize事件每秒会触发几十次,频繁更新DOM或计算响应式数据还是会消耗性能。

项目里真的要用?这两个优化是标配

刚才说的两个大问题,全屏切换和防抖节流,是项目级监听window resize必须解决的,咱们逐个来优化。

优化1:补上全屏切换的监听

为什么PC端全屏切换有的浏览器不会触发resize?因为全屏切换属于“视口的显示状态改变”,不是单纯的“尺寸调整”,虽然Chrome最新版好像已经默认触发了,但为了兼容旧版Chrome、Firefox、Safari等,最好还是加上全屏相关的事件监听。

需要监听的全屏事件有四个:fullscreenchangewebkitfullscreenchangemozfullscreenchangemsfullscreenchange,分别对应标准浏览器、旧版WebKit内核(比如旧版Safari、旧版Chrome)、旧版Firefox、旧版Edge,咱们可以写个工具函数,统一绑定这些前缀事件:

// 可以单独放在utils/event.js里复用
export const addFullscreenChangeEvent = (handler) => {
  const events = [
    'fullscreenchange',
    'webkitfullscreenchange',
    'mozfullscreenchange',
    'msfullscreenchange'
  ]
  events.forEach(event => {
    document.addEventListener(event, handler)
  })
  // 返回解绑函数,方便组件卸载时调用
  return () => {
    events.forEach(event => {
      document.removeEventListener(event, handler)
    })
  }
}

然后把这个工具函数用到之前的setup里,和resize事件绑定放在一起,更新逻辑还是共用updateWindowSize就行。

优化2:加上防抖还是节流?选对很重要

很多朋友这时候会说“不就是加个防抖吗?把lodash的debounce一引就行”——别急,先想清楚你的需求是什么:

  • 如果你需要用户停止调整窗口后才更新数据(比如只需要展示最终的窗口尺寸,不需要实时调整布局),那用防抖(debounce)比较合适,延迟时间可以设个200-300ms;
  • 如果你需要用户调整窗口的过程中就平滑更新布局(比如响应式导航栏、响应式网格布局,拖动时要跟着变才自然),那用节流(throttle)更合适,延迟时间可以设个100-200ms,既能保证平滑,又能减少触发次数。

这里不建议直接引整个lodash库,太占体积了,咱们可以自己写个简单的防抖和节流工具函数:

// utils/debounce.js
export const debounce = (fn, delay = 200) => {
  let timer = null
  return function(...args) {
    clearTimeout(timer)
    timer = setTimeout(() => fn.apply(this, args), delay)
  }
}
// utils/throttle.js
export const throttle = (fn, delay = 100) => {
  let lastTime = 0
  return function(...args) {
    const now = Date.now()
    if (now - lastTime >= delay) {
      fn.apply(this, args)
      lastTime = now
    }
  }
}

然后把updateWindowSize用debounce或throttle包裹一下就行——注意!包裹后要保存一个引用,不能每次绑定的时候重新包裹,否则解绑的时候会失效,因为每次debounce/throttle返回的都是一个新的匿名函数。

有没有更优雅的Vue3写法?组合式函数了解一下

刚才的写法虽然已经能用了,但如果多个组件都需要监听窗口尺寸,难道每个组件都要写一遍onMounted、onUnmounted、加防抖节流、加全屏事件吗?那代码重复率也太高了,不符合Vue3“逻辑复用”的设计理念。

这时候咱们可以把监听窗口尺寸的逻辑封装成一个组合式函数(Composable),名字可以叫useWindowSize,这样哪个组件需要用,直接引入调用就行:

// composables/useWindowSize.js
import { ref, onMounted, onUnmounted } from 'vue'
import { addFullscreenChangeEvent } from '../utils/event'
import { throttle } from '../utils/throttle' // 这里假设默认用节流,也可以做成参数可选
export const useWindowSize = (options = {}) => {
  // 解构参数,支持自定义延迟时间、是否监听全屏、是防抖还是节流
  const {
    delay = 100,
    listenFullscreen = true,
    useDebounce = false
  } = options
  // 这里也可以引自己写的debounce
  const debounceFunc = useDebounce ? require('../utils/debounce').debounce : null
  const windowWidth = ref(window.innerWidth)
  const windowHeight = ref(window.innerHeight)
  const updateWindowSize = () => {
    windowWidth.value = window.innerWidth
    windowHeight.value = window.innerHeight
  }
  // 包裹更新函数,保存引用
  const wrappedUpdate = useDebounce 
    ? debounceFunc(updateWindowSize, delay)
    : throttle(updateWindowSize, delay)
  let removeFullscreenListener = null
  onMounted(() => {
    // 绑定resize事件
    window.addEventListener('resize', wrappedUpdate)
    // 如果需要监听全屏
    if (listenFullscreen) {
      removeFullscreenListener = addFullscreenChangeEvent(wrappedUpdate)
    }
    // 组件挂载时先更新一次,避免初始值不对(虽然ref已经设了,但万一有其他情况呢?)
    updateWindowSize()
  })
  onUnmounted(() => {
    // 解绑resize事件
    window.removeEventListener('resize', wrappedUpdate)
    // 解绑全屏事件
    if (removeFullscreenListener) {
      removeFullscreenListener()
    }
  })
  return { windowWidth, windowHeight }
}

这个组合式函数已经非常完善了:支持自定义参数、复用性强、没有代码重复、解决了之前所有的坑,咱们在组件里用的时候也超级简单:

<template>
  <div>
    <p>当前窗口宽度:{{ windowWidth }}px</p>
    <p>当前窗口高度:{{ windowHeight }}px</p>
    <!-- 可以根据windowWidth判断设备类型,做响应式布局 -->
    <div v-if="windowWidth >= 1024" class="desktop-layout">桌面端布局</div>
    <div v-else-if="windowWidth >= 768" class="tablet-layout">平板端布局</div>
    <div v-else class="mobile-layout">移动端布局</div>
  </div>
</template>
<script setup>
import { useWindowSize } from './composables/useWindowSize'
// 这里可以传自定义参数,比如延迟200ms、不用监听全屏、用防抖
// const { windowWidth, windowHeight } = useWindowSize({ delay: 200, listenFullscreen: false, useDebounce: true })
const { windowWidth, windowHeight } = useWindowSize()
</script>
<style scoped>
/* 随便写点响应式样式 */
.desktop-layout { background-color: #f0f0f0; padding: 20px; }
.tablet-layout { background-color: #e0e0e0; padding: 15px; }
.mobile-layout { background-color: #d0d0d0; padding: 10px; }
</style>

还有两个很多人忽略的细节

刚才讲的都是核心逻辑和优化,但还有两个细节容易被忽略,咱们也提一下:

细节1:SSR(服务端渲染)环境下不能直接用window

如果你的项目用了Nuxt3、Vite SSR等服务端渲染框架,那在setup函数里直接访问window对象会报错,因为服务端没有window、document这些浏览器全局对象,这时候咱们可以用Vue3提供的onMounted钩子来包裹访问window的代码,或者用Nuxt3提供的process.client、Vite SSR提供的import.meta.env.SSR来判断环境:

// 通用的SSR兼容写法
const windowWidth = ref(0)
const windowHeight = ref(0)
onMounted(() => {
  // 只有在浏览器端挂载后才会访问window
  windowWidth.value = window.innerWidth
  windowHeight.value = window.innerHeight
  // 后面的事件绑定逻辑也放在这里
})

细节2:不要过度依赖window.innerWidth/innerHeight

如果你的响应式布局只需要判断设备类型(桌面/平板/移动),那最好用CSS媒体查询,而不是监听window resize——因为CSS媒体查询的性能比JavaScript事件监听好太多了,而且更简单、更稳定,只有当你需要根据窗口尺寸做一些JavaScript逻辑(比如动态加载不同的组件、动态调整某个DOM元素的位置/大小、调用某个API获取对应尺寸的图片等)时,才用刚才的组合式函数。

总结一下

Vue3监听window resize这件事,说简单也简单,说复杂也复杂——刚入门的时候随便写写就能跑,但要用到项目里,必须解决重复绑定、内存泄漏、全屏切换不兼容、性能消耗这几个问题,封装成组合式函数是最优雅的做法,复用性强,代码也干净,最后记住:能用CSS媒体查询解决的,就别用JavaScript监听window resize

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门