寫在前面
本文是參考稀土掘金的文章,整理得出,版權歸原作者所有!
參考鏈接:https://juejin.cn/book/7168418382318927880/section/7171376753263247396
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 = thisvar 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
?。
媒體約束 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
。
var promise = navigator.mediaDevices.getDisplayMedia(constraints);## 獲取屏幕分享
navigator.mediaDevices.getDisplayMedia(constraints).then((stream) => {/* use the stream */}).catch((err) => {/* handle the error */});
參數 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-syntaxcanIUseDataChannel = '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-syntaxhasDataChannel = '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 和 DataChannelconst 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;
}
?