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;
关键优化点
- 用useRef存储非渲染状态:
audioContext、audioBuffer、currentTime不需要触发组件重渲染,用useRef可以随时获取最新值,避免异步状态更新的问题。 - 监听playing状态启动播放:通过
useEffect监听playing的变化,当playing变为true时自动启动播放,确保每次启动都能拿到最新的playing和bpm值。 - 在onended中实时判断playing:每次
onended触发时直接读取当前的playing状态,确保停止指令能立即生效。 - 避免不必要的重渲染:将不需要触发UI更新的变量从
useState改为useRef,减少组件重渲染次数,提升性能。
内容的提问来源于stack exchange,提问作者Astrank
相关产品推荐
相关产品推荐

