正在整理这一页的内容…
Project Detail
用 WebRTC 做一个局域网文件传输工具
这篇笔记记录一下我对一个局域网文件传输工具的理解。 我以前经常遇到一个很小但很烦的问题: 如果文件不大,这样当然也能用。 但我总觉得这个过程有点重。明明两台设备就在同一个 WiFi 下面,为什么不能直接打开一个网页,把文件传过去? 后来我用 WebRTC 做了一个小工具: 它不是网盘,也不是聊天软件。 我更愿意把它理解

这篇笔记记录一下我对一个局域网文件传输工具的理解。
我以前经常遇到一个很小但很烦的问题:
只是想把手机里的一个文件传到电脑,
或者把电脑上的一个安装包传到手机,
却要打开微信、登录账号、找到文件助手。如果文件不大,这样当然也能用。
但我总觉得这个过程有点重。明明两台设备就在同一个 Wi-Fi 下面,为什么不能直接打开一个网页,把文件传过去?
后来我用 WebRTC 做了一个小工具:
两台设备打开同一个网页
-> 扫码或输入房间号
-> 建立连接
-> 直接传文字和文件它不是网盘,也不是聊天软件。
我更愿意把它理解成一个临时传输通道。
先说结论
这个工具的核心思路是:
Socket.IO 负责让两台设备互相找到对方。
WebRTC DataChannel 负责真正传消息和文件。也就是说,服务器不负责保存文件。
服务器只做连接建立前的“牵线”工作。等 WebRTC 连接建立成功以后,文件数据就通过浏览器之间的 DataChannel 传输。
可以粗略理解成:
配对阶段:
设备 A -> 信令服务器 -> 设备 B
传输阶段:
设备 A -> WebRTC DataChannel -> 设备 B这里最重要的分工是:
信令服务器负责建立连接前的信息交换。
DataChannel 负责连接建立后的数据传输。为什么需要信令服务器
一开始我对 WebRTC 有个误解。
我以为既然 WebRTC 是点对点通信,那两台浏览器应该可以直接连上。
但后来发现,浏览器并不知道自己应该连接谁。
比如电脑打开网页以后,它不知道:
- 手机在哪里。
- 对方支持什么连接方式。
- 对方的网络地址候选有哪些。
- 双方谁先发起连接。
所以 WebRTC 建连之前,需要先通过一个普通服务器交换信息。
这个过程叫信令。
信令服务器本身不一定复杂。它只需要帮两端传话:
设备 A:这是我的 offer。
服务器:我转给设备 B。
设备 B:这是我的 answer。
服务器:我转给设备 A。
设备 A/B:这是我的 ICE Candidate。
服务器:我转给另一边。真正的数据传输不一定经过它。
项目大概结构
这个项目用 React + Vite 写前端,用 Socket.IO 写信令服务。
大概结构是:
src/
App.jsx
components/
QrPanel.jsx
ChatPanel.jsx
hooks/
useWebRtc.js
server/
index.js可以粗略理解成:
| 文件 | 作用 |
|---|---|
App.jsx | 管理页面状态、房间号、Socket.IO 连接 |
QrPanel.jsx | 生成房间二维码,也支持扫码加入 |
ChatPanel.jsx | 展示消息、选择文件、提供下载入口 |
useWebRtc.js | 管理 RTCPeerConnection 和 DataChannel |
server/index.js | Socket.IO 信令服务器 |
依赖里比较关键的是:
socket.io和socket.io-client:做房间和信令转发。qrcode:把房间链接变成二维码。@zxing/browser:调用摄像头扫描二维码。- React 和 Vite:负责页面和本地开发。
房间号解决了什么问题
这个工具不需要账号。
两台设备要配对,就需要一个临时凭证。
这里用的是房间号。
页面打开后生成一个随机房间名,比如:
A7K2然后把它拼到当前页面地址里:
http://电脑IP:5173/?room=A7K2再生成二维码。
手机扫码后,就能打开同一个地址,并自动带上同一个房间号。
这时两台设备都知道:
我要加入 A7K2 这个房间。前端连接信令服务器后,会发送:
socket.emit("join-room", {
roomName: normalizedRoom,
});服务端把同一个房间里的两个 socket 放到一起。
为什么一个房间只放两个人
这个工具的目标很窄:
一台设备传给另一台设备。所以一个房间限制最多两个人会让逻辑简单很多。
服务端可以这样分配角色:
第一个加入的人:initiator
第二个加入的人:receiver当第二个人加入后,服务端广播:
pairing-success前端收到以后,就可以开始 WebRTC 建连。
如果房间里已经有两个人,再有人加入,就返回:
room-full这样可以避免一个文件不知道发给谁,也减少误传的可能。
WebRTC 建连大概做了什么
WebRTC 连接主要由 RTCPeerConnection 管理。
发起方大概做这些事:
创建 RTCPeerConnection
-> 创建 DataChannel
-> createOffer()
-> setLocalDescription()
-> 通过信令服务器发送 offer代码大概是:
const peer = new RTCPeerConnection({
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
],
});
const channel = peer.createDataChannel("chat");
const offer = await peer.createOffer();
await peer.setLocalDescription(offer);
sendSignal("signal-sdp", peer.localDescription);另一端收到 offer 后:
setRemoteDescription(offer)
-> createAnswer()
-> setLocalDescription(answer)
-> 通过信令服务器发送 answer发起方再收到 answer:
setRemoteDescription(answer)这样双方就交换了基本连接描述。
SDP 和 ICE 是什么
WebRTC 里经常会看到两个词:
SDP
ICE Candidate可以先不用理解得太底层。
SDP 可以粗略理解成:
我这边支持什么连接能力,以及我想怎么建立连接。ICE Candidate 可以粗略理解成:
我这边可能可以被连接到的网络地址候选。信令服务器不需要真正理解它们。
它只需要转发:
socket.on("signal-sdp", ({ roomName, payload, sdp } = {}) => {
socket.to(roomName || socket.data.roomName).emit(
"signal-sdp",
payload ?? sdp,
);
});
socket.on("signal-ice", ({ roomName, payload, candidate } = {}) => {
socket.to(roomName || socket.data.roomName).emit(
"signal-ice",
payload ?? candidate,
);
});所以这个信令服务器很薄。
它更像一个中转员,不像一个业务服务器。
STUN 和 TURN 的区别
项目里配置了一个 STUN:
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
]STUN 的作用是帮助浏览器发现自己在 NAT 后面的地址情况。
在同一个局域网里,手机和电脑通常比较容易建立连接。
因为浏览器可以发现局域网地址,两台设备之间网络距离也近。
但如果网络很复杂,比如:
- 两边不在同一个网络。
- 公司网络做了隔离。
- NAT 类型比较严格。
- 跨运营商环境不稳定。
这时只靠 STUN 可能不够。
就需要 TURN。
TURN 会作为中继服务器帮忙转发数据。
它的连接成功率更高,但代价是:
数据会经过 TURN 服务器中转,
不再是完全点对点直连。所以这个工具定位局域网传输时,先用 STUN 就够理解主流程了。
DataChannel 像什么
WebRTC 建连成功以后,就可以使用 DataChannel。
它的使用体验有点像 WebSocket:
channel.send("hello");
channel.onmessage = (event) => {
console.log(event.data);
};但方向不一样。
WebSocket 是:
浏览器 -> 服务器DataChannel 是:
浏览器 -> 浏览器这个区别很重要。
如果只是传一条文字消息,可以包装成 JSON:
channel.send(JSON.stringify({
type: "message",
text,
}));对方收到后解析:
const message = JSON.parse(event.data);如果 type 是 message,就追加到消息列表。
文件为什么要分片传输
传文字很简单。
传文件就不能这么粗暴。
如果直接把一个大文件一次性塞进 DataChannel,可能会带来几个问题:
- 浏览器缓冲区被撑满。
- 页面内存突然升高。
- 无法显示传输进度。
- 连接不稳定时不好恢复。
所以更稳妥的方式是分片。
这个项目的协议大概是:
file-start
-> chunk
-> chunk
-> chunk
file-end也就是:
- 先告诉对方文件开始了。
- 再一片一片发送二进制数据。
- 最后告诉对方文件结束了。
文件开始消息
发送文件前,先发一个元信息:
channel.send(JSON.stringify({
type: "file-start",
id,
name: file.name,
size: file.size,
}));这条消息告诉接收端:
接下来有一个文件要来了。
它叫什么名字。
它有多大。
它的 ID 是什么。文件 ID 很重要。
如果以后支持多个文件同时或连续传输,就需要用 ID 区分这些分片属于哪个文件。
文件分片发送
文件内容按固定大小切片。
例如每片 16KB:
const CHUNK_SIZE = 16 * 1024;
for (let offset = 0; offset < file.size; offset += CHUNK_SIZE) {
const chunk = await file
.slice(offset, offset + CHUNK_SIZE)
.arrayBuffer();
channel.send(chunk);
}这里发送的是二进制 ArrayBuffer。
接收端收到二进制数据后,把它追加到当前文件的分片列表里。
等所有分片都到齐,再拼成一个完整文件。
为什么要处理 bufferedAmount
这里有一个容易忽略的问题。
channel.send() 调用成功,不代表数据已经真正发到对方设备了。
它可能只是先进入浏览器的发送缓冲区。
如果发送端循环太快:
for (...) {
channel.send(chunk);
}缓冲区可能越堆越多。
所以项目里加了一个等待逻辑:
await waitForWritable(channel);它会检查:
channel.bufferedAmount如果待发送的数据太多,就先等一等,直到 bufferedamountlow 事件触发。
这个过程可以理解成背压控制。
对方或网络处理不过来时,
发送端不要继续无脑塞数据。这个细节对文件传输很重要。
文件结束消息
分片发送完成后,再发送结束标记:
channel.send(JSON.stringify({
type: "file-end",
id,
}));接收端收到 file-end 后,就知道这个文件已经传完。
然后可以把前面收到的所有分片合并:
const blob = new Blob(file.chunks);
const url = URL.createObjectURL(blob);最后生成一个下载链接。
这意味着接收文件不是:
上传到服务器
-> 再从服务器下载而是:
对方浏览器发来二进制分片
-> 本机浏览器在内存里拼成 Blob
-> 生成本地下载地址为什么它适合局域网
这个工具特别适合手机和电脑在同一个 Wi-Fi 下临时传文件。
原因有几个:
- 不需要登录账号。
- 扫二维码就能加入同一个房间。
- 文件数据不占用业务服务器带宽。
- 局域网延迟低,连接通常更直接。
- 页面关闭后连接自然结束。
它解决的不是长期文件管理问题。
它解决的是:
我现在就想把这个文件传到另一台设备上。因为需求很窄,所以整个方案也可以做得很轻。
安全边界在哪里
这个项目的安全边界比较简单。
房间号就是临时配对凭证。
知道房间号的人才能加入房间。
服务端限制每个房间最多两个人。
WebRTC 传输本身也会加密。
但这不代表它就是完整权限系统。
如果房间号很短,比如只有 4 位,理论上可能被猜到。
如果要长期部署到公网,最好继续增强:
- 使用更长的随机房间码。
- 给房间设置过期时间。
- 加一次性 token。
- 加入房间时让已有设备确认。
- 限制尝试加入频率。
临时局域网工具可以简单一点。
公网长期服务就不能只靠一个短房间号。
还有哪些限制
当前方案还有一些现实限制。
大文件会占内存
接收端把分片放在内存里,最后用 Blob 合并。
中小文件没问题。
但特别大的文件可能会占用很多内存。
后续可以考虑 File System Access API,把文件流式写入本地。
网络复杂时可能连不上
同一个 Wi-Fi 下通常比较顺。
但跨网络、严格 NAT、公司内网隔离时,连接可能失败。
这时需要 TURN 提高成功率。
断点续传还没做
如果传输到一半断开,现在通常要重新传。
要做得更完整,需要继续设计:
- 分片序号。
- 文件哈希。
- 已接收范围。
- 重连后续传。
对于一个轻量工具来说,可以先不做。
但如果要传大文件,这些能力迟早会变重要。
我最后想通的地方
这个项目的核心不是“做一个聊天软件”。
它真正想解决的是一个很具体的小问题:
两台在附近的设备,临时传一点东西。围绕这个目标,技术分工就变得很清楚:
二维码负责快速加入房间
Socket.IO 负责信令和配对
WebRTC 负责建立点对点连接
DataChannel 负责传消息和文件
分片协议负责让文件稳定传输我以前容易把 WebRTC 想得很复杂。
但拆开以后会发现,它在这个项目里主要做两件事:
先通过信令交换连接信息
再通过 DataChannel 传真实数据信令服务器不是文件服务器。
DataChannel 也不是普通 WebSocket。
理解这两个边界以后,整个工具就清楚了:
服务器只牵线,
浏览器之间真正传文件。这就是它轻的地方,也是它适合局域网临时传输的原因。