React+PeerJS问题:远程流loadMetadata()仅首次调用生效
问题描述
首次发起通话时,可正常接收远程流,loadMetadata()能正常触发事件;挂断后再次呼叫同一peerId,虽能接收远程流,但loadMetadata()不再触发任何事件。
相关代码如下:
MainContainer.tsx
import React, { useState, useEffect } from "react"; // @ts-ignore import { Peer } from "peerjs"; import decodeJWT from "jwt-decode"; import callStates from "../helpers/callStates"; import { CALL_EVENTS } from "../helpers/callEvents"; import VideoBox from "./InCallView"; import OutgoingCallView from "./OutgoingCallView"; import IncomingCallView from "./IncomingCallView"; import StandbyView from "./StandbyView"; import { Box } from "@mui/material"; export interface Contacts { firstName: string; lastName: string; peerId: string; } function MainContainer() { const [callState, setCallState] = useState(callStates.standby); const [contactsList, setContactsList] = useState<Contacts[]>([]); const [callerState, setCallerState] = useState<"receiver" | "caller">(); const resetCallState = () => { setCallState(callStates.standby); }; useEffect(() => { peer.on("connection", (conn: any) => { conn.on("data", async (data: { event: string }) => { console.log("EVENT TYPE:: ", data.event); if (data.event === CALL_EVENTS.CALL_REQUEST) { setCallerState("receiver"); if (callState === callStates.standby) { setCallState(callStates.incomingCall); } } if (data.event === CALL_EVENTS.ANSWERED_CALL) { setCallerState("caller"); setCallState(callStates.inCall); } if (data.event === CALL_EVENTS.REJECT_CALL) { resetCallState(); } if (data.event === CALL_EVENTS.END_CALL) { resetCallState(); } }); }); // eslint-disable-next-line }, [peer]); const handleCall = async () => { console.log("%c Call sent! ", "background: #222; color: #bada55; font-size: 25px"); const conn = await peer.connect(alaaId); console.log({ conn }); setCallState(callStates.outgoingCall); conn.on("open", () => { console.log("%c connection opened! ", "background: #222; color: #bada55"); conn.send({ event: CALL_EVENTS.CALL_REQUEST }); }); }; const answerCall = async () => { await setCallState(callStates.inCall); const conn = peer.connect(alaaId); conn.on("open", () => { conn.send({ event: CALL_EVENTS.ANSWERED_CALL }); }); }; const rejectCall = () => { const conn = peer.connect(alaaId); conn.on("open", () => { conn.send({ event: CALL_EVENTS.REJECT_CALL }); resetCallState(); }); }; const endCall = () => { const conn = peer.connect(alaaId); conn.on("open", () => { resetCallState(); conn.send({ event: CALL_EVENTS.END_CALL }); }); }; const hangupCall = () => { const conn = peer.connect(alaaId); console.log({ conn }); conn.on("open", () => { conn.send({ event: CALL_EVENTS.HANGUP_CALL }); resetCallState(); }); }; return ( <> <Box sx={{ display: callState === callStates.standby ? "none" : "block" }}> {callState === callStates.inCall && <VideoBox callState={callerState} peer={peer} peerId={alaaId} endCall={endCall} />} {callState !== callStates.standby && callState !== callStates.inCall && ( <React.Fragment> {callState === callStates.outgoingCall && <OutgoingCallView hangupCall={hangupCall} />} {callState === callStates.incomingCall && <IncomingCallView answerCall={answerCall} rejectCall={rejectCall} />} </React.Fragment> )} </Box> {callState === callStates.standby && <StandbyView handleCall={handleCall} contactsList={contactsList} />} </> ); } export default MainContainer;
InCallView.tsx
import { useDebugValue, useEffect, useState } from "react"; function useUserMedia(constraints: any) { const [stream, setStream] = useState<MediaStream | null>(); const [error, setError] = useState(); useDebugValue({ error, stream }); useEffect(() => { console.log("inside main useeffect"); let canceled = false; navigator.mediaDevices.getUserMedia(constraints).then( (stream) => { if (!canceled) { console.log("%c inside canceled condition", "font-size:60px"); setStream(stream); } }, (error) => { if (!canceled) { setError(error); } } ); return () => { canceled = true; setStream(null); }; }, [constraints]); useEffect( () => () => { console.log("inside cancel useeffect"); if (stream) stream.getTracks().forEach((track) => { track.stop(); }); }, [stream] ); return { error, stream }; } export default useUserMedia;
useMediaStream hook
import { useState, useEffect } from "react"; export function useUserMedia() { const [mediaStream, setMediaStream] = useState<MediaStream>(); useEffect(() => { async function enableVideoStream() { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: false, video: { frameRate: { exact: 15, ideal: 15 }, echoCancellation: true, noiseSuppression: true, width: { min: 300, max: 640 }, height: { min: 300, max: 480 }, }, }); setMediaStream(stream); } catch (err) { console.log("Video error: ", err); } } if (!mediaStream) { enableVideoStream(); } else { return function cleanup() { mediaStream.getTracks().forEach((track) => { track.stop(); }); }; } // eslint-disable-next-line }, [mediaStream]); return mediaStream; }
核心问题分析
- Peer连接重复创建:每次通话操作都调用
peer.connect(alaaId)生成新连接,未复用或清理旧连接,导致事件监听器堆积、上下文混乱。 - 媒体流清理不彻底:挂断后旧流轨道未完全停止或解绑,新流的
loadMetadata()事件被旧上下文阻塞。 - 事件绑定时机错误:
loadMetadata()可能仅在组件初始化时绑定一次,后续新流创建时未重新绑定事件。
修复方案
1. 统一管理Peer连接,避免重复创建
在MainContainer中维护全局连接实例,复用已有连接并在通话结束时主动清理:
function MainContainer() { // 新增:维护活跃连接实例 const [activeConn, setActiveConn] = useState(null); const handleCall = async () => { console.log("%c Call sent! ", "background: #222; color: #bada55; font-size: 25px"); // 复用已有连接,无活跃连接时再创建 let conn = activeConn; if (!conn || conn.open === false) { conn = await peer.connect(alaaId); setActiveConn(conn); // 监听连接关闭,自动清理状态 conn.on("close", () => setActiveConn(null)); } setCallState(callStates.outgoingCall); conn.on("open", () => { console.log("%c connection opened! ", "background: #222; color: #bada55"); conn.send({ event: CALL_EVENTS.CALL_REQUEST }); }); }; // 修改endCall,直接使用活跃连接并主动关闭 const endCall = () => { if (activeConn) { activeConn.send({ event: CALL_EVENTS.END_CALL }); activeConn.close(); } resetCallState(); }; // 同理修改answerCall、rejectCall、hangupCall,全部使用activeConn而非新建连接 // 组件卸载时清理连接 useEffect(() => { return () => { activeConn?.close(); }; }, [activeConn]); }
2. 彻底清理媒体流及事件监听器
修改useUserMedia hook,确保流切换时停止旧轨道并移除所有相关监听器:
function useUserMedia(constraints: any) { const [stream, setStream] = useState<MediaStream | null>(); const [error, setError] = useState(); useDebugValue({ error, stream }); useEffect(() => { let canceled = false; let mediaStream: MediaStream | null = null; const getStream = async () => { try { mediaStream = await navigator.mediaDevices.getUserMedia(constraints); if (!canceled) setStream(mediaStream); } catch (err) { if (!canceled) setError(err); } }; getStream(); return () => { canceled = true; mediaStream?.getTracks().forEach(track => track.stop()); setStream(null); }; }, [constraints]); // 确保每次流更新都清理旧资源 useEffect(() => { return () => { stream?.getTracks().forEach(track => { track.stop(); // 移除所有可能绑定的事件监听器 track.removeEventListener('ended', () => {}); }); }; }, [stream]); return { error, stream }; }
3. 确保loadMetadata()随新流重新绑定
在VideoBox组件中,每次远程流更新时重新绑定loadedmetadata事件:
// VideoBox组件内的逻辑 useEffect(() => { const remoteVideo = document.getElementById('remote-video'); if (!remoteVideo || !remoteStream) return; remoteVideo.srcObject = remoteStream; const handleMetadataLoaded = () => { console.log('Metadata loaded'); // 执行你的业务逻辑 }; remoteVideo.addEventListener('loadedmetadata', handleMetadataLoaded); // 组件卸载或流更新时移除监听器 return () => { remoteVideo.removeEventListener('loadedmetadata', handleMetadataLoaded); remoteVideo.srcObject = null; }; }, [remoteStream]);
4. 修复useMediaStream hook的清理逻辑
原hook的清理逻辑仅在mediaStream存在时生效,修改为初始化时就注册清理函数:
export function useUserMedia() { const [mediaStream, setMediaStream] = useState<MediaStream>(); useEffect(() => { let cleanupCalled = false; async function enableVideoStream() { try { if (cleanupCalled) return; const stream = await navigator.mediaDevices.getUserMedia({ audio: false, video: { frameRate: { exact: 15, ideal: 15 }, echoCancellation: true, noiseSuppression: true, width: { min: 300, max: 640 }, height: { min: 300, max: 480 }, }, }); if (!cleanupCalled) setMediaStream(stream); } catch (err) { console.log("Video error: ", err); } } enableVideoStream(); return function cleanup() { cleanupCalled = true; mediaStream?.getTracks().forEach(track => track.stop()); setMediaStream(undefined); }; }, []); // 仅初始化一次 return mediaStream; }
验证步骤
- 首次发起通话,确认
loadMetadata()正常触发。 - 挂断通话,检查控制台是否有流轨道停止的日志输出。
- 再次呼叫同一peerId,确认远程流正常加载且
loadMetadata()事件触发。
内容的提问来源于stack exchange,提问作者Steven Ragy
相关产品推荐
相关产品推荐

