写在前面
本文是参考稀土掘金的文章,整理得出,版权归原作者所有!参考链接请点击跳转
WebRTC(Web Real-Time Communication) 是一项开源技术,允许浏览器和移动应用直接进行实时音视频通信和数据传输,无需安装插件或第三方软件。它由 Google 发起,现已成为 W3C 和 IETF 的标准。
核心特点:
-  点对点(P2P)连接 -  设备间直接通信,降低延迟,提升效率。 
-  但需通过 ICE/STUN/TURN 服务器解决 NAT 穿越问题。 
 
-  
-  无需插件 -  原生支持主流浏览器(Chrome、Firefox、Safari 等)。 
 
-  
-  关键组件: -  MediaStream(getUserMedia):获取摄像头/麦克风数据。 
-  RTCPeerConnection:建立音视频传输连接。 
-  RTCDataChannel:支持任意数据(如文件、游戏指令)传输。 
 
-  
-  安全加密 -  强制使用 SRTP(音视频加密)和 DTLS(数据加密)。 
 
-  
-  适应网络变化 -  自动调整码率、抗丢包,适应不同网络条件。 
 
-  
常见应用场景:
-  视频会议(如 Google Meet、Zoom 的网页版) 
-  在线教育、远程医疗 
-  文件共享、屏幕共享 
-  物联网设备控制 
摄像头和麦克风属于用户的隐私设备,WebRTC既然成为了浏览器中音视频即时通信的W3C标准,因此必然会提供API,让有一定代码开发能力的人去调用;
注意敲黑板: 使用这些API是有前提条件的哦,首先在安全源访问,调用API才没有任何阻碍的。那什么是安全源呢?看下面思维导图(更详细的看:chrome官方文档),且记住这句话:安全源 是至少匹配以下( Scheme 、 Host 、 Port )模式之一的源
举个简单的例子:你本地开发用HTTP请求地址获取摄像头API没有问题,但是你的同事用他的电脑访问你电脑IP对应的项目地址时,摄像头调用失败,为什么呢?
因为在他的浏览器中,你的项目访问地址非HTTPS,在非HTTPS的情况下,如果IP不是localhost或127.0.0.1,都不属于安全源。
当然事非绝对,在特定情况下必须使用非HTTPS访问也是可以的,Chrome提供了对应的取消限制但是不太建议用(安全为上),因此我在这里就不再多余阐述。
所以经常有人问,为什么我的代码在自己浏览器中可以获取到摄像头,但是在区域网下别的电脑的浏览器中获取不到?同样的浏览器、同样的操作系统,为什么获取不到呢?原因就是上面的安全源限制。
getUserMedia ★ 重要
以前的版本中我们经常使用 navigator.getUserMedia 来获取计算机的摄像头或者麦克风,但是现在这个接口废弃,变更为 navigator.mediaDevices.getUserMedia,因此后面我们均使用新的API来完成代码编写。
getUserMedia可以干什么?  ★ 重要
意如其名,那就是获取用户层面的媒体,当你的计算机通过 USB 或者其他网络形式接入了 N 多个摄像头或虚拟设备时,都是可以通过这个 API 获取到的。 当然不仅仅是视频设备,还包括音频设备和虚拟音频设备。 获取媒体设备是最简单的操作,它还可以控制获取到媒体的分辨率,以及其他的以一些可选项。
PS:在很多云会议中,我们开会只能选择一个摄像头,这并不是只能使用一个摄像头,而是厂商针对“大多数场景中只会用到一个摄像头”而设计的;但在有些业务中,我们可能需要自己设备上的N 个摄像头(带USB摄像头)同时使用,那么如何办到呢(这个场景其实蛮多的,后面留个课后题)。因此熟知这个
API对于解决基本的会议和其他复杂场景问题很有用。
如何使用 getUserMedia?  ★ 重要
有简单的用法,有复杂的用法。一般简易场景下,大多数 API 用默认参数就可以实现对应功能,getUserMedia也一样,直接调用不使用任何参数,则获取的就是 PC 的默认摄像头和麦克风。
但是,当我们遇到复杂一点的应用场景,比如你的电脑上自带麦克风,同时你连接了蓝牙耳机和有线耳机,那么在视频通话过程中,你如何主动选择使用哪个呢?也就是说, 在用摄像头或者麦克风之前,我们先要解决如何从 N 个摄像头或者麦克风中选择我们想要的。
要解决这个问题,我们必须先有个大体的思路(当然这个思路并不是凭空想象出来的,而是在一定的技术储备下才有的。如果你开始前没有任何思路也没关系,可以参考他人的经验),如下:
-  获取当前设备所有的摄像头和麦克风信息; 
-  从所有的设备信息中遍历筛选出我们想要使用的设备; 
-  将我们想要使用的设备以某种参数的形式传递给浏览器 API;
-  浏览器 API去执行获取的任务。
上面提到的设备以某种参数的形式传递给 API,那么这个设备必然是以参数存在的,因此这里有几个概念需要提前知道,如下:
设备分成了图中的三个大类型,每个类型都有固定的字段,比如 ID、kind、label ,而其中用于区分它们的就是kind字段中的固定值,最核心的字段就是 ID,后面我们经常用的就是这个 ID。
那么,在前端如何使用 JavaScript获取到这些信息?
大家先看下面这段代码,大体上过一遍,并留意 initInnerLocalDevice函数内部执行顺序。
function handleError(error) {
    alert("摄像头无法正常使用,请检查是否占用或缺失")
    console.error('navigator.MediaDevices.getUserMedia error: ', error.message, error.name);
}
/**
 * @author suke
 * device list init 
 */
