React自定义Hook每次调用是否会重复添加事件监听器?如何优化?
多个组件用这个Hook会加多个监听器吗?
没错,每个使用useMediaQuery的组件都会独立给window添加一个resize事件监听器。因为你Hook里的useEffect是空依赖,每次组件挂载时都会执行window.addEventListener('resize', listener),而且每个组件里的listener都是独立的函数引用,所以最终会注册一堆监听器。组件多了之后,每次窗口resize时所有监听器都会跑一遍,重复做matchMedia判断和状态更新,确实会浪费性能。
怎么避免重复监听?
核心思路就是全局只注册一次resize监听器,让所有用这个Hook的组件共享这个监听器的结果,不用各自注册。下面给两种实用的实现方式:
方式一:React Context + 全局监听
搞一个Context来统一管理设备类型状态,在顶层组件里注册一次resize监听器,所有子组件直接从Context拿状态就行,不用自己监听。
// MediaQueryContext.tsx import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; // 这里替换成你实际的断点查询 const desktopQuery = '(min-width: 1024px)'; export enum DeviceType { Desktop = 'desktop', Mobile = 'mobile' } interface MediaQueryContextType { deviceType: DeviceType; } const MediaQueryContext = createContext<MediaQueryContextType | undefined>(undefined); export const MediaQueryProvider = ({ children }: { children: ReactNode }) => { // 初始化设备类型 const [deviceType, setDeviceType] = useState<DeviceType>( window.matchMedia(desktopQuery).matches ? DeviceType.Desktop : DeviceType.Mobile ); useEffect(() => { const handleResize = () => { const isDesktop = window.matchMedia(desktopQuery).matches; setDeviceType(isDesktop ? DeviceType.Desktop : DeviceType.Mobile); }; // 只注册一次全局监听器 window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); return ( <MediaQueryContext.Provider value={{ deviceType }}> {children} </MediaQueryContext.Provider> ); }; // 自定义Hook,从Context取状态 export const useMediaQuery = () => { const context = useContext(MediaQueryContext); if (!context) { throw new Error('useMediaQuery必须在MediaQueryProvider内部使用'); } return context; };
用的时候,在App最外层包个MediaQueryProvider,然后各个组件直接调用useMediaQuery()就能拿到设备类型,全程只有一个resize监听器。
方式二:模块级变量维护订阅列表
不想用Context的话,也可以在Hook所在的模块里搞个全局的订阅者数组,只注册一次监听器,每次resize时通知所有订阅的组件更新状态。
// useMediaQuery.ts import { useState, useEffect } from 'react'; const desktopQuery = '(min-width: 1024px)'; export enum DeviceType { Desktop = 'desktop', Mobile = 'mobile' } // 全局变量:存所有组件的状态更新函数 let subscribers = new Set<(deviceType: DeviceType) => void>(); let currentDeviceType: DeviceType; let isListenerRegistered = false; // 获取当前设备类型 const getCurrentDevice = (): DeviceType => { return window.matchMedia(desktopQuery).matches ? DeviceType.Desktop : DeviceType.Mobile; }; // 通知所有订阅的组件更新状态 const notifyAll = () => { currentDeviceType = getCurrentDevice(); subscribers.forEach(callback => callback(currentDeviceType)); }; export const useMediaQuery = () => { const [deviceType, setDeviceType] = useState<DeviceType>(getCurrentDevice()); useEffect(() => { // 把当前组件的更新函数加入订阅列表 subscribers.add(setDeviceType); // 如果还没注册监听器,就注册一次 if (!isListenerRegistered) { window.addEventListener('resize', notifyAll); isListenerRegistered = true; currentDeviceType = getCurrentDevice(); } // 组件卸载时移除订阅 return () => { subscribers.delete(setDeviceType); // 没人订阅了就移除监听器,省资源 if (subscribers.size === 0) { window.removeEventListener('resize', notifyAll); isListenerRegistered = false; } }; }, []); return { deviceType }; };
这种方式不用改组件结构,直接用Hook就行,全局只有一个resize监听器,而且当没有组件用这个Hook时,还会自动删掉监听器,不会占内存。
额外优化:给resize加防抖
resize事件触发得特别频繁,建议给监听器加个防抖,减少不必要的计算和状态更新。比如在方式一中修改handleResize:
// 先声明定时器变量 let resizeTimeout: NodeJS.Timeout; const handleResize = () => { clearTimeout(resizeTimeout); // 100ms内连续触发的话,只执行最后一次 resizeTimeout = setTimeout(() => { const isDesktop = window.matchMedia(desktopQuery).matches; setDeviceType(isDesktop ? DeviceType.Desktop : DeviceType.Mobile); }, 100); }; // 清理函数里别忘了清定时器 return () => { window.removeEventListener('resize', handleResize); clearTimeout(resizeTimeout); };
这样窗口拖动过程中不会频繁更新状态,性能会更好。
内容的提问来源于stack exchange,提问作者Bolza

