简介:这是一套基于 Electron 框架的 UDP 调试工具完整源码,使用 JavaScript 编写,面向需要快速搭建 UDP 客户端与服务器场景的开发者,也适合网络相关课程演示。应用支持添加任意数量的 UDP 客户端和服务器,并为每个实例指定主机与端口;内置随机数据生成器,可按固定时间间隔从客户端自动发送数据,用于验证或测试其他 UDP 服务。压缩包内共 30 个文件,包括 12 个 JavaScript 逻辑模块、5 个 CSS 样式、HTML 页面入口及 JSON 配置等,体积仅 301KB,轻量易部署,便于直接阅读和二次开发;附带的 README 文档与界面预览图能帮助使用者快速上手。已有 1300 人学习下载。借助该资源,读者可快速启动并打包为独立桌面程序,代码中客户端管理、服务器管理、数据生成器等模块层次清晰,不仅可作为日常网络调试工具,也能作为学习 Electron 桌面应用开发的可运行范例。
1. 一个用 Electron 写 UDP 调试工具的真实需求
做网络调试的时候,最烦的就是手里没有趁手的 UDP 发包工具。TCP 有各种现成客户端,但 UDP 数据报这种面向无连接的东西,要么得装庞大的网络分析软件,要么就得临时写脚本。这个 electron-udp 项目解决的就是这个问题:它是一个基于 Electron 和 JavaScript 的桌面应用,把 UDP 客户端和服务器都塞进了一个图形界面里,可以创建本地 UDP 端点来发送和接收数据报,适合做协议调试、设备通信测试、局域网服务探测这类场景。
我拆这个项目的时候最大的感受是:它麻雀虽小,但把 Electron 主进程和渲染进程之间怎么协作、Node.js 原生模块怎么暴露给界面层这些基本功展示得很完整。对刚接触 Electron 的开发者来说,这是一个能快速跑通、而且能直接改成自己工具的起点;对熟手来说,它的模块边界划分也有可参考的地方。这篇笔记就沿着「项目在做什么 → 核心代码怎么实现 → 怎么跑起来 → 怎么改」这条线,把整个项目完整过一遍。
2. 项目定位与技术选型:为什么用 Electron 做 UDP 调试工具
2.1 这个项目要解决什么问题
UDP 调试最原始的做法是写 Node.js 脚本,用dgram模块在终端里收发数据,配合console.log看输出。这个方式有几个明显的痛点:一是数据不直观,十六进制和字符串混在一起看,多几条消息就花了眼;二是没法同时管两端的收发,客户端和服务器通常要开两个终端窗口,切换来切换去容易乱。
electron-udp 的做法是做一个带界面的桌面应用,把 UDP 服务器和客户端放进同一个窗口里,启动、停止、发送、清空记录都靠按钮完成,收发的每条数据报按时间顺序展示在列表里。这样一来,本地协议联调、嵌入式设备通信测试这类场景,就能在一个工具里闭环完成。
它的适用人群很明确:做物联网设备调试的开发者、写自定义 UDP 协议的嵌入式工程师、需要快速验证网络服务的后端开发。项目本身不复杂,但围绕「UDP 数据报收发」这件事,把完整链路都打通了。
2.2 技术栈与架构分层
这个项目的技术栈核心是三类:
- Electron:负责桌面应用外壳,提供主进程和渲染进程的运行时环境。
- Node.js 原生 dgram 模块:UDP socket 的创建、绑定、发送和接收全部由它完成,运行在主进程侧。
- HTML/CSS/JavaScript 前端:负责界面渲染和用户交互,通过 IPC(进程间通信)调用主进程的能力。
整体架构沿用了 Electron 标准的双进程模型。主进程持有 dgram 的 socket 实例,负责真正的网络 I/O;渲染进程只负责界面展示和用户操作,不直接触碰网络能力。两者之间的数据交换通过ipcMain和ipcRenderer完成。
这个分层有一个很实际的考量:Electron 渲染进程出于安全策略默认开启了contextIsolation,窗口里不能直接用 Node.js 的require。把网络操作全部收敛到主进程,渲染进程只通过预先暴露的接口发指令,结构更干净,也符合 Electron 的安全实践。
2.3 目录结构与职责划分
项目源码拆解之后,核心部分大致长这样:
electron-udp/ ├── main.js # 主进程入口,创建窗口、注册 IPC、管理 UDP socket ├── preload.js # 预加载脚本,通过 contextBridge 暴露安全接口 ├── renderer/ │ ├── index.html # 界面结构:服务器区、客户端区、消息记录区 │ ├── style.css # 界面样式 │ └── renderer.js # 渲染进程逻辑,绑定按钮事件、渲染消息列表 └── package.json # 项目配置、依赖和启动脚本main.js是核心,所有 UDP 能力都在这里;preload.js是渲染进程和主进程之间的桥梁;renderer目录下是纯前端代码。这个结构对一个小项目来说是合理的——网络逻辑和界面逻辑完全分离,想替换 UI 层只需要改renderer目录,想改协议处理只需要动main.js。
2.4 主进程与渲染进程的职责边界
把代码读一遍之后会发现,项目的职责边界非常清楚:
主进程(main.js)负责:创建 BrowserWindow 窗口、创建和销毁 UDP socket、处理ipcMain收到的各种请求、把收到的数据报推送给渲染进程。
渲染进程(renderer.js)负责:读取用户输入的 IP、端口、消息内容,把这些参数通过ipcRenderer发给主进程,然后把主进程返回的结果展示在界面上。
preload.js是两者的桥梁,通过contextBridge.exposeInMainWorld把sendMessage、startServer这类方法挂到window对象上,渲染进程调用window.electronUDP.sendMessage(...)时,实际上是向主进程发起了 IPC 请求。
这个设计的巧妙之处在于:渲染进程完全不感知 UDP 协议的细节,它只知道自己调了一个方法,传进去了字符串,然后收到了结果。协议层面的东西全部隔离在主进程里,后续要换 TCP、换 WebSocket,界面层几乎不用动。
3. 核心模块拆解:一条 UDP 数据报从进入到返回的完整链路
3.1 服务器端:创建 socket、绑定端口、监听消息
服务器端的核心代码在主进程里,流程很直接:创建 dgram socket、调用bind绑定端口、监听message事件。
// main.js 服务器端核心逻辑 const dgram = require('dgram'); let serverSocket = null; function startUdpServer(port, onMessage) { // 创建 IPv4 的 UDP socket serverSocket = dgram.createSocket('udp4'); serverSocket.on('error', (err) => { console.error(`服务器错误: ${err.message}`); serverSocket.close(); }); serverSocket.on('message', (msg, rinfo) => { // rinfo 包含发送方的地址和端口 const text = msg.toString('utf-8'); onMessage({ from: `${rinfo.address}:${rinfo.port}`, content: text, time: new Date().toLocaleTimeString() }); }); serverSocket.bind(port, () => { const address = serverSocket.address(); console.log(`UDP 服务器已启动: ${address.address}:${address.port}`); }); }这段代码的逻辑链路是:dgram.createSocket('udp4')创建一个 UDP socket,bind(port)让它监听指定端口,之后所有发到这个端口的数据报都会触发message回调。回调收到的msg是 Buffer 类型,需要调用toString()转成字符串;rinfo里带了发送方的 IP 和端口,这是回显来源地址的关键信息。
有一个细节值得注意:dgram.createSocket支持udp4和udp6两种协议族参数。这个项目默认用udp4,如果需要支持 IPv6 场景,把这里的参数换成udp6,绑定时再处理一下地址格式就行。另外,bind的第二个参数是回调函数,它在绑定成功后才触发,在这个时机读取serverSocket.address()能拿到实际分配的端口——如果不指定端口号让系统随机分配,就需要靠这个回调里的返回值去确认最终端口。
3.2 客户端:发送数据报与回调处理
客户端的核心是send方法,它的完整参数链值得仔细看。
// main.js 客户端发送逻辑 function sendUdpMessage(host, port, message, callback) { const clientSocket = dgram.createSocket('udp4'); const buffer = Buffer.from(message, 'utf-8'); clientSocket.send(buffer, 0, buffer.length, port, host, (err) => { if (err) { callback({ success: false, error: err.message }); } else { callback({ success: true }); } // 发送完成后立即关闭,避免 socket 泄漏 clientSocket.close(); }); }clientSocket.send的参数顺序是(buffer, offset, length, port, host, callback)。offset和length表示从 Buffer 的哪个位置开始发送、发多少字节,这在一段 Buffer 里只发部分数据时很有用。callback在数据报被交给系统网络栈之后触发,注意它不等于对端已经收到——UDP 本身是无连接的,这个回调只代表本机发出成功。
这个实现里每次发送都新建一个 socket,发送完立刻关闭,这是一种「短连接」式用法。优点是简单直接,不需要维护 socket 生命周期;缺点是高频发送时频繁创建销毁 socket 会有性能损耗。如果你的场景是每秒几十上百条消息的连续发送,更好的做法是复用同一个 socket,这点在后面的二次开发章节会展开。
3.3 UI 层与 IPC 通信:preload 的桥接设计
渲染进程不能直接调用 Node.js 模块,这是 Electron 的安全边界。项目通过 preload 脚本和 IPC 机制打通这条链路。
// preload.js 暴露安全接口给渲染进程 const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('electronUDP', { startServer: (port) => ipcRenderer.invoke('udp:start-server', port), stopServer: () => ipcRenderer.invoke('udp:stop-server'), sendMessage: (host, port, message) => ipcRenderer.invoke('udp:send', host, port, message), onMessage: (callback) => { // 订阅主进程推送的消息事件 ipcRenderer.on('udp:message-received', (event, data) => callback(data)); } });// renderer.js 界面绑定与消息渲染 const sendBtn = document.getElementById('sendBtn'); const hostInput = document.getElementById('hostInput'); const portInput = document.getElementById('portInput'); const messageInput = document.getElementById('messageInput'); const messageList = document.getElementById('messageList'); sendBtn.addEventListener('click', async () => { const host = hostInput.value.trim(); const port = parseInt(portInput.value, 10); const message = messageInput.value; // 调用 preload 暴露的方法,经由 IPC 到达主进程 const result = await window.electronUDP.sendMessage(host, port, message); if (result.success) { appendMessage(`[发送] 目标 ${host}:${port} 内容: ${message}`); } else { appendMessage(`[发送失败] ${result.error}`); } }); // 订阅消息接收事件 window.electronUDP.onMessage((data) => { appendMessage(`[接收] 来源 ${data.from} 内容: ${data.content}`); });这套链路从界面按钮到 UDP socket,完整路径是:renderer.js里按钮的 click 事件 → 调用window.electronUDP.sendMessage→ preload 里的ipcRenderer.invoke发起 IPC 请求 → 主进程ipcMain.handle处理请求并执行实际的dgram发送 → 结果通过 Promise 返回给渲染进程。
ipcRenderer.invoke和ipcMain.handle是一对 Promise 风格的 IPC API,比传统的send+on方式更好用,因为调用方可以直接拿到异步结果,不需要额外的事件对账。这里onMessage用的是ipcRenderer.on,因为它是主进程主动推送的事件,属于「服务端 → 客户端」方向的单向通知,用事件模型更合适。
3.4 关键参数与行为说明
把 main.js 里涉及的关键参数整理一遍,方便改代码时对照:
| 参数 | 位置 | 说明 | 常见取值 |
|---|---|---|---|
udp4/udp6 | createSocket | 协议族 | 默认udp4,IPv6 环境改udp6 |
offset/length | send | 发送 Buffer 范围 | 整包发送时 offset=0, length=buffer.length |
port | bind/send | 绑定或目标端口 | 0-65535,1024 以下需要特权 |
host | send | 目标地址 | 本地测试用 127.0.0.1 |
rinfo.address | message回调 | 发送方 IP | 无法伪造,UDP 场景下可信 |
rinfo.port | message回调 | 发送方端口 | 回显给对方时要用它 |
两个容易搞混的端口概念:bind参数里的是本机监听端口,决定「数据报发到哪个端口能被这个 socket 收到」;send参数里的是目标端口,决定「数据报要发给对端的哪个端口」。在测试环境里,服务器绑定 12345,客户端发送到 127.0.0.1:12345,两者必须对应上。
另一个值得注意的行为差异:dgram socket 在绑定之后,可以用同一个 socket 既能接收又能发送。这个项目把服务器和客户端分开走了两条逻辑,但底层其实都是同一个 dgram API,只是关注点不同——服务器重在bind+message监听,客户端重在send。理解了这一点,你就知道为什么协议栈代码可以写得这么短了。
4. 运行、调试与避坑指南
4.1 环境准备与启动步骤
跑这个项目之前,先确认本机有 Node.js 环境和 npm。Electron 本身是跨平台的,Windows、macOS、Linux 都能跑,但 Windows 上如果遇到权限问题或者防火墙拦截,处理方式会略有差异。
# 安装依赖(Electron 二进制包较大,耐心等) npm install # 启动开发模式 npm start如果package.json里配置了start脚本指向electron .,npm start 就会拉起 Electron 窗口。首次启动时如果界面没出来,先在终端看有没有报错输出——最常见的两个问题:一是 Electron 镜像下载超时,二是 Node 版本过低导致 dgram API 不兼容。
启动后先做一个最简单的自测:在服务器区填一个端口,比如 12345,点击启动;然后切到客户端区,目标地址填127.0.0.1,目标端口填12345,输入一条测试消息,点击发送。正常的话,消息记录区会先显示[发送]的记录,紧接着服务器回调触发,显示[接收]的记录。这两条记录同时出现,就说明「发送 → 服务器监听 → 消息回调 → UI 展示」的完整链路已经通了。
4.2 一个值得做的联调验证
如果你身边有别的 UDP 服务,比如某个设备或者另一个终端的 Node 脚本,可以做跨端验证。在终端里跑一个最小化的接收端:
node -e "const dgram=require('dgram');const s=dgram.createSocket('udp4');s.bind(9999,()=>console.log('listening'));s.on('message',(m,r)=>console.log(r.address+':'+r.port+' '+m.toString()));"然后在这个工具的客户端区向127.0.0.1:9999发消息。如果终端里打印出了消息内容,说明这个 Electron 应用的 UDP 发送不只是在应用内部自嗨,而是真的能和外部的 Node.js 进程互通。这一步验证很重要——很多初学 Electron UDP 的人,第一次测试都是在同一窗口里自收自发,会误以为链路通了,实际上根本没有走到真正的网络层。
4.3 高频踩坑记录
这个项目我拆的时候踩了不少坑,下面这几条是最典型的,按「现象 → 原因 → 解决」写出来,大概率你也会遇到。
踩坑一:端口启动报 EADDRINUSE
现象:点击启动服务器,终端或者界面上报Error: listen EADDRINUSE: address already in use :::12345。
原因:这个端口已经被别的程序占用了。UDP 和 TCP 一样遵循端口独占原则,一个端口同时只能被一个 socket 绑定。最常见的情况是上次测试没关干净,进程还挂在后台。
解决:先用lsof -i:12345(macOS/Linux)或netstat -ano | findstr 12345(Windows)查占用进程,用 taskkill 或者 kill 掉对应进程后重试;要么直接把端口换一个。从那以后我每次写网络相关的代码,都会先把「端口占用检查」列进启动前 checklist。
踩坑二:服务器能启动,但客户端发消息后收不到任何回显
现象:点击发送,界面上[发送]记录正常,但没有任何[接收]记录出现。
原因:这不是代码 bug,大概率是数据报确实发出去了,但对端没有响应或者被防火墙拦了。UDP 不保证送达,也不存在 TCP 那样的连接建立过程,发送端永远无法感知对端是否真正消费了数据。如果发到公网地址,丢包更是家常便饭。
解决:先确认目标地址和端口是可达的——用 ping 测主机连通性,在本机测试时优先用127.0.0.1而不是局域网 IP;再看防火墙是否放行了 UDP 端口的入站规则。Windows 默认会拦截部分 UDP 入站流量,需要在防火墙设置里给 Electron 应用放行。另外记得,UDP 测试「发送 → 接收」必须是两个方向都有人处理,你的服务器端有没有在监听?代码分支是不是没走到?沿着代码路径逐段排查。
踩坑三:Windows 上中文消息显示乱码
现象:发送中文内容,对端收到的是测试这种乱码。
原因:没注意编码转换。dgram 收发的是 Buffer,底层字节流本身没有编码信息。代码里发送时用Buffer.from(message, 'utf-8')编码,接收端toString('utf-8')解码,两边一致才没问题。如果对端是 Java 写的服务,默认用的是 UTF-16,或者对端按 GBK 解码你的 UTF-8 字节,必然出现乱码。
解决:在自己项目里统一收发的编码方式。如果对接的是第三方系统,先确认对方用什么编码。另外,Buffer.from默认就是 utf-8,但显式写出来更不容易产生误会。我一般会建议所有处理网络收发的代码,在入口和出口都显式标注编码,这个习惯能省掉大量联调时间。
踩坑四:preload 里暴露的接口在渲染进程里 undefined
现象:打开开发者工具,输入window.electronUDP返回 undefined,界面上的按钮全部报错。
原因:preload 脚本没有被正确加载,或者contextIsolation配置和预加载脚本不匹配。Electron 从 12 版本开始默认开启contextIsolation,preload 脚本运行在隔离环境里,不能直接往window上挂属性,必须通过contextBridge。如果main.js里创建 BrowserWindow 时没有写preload路径,或者路径写错了,这个桥就不会建立。
解决:检查 BrowserWindow 的 webPreferences 配置:
// main.js 窗口创建配置 const win = new BrowserWindow({ width: 900, height: 600, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false } });注意nodeIntegration保持 false 是安全推荐做法,渲染进程不直接开 Node 集成,所有能力都走 preload 桥接。如果发现接口还是 undefined,在 preload 里加一句console.log('preload loaded'),看开发者工具的 console 里有没有输出——没有输出就说明 preload 压根没执行。
5. 二次开发:从 Demo 到工具的改造路径
5.1 改造方向一:增加十六进制收发与协议帧解析
字符串收发只是最基础的用法。做嵌入式或者自定义协议调试时,最常看到的是十六进制数据报。改造思路是在渲染进程里增加一个「文本/Hex」切换开关,发送时把用户输入从 hex 字符串转成 Buffer,接收时把 Buffer 转回 hex 展示。
// renderer.js 十六进制转换逻辑 function hexStringToBuffer(hex) { // 去掉空格,每两个字符当一组,转成字节 const clean = hex.replace(/\s+/g, ''); const bytes = new Uint8Array(clean.length / 2); for (let i = 0; i < bytes.length; i++) { bytes[i] = parseInt(clean.substr(i * 2, 2), 16); } return Buffer.from(bytes); } function bufferToHexString(buffer) { // Buffer 转 hex,每字节两位,用空格分隔 return Array.from(buffer) .map((byte) => byte.toString(16).padStart(2, '0')) .join(' '); }这个改动只需要动渲染进程和 preload 的传参类型,主进程里的 dgram 收发逻辑不用变化。在这个基础上,还可以进一步做协议帧解析——比如收到数据后按固定的头部长度和字段偏移拆解,把命令字、长度、载荷、校验和分列显示。这类改造对排查私有协议问题很有价值,聊到这儿,能顺手把一个带seq自增和timestamp的自定义帧格式直接解析出来。
5.2 改造方向二:复用 socket,支持多客户端会话管理
原始实现里每次发送都新建 socket,这在低频测试中可以接受,但做性能测试或者需要维护会话状态时就不太合适。改成复用 socket 的思路:启动时创建一个全局的 dgram socket,发送时不再创建销毁,接收时统一走一个message监听。
// main.js 复用 socket 方案的简化代码 const dgram = require('dgram'); // 全局共享 socket,应用启动后创建一次 const sharedSocket = dgram.createSocket('udp4'); sharedSocket.bind(() => { // 绑定随机端口,仅用于接收回包 console.log('shared socket bound'); }); sharedSocket.on('message', (msg, rinfo) => { // 同一个 socket 接收所有来源的回包,创建会话表 const sessionKey = `${rinfo.address}:${rinfo.port}`; handleResponse(sessionKey, msg); }); function sendWithSession(host, port, payload) { return new Promise((resolve, reject) => { const buffer = Buffer.from(payload); sharedSocket.send(buffer, 0, buffer.length, port, host, (err) => { if (err) return reject(err); // 等待对端回包,3 秒超时则报错 setTimeout(() => reject(new Error('response timeout')), 3000); }); }); }如果遇到的场景是「一个客户端要和多个不同地址的服务通信,每个地址维护独立会话」,那在共享 socket 上按rinfo.address:rinfo.port做会话表是常见做法。你可能会在改造更复杂的场景时遇到需要把回包和请求关联的问题——这个问题在 UDP 场景下的标准解法是「基于 socket 的并发超时管理,配上请求序列号做关联」。这个 Demo 的 restful 不做关联也能收发,但从工具走向生产,这一步绕不开。
5.3 改造方向三:打包发布与自动重连
开发调试跑通了,最终要分发给别人用,需要配置打包。这个项目加electron-builder或electron-packager之后,一条命令就能产出安装包。
# 安装打包工具 npm install --save-dev electron-builder # 打包 Windows 安装包 npx electron-builder --win # 打包 macOS 安装包(在 macOS 上执行) npx electron-builder --mac打包配置可以写在package.json的build字段里,指定appId、产品名、图标路径和输出目录。注意 Electron 应用打包后体积偏大,这是框架决定的,不要试图在这方面做什么极端优化——asar压缩机制在 resources 目录里放着,不必纠结。
另一个高频需求是自动重连机制。比如给设备调试时,UDP 服务器启动后如果因为端口冲突退出,应用应该自动尝试换个端口重绑,或者弹出提示而不是直接崩溃。这类逻辑可以加在main.js的error事件处理里,遇到EADDRINUSE时自动port + 1重试。从那以后我每次写完 Electron 的网络功能,都会强制走一遍「改端口重试 → 非法输入 → 异常断网」三个场景,确认应用不会直接挂掉再收工。
整体来说,这个 electron-udp 项目是一个把 Electron 主进程、IPC 桥接、Node.js 原生网络模块整合在一起的完整示例。沿着上面的路径做三次递进式改造(协议解析 → socket 复用 → 打包加固),你的能力就能从「跑通 Demo」提升到「做成工具」。如果改造过程中遇到问题,建议反复看 main.js 里 dgram 的调用方式和 preload 的桥接逻辑——项目的问题基本都集中在这两个文件里。希望帮到你。
本文还有配套的精品资源,点击获取