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

React函数能否持续自调用至状态变更?复刻Vue节拍器遇异步状态问题

React复刻Vue节拍器:解决状态异步导致无法停止循环的问题

我要复刻一个Vue开发的节拍器,核心逻辑是play函数在playing为true时通过onended持续调用自身。Vue实现代码如下:

data() {
    return {
      playing: false,
      audioContext: null,
      audioBuffer: null,
      currentTime: null
    }
},
mounted() {
    this.initContext();
},
methods: {
    toggle() {
      if (!this.playing) {
        this.currentTime = this.audioContext.currentTime;
        this.playing = true;
        this.play();
      } else {
        this.playing = false;
      }
    },
    play() {
      this.currentTime += 60 / this.$store.state.tempo;
      const source = this.audioContext.createBufferSource();
      source.buffer = this.audioBuffer;
      source.connect(this.audioContext.destination);
      source.onended = this.playing ? this.play : "";
      source.start(this.currentTime);
    },
    async initContext() {
      this.audioContext = new AudioContext();
      this.audioBuffer = await fetch("click.wav")
        .then(res => res.arrayBuffer())
        .then(arrayBuffer => 
          this.audioContext.decodeAudioData(arrayBuffer)
        )
    }
}

尝试的React代码遇到问题:由于React状态是异步的,play函数运行时始终拿不到更新后的playing值,导致无法停止循环:

const firstRender = useRef(true);
const [bpm, setBpm] = useState<number>(60);
const [playing, setPlaying] = useState<boolean>(false);
const [context, setContext] = useState<AudioContext>(undefined);
const [buffer, setBuffer] = useState<AudioBuffer>(undefined);
const [currentTime, setCurrentTime] = useState<number>(0);

useEffect(() => {
    if (firstRender.current) {
        firstRender.current = false;
        initContext();
        return;
    }
});

const toggle = () => {
    if (!playing) {
        setCurrentTime(context.currentTime);
        setPlaying(true);
        play();
    } else {
        setPlaying(false)
    }
}
  
const play = () => {
    setCurrentTime(currentTime + 60 / bpm);
    let source = context.createBufferSource();
    source.buffer = buffer;
    source.connect(context.destination);
    source.onended = playing ? play : undefined;
    source.start(currentTime);
}

const initContext = async () => {
    const audioContext = new AudioContext();
    const audioBuffer = await fetch("click.wav")
        .then(res => res.arrayBuffer())
        .then(arrayBuffer => 
            audioContext.decodeAudioData(arrayBuffer)
        )
    
    setContext(audioContext);
    setBuffer(audioBuffer);
}    

问题根源

React状态更新是异步的,且函数组件中的函数会捕获当前闭包的状态值。play函数第一次调用时捕获的是playing=true的状态,后续调用setPlaying(false)后,play函数里的playing依然是旧的true,导致onended始终绑定play,无法终止循环。

另外,audioContext、audioBuffer、currentTime这些不需要触发组件重渲染的变量,用useState存储会导致不必要的重渲染,也不利于获取实时值。

修复后的代码

import { useState, useRef, useEffect } from 'react';

const Metronome = () => {
  const [bpm, setBpm] = useState<number>(60);
  const [playing, setPlaying] = useState<boolean>(false);
  
  // 用useRef存储不需要触发重渲染的变量,获取实时值
  const audioContextRef = useRef<AudioContext | null>(null);
  const audioBufferRef = useRef<AudioBuffer | null>(null);
  const currentTimeRef = useRef<number>(0);

  // 初始化音频上下文
  useEffect(() => {
    const initContext = async () => {
      const audioContext = new AudioContext();
      const response = await fetch("click.wav");
      const arrayBuffer = await response.arrayBuffer();
      const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
      
      audioContextRef.current = audioContext;
      audioBufferRef.current = audioBuffer;
    };

    initContext();
  }, []);

  // 播放函数,用ref获取实时状态
  const play = () => {
    const context = audioContextRef.current;
    const buffer = audioBufferRef.current;
    if (!context || !buffer) return;

    currentTimeRef.current += 60 / bpm;
    const source = context.createBufferSource();
    source.buffer = buffer;
    source.connect(context.destination);
    
    // 实时判断playing状态
    source.onended = () => {
      if (playing) {
        play();
      }
    };
    source.start(currentTimeRef.current);
  };

  // 监听playing变化,控制播放循环
  useEffect(() => {
    if (playing) {
      const context = audioContextRef.current;
      if (!context) return;
      // 开始播放时重置当前时间
      currentTimeRef.current = context.currentTime;
      play();
    }
  }, [playing, bpm]);

  const toggle = () => {
    setPlaying(prev => !prev);
  };

  return (
    <div>
      <input
        type="number"
        value={bpm}
        onChange={(e) => setBpm(Number(e.target.value))}
        min="40"
        max="200"
      />
      <button onClick={toggle}>{playing ? '停止' : '开始'}</button>
    </div>
  );
};

export default Metronome;

关键优化点

  1. 用useRef存储非渲染状态:audioContext、audioBuffer、currentTime不需要触发组件重渲染,用useRef可以随时获取最新值,避免异步状态更新的问题。
  2. 监听playing状态启动播放:通过useEffect监听playing的变化,当playing变为true时自动启动播放,确保每次启动都能拿到最新的playing和bpm值。
  3. 在onended中实时判断playing:每次onended触发时直接读取当前的playing状态,确保停止指令能立即生效。
  4. 避免不必要的重渲染:将不需要触发UI更新的变量从useState改为useRef,减少组件重渲染次数,提升性能。

内容的提问来源于stack exchange,提问作者Astrank

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.05 06:50:30