Skip to content

手工信令与DataChannel实践 ​

前置:理解 Promise、浏览器事件、信令与数据通道的区别;准备支持 RTCPeerConnection 的浏览器,本例不配置 STUN/TURN。教学范围为W3C WebRTC Recommendation;浏览器API;RFC8835传输。核对日期2026-10-17。示例未执行,本轮没有安装依赖、生成协议代码、启动服务或浏览器预览。完整源码和输入文件见下方「同源完整文件」;推演结论只适用于写明的模型。

完整手工信令数据应用 ​

完整手工信令数据应用包含单个HTML,两端通过复制粘贴JSON传递描述,无信令服务器;默认不配置STUN或TURN,只适合同机或路径允许的教学场景。等待候选收集完成再复制,避免漏传后续候选;本轮没有打开页面或建立连接。

DataChannel预期状态 ​

DataChannel预期状态是A创建offer,B设置远端offer并生成answer,A设置远端answer后等待open,再发送hello;对端应在日志看到hello。网络、浏览器权限和候选策略会让连接失败,预期状态不是已实测结果;关闭按钮会关闭通道与连接。协商尚未结束就关闭并重建时,旧异步操作只引用旧连接,不能把旧描述写入新会话;读者应额外按此顺序验证,重新交换新描述。

会话顺序练习 ​

会话顺序练习让B收到answer或A收到offer,判断哪个角色校验应拒绝;再在通道未open前点击发送,必须明确报错。修改为trickle ICE时需独立传递candidate并在合适时机addIceCandidate,不能继续只复制最初SDP。

同源完整文件 ​

以下README含前置、输入、读者自跑步骤、预期结果、失败、恢复与清理;只做静态核对,没有执行。

index.html ​

html
<!doctype html>
<html lang="zh-CN"><meta charset="utf-8"><title>手工信令 DataChannel</title>
<body><h1>手工信令 DataChannel(示例未执行)</h1>
<p>两端分别选A/B;依次复制完整描述。无STUN/TURN,不保证跨公网。</p>
<select id="role"><option>A</option><option>B</option></select>
<button id="start">创建连接</button><button id="offer">A创建offer</button>
<textarea id="local" aria-label="本地描述" readonly></textarea>
<textarea id="remote" aria-label="远端描述"></textarea>
<button id="accept">设置远端描述(B自动生成answer)</button>
<input id="message" value="hello" aria-label="消息"><button id="send">发送</button>
<button id="close">关闭</button><pre id="log"></pre>
<script>
let pc, channel, activeRole;
const el = id => document.getElementById(id);
const log = message => { el('log').textContent += message + '\n'; };
function bind(dc) {
  channel = dc;
  dc.onopen = () => log('open');
  dc.onmessage = event => log('received: ' + event.data);
  dc.onclose = () => log('channel closed');
  dc.onerror = () => log('channel error');
}
async function collected(connection) {
  if (connection.iceGatheringState === 'complete') return;
  await new Promise((resolve, reject) => {
    const timer = setTimeout(() => finish(new Error('ICE gathering timeout')), 10000);
    function finish(error) {
      clearTimeout(timer);
      connection.removeEventListener('icegatheringstatechange', check);
      error ? reject(error) : resolve();
    }
    function check() { if (connection.iceGatheringState === 'complete') finish(); }
    connection.addEventListener('icegatheringstatechange', check);
    check();
  });
}
function action(id, task) {
  el(id).onclick = async () => {
    try { await task(); } catch (error) { log(error.message); }
  };
}
action('start', async () => {
  if (pc) throw new Error('close the previous connection first');
  activeRole = el('role').value;
  const connection = new RTCPeerConnection({iceServers: []});
  pc = connection;
  pc.onconnectionstatechange = () => log('connection: ' + connection.connectionState);
  pc.oniceconnectionstatechange = () => log('ICE: ' + connection.iceConnectionState);
  pc.ondatachannel = event => { if (pc === connection) bind(event.channel); };
  if (activeRole === 'A') bind(pc.createDataChannel('chat', {ordered: true}));
});
action('offer', async () => {
  if (!pc || activeRole !== 'A' || pc.signalingState !== 'stable') throw new Error('A must be stable');
  const connection = pc;
  await connection.setLocalDescription(await connection.createOffer());
  await collected(connection);
  if (pc === connection) el('local').value = JSON.stringify(connection.localDescription);
});
action('accept', async () => {
  if (!pc) throw new Error('create connection first');
  const connection = pc, role = activeRole;
  const description = JSON.parse(el('remote').value);
  const expected = activeRole === 'B' ? 'offer' : 'answer';
  if (description.type !== expected || typeof description.sdp !== 'string') throw new Error('wrong role or description');
  await connection.setRemoteDescription(description);
  if (role === 'B') {
    await connection.setLocalDescription(await connection.createAnswer());
    await collected(connection);
    if (pc === connection) el('local').value = JSON.stringify(connection.localDescription);
  }
});
action('send', async () => {
  if (!channel || channel.readyState !== 'open') throw new Error('channel not open');
  if (channel.bufferedAmount > 65536) throw new Error('application buffer full');
  const text = el('message').value;
  if (new TextEncoder().encode(text).length > 1024) throw new Error('message exceeds 1024 bytes');
  channel.send(text);
  log('sent: ' + text);
});
action('close', async () => {
  if (channel) channel.close();
  if (pc) { pc.onconnectionstatechange = null; pc.oniceconnectionstatechange = null; pc.close(); }
  channel = undefined; pc = undefined;
  el('local').value = ''; el('remote').value = '';
  log('closed; a new session requires fresh descriptions');
});
</script></body></html>

