19、聊天与协作:文字聊天、表情与富文本、白板协作、文件共享
多人视频会议做到这一步,音视频通了,信令也稳了。但说实话,一个会议系统能不能留住用户,往往不是看画面清不清晰,而是看「能不能边聊边干活」。我见过太多项目,音视频流跑得飞起,结果用户吐槽「连个表情都发不了」——这就很尴尬了。
这一章,我们聊聊会议里的「非实时」协作。文字聊天、表情回复、富文本编辑、白板画图、文件传输。这些东西看似简单,但跟 WebRTC 结合时,坑其实不少。
核心思路:聊天和协作走的是数据通道(DataChannel),不是媒体流。DataChannel 基于 SCTP 协议,可靠又灵活,非常适合传这些「小包但重要」的信息。
19.1 文字聊天:最基础也最容易翻车
文字聊天是所有协作功能的起点。实现起来不复杂,但要做好,有几个细节得注意。
我习惯把聊天消息设计成 JSON 格式,通过 DataChannel 发送。每条消息至少包含:发送者 ID、昵称、时间戳、消息内容、消息类型。
// 消息结构示例
{
"type": "chat",
"from": "user_abc123",
"name": "张三",
"timestamp": 1695000000000,
"content": "大家能听到吗?",
"msgType": "text"
}
发送端用 dataChannel.send() 把 JSON 字符串发出去。接收端解析后渲染到聊天面板。嗯,这里要注意:DataChannel 默认是可靠的,但如果你用的是 ordered: false 模式,消息可能乱序到达。我建议聊天场景用 ordered: true,保证消息顺序不乱。
小技巧:聊天消息最好带上一个本地自增的序列号。万一网络抖动导致消息重复或丢失,客户端可以根据序列号做去重和补序。我在项目中吃过这个亏——用户发了两条消息,结果对方看到的是反的,排查了半天才发现是 DataChannel 的乱序问题。
19.2 表情与富文本:别让用户只会发纯文本
纯文本聊天太枯燥了。用户想要表情、加粗、超链接,甚至代码块。怎么办?
我的做法是:前端用富文本编辑器,后端只传 HTML 或 Markdown。具体选哪个,看你的团队偏好。
- 方案一:传 HTML。直接用
contentEditable或 Quill.js 这类库,用户编辑完直接取 innerHTML,通过 DataChannel 发送。接收端用dangerouslySetInnerHTML(React)或innerHTML渲染。 - 方案二:传 Markdown。用户输入 Markdown 文本,发送前用库(如 marked.js)转成 HTML,或者直接传原始 Markdown,接收端再渲染。我个人更推荐这个方案,因为 Markdown 更安全,不容易被 XSS 攻击。
安全警告:千万不要直接渲染用户输入的 HTML!我曾经见过一个会议系统,有人在聊天里插入了 <script>alert('xss')</script>,结果所有参会者的浏览器都弹了窗。一定要做 HTML 转义或使用安全的渲染库。
表情包怎么处理?简单点,用 Unicode emoji 就够了。如果非要自定义表情,可以定义一套短代码,比如 :smile: 对应一个图片 URL,发送时传短代码,接收端解析成图片。注意图片要提前加载好,别让用户看到裂图。
19.3 白板协作:画布同步才是硬骨头
白板是多人协作里最「炫」的功能,也是最容易出问题的。说白了,白板就是一块共享画布,每个人在上面画线、画框、写字,所有操作要实时同步到其他人。
实现思路有两种:
- 操作同步(推荐):每个用户的操作(比如「从 (x1,y1) 画线到 (x2,y2)」)作为一个事件,通过 DataChannel 广播。其他人收到事件后,在自己的画布上重放这个操作。这样画布状态始终一致。
- 状态同步:每隔一段时间把整个画布截图或序列化后发出去。简单但带宽消耗大,而且容易冲突。
我建议用操作同步。每条操作事件大概长这样:
{
"type": "whiteboard",
"action": "draw",
"tool": "pen",
"points": [[100, 200], [105, 205], [110, 210]],
"color": "#ff0000",
"width": 3,
"userId": "user_abc123"
}
接收端用 Canvas API 把这些点连成线。注意:所有操作要按时间顺序执行,否则画布会乱。我建议用一个操作队列,按时间戳排序后依次执行。
避坑指南:白板操作要支持「撤销」。但多人场景下,撤销不能只删自己最后一步,而是要删「上一步操作」,不管是谁画的。我曾经实现过一个方案:每个操作有一个全局唯一的 ID,撤销时广播一个 undo 事件,带上要撤销的操作 ID。其他人收到后,从操作历史里移除该操作,并重新绘制整个画布。
画布大小怎么定?我习惯用固定比例,比如 16:9。所有坐标都归一化到 0-1 之间,这样不同屏幕尺寸的用户看到的白板比例一致。发送时传归一化坐标,接收端再根据本地画布尺寸换算回去。
19.4 文件共享:别用 DataChannel 传大文件
文件共享是协作的刚需。但这里有个常见的误区:不要用 DataChannel 传大文件。
DataChannel 虽然可靠,但它是基于 SCTP 的,没有拥塞控制。传几个 KB 的文本没问题,传几十 MB 的图片或 PDF,很容易把音视频流的带宽抢光,导致会议卡顿。
我的做法是:小文件走 DataChannel,大文件走 HTTP 或 WebTorrent。
| 文件大小 | 传输方式 | 说明 |
|---|---|---|
| < 1 MB | DataChannel | 直接转 Base64 或 ArrayBuffer 发送 |
| 1 MB ~ 50 MB | HTTP 上传 + 下载 | 上传到服务器,广播下载链接 |
| > 50 MB | WebTorrent 或类似 P2P | 点对点传输,不经过服务器 |
具体实现时,用户选择文件后,先判断大小。小文件直接读成 ArrayBuffer,通过 DataChannel 发送。接收端收到后,用 Blob 对象重建文件,触发下载。
// 发送小文件
const file = fileInput.files[0];
const reader = new FileReader();
reader.onload = () => {
const arrayBuffer = reader.result;
dataChannel.send({
type: "file",
name: file.name,
size: file.size,
mimeType: file.type,
data: arrayBuffer
});
};
reader.readAsArrayBuffer(file);
大文件我推荐用 WebTorrent。它基于 WebRTC 的 DataChannel 实现了 BitTorrent 协议,可以做到浏览器之间的点对点文件传输,不占用服务器带宽。用户上传文件后,生成一个 magnet 链接,通过 DataChannel 广播给其他人。其他人拿到链接后,直接用 WebTorrent 客户端下载。
经验之谈:文件传输一定要显示进度条。用户最怕的就是「点了发送,不知道发没发完」。DataChannel 的 bufferedAmount 属性可以告诉你还有多少数据没发出去,配合 bufferedAmountLowThreshold 事件,可以实现流控和进度反馈。
19.5 知识体系总览
下面这张图概括了本章的核心逻辑。你可以看到,所有协作功能都建立在 DataChannel 之上,但不同的数据类型走不同的处理路径。
你看,整个架构其实不复杂。核心就是一条:DataChannel 是高速公路,但不同的车要走不同的车道。文字和表情是小轿车,跑得快;白板操作是公交车,要按站停靠;文件是大货车,得走专门的货运通道。
我在做第一个多人会议项目时,把所有数据都塞进 DataChannel,结果视频卡成幻灯片。后来才明白,DataChannel 和媒体流共享带宽,你这边传大文件,那边视频就得降质。所以一定要做分流和优先级控制。
最后说一句:聊天和协作功能,用户感知最强。音视频卡顿用户可能忍了,但消息发不出去、白板画不上、文件传不过去,用户会直接骂娘。所以,测试时一定要模拟弱网环境,看看这些功能在丢包 10% 的情况下还能不能用。
公众号:蓝海资料掘金营,微信deep3321