React自定义useAudio Hook:事件监听器内调用setVolume时audio为null
问题描述
我写了一个用于播放音频的React自定义Hook:
"use client"; import { useEffect, useState } from "react"; export const useAudio = (url: string) => { const [audio, setAudio] = useState<HTMLAudioElement | null>(null); const [playing, setPlaying] = useState(false); const setVolume = (volume: number) => { if (audio) audio.volume = volume; console.log(audio); }; const toggle = () => { setPlaying(!playing); console.log(audio); }; useEffect(() => { playing ? audio?.play() : audio?.pause(); }, [playing]); useEffect(() => { audio?.addEventListener("ended", () => audio?.play()); }, [audio]); useEffect(() => { setAudio(new Audio(url)); return () => { audio?.removeEventListener("ended", () => audio.play()); }; }, []); return { playing, toggle, setVolume }; };
在toggle函数里打印audio能得到正确结果,但setVolume函数里的audio始终是null。我在鼠标移动事件监听器里调用setVolume,代码如下:
document.addEventListener("mousemove", function (e) { let element = document.getElementById("follow"); let left = e.pageX; let top = e.pageY; element!.style.left = left + "px"; element!.style.top = top + "px"; const distance = Math.sqrt( Math.pow(left - devPosition.current.x, 2) + Math.pow(top - devPosition.current.y, 2) ); setVolume(1 - distance / diagonal.current); });
请问这是不是因为在事件监听器里调用setVolume导致的?如果是,该怎么解决?
问题原因及解决方案
原因
这是闭包陷阱导致的:
- 组件挂载时添加的
mousemove监听器,捕获的是Hook初始渲染时创建的setVolume函数,这个版本的函数引用的是初始状态的audio(即null)。 - 后续Hook更新生成新的
setVolume函数后,旧的监听器依然持有最初的函数引用,所以调用时audio始终为null。 - 而
toggle函数是在React组件的渲染上下文中被调用,每次渲染都会获取最新的setVolume和audio引用,因此能拿到正确的实例。
解决方案
有两种常用的解决方式:
方式1:用useRef存储audio实例
将audio从useState改为useRef,useRef的.current属性是可变的,不会受闭包限制:
"use client"; import { useEffect, useState, useRef } from "react"; export const useAudio = (url: string) => { const audioRef = useRef<HTMLAudioElement | null>(null); const [playing, setPlaying] = useState(false); const setVolume = (volume: number) => { if (audioRef.current) audioRef.current.volume = volume; }; const toggle = () => { setPlaying(!playing); }; useEffect(() => { playing ? audioRef.current?.play() : audioRef.current?.pause(); }, [playing]); useEffect(() => { audioRef.current = new Audio(url); const audio = audioRef.current; const loopHandler = () => audio.play(); audio.addEventListener("ended", loopHandler); return () => { audio.removeEventListener("ended", loopHandler); audio.pause(); audio.src = ""; // 清理音频资源,避免内存泄漏 }; }, [url]); return { playing, toggle, setVolume }; };
修改后,无论setVolume何时被调用,都能获取到最新的audio实例。
方式2:动态更新事件监听器
在使用useAudio的组件中,通过useEffect监听setVolume的变化,每次更新时移除旧监听器并添加新的:
// 在使用useAudio的组件中 import { useAudio, useCallback, useEffect } from "react"; function YourComponent() { const { playing, toggle, setVolume } = useAudio("your-audio-url"); // 用useCallback固定setVolume的引用,确保依赖变化时触发更新 const memoizedSetVolume = useCallback(setVolume, [setVolume]); useEffect(() => { const handleMouseMove = (e) => { let element = document.getElementById("follow"); let left = e.pageX; let top = e.pageY; element!.style.left = left + "px"; element!.style.top = top + "px"; const distance = Math.sqrt( Math.pow(left - devPosition.current.x, 2) + Math.pow(top - devPosition.current.y, 2) ); memoizedSetVolume(1 - distance / diagonal.current); }; document.addEventListener("mousemove", handleMouseMove); return () => { document.removeEventListener("mousemove", handleMouseMove); }; }, [memoizedSetVolume]); // ...其他组件逻辑 }
这种方式借助React的依赖追踪,确保每次setVolume更新时,事件监听器也会同步更新,拿到最新的函数引用。
额外优化建议
- 组件卸载时务必清理音频资源:暂停播放、清空
src,避免内存泄漏。 - 可给
audio实例添加error事件监听,捕获音频加载失败的情况,提升容错性。
内容的提问来源于stack exchange,提问作者JaRoMaster
相关产品推荐
相关产品推荐