function initInnerLocalDevice(){
        const that  = this
        var localDevice = {
            audioIn:[],
            videoIn: [],
            audioOut: []
        }
        let constraints = {video:true, audio: true}
        if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
            console.log("浏览器不支持获取媒体设备");
            return;
        }
        navigator.mediaDevices.getUserMedia(constraints)
            .then(function(stream) {
                stream.getTracks().forEach(trick => {
                    trick.stop()
                })
                // List cameras and microphones.
                navigator.mediaDevices.enumerateDevices()
                    .then(function(devices) {
                        devices.forEach(function(device) {
                            let obj = {id:device.deviceId, kind:device.kind, label:device.label}
                            if(device.kind === 'audioinput'){
                                if(localDevice.audioIn.filter(e=>e.id === device.deviceId).length === 0){
                                    localDevice.audioIn.push(obj)
                                }
                            }if(device.kind === 'audiooutput'){
                                if(localDevice.audioOut.filter(e=>e.id === device.deviceId).length === 0){
                                    localDevice.audioOut.push(obj)
                                }
                            }else if(device.kind === 'videoinput' ){
                                if(localDevice.videoIn.filter(e=>e.id === device.deviceId).length === 0){
                                    localDevice.videoIn.push(obj)
                                }
                            }
                        });
                    })
                    .catch(handleError);
            })
            .catch(handleError);
    }
这个代码片段的主要作用就是获取用户设备上所有的摄像头和麦克风信息,起关键作用的是enumerateDevices函数,但是在调用这个关键函数之前,getUserMedia函数出现在了这里,它的出现是用户在访问服务时直接调用用户摄像头,此时如果用户授权且同意使用设备摄像头、麦克风,那么enumerateDevices函数就能获取设备信息了,在这里getUserMedia函数可以理解为获取摄像头或者麦克风权限集合的探路函数。
看下图,我将我电脑上使用enumerateDevices函数加载到的信息,根据前面提到的字段kind,将其分三类并打印到控制台。
千万不要小看现在获取到的这些信息哦,在后面视频通话或会议过程中,我们需要抉择摄像头用前置还是后置,麦克风是用蓝牙还是有线,都是离不开这些信息的。
在拿到所有的摄像头麦克风信息之后,我们需选出最终要参与视频通话的那个信息体,看上图中 VideoIn数组里面label:"eseSoft Vcam" , 这个摄像头就是我想要参会的摄像头,那么我怎样指定让代码去选择这个摄像头呢?这里就涉及到了getUserMedia的约束参数constraints 。
判断摄像头是前置还是后置 ★ 常用
 获取设备列表:使用 navigator.mediaDevices.enumerateDevices() 方法获取设备列表。
 检查设备类型:在设备列表中,查找 kind 为 videoinput 的设备。
 判断前置或后置:通常,前置摄像头的 label 包含“front”或“前置”等字样,
 而后置摄像头的 label 可能包含“back”或“后置”等字样。
 具体内容可能因设备和浏览器而异,因此需要根据实际情况进行判断。
