
简介面向 Web 前端初学者与实时通信开发者资源包内含可直接运行的 WebSocket 客户端测试页面。借助 HTML5 WebSocket API浏览器页面能够与服务器建立长连接实现双向数据收发适合用来理解握手建立、连接状态切换以及消息事件驱动的基本流程。资源包共 3 个文件大小仅 34KBindex.html 负责页面布局与用户交互入口jquery.min.js 提供便捷的 DOM 操作和事件绑定支持site.js 则集中封装 WebSocket 对象创建、连接监听、消息发送以及 onopen、onmessage、onerror、onclose 等关键回调逻辑。目前已有 698 人学习浏览。通过这个小而完整的示例可以快速掌握浏览器端 WebSocket 编程的核心写法同时了解 wss 加密传输、跨域来源限制等实际部署中容易忽略的要点简洁的目录结构也方便在此基础上继续扩展用于开发在线聊天、实时数据看板、多人协作工具等原型。1. 为什么要单独做 HTML 页面来测 WebSocket浏览器控制台里执行一句new WebSocket(ws://...)确实能连上一个 WebSocket 服务但它只能回答“连上没连上”这一个问题。真正的联调里你还要观察握手时带的鉴权参数对不对、断线后的 close code 是多少、二进制帧有没有被正确解析、重连时会不会重复注册事件。把这些反复出现的检查项固化成独立页面比每次在控制台敲命令可靠得多后端同事和运维也能直接拿着它做连接验证。这个标题本质上是两件事一是 WebSocket 通信的协议行为二是 HTML 页面作为测试工具的交互设计。页面不用做得多花哨把连接状态、收发帧、日志证据记录清楚就比绝大多数通用调试工具更适合日常联调。本文从协议握手讲起再给出一个可复现的本地测试工程最后落到帧级验证和排错手段上。2. WebSocket 协议与 HTML 页面 API测试要盯住的三层信号2.1 一次握手的 101 与之后的帧传输WebSocket 与 HTTP 的关系经常被说成“HTTP 升级”更准确的说法是客户端先发一条带Upgrade: websocket的 HTTP 请求服务端确认后返回101 Switching Protocols随后连接切换到 WebSocket 帧协议不再使用 HTTP 语义。这个细节直接决定了测试页的观察重点握手阶段看的是 101连接阶段看的是 close code 和帧内容不能用普通 HTTP 的状态码思维去套。浏览器端暴露的 WebSocket API 非常薄一个构造函数、四个回调事件、一个send方法、一个close方法。正因为接口少HTML 测试页的核心工作就是把每个回调触发时的event对象完整记录下来尤其是CloseEvent里的code与reason。我一般要求页面日志必须原样打出事件名、触发时间、readyState和event.data这样后端说“对端断了”的时候两边才能按同一时间轴对上话。需要区分“握手成功”与“连接保持中”两种情况。如果页面停在 CONNECTING 超过 3 秒说明 101 始终没回来问题大概率出在服务端没处理 Upgrade 请求或者端口路径不对如果页面显示 open 但 10 秒后自动变成 CLOSED这才轮到看 close code 和网络回收策略。2.2 四个事件与 readyState 的映射关系浏览器把 WebSocket 连接状态收敛成 4 个枚举值测试页界面上显示的状态应当直接跟这组值走而不是自己另维护一套 boolean 标记。否则很容易出现“界面显示在线实际连接早断了”的假象。readyState枚举名含义测试页该做什么0CONNECTING已发起握手尚未 101置灰发送按钮显示“连接中”1OPEN握手成功可以收发帧亮起连接灯启用发送与重连按钮2CLOSING一侧已发起关闭握手记录 close() 调用时间3CLOSED连接已关闭打印 code/reason准备重连四个回调的触发顺序值得背下来onopen后状态从 0 变 1onmessage只在 OPEN 状态出现onclose后状态为 3onerror之后通常立刻跟onclose。因此页面里的错误展示不要把onerror当最终结果真正拿到关闭原因的是onclose里的 code。排查时如果只看到 onerror 而 onclose 没有输出多半是页面代码在错误分支里提前 return 掉了。2.3 文本、二进制与心跳测试页必须区分的帧类型WebSocket 帧在浏览器端被还原成MessageEvent.data类型可能是 string 或 Blob/ArrayBuffer取决于服务端发的是文本帧还是二进制帧。测试页在onmessage里如果不做类型判断直接把 data 塞进div二进制内容会被强制转成字符串字节错乱后很难看出问题。ws.onmessage (e) { if (e.data instanceof ArrayBuffer) { // 二进制帧按字节长度和 hex 前缀展示 } else if (typeof e.data string) { // 文本帧原样打进日志区 } else if (e.data instanceof Blob) { // Blob 需要 e.data.text() 或 arrayBuffer() 转出来 } };这里有一个经常被忽略的参数浏览器默认把二进制帧还原成 Blob只有显式设置ws.binaryType arraybuffer后e.data才是 ArrayBuffer。测试页应在onopen里就锁定binaryType让日志分支固定否则同一份代码在不同浏览器里表现可能不一致。这个点也常出现在 websocket 面试题里实际调试时更能体会到它的存在价值。服务端的 ping/pong 帧不会暴露给onmessage浏览器收到 ping 后会静默回 pong页面 JS 看不到但 DevTools 的 Network 面板能捕捉到。联调时后端常怀疑“客户端是不是没回 pong”此时让页面统计近 30 秒内收到的帧数量就能明确区分“网络层空转”和“业务层无消息”。3. 落地第一版HTML 测试页 Node 端 WebSocket 最小工程3.1 起一个本地 ws 服务给测试页提供完整连接面常见做法是用 Node 的ws包作为测试服务端它不依赖浏览器握手、二进制、心跳这些协议细节都直接开放给开发者。工程目录只需要两个文件ws-test/ server.js index.html初始化并安装依赖mkdir ws-test cd ws-test npm init -y npm install ws node server.jsserver.js里监听 9321 端口路径固定为/ws。监听地址要写0.0.0.0不要写127.0.0.1否则同局域网的手机或其他电脑连不上。除了 echo 回显测试服务还要主动定时推送消息才能验证空闲连接是否会被中间链路回收。const { WebSocketServer } require(ws); const wss new WebSocketServer({ port: 9321, path: /ws }); let online 0; wss.on(connection, (ws, req) { online; ws.id online; console.log([${new Date().toISOString()}] client ${ws.id} connected); ws.send(JSON.stringify({ type: welcome, id: ws.id, time: Date.now() })); const timer setInterval(() { if (ws.readyState ws.OPEN) { ws.send(JSON.stringify({ type: tick, t: Date.now(), id: ws.id })); } }, 5000); ws.on(message, (data, isBinary) { if (isBinary) { console.log(client ${ws.id} sent binary, length${data.length}); ws.send(data, { binary: true }); return; } const text data.toString(); console.log(client ${ws.id} sent: ${text}); ws.send(echo: ${text}); }); ws.on(close, (code, reason) { clearInterval(timer); online--; console.log(client ${ws.id} closed, code${code}, reason${reason.toString()}); }); }); console.log(ws server listening on ws://0.0.0.0:9321/ws);代码里有三个值得照搬的细节。message回调的第二个参数isBinary直接区分文本与二进制省去对 data 类型做猜测setInterval每 5 秒推一个 tick用来验证页面在无业务消息时连接是否被断close 回调里打印 code 和 reason可以跟浏览器端onclose的日志逐条对照。需要留意的是 ws 包收到的数据默认是 Buffer字符串消息要先toString()二进制消息保持原样转发。服务端的可调参数集中在构造器里改动后要同步页面的地址栏server.js 配置本工程取值作用与测试意义port9321监听端口改完页面 host 要同步path/ws只接受带该路径的握手请求防串台maxPayload默认 100MB超过上限会直接断开测大帧时按需调小clientTracking默认 true可通过 wss.clients 拿到全部连接用于广播测试3.2 HTML 页面代码连接、收发、日志三区页面不引框架原生 JavaScript 在测试场景里反而更直观。布局分成连接参数区、消息操作区、日志区三块所有事件统一落到一个log()函数里。HTML 页面测试 WebSocket 的代码主体就是下面这份可以直接存成index.html使用。!doctype html html langzh-cn head meta charsetutf-8 titleHTML 页面测试 WebSocket/title style body { font-family: monospace; max-width: 900px; margin: 24px auto; padding: 0 16px; } input, button { font-size: 14px; padding: 6px 10px; } #log { background: #111; color: #0f0; height: 360px; overflow-y: auto; padding: 10px; font-size: 13px; } .row { margin-bottom: 12px; } /style /head body div classrow label地址 ws://input idhost value127.0.0.1:9321/ws size22/label button onclickconnect()连接/button button onclickdisconnect()断开/button span idstateCLOSED/span /div div classrow input idmsg typetext valuehello websocket size26 button onclicksendText()发送文本/button button onclicksendBinary()发送二进制/button /div div idlog/div script let ws null; const $ (id) document.getElementById(id); function log(kind, detail) { const line document.createElement(div); const time new Date().toLocaleTimeString(zh-CN, { hour12: false }); line.textContent [${time}] [${kind}] ${detail}; $(log).appendChild(line); $(log).scrollTop $(log).scrollHeight; } function connect() { const url ws:// $(host).value.trim(); ws new WebSocket(url); ws.binaryType arraybuffer; ws.onopen () { $(state).textContent OPEN; log(open, url); }; ws.onmessage (e) { if (e.data instanceof ArrayBuffer) { log(binary, len${e.data.byteLength} bytes); } else { log(message, e.data); } }; ws.onerror () log(error, onerror 触发等待 onclose 拿 code); ws.onclose (e) { $(state).textContent CLOSED; log(close, code${e.code} reason${e.reason || -} clean${e.wasClean}); ws null; }; } function disconnect() { if (ws) { log(manual, 调用 close()); ws.close(1000, bye); } } function sendText() { if (!ws || ws.readyState ! 1) { log(warn, 连接不在 OPEN 状态); return; } ws.send($(msg).value); log(send, $(msg).value); } function sendBinary() { if (!ws || ws.readyState ! 1) { log(warn, 连接不在 OPEN 状态); return; } const buf new Uint8Array([0x01, 0x02, 0x03, 0xff]); ws.send(buf.buffer); log(send-binary, 01 02 03 ff); } /script /body /html地址输入框默认填127.0.0.1:9321/ws前缀ws://由代码拼接避免用户在输入框复制出ws://ws://的双协议头。onclose里把ws置为 null防止后续调用拿一个 CLOSED 的实例还按 OPEN 处理。sendBinary用Uint8Array构造 4 个字节服务端原样返回后页面按 ArrayBuffer 分支打印长度二进制链路通不通一眼能看出来。3.3 第一轮验证echo 与 tick 各测什么打开index.html点连接日志区先出现 open随后服务端推送 welcome JSON再隔 5 秒出现一条 tick。在消息框输入内容能收到带echo:前缀的返回。这几条就足够验证核心链路握手、服务端主动推送、请求回显、文本帧收发。下一步把页面放到电脑的局域网地址上让手机访问。注意修改 HTML 里 host 为电脑的实际局域网 IP服务端监听地址已经是0.0.0.0不用动。这样可以在真实网络条件下观察往返延迟和断连行为比只在本机回环测试更有说服力。4. 让测试页能扛住真实联调断线重连、二进制显示与各层定位4.1 断线重连的指数退避不能只在本地开发里写宽松真实场景中服务端不会永远在同一地址联调时最常见的是网关层把空闲连接回收浏览器收到close code 1006表示连接异常关闭且没有 close 帧。如果测试页立刻重连可能在故障窗口内连续撞墙如果一直不重连后端修复完你也察觉不到。默认策略是首次重连等 1 秒之后每次翻倍最多 15 秒。let retry 0; function scheduleReconnect(code) { const wait Math.min(1000 * Math.pow(2, retry), 15000); retry; log(reconnect, wait${wait}ms after code${code}); setTimeout(() { if (ws ws.readyState 3) { connect(); } }, wait); }重连条件写成检查现有连接的 readyState而不是无脑调connect()可以避免重复创建连接实例。retry必须在onopen里重置为 0否则一次短暂断开后后续重连等待时间会一直按 15 秒上限走页面看起来就像卡住了。在connect()的 open 回调里加一行retry 0;即可。4.2 日志区既要能显示二进制帧也要保留原始内容第二版页面收到的消息类型会变多文本帧直接显示没问题二进制帧如果只显示长度后续想对字节内容就难了。给二进制分支加一个 hex 输出函数把 ArrayBuffer 转成 16 进制字符串并打印长度。function toHex(buf) { const bytes new Uint8Array(buf); let s ; for (let i 0; i bytes.length i 32; i) { s bytes[i].toString(16).padStart(2, 0) ; } return len${bytes.length}, first32${s.trim()}; }限制打印前 32 字节是为了避免一帧几 MB 时把浏览器卡死。做物联网或视频类设备联调时后端常发“二进制帧 文本元信息”混用的消息页面把两种日志用不同前缀区分肉眼就能看出是否错帧。测试页不承担业务协议解析能确认“收到、类型对、长度对、内容前缀对”就已经完成使命细节解析交给业务方自己的工具。4.3 浏览器里最常见的几类握手失败怎么逐层看页面现象真实状态定位方向连接按钮一直停在 CONNECTING服务端没监听该端口或路径错误先确认端口可达再检查服务进程是否存活控制台报 404请求到了某个 HTTP 服务但路径没匹配核对服务端 path 配置/ws 路径之外都 404返回 200 而不是 101端口被普通 Web 服务占用没有 Upgrade 处理看启动日志确认监听进程是 ws 服务onclose code1006TCP 层断开没有完整 close 帧看服务端 close 日志判断是进程退出还是链路重置403 且带 token 参数服务端鉴权失败检查 query 里 token 拼写与特殊字符转义这五类覆盖了 HTML 页面测试 WebSocket 时九成的问题。遇到 404 先别改前端用curl -i http://127.0.0.1:9321/看响应头同时核对服务端的path选项。1006 在本地直连 Node 服务时很少出现一旦出现优先怀疑服务端握手后主动断开或触发未捕获异常把服务端的 server 日志打开最直接。403 多半是鉴权中间件先于 WebSocket 处理器执行前端用ws://host/ws?tokenxxx拼参数即可注意 token 里如果有或中文字符必须encodeURIComponent转义。4.4 从 ws 切到 wss地址变了测试流程不变联调末段通常会换到带证书的域名环境浏览器只允许wss://连入。页面业务代码几乎不用动host输入框改成wss://domain/ws即可。尤其要注意证书信任自签名证书必须先通过浏览器访问一次首页并信任否则页面会停在握手前的错误阶段日志区只留下一个模糊的 onerror看不到 close code。判断证书问题与协议问题有一个简单方法看报错时机。请求发出去之前就失败多为证书或地址解析发出后停在 CONNECTING多为网络链路或服务端握手问题。本机回环用 ws、局域网用 ws、公网域名用 wss三种环境的测试流程完全一致页面代码只改地址输入框的内容这本身就是测试工具应该具备的稳定性。5. 用浏览器工具与脚本佐证帧视图、自动循环与一键导出5.1 DevTools 的 WS 帧视图比日志区更客观日志区记录的是业务层数据而 Network 面板的 Frames 标签展示的是真实帧序列两者结合才知道有没有丢帧。打开 DevTools切到 Network刷新页面后选 WS 过滤器点开连接条目再进 Frames 子标签。你会看到一条条 Message、Ping、Pong 记录每条都带方向和时间戳。验证 tick 是否每 5 秒出现一次发送的 echo 是否按顺序返回中间有没有迟到超过 2 秒的帧。如果看到一个方向连续多帧、另一个方向空白说明链路里有单向黑洞这时候再去查网关的连接超时配置而不是先怀疑服务端代码。帧视图也是给后端展示“你发的 ping 我确实收到了”的最直接证据。5.2 用自动循环把页面变成简易压测工具需要观察服务端在连续消息下的表现时加一个自动循环开关比手动点按钮更快。下面这段 100 条、间隔 200ms 的循环已经能暴露大部分乱序和丢包问题。let loopTimer null; function startLoop() { if (!ws || ws.readyState ! 1) return; let i 0; loopTimer setInterval(() { if (ws.readyState ! 1) { clearInterval(loopTimer); return; } ws.send(seq i); i; if (i 100) clearInterval(loopTimer); }, 200); }日志区每收到一条 echo 都会打印只要看到 seq 不是按顺序回来基本可以断定服务端并发处理或网络缓冲出了问题。再往上加压力就该换成 Node 脚本HTML 页面的意义在于把现象可视化而不是真的打满带宽。想要并发探活可以用for循环创建多个 WebSocket但注意同一来源在同一浏览器内的连接数有限制并发数量控制在浏览器允许范围内更多的并发交给服务端工具去做。5.3 一键导出诊断状态缩短和后端的对话链路最后一公里通常是和后端对时间戳。在页面上加一个“导出状态”按钮把当前 URL、连接状态、总帧数、最后一次 close 的 code 与 reason 拼成一行文本写入剪贴板。后端拿到这行字再对照他自己的日志几秒钟就能判断问题出在握手前、握手中还是长连接保持阶段。async function exportStatus() { const text [ urlws://${$(host).value.trim()}, state${$(state).textContent}, frames${frameCount}, lastClose${lastCloseCode || -} ].join( | ); await navigator.clipboard.writeText(text); log(export, text); }把这段粘贴到测试页的 script 里配合前面的自动循环同一套页面就能完成“连上、收发、断线、重连、打证据”的完整闭环。后端问起问题时你直接甩过去一条包含 close code 和帧数的状态行剩下的就是看服务端日志里同一时间戳发生了什么。本文还有配套的精品资源点击获取