前言
歡迎來到實時通信(Real-Time Communication, RTC)的世界!如果你是一名 JavaScript 開發者,渴望讓你的 Web 應用擁有語音通話、視頻聊天甚至即時消息的能力,那么你來對地方了。這本書是為你量身打造的指南,它將帶領你從零開始,一步步掌握強大的 JsSIP
庫,最終構建出一個功能完善的網頁電話(Web Softphone)。
我們假設你只熟悉 JavaScript,對 SIP、WebRTC 這些復雜的通信協議一無所知。這完全沒問題!本書的設計初衷就是“由淺入深”。我們將首先為你揭開通信協議的神秘面紗,用通俗易懂的語言和生動的比喻,讓你理解電話是如何在互聯網上“打通”的。然后,我們將深入 JsSIP
的世界,從第一個 “Hello, World!” 程序開始,到處理復雜的通話控制,再到詳盡的 API 解析和排錯技巧。
本書不僅僅是 API 的羅列,更是一本實踐手冊。每一章都建立在前一章的基礎上,理論與代碼緊密結合。在本書的最后,我們將整合所有知識,從界面設計到核心邏輯,手把手帶你構建一個完整的、可以實際運行的 Web 電話項目。
準備好了嗎?讓我們一起開啟這段激動人心的旅程,讓瀏覽器真正地“開口說話”!
第一部分:理論基石
在編寫任何代碼之前,理解其背后的原理至關重要。這一部分將為你鋪設堅實的理論基礎。不理解 JsSIP
正在為你處理什么,你就無法真正地駕馭它。我們將一起揭開實時通信背后的“魔法”,讓你不僅知其然,更知其所以然。
第一章:通信世界的基石——SIP 協議入門
想象一下,你想給朋友打個電話。你需要先撥號,等待對方接聽,通話結束后再掛斷。在互聯網世界里,完成這一系列“握手”和“告別”動作的規則,就是 SIP 協議。你可以把它理解為通信世界的 “HTTP”,它不是用來傳輸通話內容的,而是用來建立、管理和結束通話這個“會話”的信令語言。
什么是 SIP?
SIP 的全稱是 會話發起協議(Session Initiation Protocol)。它是一個在應用層工作的信令協議,其核心任務是發起、維持和終止實時的通信會話 1。這些會話可以包含語音、視頻、即時消息等多種媒體形式。
與你可能熟悉的 HTTP 或 SMTP 協議類似,SIP 也是一個基于文本的協議 1。這意味著它的消息是人類可讀的,這在調試時非常方便。SIP 由互聯網工程任務組(IETF)在著名的
RFC 3261
文檔中進行了標準化,并已成為現代網絡電話(VoIP)和實時通信領域的行業標準,廣泛取代了像 H.323
這樣的早期協議 3。
SIP 架構的核心組件
一個典型的 SIP 網絡由多個組件協同工作,就像一個電話系統需要有電話機、接線員和電話簿一樣。
-
用戶代理 (User Agents - UA)
這是 SIP 世界的終端設備,比如你的電腦上運行的軟電話,或者一個 IP 電話機。一個用戶代理具有雙重身份 1:
-
用戶代理客戶端 (User Agent Client - UAC): 當你發起一個呼叫時,你的設備就扮演了 UAC 的角色,它會發送 SIP 請求。
-
用戶代理服務器 (User Agent Server - UAS): 當你接收一個來電時,你的設備則扮演 UAS 的角色,它會響應這個 SIP 請求。
在 JsSIP 中,你將要創建和操作的核心對象 JsSIP.UA,就是這個概念的軟件實現。
-
-
服務器 (Servers)
服務器在 SIP 網絡中扮演著關鍵的中間人角色。
- 代理服務器 (Proxy Server): 這是最核心的服務器,就像一個智能的電話接線員。它接收來自 UAC 的請求,然后根據一定的規則將請求路由(轉發)到下一個目的地 2。
- 注冊服務器 (Registrar Server): 這個服務器像一本“地址簿”。當你啟動軟電話并登錄時,它會發送一個
REGISTER
請求到注冊服務器。注冊服務器會記錄下你的 SIP 地址(例如sip:alice@example.com
)和你當前的實際網絡位置(IP 地址和端口)之間的映射關系。這樣,當別人呼叫你時,代理服務器就能通過查詢注冊服務器找到你 2。 - 重定向服務器 (Redirect Server): 這個服務器比較特殊,它不轉發請求,而是直接告訴 UAC:“你應該去聯系這個地址”,讓 UAC 自己去發起新的請求 2。
SIP 消息與方法
SIP 遵循一個簡單的請求/響應模型,與 HTTP 非常相似 1。UAC 發送一個請求,UAS 回復一個或多個響應。以下是一些最核心的 SIP 方法(可以理解為命令),以及它們的通俗解釋 3:
INVITE
: “我想和你通話。” 這是發起一個呼叫的請求。ACK
: “我收到你的確認了,連接正式建立。” 這是對成功響應(2xx
)的確認。BYE
: “再見,掛斷電話。” 這是用來終止一個已建立的通話。CANCEL
: “算了,我不想打了。” 在對方還未接聽時,用來取消之前的INVITE
請求。REGISTER
: “嗨,服務器,我上線了,我的地址是這個。” 這是向注冊服務器登記自己的位置。
一個基本的 SIP 呼叫流程
理解一個完整的呼叫流程至關重要,因為它直接映射到你之后將要處理的 JsSIP
事件。讓我們看看從 Alice 呼叫 Bob 的過程中,SIP 消息是如何流轉的 3。
!(https://i.imgur.com/qg9bYyH.png)
- 發起呼叫 (INVITE): Alice 的軟電話(UAC)向代理服務器發送一個
INVITE
請求,請求呼叫 Bob (sip:bob@example.com
)。 - 嘗試連接 (100 Trying): 代理服務器收到
INVITE
后,會立即回復一個100 Trying
響應,告訴 Alice:“我收到了,正在處理,請不要重復發送INVITE
。” - 路由與振鈴 (INVITE & 180 Ringing): 代理服務器查詢注冊服務器,找到了 Bob 的當前位置,并將
INVITE
請求轉發給 Bob 的軟電話。Bob 的電話收到后開始響鈴,并回復一個180 Ringing
響應,這個響應會通過代理服務器傳回給 Alice。Alice 的軟電話收到后,就會播放“嘟…嘟…”的回鈴音。 - 接聽通話 (200 OK): Bob 點擊接聽。他的軟電話(現在是 UAS)發送一個
200 OK
響應,表示呼叫被成功接受。這個響應也會傳回給 Alice。 - 確認連接 (ACK): Alice 的軟電話收到
200 OK
后,知道對方已經接聽,于是發送一個ACK
消息作為最終確認。這個ACK
可能直接發送給 Bob,也可能通過代理。當 Bob 收到ACK
后,一個完整的 SIP 會話(也稱為 Dialog)就建立成功了。 - 媒體傳輸 (RTP): 此時,SIP 的主要任務已經完成。雙方的音視頻數據開始通過另一個獨立的協議——實時傳輸協議 (Real-time Transport Protocol, RTP)——直接在 Alice 和 Bob 之間傳輸。這是一個非常關鍵的概念:SIP 只負責信令(建立和控制),不負責傳輸媒體本身 3。SIP 就像是安排兩位貴賓見面的禮賓司,而 RTP 則是運送貴賓的專車。
- 結束通話 (BYE): 通話結束后,任何一方(比如 Alice)都可以發送一個
BYE
請求來終止會話。 - 確認掛斷 (200 OK): 另一方(Bob)收到
BYE
后,回復一個200 OK
,確認通話結束。至此,整個會話生命周期完成。
理解了這個流程,你就能明白為什么在 JsSIP
中會有 progress
(對應 180 Ringing
)、accepted
(對應 200 OK
)、confirmed
(對應 ACK
)和 ended
(對應 BYE
)這些事件了。它們正是這個底層協議流程在 JavaScript 世界的映射。
第二章:讓瀏覽器開口說話——WebRTC 與信令
上一章我們了解了 SIP,這個負責建立和控制通信會話的“大腦”。但我們也提到了,SIP 本身不傳輸聲音和圖像。那么,在瀏覽器中,誰來扮演這個“運輸卡車”的角色呢?答案就是 WebRTC。
WebRTC 簡介
WebRTC,全稱 Web Real-Time Communication,是一項革命性的技術。它是一套開放標準和 API,允許 Web 應用程序在不需要安裝任何額外插件(如 Flash 或 Java Applets)的情況下,直接在瀏覽器之間捕獲和流式傳輸音頻、視頻媒體,以及交換任意數據 9。
WebRTC 主要由以下幾個核心的 JavaScript API 組成 10:
getUserMedia
: 這個 API 用于獲取用戶的媒體設備權限,比如請求訪問攝像頭和麥克風。這是所有音視頻通話的第一步。RTCPeerConnection
: 這是 WebRTC 的心臟。它負責在兩個瀏覽器(稱為“對等端”或 Peer)之間建立和管理一個高效、穩定的點對點(Peer-to-Peer, P2P)連接。它處理所有復雜的工作,如信號處理、編解碼器協商、安全加密和帶寬管理 9。RTCDataChannel
: 除了音視頻,WebRTC 還允許通過RTCDataChannel
在對等端之間建立一個低延遲的雙向數據通道,可以用來傳輸聊天消息、游戲狀態、文件等任意數據 9。
WebRTC 的“缺失環節”:信令
WebRTC 非常強大,但它有一個“故意”的設計留白:它本身不包含**信令(Signaling)**機制 10。
想象一下,RTCPeerConnection
就像一部功能強大的對講機,但它沒有撥號盤,也不知道其他對講機的頻率。它不知道:
- 要和誰通話?
- 對方是否愿意通話?
- 如何找到對方的網絡地址?
- 雙方支持哪些音視頻格式(編解碼器)?
這些信息必須通過一個獨立于 WebRTC 的“帶外”機制來交換,這個過程就叫做信令。開發者可以使用任何技術來實現信令,比如 WebSocket、HTTP 請求,甚至是信鴿 13。信令服務器就像一個中間人或郵局,負責在兩個希望通話的瀏覽器之間傳遞“信件”。
信令過程主要交換三類信息 14:
- 會話控制消息: 用于初始化、關閉和修改通信會話,比如“我想打給你”或“我掛了”。
- 網絡配置信息: 比如對方的 IP 地址和端口,這樣瀏覽器才知道把媒體數據包發到哪里去。
- 媒體能力信息: 比如雙方各自支持哪些視頻編碼格式(H.264, VP8?)和音頻編碼格式(Opus, G.711?)。
這個交換過程通常遵循一個 Offer/Answer 模型。一方(呼叫方)創建一個包含其網絡和媒體信息的“Offer”(提議),通過信令服務器發送給另一方。另一方(被叫方)收到后,生成一個包含自己信息的“Answer”(應答),再通過信令服務器回傳給呼叫方。一旦交換完成,雙方就有了建立 P2P 連接所需的所有信息 13。這些 Offer 和 Answer 的格式,遵循一種叫做
SDP (Session Description Protocol) 的規范。
穿越網絡迷霧:ICE, STUN, 和 TURN
理論上,一旦雙方通過信令交換了 IP 地址,就可以直接建立 P2P 連接了。但現實網絡環境遠比這復雜。我們大多數人的設備都位于家庭或公司路由器后面,使用著一個叫做 NAT(網絡地址轉換) 的技術。這意味著我們的設備沒有一個公網 IP 地址,而是只有一個內網 IP 地址(比如 192.168.1.100
),這在公網上是無法被直接訪問的。這就好比你住在一個大公寓樓里,你的地址是“XX 公寓 1802 房”,但郵遞員只知道“XX 公寓”這個大樓地址,不知道如何把信直接送到你的房門口。
為了解決這個問題,WebRTC 使用了一個名為 ICE (Interactive Connectivity Establishment) 的框架 14。ICE 的工作就是想盡一切辦法,為兩個處于 NAT 后面的設備找到一條可以通信的路徑。它主要使用兩種工具:STUN 和 TURN。
-
STUN (Session Traversal Utilities for NAT)
STUN 服務器非常簡單,它就像一面放在公網上的鏡子。當你的設備向 STUN 服務器發送一個請求時,STUN 服務器會看到這個請求來自哪個公網 IP 地址和端口,然后把這個“公網地址”信息告訴你的設備。你的設備拿到這個地址后,就可以通過信令告訴對方:“嘿,你在公網上可以從這個地址找到我。” 17。在很多情況下,只要 NAT 類型不是太嚴格,STUN 就足以幫助雙方建立直接的 P2P 連接。
-
TURN (Traversal Using Relays around NAT)
然而,在某些復雜的網絡環境下(比如“對稱型 NAT”或嚴格的企業防火墻),即使知道了對方的公網地址,也無法直接打通連接。這時,就需要 TURN 服務器作為最后的手段。
TURN 服務器不再是“鏡子”,而是變成了一個“中繼站”或“郵政中轉中心”。當 P2P 直連失敗時,雙方都會把自己的媒體數據包發送給 TURN 服務器,然后由 TURN 服務器負責將數據包轉發給另一方 17。這種方式保證了連接的成功率,但缺點也很明顯:所有數據都要經過服務器中轉,這會增加延遲,并且極大地消耗服務器的帶寬和成本。因此,TURN 通常只在 P2P 直連失敗時作為備用方案。
!(https://i.imgur.com/k6t789c.png)
現在,我們可以將所有概念串聯起來了。JsSIP
的核心價值,正是為強大的 WebRTC 媒體引擎提供了一套成熟、標準化的 SIP 信令機制。它通過 SIP over WebSocket 的方式在瀏覽器和 SIP 服務器之間傳遞信令,交換 Offer/Answer 和 ICE 候選地址,最終配置好 RTCPeerConnection
,讓音視頻數據在對等端之間奔跑起來。
當你使用 JsSIP
開發應用時,遇到最常見的問題之一可能就是“通話接通了,但聽不到對方聲音”。十有八九,這并不是 JsSIP
的 API 調用錯了,而是底層的 WebRTC 媒體路徑沒有成功建立。這通常是因為 ICE 過程失敗,特別是當通話雙方都處于復雜的 NAT 網絡后,而你又沒有在配置中提供一個可用的 TURN 服務器。因此,深刻理解本章介紹的信令、ICE、STUN 和 TURN 的概念,將為你后續的開發和排錯之路掃清最大的障礙。
第三章:連接 SIP 與 WebRTC 的橋梁——JsSIP 登場
前面兩章,我們分別了解了通信世界的兩大主角:負責信令的 SIP 和負責媒體傳輸的 WebRTC。現在,是時候請出我們的主角——JsSIP
了。JsSIP
正是那座精心搭建的橋梁,它將經典、強大的 SIP 協議引入現代瀏覽器,使其成為 WebRTC 的完美信令搭檔。
JsSIP 是什么?
JsSIP
是一個輕量級、功能強大且 100% 純 JavaScript 編寫的庫。它能讓你在任何網站中,僅用幾行代碼,就能構建出一個功能齊全的 SIP 終端(用戶代理),從而實現音視頻通話、即時消息等實時通信功能 20。
它的核心特性包括 20:
- SIP over WebSocket: 這是
JsSIP
的基石,我們稍后會詳細解釋。 - 音視頻通話與即時消息: 全面支持 WebRTC 的媒體能力和 SIP 的消息能力。
- 輕量級: 庫文件大小經過優化(約 140KB),對頁面性能影響小。
- 純 JavaScript: 無需任何瀏覽器插件,易于集成到現代前端開發流程中。
- 易用且強大的 API: 提供了對開發者友好的高層 API,同時也保留了足夠的靈活性進行深度定制。
- 專業背景: 該庫由
RFC 7118
(“The WebSocket Protocol as a Transport for SIP” 標準文檔)的作者親自編寫,保證了其對 SIP 標準的深刻理解和精準實現。
值得一提的是,在 JavaScript 的 RTC 領域,除了 JsSIP
,還有另一個流行的庫叫做 SIP.js
24。雖然它們的目標相似,都是為了在瀏覽器中實現 SIP 通信,但它們的 API 設計、社區和發展路線有所不同。本書將完全專注于
JsSIP
,在學習和查閱資料時,請注意區分,避免將兩個庫的示例代碼和文檔混淆。
JsSIP 的架構:SIP over WebSocket
我們知道,傳統的 SIP 協議主要運行在 UDP 或 TCP 之上。但是,出于安全考慮,瀏覽器中的 JavaScript 無法直接創建和操作底層的 TCP/UDP 套接字。那么,JsSIP
是如何在瀏覽器里發送和接收 SIP 消息的呢?答案是 WebSocket。
WebSocket 協議提供了一個在單個 TCP 連接上進行全雙工通信的通道。它就像一條在瀏覽器和服務器之間建立的持久化、雙向的“高速公路”。JsSIP
正是利用這條高速公路來傳輸 SIP 消息,這個技術被稱為 SIP over WebSocket 20。
其工作流程如下圖所示:
!(https://i.imgur.com/G3Cq35f.png)
- 你的 Web 應用(客戶端)使用
JsSIP
庫。 JsSIP
通過瀏覽器內置的 WebSocket API,與一臺支持 SIP over WebSocket 的服務器建立連接。- 所有的 SIP 信令(如
INVITE
,REGISTER
,BYE
等)都被打包成文本消息,通過這條 WebSocket 隧道發送到服務器。 - SIP 服務器(如 Kamailio, Asterisk)解開消息,像處理普通 SIP 請求一樣進行路由、認證等操作,并與其他 SIP 網絡(例如另一個
JsSIP
客戶端、一個物理 IP 電話,甚至是傳統的電話網絡 PSTN)進行交互。 - 來自其他 SIP 網絡的響應或新請求,也通過 SIP 服務器打包,沿著 WebSocket 隧道發回給你的
JsSIP
客戶端。
這種架構的最大優勢在于,JsSIP
在瀏覽器中說的是“真正的 SIP” 20。它沒有對 SIP 協議進行任何刪減或轉換,這意味著你的 Web 應用可以無縫地融入龐大而成熟的現有 SIP 生態系統,與各種 SIP 設備和平臺進行互聯互通。
運行 JsSIP 的先決條件
理解 JsSIP
的架構后,一個至關重要的前提就浮出水面了:JsSIP
是一個純客戶端庫,它無法獨立工作,必須連接到一個支持 SIP over WebSocket 的后端服務器 21。
這個服務器是 JsSIP
應用的大腦和網關,負責處理用戶注冊、呼叫路由等所有信令邏輯。目前,許多主流的開源 SIP 服務器都已經支持 WebSocket,例如 20:
- Kamailio
- Asterisk
- OverSIP
- FreeSWITCH
如果你已經有了一個 SIP 服務提供商(比如公司的電話系統),你需要向他們咨詢 WebSocket 連接地址(通常以 ws://
或 wss://
開頭)以及你的 SIP 賬戶信息。如果你的現有 SIP 服務器不支持 WebSocket,也可以在其前端部署一個像 OverSIP 這樣的 WebSocket 代理服務器 21。
本書的目標是教會你如何使用 JsSIP
這個客戶端庫。我們不會深入講解如何從零開始搭建和配置一個 SIP 服務器,因為那本身就是另一個龐大而復雜的領域。在后續的示例中,我們將假設你已經擁有了必要的服務器連接信息。你可以使用一些公開的測試服務,或者從你的 VoIP 提供商處獲取。
現在,理論基礎已經牢固。從下一部分開始,我們將卷起袖子,真正開始編寫代碼,讓 JsSIP
在你的項目中運行起來!
第二部分:JsSIP 核心實踐
理論學習告一段落,現在是時候將知識轉化為代碼了。在這一部分,我們將從零開始,一步步構建你的第一個 JsSIP
應用。你將學會如何配置和啟動客戶端,如何發起和接聽電話,以及如何實現通話中常見的各種高級功能。讓我們一起進入 JsSIP
的核心實踐環節。
第四章:第一個 JsSIP 應用:Hello, World!
任何編程學習之旅都始于一個經典的 “Hello, World!”。在 JsSIP
的世界里,我們的 “Hello, World!” 就是成功地讓一個客戶端連接并注冊到 SIP 服務器。這標志著你的 Web 應用已經正式踏入了 SIP 通信網絡。
安裝 JsSIP
在開始編碼之前,首先需要將 JsSIP
庫引入到你的項目中。推薦使用 npm
進行安裝,這能更好地與現代前端工程化流程(如 Webpack, Vite)集成 23。
在你的項目目錄下,打開終端并運行:
Bash
npm install jssip
安裝完成后,你就可以在你的 JavaScript 文件中通過 import
或 require
來使用它。如果你的項目沒有使用構建工具,也可以通過在 HTML 中直接引入 JsSIP
的發行版文件 23。
核心對象:JsSIP.UA
JsSIP
的一切操作都圍繞著它的核心對象——JsSIP.UA
(User Agent) 展開。這個對象在代碼中代表了一個 SIP 客戶端,它與一個唯一的 SIP 賬戶相關聯 27。你可以把它想象成你的軟電話實例。
配置你的用戶代理
要創建一個 UA
實例,你必須提供一個配置對象。這個對象告訴 JsSIP
如何連接服務器以及使用哪個身份。以下是一個最基礎的配置示例 27:
JavaScript
// 首先,導入 JsSIP 庫
import * as JsSIP from 'jssip';// 1. 定義 WebSocket 連接接口
const socket = new JsSIP.WebSocketInterface('wss://sip.myhost.com');// 2. 創建配置對象
const configuration = {sockets: [socket],uri: 'sip:alice@example.com',password: 'superpassword'
};
讓我們來逐一解析這三個必填的配置參數 27:
sockets
: 這是一個數組,用于定義一個或多個 WebSocket 連接。數組中的每一項都必須是一個JsSIP.WebSocketInterface
的實例。你需要在實例化WebSocketInterface
時傳入你的 SIP 服務器的 WebSocket 地址(以wss://
或ws://
開頭)。之所以設計成數組,是為了實現連接的高可用性。你可以提供多個服務器地址,當第一個連接失敗時,JsSIP
會自動嘗試下一個,從而實現故障轉移。uri
: 這是一個字符串,代表你的 SIP 地址(也稱為 SIP URI)。它通常由你的 SIP 服務提供商分配,格式類似于一個電子郵件地址。password
: 這是一個字符串,即你的 SIP 賬戶的認證密碼。
啟動與停止
有了配置對象,我們就可以實例化并啟動 UA
了:
JavaScript
// 3. 實例化 UA
const ua = new JsSIP.UA(configuration);// 4. 啟動 UA
ua.start();
new JsSIP.UA(configuration)
創建了一個用戶代理實例。ua.start()
是一個至關重要的方法。調用它之后,JsSIP
會開始嘗試連接到你在sockets
中指定的 WebSocket 服務器。連接成功后,如果配置中沒有禁用自動注冊,它還會自動發送REGISTER
請求到 SIP 服務器,以宣告自己的在線狀態 27。
與 start()
對應的是 ua.stop()
方法。調用 ua.stop()
會讓 JsSIP
優雅地關閉:它會先向服務器發送注銷請求,然后終止所有活動會話,最后斷開 WebSocket 連接 29。
監聽生命周期事件
UA
在其生命周期中會經歷多種狀態變化(連接中、已連接、注冊成功、注冊失敗等)。JsSIP
通過一個事件系統來通知我們這些變化。我們可以使用 ua.on('eventName', callback)
的方式來監聽這些事件 30。
讓我們為 UA
的關鍵生命周期事件添加監聽器,以便實時了解其狀態 27:
JavaScript
ua.on('connecting', () => {console.log('UA 正在連接...');
});ua.on('connected', () => {console.log('UA 已連接!');
});ua.on('disconnected', () => {console.log('UA 已斷開連接。');
});ua.on('registered', () => {console.log('UA 注冊成功!');// 在這里,你的軟電話已經準備好撥打和接聽電話了
});ua.on('unregistered', () => {console.log('UA 已注銷。');
});ua.on('registrationFailed', (e) => {console.error('UA 注冊失敗!原因:', e.cause);// `e.cause` 提供了失敗的具體原因,例如 'Authentication Error'
});// 最后,別忘了啟動 UA
ua.start();
在上面的代碼中,我們為 UA
的主要狀態轉換都注冊了回調函數。當 UA
啟動后,你將在瀏覽器的控制臺中看到它狀態變化的實時日志。特別是 registered
事件,它的觸發標志著你的軟電話已經完全就緒。而如果 registrationFailed
事件被觸發,你可以通過檢查事件對象 e
中的 cause
屬性來診斷問題,這對于排錯至關重要 31。
至此,你已經完成了 JsSIP
的 “Hello, World!”。你的代碼已經能夠與 SIP 服務器建立連接并完成身份認證。這是構建更復雜功能的第一步,也是最重要的一步。
第五章:撥打與接聽:實現音視頻通話
成功注冊到 SIP 網絡后,我們的軟電話就擁有了“身份證”,現在是時候讓它發揮核心作用——進行音視頻通話了。本章將深入探討 JsSIP
中最激動人心的部分:如何發起呼叫、如何處理來電,以及如何將真實的音視頻流渲染到網頁上。
發起呼叫 (ua.call()
)
要發起一個呼叫,我們使用 ua.call(target, options)
方法 29。
-
target
: 字符串類型,代表你希望呼叫的對象。它可以是一個簡單的用戶名(如'bob'
),JsSIP
會根據你的uri
自動補全域名;也可以是一個完整的 SIP URI(如'sip:bob@example.com'
)。 -
options
: 這是一個非常重要的配置對象,它能讓你精細地控制這次呼叫。以下是幾個關鍵的選項:-
mediaConstraints
: 一個對象,用于指定你希望使用的媒體類型。例如,{ audio: true, video: true }
表示你想發起一個音視頻通話。如果只想進行音頻通話,可以設置為{ audio: true, video: false }
29。 -
pcConfig
: 這個對象用于直接配置底層的RTCPeerConnection
。最重要的用途就是在這里提供 STUN 和 TURN 服務器的地址,以幫助 WebRTC 進行 NAT 穿透 34。例如:JavaScript
const pcConfig = {iceServers: [{ urls: 'stun:stun.l.google.com:19302' },{ urls: 'turn:my.turn.server.com:3478',username: 'turn_user',credential: 'turn_password'}] };
-
eventHandlers
: 一個事件處理器對象。你可以為這次呼叫的會話(RTCSession
)預先注冊好事件監聽器,而無需等待會話創建后再綁定。這是一種非常便捷的編碼方式 29。
-
下面是一個發起視頻通話的完整示例:
JavaScript
const target = 'sip:bob@example.com';const options = {mediaConstraints: { audio: true, video: true },pcConfig: {iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]},eventHandlers: {progress: (e) => { console.log('呼叫進行中...'); },failed: (e) => { console.log(`呼叫失敗: ${e.cause}`); },ended: (e) => { console.log('呼叫結束。'); },confirmed: (e) => { console.log('呼叫已接通!'); }}
};// 發起呼叫
ua.call(target, options);
處理來電 (newRTCSession
事件)
無論是你發起的呼叫(去電),還是別人打給你的呼叫(來電),都會觸發 UA
實例上的 newRTCSession
事件。這個事件是所有通話的統一入口點 27。
JavaScript
let currentSession = null;ua.on('newRTCSession', (data) => {console.log('新的 RTC 會話已創建');// 保存會話對象currentSession = data.session;// 監聽會話的各種事件setupSessionEventHandlers(currentSession);if (currentSession.direction === 'incoming') {console.log('這是一個來電,來自:', currentSession.remote_identity.uri.toString());// 需要在這里處理UI,比如彈出一個接聽/掛斷的窗口} else if (currentSession.direction === 'outgoing') {console.log('這是一個去電,目標是:', currentSession.remote_identity.uri.toString());}
});
newRTCSession
事件的回調函數接收一個 data
對象,其中包含三個關鍵屬性 36:
originator
: 字符串'local'
或'remote'
,指明會話是由本地發起還是由遠端發起。session
: 核心的JsSIP.RTCSession
對象實例。這是我們管理這次特定通話的句柄。request
: 原始的 SIPINVITE
請求對象。
通過檢查 session.direction
屬性(值為 'incoming'
或 'outgoing'
),我們可以輕松地區分來電和去電,并執行不同的邏輯 34。
管理通話會話 (JsSIP.RTCSession
)
RTCSession
對象代表了一個獨立的通話會話。它擁有一系列方法和事件,用于管理通話的全過程。
-
接聽來電: 對于一個來電會話(
direction === 'incoming'
),你需要調用session.answer(options)
方法來接聽電話。options
參數與ua.call()
中的類似,你可以在這里指定媒體約束等 34。JavaScript
// 假設用戶點擊了“接聽”按鈕 function answerCall() {if (currentSession && currentSession.direction === 'incoming') {const answerOptions = {mediaConstraints: { audio: true, video: true }};currentSession.answer(answerOptions);} }
-
掛斷通話: 無論通話處于何種狀態(正在呼叫、已接通、來電振鈴中),你都可以調用
session.terminate()
來結束或拒絕這次通話 38。JavaScript
// 假設用戶點擊了“掛斷”按鈕 function terminateCall() {if (currentSession) {currentSession.terminate();} }
-
獲取通話信息: 你可以從
session
對象上獲取對方的身份信息,這對于在 UI 上顯示非常有用 38。session.remote_identity.display_name
: 對方的顯示名(昵稱)。session.remote_identity.uri
: 對方的完整 SIP URI。
渲染音視頻流
這是將通話“可視化”的關鍵一步。我們需要從 RTCPeerConnection
中捕獲到遠程的媒體流,并將其附加到 HTML 的 <video>
元素上。
一個常見的誤區是試圖在 newRTCSession
事件觸發后立即獲取媒體流。此時,對于來電而言,RTCPeerConnection
可能還未建立。正確的做法是監聽 RTCSession
上的 peerconnection
事件,這個事件在底層的 RTCPeerConnection
實例被創建后觸發。然后,我們再在該 peerconnection
對象上監聽 track
事件 41。
JavaScript
function setupSessionEventHandlers(session) {//... 其他事件監聽器,如 ended, failed...session.on('peerconnection', (data) => {console.log('RTCPeerConnection 已創建');const peerconnection = data.peerconnection;peerconnection.addEventListener('track', (event) => {console.log('接收到遠程媒體軌道');const remoteStream = event.streams;const remoteVideo = document.getElementById('remoteVideo');// 將遠程視頻流附加到 video 元素if (remoteVideo) {remoteVideo.srcObject = remoteStream;}});});// 同時,我們也需要獲取并顯示本地視頻流session.on('accepted', () => {console.log('通話被接受,顯示本地視頻');const localStream = session.connection.getLocalStreams();const localVideo = document.getElementById('localVideo');if (localVideo && localStream) {localVideo.srcObject = localStream;}});
}
peerconnection
事件: 在這個事件的回調中,我們可以安全地訪問data.peerconnection
對象。track
事件: 當遠程對等端添加媒體軌道時,此事件被觸發。event.streams
就是我們需要的遠程MediaStream
對象。- 附加到
<video>
元素: 我們通過videoElement.srcObject = stream;
的方式將媒體流賦給視頻標簽。確保你的 HTML 中有<video id="remoteVideo" autoplay></video>
這樣的元素,autoplay
屬性可以讓視頻自動播放 34。 - 本地視頻流: 你可以通過
session.connection.getLocalStreams()
獲取本地的媒體流,并將其顯示在另一個<video>
元素中,作為本地預覽 39。
通過本章的學習,你已經掌握了 JsSIP
最核心的通話功能。但一個優秀的軟電話還需要更多交互能力,下一章我們將學習如何在通話中實現靜音、保持等高級操作。
第六章:通話中的高級操作
一個基本的通話功能已經實現,但要構建一個用戶體驗良好的軟電話,還需要提供一些通話中常用的控制功能。本章將教你如何使用 JsSIP
來實現通話的靜音、保持以及發送電話按鍵音(DTMF)。
靜音與取消靜音 (Mute and Unmute)
在通話中讓對方聽不到自己的聲音,是一個非常基礎且必要的功能。JsSIP
提供了簡潔的 API 來實現這一點。
- 方法:
session.mute(options)
: 將通話靜音。session.unmute(options)
: 取消靜音。session.isMuted()
: 返回一個布爾值,告訴你當前是否處于靜音狀態 43。
options
參數可以指定只靜音音頻或視頻,例如 session.mute({ audio: true, video: false })
。如果不傳,則默認同時靜音音視頻。
- 事件:
muted
: 當通話被靜音時觸發。unmuted
: 當通話取消靜音時觸發 44。
你應該監聽這些事件來更新你的 UI,例如改變靜音按鈕的圖標或狀態。
-
實現原理:
調用 mute() 或 unmute() 并非只是一個簡單的本地操作。它實際上是通過操作本地 MediaStreamTrack 的 enabled 屬性來停止或恢復發送媒體數據。在某些配置下,JsSIP 還可能會通過發送一個更新后的 SIP 請求(如 re-INVITE 或 UPDATE)來通知對方媒體流的狀態發生了變化。理解這一點很重要,因為它解釋了為什么這些操作是異步的。
示例代碼:
JavaScript
// HTML 中有一個 id 為 'muteButton' 的按鈕
const muteButton = document.getElementById('muteButton');muteButton.addEventListener('click', () => {if (currentSession && currentSession.isEstablished()) {if (currentSession.isMuted().audio) {currentSession.unmute({ audio: true });} else {currentSession.mute({ audio: true });}}
});// 在會話事件處理器中更新 UI
function setupSessionEventHandlers(session) {//...session.on('muted', () => {console.log('通話已靜音');muteButton.textContent = '取消靜音';});session.on('unmuted', () => {console.log('通話已取消靜音');muteButton.textContent = '靜音';});//...
}
通話保持與恢復 (Hold and Unhold)
通話保持功能允許用戶暫時中斷與一個人的通話(例如,去接聽另一個電話),而不會掛斷當前通話。
-
方法:
session.hold()
: 將通話置于保持狀態。session.unhold()
: 從保持狀態中恢復通話。session.isOnHold()
: 返回一個對象,告訴你本地和遠程的保持狀態 43。
-
事件:
hold
: 當通話被任一方置于保持狀態時觸發。unhold
: 當通話從保持狀態恢復時觸發 43。
-
實現原理:
通話保持是一個純粹的信令層操作。當調用 session.hold() 時,JsSIP 會構造一個新的 SDP(會話描述協議)內容,在其中將媒體流的方向屬性標記為 sendonly(只發送,不接收)或 inactive(不發送也不接收)。然后,它會通過一個 re-INVITE 或 UPDATE 請求將這個新的 SDP 發送給對方。對方收到并同意后,雙方的客戶端就會停止處理媒體流,從而實現“保持”的效果 47。這個過程被稱為“會話重新協商”(re-negotiation)。
示例代碼:
JavaScript
// HTML 中有一個 id 為 'holdButton' 的按鈕
const holdButton = document.getElementById('holdButton');holdButton.addEventListener('click', () => {if (currentSession && currentSession.isEstablished()) {if (currentSession.isOnHold().local) {currentSession.unhold();} else {currentSession.hold();}}
});// 在會話事件處理器中更新 UI
function setupSessionEventHandlers(session) {//...session.on('hold', () => {console.log('通話已保持');holdButton.textContent = '恢復通話';});session.on('unhold', () => {console.log('通話已恢復');holdButton.textContent = '保持';});//...
}
當你理解了 hold
是一個異步的重新協商過程后,就能編寫出更健壯的 UI 邏輯。例如,在調用 hold()
后,你可以將按鈕狀態設置為“正在保持…”,直到接收到 hold
事件,再將其更新為“恢復通話”。
發送 DTMF (Sending DTMF Tones)
DTMF(雙音多頻)就是我們平時在電話鍵盤上按鍵時聽到的聲音。在 VoIP 通話中,發送 DTMF 信號通常用于與自動語音應答系統(IVR)進行交互,例如在致電銀行時根據語音提示按“1”選擇服務,按“2”輸入密碼等 48。
-
方法:
session.sendDTMF(tone, options)
: 在當前通話中發送一個或多個 DTMF 音 38。tone
: 一個字符串或數字,代表要發送的按鍵。可以是單個字符如'1'
,'#'
,也可以是連續的字符串如'1234#'
。options
: 一個可選對象,可以設置duration
(每個音的持續時間,單位毫秒)和interToneGap
(多個音之間的間隔時間)49。
-
事件:
newDTMF
: 當收到來自對方的 DTMF 信號時觸發 49。
-
實現原理:
DTMF 信號主要有兩種發送方式:帶內(in-band)和帶外(out-of-band)。帶內方式是將 DTMF 音作為特殊的 RTP 包(RFC 2833)在媒體流中傳輸。帶外方式則是通過信令協議(如 SIP INFO 或 MESSAGE 請求)來發送。JsSIP 默認使用 SIP INFO 請求這種帶外方式來發送 DTMF,這種方式通常更可靠 49。
示例代碼:
JavaScript
// 假設我們有一個撥號盤,點擊數字按鈕時調用此函數
function sendDTMFDigit(digit) {if (currentSession && currentSession.isEstablished()) {const options = {duration: 160,interToneGap: 120};currentSession.sendDTMF(digit, options);console.log(`已發送 DTMF: ${digit}`);}
}// 監聽收到的 DTMF
function setupSessionEventHandlers(session) {//...session.on('newDTMF', (data) => {if (data.originator === 'remote') {console.log(`收到遠程 DTMF: ${data.dtmf.tone}`);// 可以在這里播放一個本地聲音提示用戶}});//...
}
通過本章的學習,你的軟電話已經從一個只能“打”和“接”的簡單工具,變成了一個具備靜音、保持、按鍵交互等實用功能的通信終端。接下來,我們將進入更廣闊的領域,探索 JsSIP
在即時消息方面的能力,并為你提供一份詳盡的配置與排錯寶典。
第三部分:深入探索與 API 寶典
你已經掌握了 JsSIP
的核心通話功能。現在,讓我們更進一步,探索 JsSIP
的其他能力,并將之前零散的知識點系統化。這一部分將作為你的“瑞士軍刀”和參考手冊,內容涵蓋即時消息、詳盡的配置參數解析、一套行之有效的排錯方法論,以及一份完整的 API 速查表。掌握了這部分內容,你將有能力獨立解決大部分開發中遇到的問題。
第七章:不止于通話:即時消息 (IM)
除了強大的音視頻通話能力,JsSIP
還支持通過 SIP MESSAGE
方法實現簡單的即時消息(Instant Messaging)功能。這讓你可以在不建立通話的情況下,向另一個 SIP 用戶發送和接收文本消息。
需要澄清的是,這里所說的即時消息,是基于 SIP 協議本身的 MESSAGE
方法實現的,它是一個獨立的、無會話的(session-less)消息傳遞機制。這與在 WebRTC 通話中利用 RTCDataChannel
實現的“通話內聊天”是兩種不同的技術。JsSIP
的 sendMessage
功能讓你擁有了獨立于通話的、類似短信的通信能力。
發送消息 (ua.sendMessage()
)
要發送一條即時消息,你只需調用 UA
實例上的 ua.sendMessage(target, body, options)
方法 27。
target
: 字符串,消息接收方的 SIP URI,例如'sip:bob@example.com'
。body
: 字符串,你想要發送的消息內容。options
: 一個可選的配置對象,其中常用的屬性是eventHandlers
,用于監聽該條消息的發送狀態 36。
示例代碼:
JavaScript
const target = 'sip:bob@example.com';
const body = '你好,Bob!這是一條測試消息。';const options = {eventHandlers: {succeeded: (e) => {console.log('消息發送成功!');// 可以在這里更新 UI,顯示消息已送達},failed: (e) => {console.error(`消息發送失敗,原因: ${e.cause}`);// 可以在這里更新 UI,標記消息發送失敗}}
};ua.sendMessage(target, body, options);
在 eventHandlers
中監聽 succeeded
和 failed
事件,可以讓你獲得關于消息投遞狀態的即時反饋,這對于構建一個可靠的聊天應用至關重要 27。
接收消息 (newMessage
事件)
當有其他人給你發送 SIP MESSAGE
時,UA
實例會觸發 newMessage
事件 27。你需要監聽這個事件來處理收到的消息。
JavaScript
ua.on('newMessage', (data) => {// 判斷是否為收到的消息if (data.originator === 'remote') {console.log('收到一條新消息!');// 獲取發送方信息const sender = data.message.remote_identity.uri.toString();// 獲取消息內容// 注意:消息內容在原始請求的 body 中const content = data.request.body;console.log(`來自 ${sender} 的消息: ${content}`);// 在 UI 上顯示收到的消息displayNewMessage(sender, content);// (可選)向發送方回復一個確認接收的響應// 這在協議層面不是強制的,但有助于實現“已讀”等功能// data.message.accept(); }
});
newMessage
事件的回調函數參數 data
對象中包含了所有你需要的信息 36:
originator
: 值為'remote'
表示是收到的消息。message
: 一個JsSIP.Message
實例,你可以從中獲取發送方的身份信息message.remote_identity
。request
: 原始的 SIPMESSAGE
請求對象,消息的正文存儲在request.body
中。
對于收到的消息,JsSIP.Message
實例還提供了 accept()
和 reject()
方法,用于向發送方回復一個 2xx 成功響應或一個非 2xx 失敗響應。雖然在許多場景下這不是必需的,但它可以被用來實現更復雜的信令邏輯,例如消息的已達回執 52。
第八章:配置與排錯
在開發過程中,遇到問題在所難免。本章旨在為你提供最強大的“武器”——詳盡的配置知識和清晰的排錯思路。掌握了它們,你就能從容應對 JsSIP
開發中的各種挑戰。
JsSIP.UA
配置參數大全
UA
的配置是 JsSIP
應用的起點,也是最容易出錯的地方。下面這張表格匯總了 JsSIP
中最重要的一些配置參數,并附有中文說明,供你隨時查閱 28。
參數名 (Parameter) | 類型 (Type) | 是否必須 (Mandatory) | 默認值 (Default) | 中文說明 |
---|---|---|---|---|
uri | String | 是 | - | 你的完整 SIP URI,如 sip:alice@example.com 。 |
sockets | Array | 是 | - | JsSIP.WebSocketInterface 實例的數組,定義 WebSocket 服務器連接。 |
password | String | 否 | - | 你的 SIP 賬戶密碼。如果服務器需要認證,則為必須。 |
ha1 | String | 否 | - | 預計算的 HA1 摘要,用于 Digest 認證,可替代 password 。 |
realm | String | 否 | - | SIP 認證域。與 ha1 配合使用。Asterisk 服務器通常為 asterisk 。 |
authorization_user | String | 否 | uri 的用戶名 | 用于認證的用戶名,如果與 uri 中的用戶名不同。 |
display_name | String | 否 | - | 你的顯示名稱,會顯示在對方的來電提示中。 |
register | Boolean | 否 | true | 是否在 ua.start() 后自動注冊。 |
register_expires | Number | 否 | 600 | 注冊有效期(秒)。UA 會在此時間到期前自動續期。 |
registrar_server | String | 否 | uri 的域 | SIP 注冊服務器的地址,如果與 uri 中的域不同。 |
no_answer_timeout | Number | 否 | 60 | 來電無應答的超時時間(秒),超時后會自動拒絕。 |
session_timers | Boolean | 否 | true | 是否啟用會話定時器(RFC 4028),用于檢測僵死會話。 |
connection_recovery_min_interval | Number | 否 | 2 | WebSocket 斷線重連的最小間隔(秒)。 |
connection_recovery_max_interval | Number | 否 | 30 | WebSocket 斷線重連的最大間隔(秒)。 |
調試你的 JsSIP 應用
當應用行為不符合預期時,第一步是獲取更多的信息。
-
開啟 JsSIP 調試日志:
這是最重要、最有效的調試手段。在你的代碼初始化階段加入下面這行代碼,JsSIP 就會在瀏覽器的開發者工具控制臺中打印出所有收發的 SIP 消息和內部狀態變化 42。
JavaScript
JsSIP.debug.enable('JsSIP:*');
通過閱讀這些日志,你可以清晰地看到
INVITE
、200 OK
、ACK
等消息的流轉過程,以及 SDP 的具體內容,這對于定位問題非常有幫助。 -
使用瀏覽器開發者工具:
- 網絡 (Network) 面板: 篩選
WS
(WebSocket) 流量,你可以看到JsSIP
與服務器之間的實時通信數據。 - 控制臺 (Console) 面板: 除了
JsSIP
的調試日志,這里還會顯示任何 JavaScript 運行時錯誤。
- 網絡 (Network) 面板: 篩選
常見錯誤與解決方案
排錯的本質是分層定位問題。一個 JsSIP
應用的故障,可能發生在網絡連接層、SIP 信令層,或是 WebRTC 媒體層。下面我們提供一個清晰的排錯流程和常見問題的解決方案。
排錯流程圖:
- 檢查連接層 ->
ua.on('connected')
觸發了嗎?- 否: 問題出在 WebSocket 連接。檢查
sockets
配置中的服務器地址是否正確、網絡是否通暢、服務器是否正在運行。
- 否: 問題出在 WebSocket 連接。檢查
- 檢查注冊層 ->
ua.on('registered')
觸發了嗎?- 否: 問題出在 SIP 注冊。監聽
registrationFailed
事件,查看e.cause
31。Authentication Error
: 密碼 (password
或ha1
) 或用戶名 (authorization_user
) 錯誤 58。Connection Error
: 無法連接到服務器。
- 否: 問題出在 SIP 注冊。監聽
- 檢查呼叫信令層 -> 對方接聽后,
session.on('confirmed')
觸發了嗎?- 否: 問題出在呼叫建立過程。監聽
session.on('failed')
事件,查看e.cause
58。Busy
: 對方正忙。Rejected
: 對方拒絕接聽。Not Found
: 對方不在線或號碼錯誤。Incompatible SDP
: 雙方媒體能力不兼容,例如沒有共同支持的編解碼器。
- 否: 問題出在呼叫建立過程。監聽
- 檢查媒體層 -> 通話已接通 (
confirmed
),但聽不到/看不到對方?- 是: 這是最常見的問題,幾乎總是 NAT 穿透失敗 導致的 60。
- 解決方案:
- 確認 STUN/TURN 配置: 檢查
ua.call()
或ua
構造函數的pcConfig.iceServers
中是否正確配置了 STUN 和 TURN 服務器。 - TURN 服務器是關鍵: STUN 只能解決部分 NAT 問題。在復雜的網絡環境中(如對稱型 NAT),必須使用 TURN 服務器進行媒體中繼。
- 檢查 TURN 憑證: 確認 TURN 服務器的地址、用戶名 (
username
) 和密碼 (credential
) 是否正確無誤。 - 查看 SDP: 在
JsSIP:*
日志中找到INVITE
或200 OK
消息里的 SDP 內容,檢查其中的a=candidate
行,確認是否有relay
類型的候選地址(這表示 TURN 服務器已生效)。如果只有host
或srflx
類型的候選地址,說明 TURN 服務器可能未生效或無法訪問。 - RTP Timeout: 如果媒體流中斷一段時間,
JsSIP
會因為收不到 RTP 包而觸發failed
事件,cause
通常為'RTP Timeout'
32。這同樣指向了 NAT 穿透問題。
- 確認 STUN/TURN 配置: 檢查
常見失敗原因 (JsSIP.C.causes
) 表 32
原因常量 (Constant) | 字符串值 (Value) | 描述 |
---|---|---|
CONNECTION_ERROR | ‘Connection Error’ | WebSocket 連接錯誤。 |
AUTHENTICATION_ERROR | ‘Authentication Error’ | 認證失敗(用戶名或密碼錯誤)。 |
BUSY | ‘Busy’ | 對方正忙(收到 486 或 600 響應)。 |
REJECTED | ‘Rejected’ | 對方拒絕(收到 403 或 603 響應)。 |
NOT_FOUND | ‘Not Found’ | 找不到目標用戶(收到 404 或 604 響應)。 |
UNAVAILABLE | ‘Unavailable’ | 對方當前不可用(收到 480, 410 等響應)。 |
INCOMPATIBLE_SDP | ‘Incompatible SDP’ | 媒體能力不兼容(收到 488 或 606 響應)。 |
NO_ANSWER | ‘No Answer’ | 來電在 no_answer_timeout 內未被接聽。 |
CANCELED | ‘Canceled’ | 呼叫在接聽前被主叫或被叫取消。 |
RTP_TIMEOUT | ‘RTP Timeout’ | 因長時間未收到 RTP 媒體包導致會話終止。 |
USER_DENIED_MEDIA_ACCESS | ‘User Denied Media Access’ | 用戶在瀏覽器彈窗中拒絕了攝像頭/麥克風權限。 |
第九章:JsSIP API 參考大全
本章是你的 JsSIP
API 速查手冊。我們將以表格的形式,清晰、完整地列出 JsSIP
核心類的主要方法和事件,方便你在開發過程中隨時查閱。
JsSIP.UA
事件 (Events)
UA
的事件是驅動應用狀態變化的核心,此表讓開發者對所有可能的狀態變化一目了然 27。
事件名 (Event) | 觸發時機 | 回調參數 data 結構 |
---|---|---|
connecting | 每次嘗試連接 WebSocket 時 | { socket, attempts } |
connected | WebSocket 連接成功建立時 | { socket } |
disconnected | WebSocket 連接斷開時 | { socket, code, reason } |
registered | SIP 注冊成功時 | { response } |
unregistered | SIP 注銷成功或注冊過期時 | { response, cause } |
registrationFailed | SIP 注冊失敗時 | { response, cause } |
newRTCSession | 收到或發起新的音視頻通話時 | { originator, session, request } |
newMessage | 收到或發起新的即時消息時 | { originator, message, request } |
JsSIP.RTCSession
方法 (Methods)
這是控制通話的“遙控器”,此表是實現所有通話功能的速查手冊 38。
方法名 (Method) | 功能描述 |
---|---|
answer(options) | 接聽來電。 |
terminate(options) | 終止通話(掛斷或拒絕)。 |
hold(options) | 將通話置于保持狀態。 |
unhold(options) | 從保持狀態恢復通話。 |
mute(options) | 將通話靜音。 |
unmute(options) | 取消通話靜音。 |
sendDTMF(tone, options) | 發送 DTMF 按鍵音。 |
refer(target, options) | 將通話轉移給第三方(呼叫轉移)。 |
isOnHold() | 檢查通話的保持狀態。 |
isMuted() | 檢查通話的靜音狀態。 |
isEstablished() | 檢查通話是否已建立。 |
isEnded() | 檢查通話是否已結束。 |
getLocalStreams() | 獲取本地媒體流數組。 |
getRemoteStreams() | 獲取遠程媒體流數組。 |
JsSIP.RTCSession
事件 (Events)
RTCSession
的事件反映了通話的完整生命周期,掌握它們是編寫健壯通話界面的關鍵 38。
事件名 (Event) | 觸發時機 | 回調參數 data 結構 |
---|---|---|
progress | 呼叫進行中(對方正在響鈴)。 | { originator, response } |
accepted | 對方已接聽通話。 | { originator, response } |
confirmed | 通話雙方確認,媒體通道建立。 | { originator, ack } |
ended | 通話正常結束。 | { originator, message, cause } |
failed | 通話建立失敗或中途異常終止。 | { originator, message, cause } |
peerconnection | 底層 RTCPeerConnection 創建時。 | { peerconnection } |
hold | 通話被任一方置于保持狀態。 | { originator } |
unhold | 通話從保持狀態恢復。 | { originator } |
muted | 本地媒體被靜音。 | - |
unmuted | 本地媒體被取消靜音。 | - |
newDTMF | 收到或發送了 DTMF 信號。 | { originator, dtmf, request } |
sdp | 本地或遠程 SDP 發生變化時。 | { originator, sdp } |
第四部分:實戰項目:從零構建一個功能完善的 Web 軟電話
理論與實踐相結合,方能真正掌握一門技術。在最后這一部分,我們將把前面所有章節的知識融會貫通,從零開始,一步步構建一個界面美觀、功能完善的 Web 軟電話。這個項目不僅是對你學習成果的檢驗,更可以作為你未來開發自己 RTC 應用的堅實模板。
第十章:項目設計與界面
一個好的產品,始于一個好的設計。在編寫核心邏輯之前,我們首先要規劃軟電話的用戶界面(UI)和用戶體驗(UX),并完成 HTML 和 CSS 的編寫。
UI/UX 設計最佳實踐
對于一個軟電話應用,清晰、直觀、高效是設計的核心原則。我們可以借鑒一些通用的 UI/UX 最佳實踐 62:
- 簡潔性與一致性: 界面元素(按鈕、輸入框、狀態顯示)的風格、顏色、字體應保持統一。避免不必要的裝飾,讓用戶專注于核心的通話功能。
- 清晰的視覺層級: 重要的信息,如通話狀態(“已連接”、“通話中”)、對方號碼、通話時長,應該在視覺上最突出。次要信息則應弱化。
- 明確的行動號召 (CTA): “呼叫”、“接聽”、“掛斷”等核心操作按鈕,應該使用高對比度的顏色、合適的尺寸和清晰的圖標,讓用戶能毫不猶豫地找到并點擊。
- 即時反饋: 用戶的每一個操作都應得到視覺反饋。例如,點擊按鈕時按鈕有按下的效果;發起呼叫后,界面狀態應立即變為“正在呼叫…”。這能消除用戶的不確定感。
- 合理的布局: 將相關功能組織在一起。例如,登錄配置區、撥號區、通話中控制區應有明確的劃分。
界面布局設計
根據上述原則,我們將軟電話界面劃分為以下幾個區域:
- 配置/登錄區 (Configuration/Login Area): 位于頁面頂部,用于輸入 SIP 服務器地址、SIP URI 和密碼。旁邊有一個“連接/斷開”按鈕和狀態指示燈。
- 撥號區 (Dialpad Area): 一個標準的電話撥號盤,包含數字 0-9、*、#,一個用于顯示輸入號碼的文本框,以及一個“呼叫”按鈕。
- 通話區 (Call Area):
- 視頻窗口: 包含兩個
<video>
元素,一個用于顯示遠程視頻流 (remoteView
),一個用于顯示本地視頻預覽 (selfView
)。 - 通話信息: 顯示對方的號碼/名稱和通話計時器。
- 通話控制欄: 在通話建立后顯示,包含“靜音”、“保持”、“鍵盤”、“掛斷”等按鈕。
- 視頻窗口: 包含兩個
HTML 結構
下面是我們軟電話的完整 HTML 骨架。我們為所有需要通過 JavaScript 操作的元素都賦予了清晰的 id
。這份代碼基于我們研究過的示例 34,并進行了重構和功能擴展。
HTML
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>JsSIP Web Softphone</title><link rel="stylesheet" href="style.css">
</head>
<body><div class="phone"><div class="config-area"><h3>配置</h3><div class="input-group"><label for="sip-uri">SIP URI:</label><input type="text" id="sip-uri" placeholder="sip:user@domain.com"></div><div class="input-group"><label for="sip-password">密碼:</label><input type="password" id="sip-password"></div><div class="input-group"><label for="ws-server">WebSocket 服務器:</label><input type="text" id="ws-server" placeholder="wss://sip.myhost.com"></div><div class="config-controls"><button id="connect-button">連接</button><span id="connection-status" class="status-light red"></span></div></div><div class="video-area"><video id="remote-video" autoplay></video><video id="local-video" autoplay muted></video></div><div class="dial-area"><div id="call-info" class="call-info-display"><span id="call-status">未連接</span><span id="call-timer">00:00</span></div><input type="text" id="dial-input" placeholder="輸入 SIP URI 或號碼"><div id="dialpad" class="dialpad-grid"></div><button id="call-button" class="action-button call" disabled>呼叫</button></div><div id="in-call-controls" class="in-call-controls-grid hidden"><button id="mute-button" class="control-button">靜音</button><button id="hold-button" class="control-button">保持</button><button id="dtmf-button" class="control-button">鍵盤</button><button id="hangup-button" class="action-button hangup">掛斷</button></div><div id="incoming-call-toast" class="incoming-toast hidden"><p>來電來自: <span id="incoming-caller"></span></p><button id="answer-button" class="action-button call">接聽</button><button id="reject-button" class="action-button hangup">拒絕</button></div></div><script src="jssip.min.js"></script><script src="main.js"></script>
</body>
</html>
CSS 樣式
為了讓界面美觀易用,我們編寫以下 style.css
文件。樣式代碼注重響應式設計和視覺清晰度。
CSS
/* style.css */
body {font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;display: flex;justify-content: center;align-items: center;min-height: 100vh;background-color: #f0f2f5;margin: 0;
}.phone {width: 360px;background: #fff;border-radius: 20px;box-shadow: 0 10px 30px rgba(0,0,0,0.1);overflow: hidden;display: flex;flex-direction: column;
}/* 配置區域 */
.config-area { padding: 20px; background-color: #f8f9fa; }
.config-area h3 { margin-top: 0; text-align: center; color: #333; }
.input-group { margin-bottom: 10px; }
.input-group label { display: block; margin-bottom: 5px; font-size: 14px; color: #555; }
.input-group input { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 5px; box-sizing: border-box; }
.config-controls { display: flex; justify-content: space-between; align-items: center; margin-top: 15px; }
#connect-button { padding: 8px 15px; border: none; border-radius: 5px; background-color: #007bff; color: white; cursor: pointer; }
.status-light { width: 12px; height: 12px; border-radius: 50%; }
.status-light.red { background-color: #dc3545; }
.status-light.yellow { background-color: #ffc107; }
.status-light.green { background-color: #28a745; }/* 視頻區域 */
.video-area { position: relative; width: 100%; background-color: #000; }
#remote-video { width: 100%; display: block; }
#local-video { position: absolute; width: 25%; bottom: 10px; right: 10px; border: 2px solid white; border-radius: 5px; }/* 撥號區域 */
.dial-area { padding: 20px; }
.call-info-display { text-align: center; margin-bottom: 15px; height: 40px; }
#call-status { display: block; font-size: 18px; color: #333; }
#call-timer { font-size: 14px; color: #888; }
#dial-input { width: 100%; padding: 10px; font-size: 20px; text-align: center; border: none; border-bottom: 2px solid #007bff; margin-bottom: 15px; box-sizing: border-box; }
.dialpad-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 20px; }
.dialpad-grid button { padding: 15px; font-size: 20px; border: 1px solid #ddd; border-radius: 50%; background-color: #f8f9fa; cursor: pointer; }/* 控制按鈕 */
.action-button { width: 100%; padding: 15px; font-size: 18px; border: none; border-radius: 10px; color: white; cursor: pointer; }
.action-button.call { background-color: #28a745; }
.action-button.hangup { background-color: #dc3545; }
.action-button:disabled { background-color: #ccc; cursor: not-allowed; }/* 通話中控制 */
.in-call-controls-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; padding: 0 20px 20px; }
.control-button { padding: 10px; font-size: 14px; border: 1px solid #ddd; border-radius: 8px; background-color: #f8f9fa; cursor: pointer; }
.control-button.active { background-color: #007bff; color: white; }/* 來電提示 */
.incoming-toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background-color: rgba(0,0,0,0.8); color: white; padding: 15px 20px; border-radius: 10px; z-index: 1000; display: flex; align-items: center; gap: 15px; }
.incoming-toast p { margin: 0; }
.incoming-toast button { padding: 8px 12px; }.hidden { display: none!important; }
現在,我們已經準備好了軟電話的“外殼”。下一章,我們將為它注入“靈魂”——編寫 main.js
文件,實現所有核心的交互邏輯。
第十一章:核心功能實現
界面已經就緒,現在是時候編寫 JavaScript 代碼,將 UI 元素與 JsSIP
的強大功能連接起來,讓我們的軟電話真正“活”起來。本章將遵循模塊化的思想,一步步實現所有核心邏輯。
代碼結構規劃
為了讓代碼清晰、可維護,我們將其劃分為幾個邏輯部分:
- 全局變量與常量: 存放
UA
實例、當前會話、DOM 元素引用等。 - UI 元素獲取: 在腳本開始時,一次性獲取所有需要操作的 DOM 元素的引用。
- UI 更新函數: 編寫獨立的、職責單一的函數來更新界面,例如
updateConnectionStatus()
、showInCallControls()
。 - 事件處理器: 集中處理
JsSIP.UA
和JsSIP.RTCSession
的所有事件。 - 動作綁定: 為 HTML 按鈕(如連接、呼叫、掛斷)綁定點擊事件。
- 初始化: 腳本的入口點,負責綁定初始事件和生成撥號盤。
分步實現 (main.js
)
下面是 main.js
的完整實現,包含了詳細的注釋來解釋每一步。
JavaScript
// main.js// 1. 全局變量與常量
let ua;
let currentSession;
let callTimerInterval;const DIALPAD_BUTTONS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'];// 2. UI 元素獲取
const ui = {sipUriInput: document.getElementById('sip-uri'),sipPasswordInput: document.getElementById('sip-password'),wsServerInput: document.getElementById('ws-server'),connectButton: document.getElementById('connect-button'),connectionStatus: document.getElementById('connection-status'),dialInput: document.getElementById('dial-input'),dialpad: document.getElementById('dialpad'),callButton: document.getElementById('call-button'),callInfo: document.getElementById('call-info'),callStatus: document.getElementById('call-status'),callTimer: document.getElementById('call-timer'),inCallControls: document.getElementById('in-call-controls'),hangupButton: document.getElementById('hangup-button'),muteButton: document.getElementById('mute-button'),holdButton: document.getElementById('hold-button'),dtmfButton: document.getElementById('dtmf-button'),incomingToast: document.getElementById('incoming-call-toast'),incomingCaller: document.getElementById('incoming-caller'),answerButton: document.getElementById('answer-button'),rejectButton: document.getElementById('reject-button'),localVideo: document.getElementById('local-video'),remoteVideo: document.getElementById('remote-video')
};// 3. UI 更新函數
function updateConnectionStatus(status) { // 'disconnected', 'connecting', 'connected', 'registered'ui.connectionStatus.className = 'status-light';switch (status) {case 'disconnected':ui.connectionStatus.classList.add('red');ui.connectButton.textContent = '連接';ui.callButton.disabled = true;break;case 'connecting':ui.connectionStatus.classList.add('yellow');ui.connectButton.textContent = '連接中...';break;case 'connected': // Connected to WebSocket, but not yet registeredui.connectionStatus.classList.add('yellow');ui.connectButton.textContent = '斷開';break;case 'registered':ui.connectionStatus.classList.add('green');ui.connectButton.textContent = '斷開';ui.callButton.disabled = false;break;}
}function updateCallStatus(status, remoteIdentity = '') {ui.callStatus.textContent = status;if (remoteIdentity) {ui.callStatus.textContent += ` - ${remoteIdentity}`;}
}function showInCallControls(show) {if (show) {ui.dialpad.classList.add('hidden');ui.callButton.classList.add('hidden');ui.inCallControls.classList.remove('hidden');ui.dialInput.disabled = true;} else {ui.dialpad.classList.remove('hidden');ui.callButton.classList.remove('hidden');ui.inCallControls.classList.add('hidden');ui.dialInput.disabled = false;ui.dialInput.value = '';updateCallStatus('已注冊');stopCallTimer();// 重置控制按鈕狀態ui.muteButton.classList.remove('active');ui.muteButton.textContent = '靜音';ui.holdButton.classList.remove('active');ui.holdButton.textContent = '保持';}
}function showIncomingCallToast(show, remoteIdentity = '') {if (show) {ui.incomingCaller.textContent = remoteIdentity;ui.incomingToast.classList.remove('hidden');} else {ui.incomingToast.classList.add('hidden');}
}function startCallTimer() {let startTime = Date.now();ui.callTimer.textContent = '00:00';callTimerInterval = setInterval(() => {let seconds = Math.floor((Date.now() - startTime) / 1000);let mins = Math.floor(seconds / 60).toString().padStart(2, '0');let secs = (seconds % 60).toString().padStart(2, '0');ui.callTimer.textContent = `${mins}:${secs}`;}, 1000);
}function stopCallTimer() {clearInterval(callTimerInterval);ui.callTimer.textContent = '00:00';
}// 4. 事件處理器
function setupUaEventHandlers() {ua.on('connecting', () => {updateConnectionStatus('connecting');updateCallStatus('正在連接服務器...');});ua.on('connected', () => {updateConnectionStatus('connected');updateCallStatus('服務器已連接,正在注冊...');});ua.on('disconnected', () => {updateConnectionStatus('disconnected');updateCallStatus('未連接');alert('WebSocket 連接已斷開。');});ua.on('registered', () => {updateConnectionStatus('registered');updateCallStatus('已注冊');});ua.on('registrationFailed', (e) => {updateConnectionStatus('connected'); // Still connected to WSupdateCallStatus('注冊失敗');alert(`注冊失敗: ${e.cause}`);});ua.on('unregistered', () => {updateConnectionStatus('connected');updateCallStatus('已注銷');});ua.on('newRTCSession', (data) => {if (currentSession) { // 如果已有通話,自動拒絕新來電data.session.terminate({ status_code: 486, reason_phrase: 'Busy Here' });return;}currentSession = data.session;setupSessionEventHandlers();if (currentSession.direction === 'incoming') {const remoteIdentity = currentSession.remote_identity.uri.toString();showIncomingCallToast(true, remoteIdentity);}});
}function setupSessionEventHandlers() {currentSession.on('progress', () => {updateCallStatus('正在呼叫...', currentSession.remote_identity.uri.user);});currentSession.on('failed', (e) => {updateCallStatus(`呼叫失敗: ${e.cause}`);showInCallControls(false);currentSession = null;});currentSession.on('ended', (e) => {updateCallStatus(`通話結束: ${e.cause}`);showInCallControls(false);currentSession = null;});currentSession.on('accepted', () => {updateCallStatus('通話已接通', currentSession.remote_identity.uri.user);showInCallControls(true);startCallTimer();});// 媒體流處理currentSession.on('peerconnection', (data) => {data.peerconnection.addEventListener('track', (e) => {ui.remoteVideo.srcObject = e.streams;});});// 靜音/保持事件currentSession.on('muted', () => {ui.muteButton.classList.add('active');ui.muteButton.textContent = '取消靜音';});currentSession.on('unmuted', () => {ui.muteButton.classList.remove('active');ui.muteButton.textContent = '靜音';});currentSession.on('hold', () => {ui.holdButton.classList.add('active');ui.holdButton.textContent = '恢復通話';});currentSession.on('unhold', () => {ui.holdButton.classList.remove('active');ui.holdButton.textContent = '保持';});
}// 5. 動作綁定
ui.connectButton.addEventListener('click', () => {if (ua && ua.isRegistered()) {ua.unregister();} else if (ua && ua.isConnected()) {ua.stop();} else {try {const socket = new JsSIP.WebSocketInterface(ui.wsServerInput.value);const configuration = {sockets: [socket],uri: ui.sipUriInput.value,password: ui.sipPasswordInput.value,register: true};ua = new JsSIP.UA(configuration);setupUaEventHandlers();ua.start();} catch (e) {alert(`配置錯誤: ${e.message}`);}}
});ui.callButton.addEventListener('click', () => {const target = ui.dialInput.value;if (!target) {alert('請輸入要呼叫的 SIP URI 或號碼');return;}const options = {mediaConstraints: { audio: true, video: true },pcConfig: {iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]}};ua.call(target, options);
});ui.hangupButton.addEventListener('click', () => {if (currentSession) {currentSession.terminate();}
});ui.answerButton.addEventListener('click', () => {if (currentSession) {const options = {mediaConstraints: { audio: true, video: true }};currentSession.answer(options);showIncomingCallToast(false);}
});ui.rejectButton.addEventListener('click', () => {if (currentSession) {currentSession.terminate({ status_code: 486, reason_phrase: 'Busy Here' });showIncomingCallToast(false);}
});ui.muteButton.addEventListener('click', () => {if (currentSession && currentSession.isMuted().audio) {currentSession.unmute({ audio: true });} else if (currentSession) {currentSession.mute({ audio: true });}
});ui.holdButton.addEventListener('click', () => {if (currentSession && currentSession.isOnHold().local) {currentSession.unhold();} else if (currentSession) {currentSession.hold();}
});ui.dtmfButton.addEventListener('click', () => {ui.dialpad.classList.toggle('hidden');
});// 6. 初始化
function initialize() {// 生成撥號盤DIALPAD_BUTTONS.forEach(btn => {const button = document.createElement('button');button.textContent = btn;button.addEventListener('click', () => {if (currentSession && currentSession.isEstablished()) {currentSession.sendDTMF(btn);} else {ui.dialInput.value += btn;}});ui.dialpad.appendChild(button);});// 默認狀態updateConnectionStatus('disconnected');showInCallControls(false);
}// 啟動應用
initialize();
至此,我們的軟電話已經具備了所有核心功能。它能夠連接、注冊、撥打、接聽、掛斷、靜音、保持和發送 DTMF。代碼結構清晰,UI 和邏輯分離,方便后續的擴展和維護。
第十二章:完整代碼與展望
恭喜你!你已經跟隨本書的腳步,從一個對 SIP 和 WebRTC 一無所知的 JavaScript 開發者,成長為能夠獨立構建一個功能完善的 Web 軟電話的實踐者。本章將為你提供最終的、完整的項目代碼,并為你未來的學習和探索之路指明方向。
最終項目代碼
我們已經將所有功能整合到了三個文件中:index.html
(結構),style.css
(樣式),以及 main.js
(邏輯)。上一章已經展示了 index.html
和 main.js
的完整代碼,style.css
也已提供。這三個文件共同構成了一個可以獨立運行的 Web 軟電話項目。
如何運行 Demo
要運行這個項目,你需要:
-
獲取
jssip.min.js
: 從JsSIP
官網下載頁面或npm
包的dist
目錄中找到最新版本的jssip.min.js
文件,并將其與index.html
,style.css
,main.js
放在同一個文件夾下。 -
獲取 SIP 賬戶信息: 你需要一個可用的 SIP 賬戶,包括:
- WebSocket 服務器地址 (如
wss://sip.example.com
) - 你的 SIP URI (如
sip:1001@example.com
) - 你的密碼
- WebSocket 服務器地址 (如
-
運行本地 Web 服務器: 由于瀏覽器安全策略的限制(特別是
getUserMedia
API 需要在安全上下文https
或localhost
中運行),你不能直接通過file://
協議打開index.html
。你需要在項目文件夾中啟動一個簡單的本地 Web 服務器。如果你安裝了 Node.js,可以使用http-server
包:Bash
# 安裝 http-server (如果尚未安裝) npm install -g http-server# 在你的項目文件夾中運行 http-server
然后,在瀏覽器中打開它提供的地址(通常是
http://localhost:8080
)。 -
配置并連接: 在打開的網頁中,填入你的 SIP 賬戶信息,點擊“連接”。如果一切順利,狀態指示燈將變為綠色,你就可以開始撥打電話了!
未來展望
你已經構建了一個堅實的基礎,但實時通信的世界遠不止于此。以下是一些你可以繼續探索的方向:
- 通話轉移 (Call Transfer):
JsSIP
支持通過session.refer()
方法實現通話轉移(包括盲轉和咨詢轉)44。你可以研究這個 API,為你的軟電話添加“轉移”按鈕。 - 多方通話 (Conference Calls):
JsSIP
本身是點對點通信的庫。要實現三人或更多人的通話,通常需要一個中心化的媒體服務器,如 MCU(多點控制單元)或 SFU(選擇性轉發單元)。你可以研究如何將JsSIP
客戶端連接到像 Janus, Jitsi, 或 Medooze 這樣的開源媒體服務器。 - 在線狀態 (Presence): SIP 協議包含了一套基于
SUBSCRIBE
和NOTIFY
方法的在線狀態(Presence)和訂閱機制。你可以利用它來訂閱一個聯系人列表的狀態,并在你的軟電話界面上顯示他們是“在線”、“離線”還是“通話中”。 - 構建生產級應用: 本書的 Demo 是一個學習工具。在構建真正的生產級應用時,你還需要考慮更多:
- 安全性: 永遠不要在客戶端代碼中硬編碼密碼。認證信息應通過安全的后端服務獲取。
- 可靠的 TURN 服務: 依賴公共 STUN 服務器是不夠的。生產應用必須部署自己的、地理分布的、高可用的 TURN 服務器,以保證在各種網絡環境下的通話成功率。
- 完善的 UI/UX: 進行更深入的用戶研究,設計更友好的交互流程,處理各種邊緣情況(如網絡斷開重連、設備切換等)。
- 質量監控: 集成 WebRTC 的
getStats()
API,監控通話質量指標(如丟包率、延遲、抖動),以便分析和優化通話體驗。
結語
實時通信是一個充滿挑戰但又極具價值的領域。通過本書的學習,你不僅掌握了 JsSIP
這個優秀的工具,更重要的是,你理解了其背后 SIP 和 WebRTC 的核心原理。這份知識將成為你未來探索更廣闊 RTC 世界的通行證。
希望這本書能成為你 RTC 開發之旅的起點。不斷實踐,不斷探索,你將能夠創造出更多連接人與人的精彩應用。祝你編碼愉快!