# JsSIP 从入门到实战:构建你的第一个 Web 电话
前言
欢迎来到实时通信(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 开发之旅的起点。不断实践,不断探索,你将能够创造出更多连接人与人的精彩应用。祝你编码愉快!