signaling.schema.json ​

json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Manual SDP description",
  "type": "object",
  "required": [
    "type",
    "sdp"
  ],
  "properties": {
    "type": {
      "enum": [
        "offer",
        "answer"
      ]
    },
    "sdp": {
      "type": "string",
      "minLength": 1
    }
  },
  "additionalProperties": false
}

requirements.txt ​

text
# Browser built-in WebRTC APIs only; no npm or external service dependency.

README.md ​

md
# 实时通信与 WebRTC:完整示例

示例未执行。核对日期2026-10-17。教学版本族:W3C WebRTC Recommendation;浏览器API;RFC8835传输。

## 文件与依赖

文件:index.html, signaling.schema.json, requirements.txt。requirements.txt声明本例只使用浏览器内建WebRTC接口,无Python或npm依赖;所有代码和配置都随本文展示。signaling.schema.json描述交换格式,页面没有引入JSON Schema验证器。

## 输入与前置

前置:使用支持 RTCPeerConnection 与 DataChannel 的浏览器,打开示例 index.html 的 A、B 两份实例并固定各自角色。交换格式为本页 schema 描述的 JSON;无 Python、npm、信令服务器或 STUN/TURN 依赖。默认只适合同机或网络路径允许的教学场景,协商步骤见下段,预期与拒绝条件见下节。

读者将来自跑(本轮没有打开页面):在支持WebRTC的桌面浏览器中打开两份index.html,或由读者自己受控的安全静态站点提供文件。浏览器对file源和本地地址候选的限制可能不同,失败时先看连接日志;无STUN/TURN无法保证跨网络连通。两端先选A/B再创建连接,A创建offer并等待文本出现;复制A本地描述到B远端框,B接受后复制其answer回A,再由A接受。等待双方open后发送hello。关闭按钮清空描述并关闭连接;新会话重新交换。此例不调用摄像头、不采集音频、不持久保存消息,不要粘贴不可信会话描述。

## 预期结果、失败与恢复

A建连接并创建offer,完整收集后复制到B远端框;B建连接后设置offer并自动产生answer,复制回A;两端open后发送hello,对方日志预期received: hello。未open发送明确channel not open,错误角色拒绝。

失败先与正文的不变量及边界核对,分享日志时去除SDP中的敏感网络信息。没有真实服务或性能验证,不以这些模型输出证明生产可用性。公网中继不在本例依赖内;关闭并新建会话后,旧异步协商不得把描述写入新会话,须重新复制新描述。

## 独立练习

B粘贴answer、A粘贴offer时各会怎样;若想在尚未完成ICE收集就交换描述,需要补什么协议消息?

参考答案:角色校验先于setRemoteDescription拒绝两种错位。trickle ICE必须把每个candidate作为独立信令发到对端,通过addIceCandidate接入,还要处理远端描述尚未设置时的候选排队与结束标记;只提前复制SDP会漏候选。

先作答,再核对 ​

B粘贴answer、A粘贴offer时各会怎样;若想在尚未完成ICE收集就交换描述,需要补什么协议消息?

逐步参考解答

角色校验先于setRemoteDescription拒绝两种错位。trickle ICE必须把每个candidate作为独立信令发到对端,通过addIceCandidate接入,还要处理远端描述尚未设置时的候选排队与结束标记;只提前复制SDP会漏候选。

手工复制描述时的检查点 ​

本例没有独立候选消息,所以必须等待ICE收集完成后复制包含候选的描述。若提前复制,双方可能都设置了描述但缺少可用候选。关闭后重建要重新交换描述,不能把旧会话answer粘贴到新连接;应用应保持会话身份与角色匹配。

查看图表代码

来源与适用边界 ​

继续阅读