判断音频设备是麦克风还是扬声器 ★ 常用
 获取设备列表:同样使用 navigator.mediaDevices.enumerateDevices() 方法。
 检查设备类型:查找 kind 为 audioinput(麦克风)和 audiooutput(扬声器)的设备。
 判断设备类型:通常,audioinput 设备会被标识为麦克风,而 audiooutput 设备会被标识为扬声器。
 耳机是归类为音频输出设备(扬声器)kind字段
媒体约束 constraints ★ 基础
在具体讲解约束参数 constraints 之前,大家先看下面这段示例代码。
let constraints = {video:true, audio: true} 
    function handleError(error) {
        console.error('navigator.MediaDevices.getUserMedia error: ', error.message, error.name);
    }
    /**
     * 获取设备 stream
     * @param constraints
     * @returns {Promise<MediaStream>}
     */
    async function getLocalUserMedia(constraints){
        return await navigator.mediaDevices.getUserMedia(constraints)
    }
    let stream = await this.getLocalUserMedia(constraints).catch(handleError);
console.log(stream)
上面的代码片段为JavaScript获取计算机摄像头和麦克风的媒体流(视频和音频流我们统称为媒体流)的一种方式,大多数情况下都是这么用的,如果电脑有摄像头、麦克风,这样获取没有任何问题,但就担心你用的时候,你的电脑上没有配摄像头或麦克风,或者有多个摄像头而你想指定其中某一个。 为了兼容更多情况,我们需要知道constraints这个参数的详细用法。
接下来我们看下这个参数在几种常见场景下的具体配置,以及为什么这样配置。
1.同时获取视频和音频输入
使用下面约束, 如果遇到计算机没有摄像头的话,你调用上述代码的过程中就会报错,因此我们在调用之前可以通过enumerateDevices返回结果主动判断有无视频输入源,没有的话,可以动态将这个参数中的 video设置为false。
{ audio: true, video: true }
2.获取指定分辨率
在会议宽带足够且流媒体传输合理的情况下,无需考虑服务端压力,而需考虑客户端用户摄像头的分辨率范围,通常我们会设置一个分辨率区间。
下面展示的①约束是请求一个 1920×1080 分辨率的视频,但是还提到 min 参数,将 320×240 作为最小分辨率,因为并不是所有的网络摄像头都可以支持 1920×1080 。当请求包含一个 ideal(应用最理想的)值时,这个值有着更高的权重,意味着浏览器会先尝试找到最接近指定理想值的设定或者摄像头(如果设备拥有不止一个摄像头)。
但是,在多人会议简单架构场景中,在不改变会议稳定性的情况下,为了让更多的客户端加入,我们通常会把高分辨率主动降低到低分辨率,约束特定摄像头获取指定分辨率如下面②配置。
    --------------------①:1--------------------------
    {
        audio: true,
        video: {
            width: { min: 320, ideal: 1280, max: 1920 },
            height: { min: 240, ideal: 720, max: 1080 }
        }
    }
    --------------------②:2--------------------------
    {
    audio: true,
    video: { width: 720, height: 480}
}
3.指定视频轨道约束:获取移动设备的前置或者后置摄像头
facingMode属性。可接受的值有:user(前置摄像头)、environment(后置摄像头);需要注意的是,这个属性在移动端可用,当我们的会议项目通过 h5 在移动端打开时,我们可以动态设置这个属性从而达到切换前后摄像头的场景。
{ audio: true, video: { facingMode: "user" } }
{ audio: true, video: { facingMode: { exact: "environment" } } }
 4.定帧速率frameRate
