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

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;
}

核心问题分析

  1. Peer连接重复创建:每次通话操作都调用peer.connect(alaaId)生成新连接,未复用或清理旧连接,导致事件监听器堆积、上下文混乱。
  2. 媒体流清理不彻底:挂断后旧流轨道未完全停止或解绑,新流的loadMetadata()事件被旧上下文阻塞。
  3. 事件绑定时机错误: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;
}

验证步骤

  1. 首次发起通话,确认loadMetadata()正常触发。
  2. 挂断通话,检查控制台是否有流轨道停止的日志输出。
  3. 再次呼叫同一peerId,确认远程流正常加载且loadMetadata()事件触发。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.11 03:50:39