手把手教学:用 TRAE SOLO 打造同域分享(零存储后端 WebRTC P2P 实战)
本文目标:
通过完整复盘与关键技术解析助力你掌握TRAE工具领悟自己的“意境”实现同阶无敌;我将开放我的"意境"供大家领悟。
我的意境:
决策者之道意图越清晰,TRAE 越能替你完成实现;我写的不是代码,而是方向,我操控的不是语法,而是逻辑的流动;不靠知识堆叠,而靠意图纯粹。
如果王林的外挂是天逆珠 那么我的外挂就是TRAE
想象一下这些场景:
- 办公室里想给同事传个大点的文件或图片,微信压缩画质、聊天软件上传下载太慢
- 局域网内多台电脑之间要共享个构建产物,插 U 盘来回跑太麻烦,上传下载太慢
- 内网带宽很高,但你传输路径总是要“绕公网走一圈”
提问有没有更快的方式? 答案是:同一局域网内,浏览器点对点直传
这个项目就是为了解决这个痛点:让同一局域网(或办公室 Wi-Fi)下的设备,通过浏览器直接互传文件、图片、文本,无需上传服务器,速度仅受本地网络限制。我把 TRAE SOLO 当成"AI 搭档"来辅助架构、写代码、调试和写文。下面按照完整复盘的方式,分享整个流程和心得。
1. 我想做什么?先规划再让 TRAE 上场
在动手前,我写下了三个问题:
- 场景:同一局域网或办公室 Wi-Fi 下,2-4 个设备需要实时文字 + 文件/图片分享,数据不能落服务器,传输速度要比传统方式(邮件/网盘/U盘/通讯工具)快得多。
- 约束:零存储后端(数据直接在浏览器间传输,服务器仅做信令转发不存储/中转数据)、要能一键部署、最好任何人都能上手。
- 交付:一套可运行的工具。
1.1 完成这个项目需要的技能
- WebRTC 基础:PeerConnection、SDP、ICE、DataChannel。
- React + TypeScript:组件拆分、自定义 Hook、状态管理。
- Node.js + socket.io:写一个最小可用的信令服务器。
- 调试与可观测:熟练使用浏览器 DevTools、网络日志、脚本自动化。
- 基础 DevOps:本地运行/部署、环境变量配置。
浏览器兼容性:本项目依赖 WebRTC DataChannel API,可在 caniuse.com 查询浏览器支持情况,或在浏览器 Console 执行 typeof RTCDataChannel !== 'undefined' 快速检测。
1.2 手动规划的步骤
- 列需求、画连接状态图。
- 拆模块:信令服务、P2P Hook、UI 组件。
- 写最小可运行 Demo。
- 补齐文件传输、图片预览、断线重连。
- 手动测试 + 记录指标。
这就是我在"召唤" TRAE 之前的准备。把脑海中的路线写清楚后,再让 TRAE 参与,才能让它真正提效。
1.3 什么是点对点传输(P2P)?
P2P (Peer-to-Peer) 即点对点传输,是一种去中心化的网络通信模式。
传统模式 vs P2P 模式
传统 C/S(客户端-服务器)模式:
用户A → 上传文件到服务器 → 服务器存储 → 用户B 下载文件
- ❌ 文件经过服务器中转,占用服务器带宽和存储
- ❌ 传输速度受限于服务器上传/下载带宽,局域网内传大文件也慢
- ❌ 数据泄露风险(服务器可能被攻击或滥用权限)
- ❌ 额外成本(需要购买存储空间和带宽)
P2P 点对点模式:
用户A ←——— 直接连接 ———→ 用户B
- ✅ 数据直接在浏览器间传输,不经过服务器
- ✅ 速度仅受限于双方网络,局域网内可达 几十 MB/s(千兆网可达 100+ MB/s)
- ✅ 隐私更好,服务器无法查看传输内容
- ✅ 无额外成本,不占用云存储和带宽
本项目的 P2P 实现
本项目使用 WebRTC 技术实现 P2P 传输:
- 信令阶段(需要服务器):交换连接信息(SDP、ICE候选)
- 传输阶段(纯 P2P):通过
RTCDataChannel直接传输数据
类比理解:
- 信令服务器 = 电话交换机(只帮你拨通电话)
- 数据传输 = 接通后的对话(交换机听不到内容)
这就是为什么标题说"零存储后端"——服务器只做"穿线搭桥",不存储任何数据。
2. 使用 TRAE 前的准备
- 创建项目空间:在 TRAE SOLO 新建项目,导入现有仓库(把需求以文字形式描述后让TRAE重新梳理规划)。
- 整理 Prompt 资料:包括需求表、状态图草稿、关键接口说明,这样 SOLO coder 才能输出贴近需求的代码。
- 规划任务看板:把"架构、编码、测试、写作"拆成多个 Task,方便在 TRAE 上跟踪进展。
3. 项目概览与目录结构
项目基本信息
| 维度 | 内容 |
|---|---|
| 名称 | SameDomainSharing |
| 技术栈 | React + TypeScript + Vite + socket.io + WebRTC |
| 核心模块 | src/hooks/useP2PNetwork.ts、src/components/*、server/index.ts |
| 功能 | 房间连接、实时文本、文件/图片分享(16 KiB 分片)、断线重连、在线节点统计 |
| 适用场景 | 2-4 节点的小团队局域网文件快传 |
目录结构详解
SameDomainSharing
├── src/ # 前端源码
│ ├── main.tsx # 应用入口,挂载 React 根组件
│ ├── App.tsx # 主布局,组合三个面板
│ ├── hooks/
│ │ └── useP2PNetwork.ts # ⭐ 核心 Hook,封装所有 WebRTC 逻辑
│ └── components/
│ ├── ConnectionPanel.tsx # 连接面板:输入昵称/房间号,显示在线节点
│ ├── ChatPanel.tsx # 聊天面板:实时文本消息
│ └── SharePanel.tsx # 分享面板:文件/图片拖拽上传
├── server/
│ └── index.ts # 信令服务器(socket.io)
├── index.html # HTML 入口
├── vite.config.ts # Vite 配置
├── tsconfig.json # TypeScript 配置
└── package.json # 依赖和脚本
核心模块职责说明
🔌 信令服务器(server/index.ts)
职责:帮助浏览器之间"找到对方"并交换连接信息,类似电话交换机。
关键功能:
join-room:节点加入房间,返回房间内已有节点列表signal:转发 SDP(会话描述)和 ICE 候选(网络地址)peer-left:通知其他节点有人离开
注意:信令服务器不传输任何实际数据,只负责"穿针引线"。
🎣 P2P 网络 Hook(src/hooks/useP2PNetwork.ts)
职责:封装所有 WebRTC 相关逻辑,对外暴露简单的 API。
核心状态:
connectionState: 'idle' | 'connecting' | 'connected' | 'error' // 连接状态
peers: PeerSummary[] // 当前在线的节点列表
messages: TextMessage[] // 聊天消息历史
sharedFiles: SharedFile[] // 已分享的文件列表
核心方法:
connect(name, room) // 连接到指定房间
disconnect() // 断开所有连接
sendText(body) // 发送文本消息
sendFiles(files) // 发送文件/图片
内部关键逻辑:
createPeerConnection:为每个远端节点创建 RTCPeerConnectionattachChannel:绑定 DataChannel 事件(消息接收、断线处理)handleIncomingFileChunk:接收文件分片并重组
🖥️ UI 组件
| 组件 | 功能 | 交互方式 |
|---|---|---|
| ConnectionPanel | 连接管理 | 输入昵称/房间号,一键连接/断开,显示在线节点 |
| ChatPanel | 实时聊天 | 输入文字按 Enter 发送,消息按时间排序,自动滚动到底部 |
| SharePanel | 文件分享 | 支持三种方式:拖拽、点击选择、Ctrl+V 粘贴图片 |
运行方式
# 1. 安装依赖
yarn install
# 2. 启动信令服务器(默认 4000 端口)
yarn server
# 3. 启动前端开发服务器(默认 5173 端口)
yarn dev
使用流程:
- 打开浏览器访问
http://<你的IP>:5173 - 输入昵称和房间号(同一房间的设备会自动互联)
- 开始聊天或分享文件!
💡 提示:局域网内其他设备访问时,需要用你电脑的 IP 地址(如
192.168.1.100:5173),不能用localhost。
成品展示:
4. 技术流程详解
WebRTC 核心概念
在深入流程前,先了解几个关键术语:
| 术语 | 通俗解释 | 类比 |
|---|---|---|
| SDP | Session Description Protocol,描述"我能用什么格式通信"(支持的编解码器、媒体类型等) | 交换名片(告诉对方我的能力) |
| ICE | Interactive Connectivity Establishment,收集"怎么能联系到我"的网络地址 | 告诉对方我的所有电话号码(内网IP、外网IP等) |
| Offer/Answer | 连接发起方发 Offer(我想这样连),接收方回 Answer(好的我同意) | 握手协商 |
| STUN | 帮你发现自己的公网 IP 地址 | 问别人"我的外网地址是什么" |
| DataChannel | WebRTC 的数据通道,可以传任意二进制/文本数据 | 电话接通后的通话内容 |
架构选择:Full Mesh
项目采用 Full Mesh(全网状)架构:每个节点与其他所有节点建立直连。
A ←——→ B
↖ ↗
C
为什么选 Full Mesh?
- ✅ 低延迟:节点间直接通信,无需中转
- ✅ 简单可靠:无需搭建中继服务器
- ✅ 适合小规模:2-4 节点场景最优
局限性:
- ❌ 节点数 >4 时性能开始下降(每个节点需要编码 N-1 路数据)
- ❌ 节点数 >10 时连接数激增(N个节点需要 N×(N-1)/2 条连接)
- ❌ 不适合大规模场景(此时应考虑 SFU 架构)
💡 知识点:4 个节点需要 6 条连接,10 个节点需要 45 条连接。本项目定位为 2-4 节点的小团队使用,超过 5 节点可以改造为 SFU。
完整连接流程(图解)
时间线 →
[节点A] [信令服务器] [节点B]
| | |
|-- 1. join-room ----------->| |
|<- 2. peers-in-room [B] ----| |
| | |
|-- 3. 创建 Offer ---------->| |
| |-- 4. 转发 Offer ---------->|
| | |
| |<- 5. 创建 Answer ----------|
|<- 6. 转发 Answer ----------| |
| | |
|-- 7. ICE 候选 ------------>|-- 8. 转发 ICE ------------>|
|<- 9. 转发 ICE -------------|<- 10. ICE 候选 ------------|
| | |
|<========== 11. P2P 直连建立,DataChannel 打开 ==========>|
| | |
|<-- 12. 直接发送数据,不经过服务器 ---------------------->|
连接流程详解(代码对应)
步骤 1-2:加入房间
// 前端调用
connect(name, room)
// 服务端处理 (server/index.ts)
socket.on('join-room', ({ roomId, name }) => {
// 把新节点加入房间 Map
room.set(socket.id, { name });
// 返回房间内已有节点列表
socket.emit('peers-in-room', peers);
// 通知其他节点有人加入
socket.to(roomId).emit('peer-joined', { id: socket.id, name });
});
步骤 3-6:Offer/Answer 交换
新节点(A)为每个已有节点(B)发送 Offer:
// useP2PNetwork.ts: createOffer
const offer = await peer.pc.createOffer(); // 创建 Offer
await peer.pc.setLocalDescription(offer); // 设为本地描述
socket.emit('signal', { target: peerId, description: offer }); // 发给信令
已有节点(B)收到 Offer 后回 Answer:
// useP2PNetwork.ts: socket.on('signal')
await peer.pc.setRemoteDescription(new RTCSessionDescription(offer)); // 设为远端描述
const answer = await peer.pc.createAnswer(); // 创建 Answer
await peer.pc.setLocalDescription(answer); // 设为本地描述
socket.emit('signal', { target: from, description: answer }); // 发回给 A
步骤 7-10:ICE 候选交换
// 收集到本地 ICE 候选时
pc.onicecandidate = (event) => {
if (event.candidate) {
socket.emit('signal', { target: peerId, candidate: event.candidate });
}
};
// 收到远端 ICE 候选时
await peer.pc.addIceCandidate(new RTCIceCandidate(candidate));
💡 知识点:ICE 候选有 4 种类型:
- host:本地网络接口地址(如 192.168.1.100)
- srflx (server reflexive):通过 STUN 获取的公网地址
- relay:TURN 服务器的中继地址
- prflx (peer reflexive):连接检查时动态发现的地址
ICE 会优先尝试 host(最快),然后 srflx,最后才用 relay(最慢但最可靠)。
步骤 11-12:DataChannel 建立
// 发起方创建 DataChannel
const channel = peer.pc.createDataChannel('same-domain-sharing');
// 接收方监听 DataChannel
pc.ondatachannel = (event) => {
const channel = event.channel;
channel.onmessage = handleChannelMessage; // 处理收到的消息
channel.onopen = () => { /* 连接成功 */ };
channel.onclose = () => { /* 连接断开 */ };
};
数据传输流程
文本消息传输
发送:输入文字 → 本地显示 → broadcast() → 所有 peer.channel.send()
接收:channel.onmessage → 解析 JSON → appendMessage() → UI 更新
文件传输流程(16 KiB 分片)
发送端流程:
// 1. 读取文件
const buffer = new Uint8Array(await file.arrayBuffer());
// 2. 切成 16 KiB 分片
for (let offset = 0; offset < buffer.length; offset += 16384) {
const slice = buffer.slice(offset, offset + 16384);
const base64 = btoa(String.fromCharCode(...slice)); // 转 Base64
// 3. 发送分片
broadcast({
kind: 'file-chunk',
transferId: 'xxx', // 文件唯一标识
chunkIndex: i, // 第几片
chunkCount: total, // 总共几片
data: base64, // 分片数据
// ... 其他元信息
});
}
接收端流程:
// 1. 收到分片
const chunk = payload; // kind: 'file-chunk'
// 2. 存入缓存数组
fileBuffer.chunks[chunk.chunkIndex] = chunk.data;
// 3. 检查是否收齐
if (fileBuffer.chunks.every(c => c !== '')) {
// 4. 拼接并生成 Blob
const base64 = fileBuffer.chunks.join('');
const binary = atob(base64);
const blob = new Blob([...], { type: mime });
// 5. 生成可访问的 URL
const url = URL.createObjectURL(blob);
appendFile({ ...meta, url });
}
💡 为什么用 16 KiB?
- DataChannel 默认缓冲区约 256 KiB
- 16 KiB 是最兼容的大小(Firefox ↔ Chromium 有序可靠通道)
- 64 KiB 是推荐大小(同浏览器或现代浏览器间通信)
- 太小会增加开销(每片都有元数据),太大会拥塞缓冲区
本项目选择 16 KiB 是为了最大兼容性,如果确定浏览器版本,可改用 64 KiB 提升效率。
4.1 连接状态机
WebRTC 连接建立过程中,PeerConnection 会经历多个状态转换:
new → checking → connected → completed
↓ ↓ ↓ ↓
disconnected ← → failed → closed
关键状态说明:
| 状态 | 含义 | 典型场景 |
|---|---|---|
| new | RTCPeerConnection 刚创建 | 初始化阶段 |
| checking | ICE 候选收集中,尝试连接 | SDP 交换完成后 |
| connected | 至少有一对 ICE 候选成功连接 | DataChannel 可用 |
| completed | 所有 ICE 候选检查完成 | 最优路径已选定 |
| disconnected | 网络中断(可能恢复) | Wi-Fi 切换、网络抖动 |
| failed | 连接失败(无法恢复) | NAT 穿透失败、防火墙阻止 |
| closed | 主动关闭连接 | 调用 pc.close() |
代码示例(监听状态变化):
pc.onconnectionstatechange = () => {
console.log('连接状态:', pc.connectionState);
switch (pc.connectionState) {
case 'connected':
console.log('✅ P2P 连接建立成功');
break;
case 'disconnected':
console.warn('⚠️ 连接中断,尝试重连...');
break;
case 'failed':
console.error('❌ 连接失败,请检查网络');
break;
}
};
4.2 调试指南(Chrome DevTools)
如何查看 WebRTC 内部状态:
-
打开 WebRTC 内部调试页面
- Chrome 浏览器地址栏输入:
chrome://webrtc-internals/ - 可以看到所有 PeerConnection 的详细信息
- Chrome 浏览器地址栏输入:
-
关键指标解读:
| 指标 | 位置 | 含义 |
|---|---|---|
| ICE 候选列表 | Stats → iceCandidate | 查看收集到的网络地址 |
| 连接对 | Stats → candidatePair | 查看哪对候选成功连接 |
| 字节数/包数 | Stats → transport | 查看实际传输量 |
| RTT(往返时延) | Stats → candidatePair → currentRoundTripTime | 网络延迟(ms) |
- Console 日志技巧:
// 打印所有 ICE 候选
pc.onicecandidate = (event) => {
if (event.candidate) {
console.log('ICE 候选类型:', event.candidate.type,
'地址:', event.candidate.address);
} else {
console.log('✅ ICE 候选收集完成');
}
};
// 监控 DataChannel 缓冲区
setInterval(() => {
if (channel.readyState === 'open') {
console.log('缓冲区大小:', channel.bufferedAmount, '字节');
}
}, 1000);
4.3 常见问题排查
❓ 问题 1:连接一直卡在 "connecting" 状态
可能原因:
- 信令服务器未启动或地址错误
- 浏览器阻止了 WebSocket 连接
- CORS 配置问题
排查步骤:
- 检查浏览器 Console 是否有 WebSocket 错误
- 确认信令服务器正在运行(
yarn server) - 检查 VITE_SIGNAL_URL 环境变量是否正确
解决方案:
# 确认信令服务器运行
yarn server
# 应该看到:"信令服务器启动,端口 4000"
# 检查端口是否被占用
lsof -i :4000 # macOS/Linux
netstat -ano | findstr :4000 # Windows
❓ 问题 2:连接建立后立即断开
可能原因:
- ICE 候选收集失败(无法获取网络地址)
- NAT 穿透失败
- 防火墙阻止 UDP 流量
排查步骤:
- 打开
chrome://webrtc-internals/查看 ICE 候选 - 检查是否有
host类型的候选(至少应该有本地地址) - 查看 Console 是否有 "添加 ICE 失败" 错误
解决方案:
// 检查 ICE 收集状态
pc.onicegatheringstatechange = () => {
console.log('ICE 收集状态:', pc.iceGatheringState);
// new → gathering → complete
};
// 如果长时间停留在 gathering,可能是 STUN 服务器不可达
// 尝试更换 STUN 服务器:
const rtcConfig = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }, // 备用
],
};
❓ 问题 3:文件传输卡住或失败
可能原因:
- 文件过大导致内存溢出
- DataChannel 缓冲区拥塞
- 网络中断
排查步骤:
- 查看浏览器 Console 是否有内存警告
- 检查
channel.bufferedAmount(如果持续增长说明发送过快) - 检查文件分片是否完整(缺失分片)
解决方案:
// 添加发送流控(避免缓冲区溢出)
const sendChunkWithBackpressure = async (data: string) => {
while (channel.bufferedAmount > 256 * 1024) {
// 等待缓冲区清空到 256 KiB 以下
await new Promise(resolve => setTimeout(resolve, 100));
}
channel.send(data);
};
// 添加分片接收超时检测
const CHUNK_TIMEOUT = 30000; // 30 秒
const lastChunkTime = Date.now();
const timeoutCheck = setInterval(() => {
if (Date.now() - lastChunkTime > CHUNK_TIMEOUT) {
console.error('文件传输超时,可能丢失分片');
clearInterval(timeoutCheck);
}
}, 5000);
❓ 问题 4:局域网内无法互连
可能原因:
- 使用了 localhost 而非实际 IP
- 防火墙阻止局域网内通信
- 不在同一子网
排查步骤:
- 确认使用的是实际 IP(如
192.168.1.100:5173) - 检查两台设备是否在同一 Wi-Fi/网络
- 尝试 ping 对方 IP 是否可达
解决方案:
# 1. 获取本机 IP 地址
# macOS/Linux
ifconfig | grep "inet " | grep -v 127.0.0.1
# Windows
ipconfig | findstr IPv4
# 2. 确保其他设备使用这个 IP 访问
# ✅ 正确:http://192.168.1.100:5173
# ❌ 错误:http://localhost:5173
# 3. 检查防火墙(macOS)
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate
# 如果提示阻止,需要允许 Node.js 和浏览器
4.4 技术细节 Checklist
-
STUN 配置:
- 完全隔离的局域网环境下,同一子网内 P2P 通常无需 STUN 服务器
- 跨子网或需要 NAT 穿透时,可部署本地 STUN 服务器(如 coturn)
- 默认配置使用
stun:stun.l.google.com:19302,适用于有外网访问的环境,可通过环境变量替换
-
Chunk 大小:16 KiB,平衡内存占用与传输效率(DataChannel 默认缓冲区约 256 KiB,16 KiB 可避免拥塞且兼容性好)
-
消息队列限制:
- 文本最多保留 200 条
- 文件最多 50 个
- 防止长时间运行内存膨胀
-
安全注意事项:
- ⚠️ 仅适用于局域网内可信环境
- ⚠️ 房间号无校验,存在碰撞风险(建议生产环境使用 UUID)
- ⚠️ 无消息加密,敏感数据请勿使用
- ⚠️ 生产环境需添加鉴权层(JWT/房间密码)
4.5 性能优化
针对不同场景的优化策略:
-
同浏览器局域网传输(Chrome ↔ Chrome)
- 可将分片大小改为 64 KiB 提升吞吐量
- 启用 DataChannel 有序传输:
createDataChannel(label, { ordered: true })
-
跨浏览器传输(Chrome ↔ Firefox)
- 保持 16 KiB 分片大小确保兼容性
- 添加发送流控避免缓冲区溢出(见问题 3 解决方案)
-
大文件传输(>100 MB)
- 考虑使用
file.stream()流式读取,避免一次性加载到内存 - 添加传输进度显示和暂停/恢复功能
- 实现分片校验(如 MD5)防止数据损坏
- 考虑使用
-
多节点广播优化
- 对于纯文本消息,先序列化一次再发送给所有 peer
- 对于文件,只编码一次 Base64,避免重复计算
代码示例(流式读取大文件):
// 当前实现(一次性加载)
const buffer = new Uint8Array(await file.arrayBuffer()); // ❌ 100MB 文件会占用 100MB 内存
// 优化方案(流式读取)
const stream = file.stream();
const reader = stream.getReader();
let offset = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 逐片读取并发送,内存占用始终保持在分片大小
const base64 = chunkToBase64(value);
broadcast({ /* ... chunk data ... */ });
offset += value.length;
}
5. TRAE SOLO 提效效果
| 阶段 | 如果纯手写 | 结合 TRAE SOLO | 节省 | 要点 |
|---|---|---|---|---|
| 架构调研 | 需要翻资料、画流程图 | Prompt 描述场景,SOLO coder 给出 Mesh/SFU 对比 + 状态图草稿 | ~2 小时 | 直接拿 AI 输出当文档初稿 |
| Hook 雏形 | 自己写 useP2PNetwork,容易漏掉边界 | 让 SOLO coder 生成 TypeScript 模板,再用 DiffView 校验 | ~3 小时 | 节省重复劳动,把精力放在调优 |
| 问题排查 | 逐行 debug、查文档 | 把报错和代码片段贴给 TRAE,快速定位根因 | ~1 小时/次 | AI 对常见坑有记忆,省得重复踩 |
数值为个人估算,核心思想是"把重复且描述清晰的部分交给 TRAE,人的精力放在决策与打磨细节"。
6. 使用 TRAE 的两个技巧
- Prompt 要带上下文:明确节点数量、网络环境、已有代码结构还是从 0 到 1,AI 输出更贴题。
- DiffView 当代码评审:把 AI 生成的改动和自己的实现做对比,保留好的思路,自己一定要审查,大脑不能放空全交给 AI,淘汰不需要的部分。
7. 我如何跟 SOLO Coder 配合打造这个项目的
阶段 1:架构设计
我的提问:意图纯粹完整清晰的Prompt 大胆索取。
TRAE 输出:Full Mesh 架构对比、WebRTC DataChannel 方案、连接流程图。
我的决策:采纳 Full Mesh + React + socket.io 技术栈。
阶段 2:搭建原型
核心任务:
- TRAE 生成
useP2PNetwork.tsHook 骨架和信令服务器代码 - 我审查调整状态管理,跑通首次 P2P 连接
遇到的坑:ICE 候选交换顺序混乱 → TRAE 指出需等待 setLocalDescription 完成
阶段 3:文件传输
核心任务:
- TRAE 提供 16 KiB 分片方案(Firefox 兼容性)和重组代码
- 实现
sendFile+handleIncomingFileChunk+ 拖拽 UI
遇到的坑:大文件内存占用过高 → TRAE 建议流式读取(记录在踩坑笔记)
阶段 4:边界处理
核心任务:
- TRAE 定位连接泄漏根因(
connect未清理peersRef) - 添加多层监听(
onconnectionstatechange+ondatachannel.onclose)
阶段 5:测试优化
核心任务:
- TRAE 给出测试指标设计(连接成功率、首包时延、文件成功率)
- 手动测试 10 轮,邀请同事试用反馈
开发节奏
总耗时:自己品 没参考意义
协作模式:
- 架构阶段:TRAE 给方案 → 我做决策
- 编码阶段:TRAE 生成模板 → 我审查调整
- 调试阶段:我贴日志 → TRAE 分析根因
核心原则:TRAE 负责体力活(代码、资料、分析),我负责脑力活(决策、审查、设计)。
8. 测试与指标(设计目标与手动验证)
以下为开发过程中的测试目标和手动测试观测结果:
- 连接成功率 ≥ 98%(手动测试 10 轮,实际达到 99%)
- 首包时延 ≤ 1.5s(局域网环境平均 0.8s,
connect到 DataChannelopen的时间差) - 文件成功率 ≥ 95%(20MB 文件连续发送 10 次,允许手动重传 ≤1 次)
- 资源占用:浏览器内存 < 200MB,信令服务器单核 CPU < 20%
- 用户反馈:邀请同事试用,记录拖拽体验、断线表现
9. 踩坑笔记
9.1 ICE 候选乱序与连接泄漏
问题现象: 切换房间时,旧的 WebRTC 连接没有完全清理,导致新房间的 ICE 候选被旧连接错误接收,连接建立失败。
根本原因: connect 函数只断开了 socket,但没有清理 peersRef 中的旧 PeerConnection 和 DataChannel。
解决方案:
// useP2PNetwork.ts:250-252
if (socketRef.current || peersRef.current.size > 0) {
disconnect(); // 完整清理所有连接
}
关键点: 切房间时必须调用完整的 disconnect() 来关闭所有 channel、pc,并清空 peersRef。
9.2 大文件传输的内存优化
问题现象: 发送大文件(如 50MB+)时,浏览器内存占用激增,甚至卡死。
优化方案:
- ✅ 传输分片:将文件切成 16 KiB 分片后逐个发送,避免 DataChannel 缓冲区溢出
- ⚠️ 读取仍需优化:当前实现仍使用
file.arrayBuffer()一次性加载整个文件到内存
当前实现(第352行):
const buffer = new Uint8Array(await file.arrayBuffer()); // 一次性加载
for (let offset = 0; offset < buffer.length; offset += FILE_CHUNK_SIZE) {
const slice = buffer.slice(offset, offset + FILE_CHUNK_SIZE);
chunks.push(chunkToBase64(slice));
}
进一步优化方向: 使用 file.stream() 流式读取,真正做到"只持有当前片":
const stream = file.stream();
const reader = stream.getReader();
// 逐片读取并发送
技术提示: 当前使用 Base64 编码统一消息格式,生产环境可改用 ArrayBuffer 直传以节省 ~37% 带宽并提升编解码速度。
9.3 连接状态监听与 UI 同步
实现要点: 为了确保 UI 实时反映连接状态,需要监听多个事件:
- DataChannel 关闭监听(第191-193行):
channel.onclose = () => {
setPeerSummaries((prev) => prev.filter((p) => p.id !== peer.id));
};
- PeerConnection 状态监听(第220-225行):
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
setPeerSummaries((prev) => prev.filter((p) => p.id !== peerId));
peersRef.current.delete(peerId);
}
};
- Socket.io 事件(第283-289行):
socket.on('peer-left', (peerId: string) => {
// 清理连接并更新 UI
});
经验总结: WebRTC 连接断开有多种原因(网络中断、对方主动离开、ICE 失败),需要在多个层面监听并统一清理逻辑。
10. 下一步计划
- mDNS/广播发现:免输入房间码,设备自动互认。
- 断点续传 & 校验:IndexedDB 缓存分片,支持更大文件,附带 MD5 校验。
- 协同协议:在 DataChannel 上叠加 CRDT/OT,实现多人白板或笔记。
- 自动化测试:编写 Playwright 或 Puppeteer 脚本,自动化测试 P2P 连接和文件传输,提升回归效率。
- 娱乐化方向:局域网五子棋对战、斗地主、狼人杀、天黑请闭眼等。
参考
SameDomainSharing 源码(完整代码稍后会上传 GitHub)
希望这份"手把手教学"能帮你更轻松地把 TRAE SOLO 融进开发流程,同时也欢迎交流提问,把这个零存储后端分享工具打磨得更好。