帧速率(你可以理解为FPS)不仅对视频质量,还对带宽有着影响,所以在我们通话过程中,如果判定网络状况不好,那么可以限制帧速率。
我们都知道,视频是通过一定速率的连续多张图像形成的,比如每秒 24 张图片才会形成一个基础流畅的视频,因此帧速率对于实时通话的质量也有影响,你可以想象成和你的游戏的FPS一个道理。
const constraints = {
    audio: true,
    video: {
        width:1920,
        height:1080,
        frameRate: { ideal: 10, max: 15 }
    }
};
实际上,通过FPS我们可以引申出来一些场合,在特定场合选择特定的FPS搭配前面的分辨率配置,以提高我们会议系统的质量,比如:
- 屏幕分享过程中,我们应当很重视高分辨率而不是帧速率,稍微卡点也没关系;
- 在普通会议过程中,我们应当重视的是画面的流畅,即帧速率而不是高分辨率;
- 在开会人数多但宽带又受限的情况下,我们重视的同样是会议的流程性,同样低分辨率更适合宽带受限的多人会议;
- ……
5.使用特定的网络摄像头或者麦克风
重点哦,我们最前面enumerateDevices函数获取到的设备集合可以派上用场了。
/**
 * 获取指定媒体设备id对应的媒体流
 * @author suke
 * @param videoId
 * @param audioId
 * @returns {Promise<void>}
 */
async function getTargetIdStream(videoId,audioId){
    const constraints = {
        audio: {deviceId: audioId ? {exact: audioId} : undefined},
        video: {
            deviceId: videoId ? {exact: videoId} : undefined,
            width:1920,
            height:1080,
            frameRate: { ideal: 10, max: 15 }
        }
    };
    if (window.stream) {
        window.stream.getTracks().forEach(track => {
            track.stop();
        });
    }
    //被调用方法前面有,此处不再重复
    let stream = await this.getLocalUserMedia(constraints).catch(handleError);
}
getDisplayMedia ★ 重要
我们日常开会,多数需要通过会议 App 来分享自己的屏幕,或者仅分享桌面上固定的应用程序那么在浏览器中实现视频通话,能否实现分享屏幕呢?答案是肯定的, W3C的 Screen Capture 标准中有说明,就是使用getDisplayMedia。
const constraints = {
  audio: true,
  video: true
};
const promise = navigator.mediaDevices.getDisplayMedia(constraints);
console.info('--promise--', promise);
navigator.mediaDevices.getDisplayMedia(constraints)
  .then((stream) => {
     /* use the stream */
    console.info('--use the stream--', stream);
  })
  .catch((err) => {
    /* handle the error */
    console.error('--handle the erro--', err);
  });
参数 Constraints
同上一个函数一样,同样需要配置constraints约束,当然这个也是可选的, 如果选择传参的话,那么参数设置如下:
getDisplayMedia({
  audio: true,
  video: true
})
但是这里的constraints配置和前面getUserMedia的约束配置是有差别的。又一个重点来了,在屏幕分享的约束中,video 是不能设置为false 的,但是可以设置指定的分辨率,如下:
getDisplayMedia({
  audio: true,
  video: {width:1920,height:1080}
})
-  audio为true  
-  audio为false  
请留意上面两图的对比,当去掉音频后,第二张图少了个勾选系统音频的 radio 框
完整案例
/**
 * 获取屏幕分享的媒体流
 * @author suke
 * @returns {Promise<void>}
 */
