用HTML页面测试WebSocket:从协议握手到帧级联调
2026/9/14 17:51:33 网站建设 项目流程

简介:面向 Web 前端初学者与实时通信开发者,资源包内含可直接运行的 WebSocket 客户端测试页面。借助 HTML5 WebSocket API,浏览器页面能够与服务器建立长连接,实现双向数据收发,适合用来理解握手建立、连接状态切换以及消息事件驱动的基本流程。资源包共 3 个文件,大小仅 34KB:index.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里的codereason。我一般要求页面日志必须原样打出事件名、触发时间、readyStateevent.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 变 1;onmessage只在 OPEN 状态出现;onclose后状态为 3;onerror之后通常立刻跟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.js

server.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 lang="zh-cn"> <head> <meta charset="utf-8"> <title>HTML 页面测试 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 class="row"> <label>地址 ws://<input id="host" value="127.0.0.1:9321/ws" size="22"></label> <button onclick="connect()">连接</button> <button onclick="disconnect()">断开</button> <span id="state">CLOSED</span> </div> <div class="row"> <input id="msg" type="text" value="hello websocket" size="26"> <button onclick="sendText()">发送文本</button> <button onclick="sendBinary()">发送二进制</button> </div> <div id="log"></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 处理。sendBinaryUint8Array构造 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 code=1006TCP 层断开,没有完整 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?token=xxx拼参数即可,注意 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 = [ `url=ws://${$('host').value.trim()}`, `state=${$('state').textContent}`, `frames=${frameCount}`, `lastClose=${lastCloseCode || '-'}` ].join(' | '); await navigator.clipboard.writeText(text); log('export', text); }

把这段粘贴到测试页的 script 里,配合前面的自动循环,同一套页面就能完成“连上、收发、断线、重连、打证据”的完整闭环。后端问起问题时,你直接甩过去一条包含 close code 和帧数的状态行,剩下的就是看服务端日志里同一时间戳发生了什么。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询