You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.09 16:34:57