async function getShareMedia(){
    const constraints = {
        video:{width:1920,height:1080},
        audio:false
    };
    if (window.stream) {
        window.stream.getTracks().forEach(track => {
            track.stop();
        });
    }
    return await navigator.mediaDevices.getDisplayMedia(constraints).catch(handleError);
}
小提示
- 在前面的案例代码中,我们在获取系统的音频或者视频的stream之前,一般会调用以下代码,目的是清除当前标签页中没有销毁的媒体流。if (window.stream) { window.stream.getTracks().forEach(track => { track.stop(); }); }如果不销毁,你可以看到在标签页旁边一直有个小红圈闪烁,鼠标按上去提示正在使用当前设备的摄像头,因此在后面的开发中保持好习惯:结束自己会议后或页面用完摄像头后,一般除了强制刷新,也可以调用上面代码清除正在使用的 stream调用。好了,这节课我们我们掌握了两个最重要的 API,下节课我们开始搭建一个信令服务器,同时完成 P2P(单人对单人)的视频通话(跑代码的时候一定要记得前面提到的安全源哦)。
检测函数
githup上检测webRtc链接:Select audio and video sources
静默基础检测
function isSupportWebRtcFlag() {
    // 获取用户代理字符串,用于检测浏览器类型
    const userAgent = navigator.userAgent,
        isIphone = userAgent.indexOf('iPhone') > -1,
        isUcBrowser = userAgent.indexOf('UCBrowser') > -1,
        isIphoneUC = isIphone && isUcBrowser;
    let canIUseDataChannel = true,
        canIUseRTCPeer = true,
        canIUseGetUserMedia = false,
        canIUseRealTime = false;
    // 检测是否支持 getUserMedia(获取设备列表)
    if (navigator.mediaDevices
        && navigator.mediaDevices.getUserMedia
        || navigator.getUserMedia
        || navigator.mozGetUserMedia
        || navigator.mozGetUserMedia) {
        canIUseGetUserMedia = true;
    }
    // 检测是否支持 RTCPeerConnection (数据通道)
    canIUseRTCPeer = Boolean(window.RTCPeerConnection)
        || Boolean(window.webkitRTCPeerConnection)
        || Boolean(window.mozRTCPeerConnection)
        || Boolean(window.msRTCPeerConnection)
        || Boolean(window.oRTCPeerConnection);
    try {
        const o = new (window.RTCPeerConnection || window.msRTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection)(null);
        // eslint-disable-next-line no-restricted-syntax
        canIUseDataChannel = 'createDataChannel' in o;
    } catch (e) {
        console.error('尝试创建 RTCPeerConnection 对象,以检测是否支持数据通道错误,error:', e);
        canIUseDataChannel = false;
    }
    // 综合判断是否支持所有 WebRTC 功能
    canIUseRealTime = canIUseGetUserMedia && canIUseRTCPeer && canIUseDataChannel && !isIphoneUC;
    if (!canIUseGetUserMedia) {
        console.warn('webRtcUtils[isSupportWebRtcFlag] --> 不支持getUserMedia');
    }
    if (!canIUseRTCPeer) {
        console.warn('webRtcUtils[isSupportWebRtcFlag] --> 不支持RTCPeerConnection');
    }
    if (!canIUseDataChannel) {
        console.warn('webRtcUtils[isSupportWebRtcFlag] --> 不支持createDataChannel');
    }
    if (canIUseRealTime) {
        console.info('webRtcUtils[isSupportWebRtcFlag] --> 支持炫彩api');
    } else {
        console.warn('webRtcUtils[isSupportWebRtcFlag] --> 不支持炫彩api');
    }
    return {
        canIUseGetUserMedia,
        canIUseRTCPeer,
        canIUseDataChannel,
        canIUseRealTime
    };
}
isSupportWebRtcFlag();静默黑名单检测
function isSupportWebRtcSilently() {
    const ua = navigator.userAgent;
    const isMobile = (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i).test(ua);
    // 1. 检测关键 API 是否存在
    const hasGetUserMedia = Boolean(navigator.mediaDevices?.getUserMedia
        || navigator.getUserMedia
        || navigator.webkitGetUserMedia
        || navigator.mozGetUserMedia);
    const hasRTCPeerConnection = Boolean(window.RTCPeerConnection
        || window.webkitRTCPeerConnection
        || window.mozRTCPeerConnection);
    // 2. 检测 DataChannel 支持
    let hasDataChannel = false;
    if (hasRTCPeerConnection) {
        try {
            const pc = new (window.RTCPeerConnection || window.webkitRTCPeerConnection)({iceServers: []});
            // eslint-disable-next-line no-restricted-syntax
            hasDataChannel = 'createDataChannel' in pc;
            pc.close();
        } catch (e) {
            console.error('检测 DataChannel 支持', e);
            hasDataChannel = false;
        }
    }
    // 3. 排除已知有问题的浏览器或场景
    const isBlockedBrowser
        // 排除 UC 浏览器、QQ 浏览器、MIUI 浏览器等
        = (/UCBrowser|QQBrowser|MiuiBrowser|Quark|baiduboxapp/i).test(ua)
        // iOS 第三方浏览器(如 Firefox Focus)可能限制 WebRTC
        || isMobile && (/Firefox/i).test(ua) && !(/FxiOS/).test(ua);
    // 4. 综合判断
    const isSupported
        = hasGetUserMedia
        && hasRTCPeerConnection
        && hasDataChannel
        && !isBlockedBrowser;
		
	const result = {
        isSupported,
        details: {
            hasGetUserMedia,
            hasRTCPeerConnection,
            hasDataChannel,
            isBlockedBrowser
        }
    };	
		console.info('--result--', result);
    return result;
}
isSupportWebRtcSilently();精准检测 (需用户授权)
async function preciseWebRTCSupportCheck() {
  const result = {
    supportsWebRTC: false,
    details: {
      hasRTCPeerConnection: false,
      hasDataChannel: false,
      hasGetUserMedia: false,
      hasIceSupport: false,
      hasCodecSupport: { video: [], audio: [] },
      errors: []
    }
  };
  try {
    // 1. 检测 RTCPeerConnection 和 DataChannel
    const RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection;
    if (!RTCPeerConnection) {
      result.details.errors.push('RTCPeerConnection API missing');
      return result;
    }
    result.details.hasRTCPeerConnection = true;
    const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
    result.details.hasDataChannel = 'createDataChannel' in pc;
    // 2. 检测 ICE 支持(网络穿透)
    let hasIce = false;
    pc.onicecandidate = (e) => {
      if (e.candidate && e.candidate.candidate) {
        hasIce = true;
        result.details.hasIceSupport = true;
      }
    };
    // 3. 检测编解码器支持(H.264/VP8/Opus)
    const sender = pc.addTransceiver('video');
    const capabilities = sender.sender.getCapabilities();
    result.details.hasCodecSupport.video = capabilities.codecs.filter(c => c.mimeType.includes('video'));
    result.details.hasCodecSupport.audio = capabilities.codecs.filter(c => c.mimeType.includes('audio'));
    // 4. 实际创建 Offer 以触发 ICE 收集
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    // 等待 ICE 收集完成(最多 2 秒)
    await new Promise(resolve => setTimeout(resolve, 2000));
    pc.close();
    // 5. 检测 getUserMedia(需用户授权)
    if (navigator.mediaDevices?.getUserMedia) {
      try {
        const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
        stream.getTracks().forEach(track => track.stop());
        result.details.hasGetUserMedia = true;
      } catch (e) {
        result.details.errors.push(`getUserMedia failed: ${e.name}`);
      }
    }
    // 综合判定
    result.supportsWebRTC = (
      result.details.hasRTCPeerConnection &&
      result.details.hasDataChannel &&
      result.details.hasIceSupport &&
      result.details.hasGetUserMedia &&
      result.details.hasCodecSupport.video.length > 0
    );
  } catch (e) {
    result.details.errors.push(`Critical error: ${e.message}`);
  }
  return result;
} 





















