ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于WebSocket的跨设备实时提醒:从原理到实现

2026/9/30 3:44:52 拓冰建站 浏览量
基于WebSocket的跨设备实时提醒:从原理到实现 最近老听到同事嘴里挂着buzz这个词倒不是聊什么蜂鸣声而是说“怎么给产品搞出点动静”。我把手头一个小工具也起名叫buzz它干的事特别朴素任何一台设备上点一下按钮其他所有打开同一页面的设备会同时响铃、震动屏幕上还会跳出一行“你被buzz了”。喊你吃饭、找手机、提醒自己别摸鱼都靠这一下子。这个小项目适合谁想玩WebSocket又不想一上来就碰重框架的前端新手、需要一个轻量家庭提醒手段的人、还有那些对“浏览器到底能调动多少硬件能力”好奇的同学。全文不依赖于任何重型依赖一台普通电脑加一部手机就能跑起来改改代码就能变成自己的遥控铃。下面把设计思路、完整代码和踩坑过程全摊开写一遍你照着抄也能跑出一个属于自己的buzz。1. 功能定位与方案选型为什么这个“buzz”值得自己做1.1 核心需求拆解“一键让所有设备响”背后其实有三个小需求先说需求来源。我有段时间总找不到手机尤其在家里手机随手一扔就没了。用别人手机打电话给自己又觉得太麻烦。后来想要是能有一个网页自己手机上也开着另一台设备一按手机就开始响那该多省事。这个需求本质上就是“跨设备的即时通知”拆开来看包含三个独立的小需求。第一个小需求是实时性。按下去的瞬间其他设备必须立刻响应晚个几秒钟这工具就失去了意义。第二个小需求是多端覆盖。家里的旧手机、主力机、电脑、平板无论手上拿的是哪一台打开浏览器就能参与不需要预先安装。第三个小需求是反馈可感知。响应不能只是一行静默的日志必须有声音、有震动、有视觉变化才能真正起到“叫一声”的效果。我也顺手排除了一些伪需求。不做账号系统、不做历史记录、不做复杂的房间管理。一开始我也想过要不要加个登录页后来一算账就放弃了为了一个提醒功能让全家老小去注册账号这本身就是反人类的设计。第一版只解决“能用”不追求“健壮”先把核心链路跑通再说。1.2 方案权衡App、轮询、WebSocket三选一我为什么站在WebSocket这边动手之前我先评估了三种主流方案原生App加消息推送、HTTP轮询、WebSocket长连接。三者的核心差异在安装成本、实时性、实现复杂度上。方案安装成本实时性实现复杂度适合场景App 系统推送高需下载安装好高要对接厂商通道单用户长期使用HTTP轮询无中等秒级延迟低对实时性要求不高WebSocket长连接无极好毫秒级中等双向实时通信App方案第一个被否掉问题不在技术而在分发和权限。手机都找不到了还得先找到另一台设备装App再授权通知权限每一步都在增加摩擦。浏览器方案的启动成本几乎为零手机上自带浏览器打开网址就行安卓用户还可以“添加到主屏幕”用起来和原生App差别不大。轮询方案是在实时性上不过关。如果每隔3秒请求一次接口最坏情况下消息有3秒延迟而且挂着好几个设备后台反复请求既费电又费流量。WebSocket是长连接服务端可以主动推数据一条消息从A设备发出到B设备播放声音局域网实测通常能控制在几十毫秒以内。用生活里的话说轮询像每隔一会儿就跑去阳台看快递员来没来WebSocket像坐在门口等快递员到了直接喊你。1.3 项目边界第一版坚决不做什么才能让第一版做得完很多小项目死在“既要又要”上。我在设计buzz的第一版时给自己划了几条不许碰的红线。第一不做用户体系。不注册、不登录、不绑定设备打开网址就是一个公共房间。第二不做消息持久化。服务端收到消息后只负责广播广播完就忘掉不写数据库。第三不做断线补发。设备在离线期间错过buzz就错过了反正提醒场景可以再按一次。第四不做点对点加密。在内网用得不多公网则通过访问口令控制入口。这些限制乍看很“简陋”但每一条都在帮我减负。不做用户体系就不用维护会话状态不做持久化就不用处理并发写入不做补发就不用设计消息队列。小工具第一版的生命线是“先让它响起来”后面想加历史记录、多房间协议里预留的时间戳字段和type字段都还有扩展空间。2. 消息流转链路从点下按钮到所有设备响铃中间发生了什么2.1 触发瞬间客户端第一步不是发消息而是“先让自己能出声”很多人以为点击按钮后的第一件事是发送网络请求实际操作中恰恰需要先处理浏览器自身的音频限制。Chrome和Safari都有自动播放策略页面在没有用户点击、触摸等交互行为之前不允许调用声音播放API。这是为了防止用户一打开网页就被莫名其妙的音频轰炸。所以客户端处理点击事件时要先检查音频上下文的状态如果是suspended暂停状态就调用resume()方法把它激活然后再发消息、再本地响一声。这个“本地立刻响”的细节也很重要按铃的人如果自己听不到声音会怀疑操作根本没有生效。按下按钮的瞬间你既发出去了网络消息也让自己听到了反馈这样体验上才是完整的。document.getElementById(buzzBtn).addEventListener(click, async () { if (audioCtx.state suspended) { await audioCtx.resume(); } ws.send(JSON.stringify({ type: buzz, from: deviceName })); makeBuzz(); });2.2 中间人服务端为什么转发时要排除发送者自己服务端在这个项目里故意做得很薄只维护当前所有人的WebSocket连接列表收到buzz类型消息就原样广播出去。为什么不让客户端之间直接通信因为浏览器之间没法直接建立持久连接必须有一个中间人做转发。我用的是Node.js的ws库代码非常短。wss.on(connection, (ws) { ws.on(message, (data) { const msg JSON.parse(data); if (msg.type buzz) { wss.clients.forEach((client) { if (client ! ws client.readyState WebSocket.OPEN) { client.send(JSON.stringify(msg)); } }); } }); });这里有一个容易被忽略的设计转发时必须判断client ! ws也就是排除发送者自己。这样做的道理很简单按铃的人不需要听两遍铃同时也能避免消息在同一个连接里反弹。消息格式我用的是JSON而不是纯文本就是为后续扩展留下余地。第一版协议里只定了type、from、at三个字段type表示消息类型from是设备标识at是毫秒级时间戳。时间戳在一开始可能显得多余但将来做历史记录功能时它就是现成的排序依据不需要再改协议。2.3 到达对端声音、震动、标题栏一个都不能少接收端的处理比发送端更讲究。消息到达时要同时调动三种反馈路径才能让用户第一时间注意到。第一条路径是声音。我用Web Audio API创建了一个600Hz的方波振荡器再接一个增益节点控制音量让音量从0.3在300毫秒内快速衰减到0.01。这样听起来就是短促的“嗡——”一声而不是刺耳的长鸣。第二条路径是震动。安卓手机通过一行navigator.vibrate(300)就能震300毫秒这个API在Chrome和Android WebView上支持良好。第三条路径是视觉反馈。页面顶部会新插入一条“收到来自xxx的BUZZ”记录同时浏览器标签页的标题也会变成“被buzz了”两秒后再改回来。标题闪烁的价值在电脑端特别大因为很多时候浏览器窗口被其他窗口遮挡着唯一能注意到的就是标签栏的变化。我写接收端时坚持一个原则提醒不能静默。如果收到的通知只是后台悄悄记录用户没有在第一时间感知到这个工具就等于白做了。至于是声音优先还是震动优先可以根据场景调整但视觉反馈必须永远保留。3. 完整实现30行服务端加一个HTML页面把buzz跑起来3.1 准备工作Node.js环境、项目目录和依赖安装环境要求其实很低电脑上装一个Node.js版本12以上就行不需要编译任何原生模块。我用的项目结构也非常简单根目录下放一个server.js里面放一个public文件夹public里只有一个index.html。整个项目就这两个核心文件。依赖只有一个ws库用于WebSocket服务。安装过程就是两条命令npm init -y npm install ws如果你从来没有用过Node.js也不用担心这里没有复杂配置。ws库是纯JavaScript写的安装过程中基本不会遇到编译错误。我建议先用局域网跑通所有功能再考虑部署到公网这样排查问题会快很多。3.2 服务端完整代码消息中转与静态页面托管一次搞定服务端其实要做两件事一个是提供页面访问让大家能打开网页另一个是维护WebSocket连接进行消息转发。两件事可以用同一个HTTP服务完成不需要额外启动Nginx之类的静态文件服务器。const http require(http); const fs require(fs); const path require(path); const WebSocket require(ws); const server http.createServer((req, res) { if (req.url / || req.url /index.html) { const filePath path.join(__dirname, public, index.html); res.writeHead(200, { Content-Type: text/html; charsetutf-8 }); res.end(fs.readFileSync(filePath)); } else { res.writeHead(404); res.end(Not Found); } }); const wss new WebSocket.Server({ server }); wss.on(connection, (ws) { console.log(新设备接入当前在线, wss.clients.size); ws.on(message, (data) { const msg JSON.parse(data.toString()); if (msg.type buzz) { wss.clients.forEach((client) { if (client ! ws client.readyState WebSocket.OPEN) { client.send(JSON.stringify(msg)); } }); } }); ws.on(close, () { console.log(设备断开当前在线, wss.clients.size); }); }); server.listen(3000, () { console.log(buzz运行在 http://localhost:3000); });这段代码有几个关键点。HTTP服务里我把页面直接读出来返回给浏览器省去了“再开一个静态服务”的步骤适合这种小项目。WebSocket服务绑定在同一个HTTP server上端口就一个3000部署时不用同时开放两个端口。连接关闭时打印在线人数方便排查问题。整个服务端代码不到四十行却完成了页面托管、连接管理、消息广播三件事。3.3 前端页面完整代码可以原样抄走的BUZZ按钮前端页面我尽量写得少一点好让你能直接看懂。完整代码如下保存为public/index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titlebuzz/title style body { font-family: system-ui, sans-serif; max-width: 640px; margin: 40px auto; padding: 0 16px; } button { display: block; width: 100%; padding: 20px; font-size: 24px; border: none; border-radius: 12px; background: #ff5a5f; color: #fff; cursor: pointer; } #log { margin-top: 20px; word-break: break-all; } /style /head body h2BUZZ 全体设备/h2 button idbuzzBtn按我让所有设备响起来/button div idlog/div script const deviceName device- Math.random().toString(36).slice(2, 6); const WS_URL (location.protocol https: ? wss:// : ws://) location.host; const ws new WebSocket(WS_URL); const audioCtx new (window.AudioContext || window.webkitAudioContext)(); function makeBuzz() { if (audioCtx.state suspended) audioCtx.resume(); const osc audioCtx.createOscillator(); const gain audioCtx.createGain(); osc.type square; osc.frequency.value 600; gain.gain.setValueAtTime(0.3, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime 0.3); osc.connect(gain); gain.connect(audioCtx.destination); osc.start(); osc.stop(audioCtx.currentTime 0.35); if (navigator.vibrate) navigator.vibrate(300); } function addLog(text) { const div document.createElement(div); div.textContent new Date().toLocaleTimeString() text; document.getElementById(log).prepend(div); } document.getElementById(buzzBtn).addEventListener(click, () { ws.send(JSON.stringify({ type: buzz, from: deviceName, at: Date.now() })); makeBuzz(); addLog(你主动发起了一次BUZZ); }); ws.onmessage (event) { const msg JSON.parse(event.data); if (msg.type buzz) { makeBuzz(); addLog(收到来自 msg.from 的BUZZ); document.title 被buzz了; setTimeout(() { document.title buzz; }, 2000); } }; ws.onclose () { addLog(连接已断开请刷新页面重试); }; /script /body /html这份代码里的deviceName是随机生成的方便在日志里区分不同设备。AudioContext用window.AudioContext || window.webkitAudioContext做了一层兼容是为了让旧版本Chrome也能工作。整个CSS非常朴素一个红色大按钮加上日志列表因为工具的核心是功能不是界面。页面逻辑拆开来说点击按钮时发送消息、本地响铃、写日志收到消息时响铃、写日志、闪烁标题。两个方向的反馈路径都覆盖到了消息发送方和接收方的体验都在同一个HTML里完成不需要额外页面。3.4 局域网与公网手机访问的两个可靠路线在局域网内使用最简单的方法是查看电脑的局域网IP地址。Windows下用ipconfig命令macOS和Linux用ifconfig或ip addr找到类似192.168.x.x这样的地址然后在手机的浏览器里访问http://192.168.x.x:3000。前提是手机和电脑连接在同一个Wi-Fi下。如果有跨网络使用的需求那就把服务端部署到一台带公网IP的云服务器上。部署过程也不复杂把server.js和public目录上传到服务器安装Node.js和ws库执行node server.js再把服务器的3000端口对外开放即可。这里我强烈建议加上访问口令后面会详细说。我自己家里用的是局域网方案办公室测试时用了一台有公网IP的小服务器两边跑起来都算顺利。4. 排查实录我在真实使用过程中踩过的几个坑4.1 点击没响但日志有消息自动播放策略Chrome的保护机制第一版代码写完后我在电脑上测试一切正常换到手机访问时点击按钮页面有响应但就是不出声。打开浏览器的控制台一看报错提示AudioContext was not allowed to start。原因就是前面提到的自动播放策略在手机上比其他浏览器更严格。解决方法是把audioCtx.resume()放到按钮点击事件的回调里并且用await等待它完成。我一开始的写法是在页面加载时就提前创建音频上下文但创建上下文不等于激活上下文真正需要出声时必须有一次用户手势才行。这个坑几乎每个做Web Audio的人都会遇到正确做法就是在第一次点击时恢复上下文后续再响就不受限了。4.2 页面跑一会儿就“连接已断开”休眠、代理与心跳机制有段时间我发现手机锁屏再解锁后页面经常显示“连接已断开”。这是因为手机进入休眠状态后系统会主动断开网络连接尤其是Wi-Fi在锁屏一段时间后可能进入省电模式WebSocket连接自然也就断了。另外家庭路由器或运营商NAT设备通常会在几分钟内清理空闲连接导致长连接被踢掉。解决方案是加一个心跳机制客户端每25秒发一条ping消息服务端收到后回一条pong这样连接始终保持活跃不容易被误杀。同时再加上断线自动重连逻辑关闭旧连接创建新连接尽量做到“掉了也能自动爬起来”。我在实践中发现加了心跳之后设备长时间挂着的掉线率明显下降这个机制对于所有WebSocket项目都值得保留。setInterval(() { if (ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ type: ping })); } }, 25000); ws.onclose () { setTimeout(() { ws new WebSocket(WS_URL); }, 3000); };4.3 iPhone收不到震动navigator.vibrate的兼容性边界测试过程中我用一台安卓手机和一台iPhone同时挂着页面。安卓设备点一下就会震动但iPhone无论怎么试都没反应。查了以后才知道iOS Safari到今天为止都不支持navigator.vibrate这个API在苹果的浏览器生态里是被忽略的。这是个典型的“功能边界”问题。解法不是去绕过系统限制而是把震动定位为增强体验而不是必备反馈。声音在iPhone上是正常的视觉日志也有所以核心提醒功能没有受影响。如果你特别需要震动可以考虑安装一个支持后台通知的浏览器App或者在iOS设备上用系统消息推送另做一套逻辑但对于我这个项目来说声音加视觉已经足够覆盖绝大多数场景。4.4 连接不稳定但不知道谁在线服务端日志帮你快速定位调试多设备连接时我一度很迷惑明明点了按钮服务端也没报错但就是有设备没响应。后来发现是因为我自己把服务端日志关了完全看不到连接状态。于是我在连接建立和断开时都加上一条日志输出当前在线人数再用一台设备反复上线下线很快就发现其中一台设备的WebSocket连接早已断开页面却因为网络环境原因没有触发onclose事件。所以排查这类问题时第一步永远是看服务端日志确认当前真正活着的连接有几条。如果服务端显示的在线人数比实际设备少就说明有设备“假在线”重点检查那台设备的网络环境和页面状态而不是怀疑代码逻辑。4.5 部署到公网后陌生人乱按一个最简口令机制我原本以为部署到公网服务器上只要不在别人面前提网址就不会有人访问。结果过了几天服务器日志里出现了来自不明来源的访问记录还有人真的按了BUZZ家里几台设备同时响了起来。这件事给我提了个醒公网上永远不要依赖“保密网址”来保证安全。最简方案是加一个访问口令。前端通过URL参数传入口令例如http://你的服务器:3000/?tokenabc123后端在WebSocket握手时检查query参数与预设口令一致才允许连接。这样不知道口令的人虽然能打开页面但WebSocket始终连不上按了按钮也不会有人响应。这套机制代码量不到十行却能把陌生人的骚扰挡在门外。5. 还能怎么进化从找手机神器到家庭轻量消息中心5.1 定时buzz用服务端setInterval实现“到点喊你”buzz跑通以后我又给它加了一个定时功能在服务端用setInterval每隔一段时间广播一次buzz相当于自动提醒。我当时的需求是提醒自己每小时站起来活动一下服务端到点就向所有设备发一条消息页面收到后照常响铃。这个功能点提醒我一个经验服务端定时器不会在服务器重启后自动恢复。如果你真想让定时任务每天固定触发最好把任务描述写进一个JSON文件服务器启动时读取文件再注册定时器。我的版本里任务表很简单就一个[{time: 14:30, channel: water}]这样的结构够用就好。5.2 文字与语音把“buzz”扩展为家庭对讲机光响铃能提醒人但说不出要提醒什么。所以我在消息协议里加了text字段前端收到带文字的消息时除了响铃还用浏览器的SpeechSynthesis接口直接朗读出来。比如“该吃药了”“饭好了”这样的短句合成语音说出来的效果还挺自然。这个扩展对家庭场景特别有用。我外婆不太会操作智能手机但她的平板开着页面家里其他人通过另一台设备发一条文字buzz平板就会响铃并把文字念出来。整个过程不需要安装任何App也不需要她学习复杂的操作。5.3 多房间隔离一套代码跑出多个互不干扰的频道如果家里和设备多大家共用一个公共频道会互相打扰。我给协议增加了一个room字段服务端维护一个Map把每个房间的连接单独存起来广播时只发给同房间的客户端。前端启动时通过URL参数指定房间号比如/?roomkitchen。这个改动其实很小服务端无非是把“遍历所有连接”改成“遍历当前房间的连接”但使用体验立刻就不一样了。厨房一个房间、书房一个房间同事办公室也可以每个人一个房间互不干扰。5.4 一点个人体会小工具也有大价值做buzz的整个过程我最深的体会有三点。第一看似简单的工具真正做好反馈链路也要花心思一个“没声音”的小坑就能卡住你半天。第二协议设计不用一开始就做得很复杂但一定要留扩展余地类型字段和时间戳就是花小钱办大事。第三功能边界要明确第一版就把“不做登录、不做持久化”写清楚才能把精力集中在核心链路。如果你也想动手试试我的建议是不要照抄完代码就关掉页面。先把它跑起来再试着把按钮的圆角改大一点把声音频率从600Hz改成800Hz把vibrate(300)改成vibrate([200, 50, 200])这种节奏型震动。亲手改一处你就会对这套链路有更深的理解。buzz不只是一个找手机的小工具它更像一个让你快速上手“浏览器如何与真实世界互动”的入口。