2026/9/15 2:56:45

纯前端聊天室实战:WebSocket连接、CSS3动画与XSS防护全解析

纯前端聊天室实战:WebSocket连接、CSS3动画与XSS防护全解析 简介基于JavaScript的HTML5CSS3聊天室设计源码是一份面向Web前端与全栈学习者的完整即时通讯项目。项目以HTML5搭建语义化结构CSS3实现响应式布局与视觉美化JavaScript处理消息收发、用户交互及部分网络通信后端另有Java类与XML/YML配置承担登录验证、数据存储等逻辑适合课程设计、毕业设计或企业内部沟通场景。压缩包共369个文件大小57.12MB涵盖156个GIF表情动效、56个JavaScript脚本、18个CSS样式、10个HTML页面以及Java源码、class字节码、SQL脚本和字体图标资源目录划分清晰便于按模块阅读。目前已有513人学习下载附带项目说明、授权等基础文档帮助开发者快速理解聊天室架构复现从登录、好友列表到群聊的完整闭环便于二次开发。1. 这个标题下的聊天室到底能聊什么一个纯粹用HTML5CSS3JavaScript写的聊天室源码很多人下载下来第一反应是“只有一个页面怎么和对面上线的人聊天”。实际上这类源码的重点通常在前端交互设计消息气泡、输入框、在线列表、表情、未读提示以及CSS3动画带来的“沉浸感”。如果没有后端常见做法是连一个公用的WebSocket测试服务或者在页面里内置一个模拟消息池把收发链路先跑通。这个标题适合两类人一类是前端入门者想用原生JavaScript把DOM操作、事件绑定和状态管理练扎实另一类是准备做即时通讯原型的全栈工程师先用纯前端把交互和视觉定下来再验证产品方案最后替换成正式服务端。它解决的问题是“不依赖React/Vue靠浏览器原生技术栈把聊天室做出来并让它跑起来”。下文给出从通信选型到布局动画、消息安全、重连优化的完整落地路径。2. 实时通信选型为什么用原生 WebSocket 而不用框架封装聊天室最核心的不是界面而是“两边发出的消息能在几秒内互相看到”。检验一套聊天室源码可用不可用第一件事就是看它的通信层拿什么搭的。如果源码里全是setInterval轮询那只能算教学演示如果用的是 WebSocket并且把断线重连、心跳都处理了才算接近生产形态。这里先把原理和边界讲清楚再给两段可以直接落地的代码一段是带自动重连的 WebSocket 客户端封装一段是用于本地调试的 Node 广播服务器。2.1 聊天室必须双工HTTP 轮询与 WebSocket 的边界即时聊天要求从 A 到 B 的消息延迟尽量短而且 A 发消息时不能因为要刷新页面而中断阅读。HTTP 协议天然是“请求-响应”模式如果只用轮询客户端每隔几秒就发一条GET /messages没有新消息时服务器也要返回空数组占用请求连接和带宽。更关键的是轮询的延迟取决于查询间隔间隔设短了请求量大设长了消息不实时陷入两难。所以生产级聊天室不会把轮询当作主力通道至多拿来兜底降级。Server-Sent EventsSSE比轮询更优雅它允许服务器持续推送数据但客户端向服务器发送仍要走普通 HTTP 请求属于“单工 半双工”的混合体。对聊天这种双方都要主动发言的场景SSE 只适合做系统通知、在线人数广播这类单向流。WebSocket 则是在 HTTP 握手完成后把连接升级为一条双向通道客户端和服务器都能在同一个 TCP 连接上随时发帧H5 游戏、协同编辑、聊天室这类双工场景它是标准答案。WebSocket 地址以ws://或wss://开头浏览器通过new WebSocket(url)建立连接。原生 API 只有四个关键事件onopen、onmessage、onerror、onclose以及一个send()方法。消息格式在 API 层面是字符串或二进制聊天室通常约定传 JSON 字符串这样便于在onmessage里解析出type、sender、content等字段。需要留意的是send()必须在onopen之后调用在重连逻辑里如果连接还没建立就调send控制台会报“WebSocket is not open”的运行时报错这正是很多新手源码跑不起来的直接原因。2.2 手写一个断开自动重连的 socket 客户端大部分聊天室源码只写了onmessage和send没有处理Wi-Fi切换、弱网、服务器重启。当移动设备从 Wi-Fi 切到流量时原 TCP 连接会被系统杀掉onclose被触发如果页面只弹一个“连接断开”聊天就彻底死了。更好的做法是封装一个带心跳和指数退避重连的客户端连接状态变化时通知界面显示“在线/离线/重连中”。下面这段代码可直接复制到源码中替换原 WebSocket 逻辑class ChatSocket { constructor({ url, token, onMessage, onStatus, heartbeatInterval 20000 }) { this.url url; this.token token; this.onMessage onMessage; this.onStatus onStatus; this.heartbeatInterval heartbeatInterval; this.ws null; this.reconnectAttempts 0; this.maxReconnectAttempts 10; this.connect(); } connect() { const ws new WebSocket(this.url); this.ws ws; ws.onopen () { this.reconnectAttempts 0; this.onStatus(online); ws.send(JSON.stringify({ type: auth, token: this.token })); this.startHeartbeat(); }; ws.onmessage (event) { let data; try { data JSON.parse(event.data); } catch (e) { data { type: text, content: event.data }; } this.onMessage(data); }; ws.onclose () { this.onStatus(offline); this.scheduleReconnect(); }; ws.onerror () ws.close(); } startHeartbeat() { this.heartbeatTimer setInterval(() { if (this.ws this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: ping })); } }, this.heartbeatInterval); } scheduleReconnect() { if (this.reconnectAttempts this.maxReconnectAttempts) { this.onStatus(failed); return; } const delay Math.min(30000, Math.pow(2, this.reconnectAttempts) * 1000); this.reconnectAttempts 1; this.onStatus(reconnecting); setTimeout(() this.connect(), delay); } send(content) { if (this.ws this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: message, content })); } } }逻辑说明构造函数接收一个配置对象url是 WebSocket 服务地址token用于模拟登录鉴权onMessage是收到消息的回调onStatus通知页面当前连接状态。真正建立连接的逻辑放在connect()中onclose和onerror都会触发重连调度。这里把重连延迟按2^attempts秒递增从 1 秒开始最大 30 秒防止服务器恢复时所有客户端同时拥进来。心跳定时器每 20 秒发送一个{type:ping}服务器只要在下一个周期内返回pong连接就不会被中间代理判定为僵尸连接如果中间链路断裂心跳发送时会因readyState不对而失败最终由onclose兜底触发重连。参数调整建议heartbeatInterval不要小于 5 秒太频繁会消耗电量和带宽maxReconnectAttempts在移动端可以设小一点比如 5 次配合页面可见性变化visibilitychange再手动恢复。2.3 用 Node.js 起一个本地 WebSocket 服务来验证如果你拿到的源码不包含服务端又不确定它连的是哪个地址最常见的调试方式是自己在本地起一个 WebSocket 服务。下面这段代码基于 Node.js 和ws库只做两件事接收客户端消息并广播给所有人定时回复心跳。npm init -y npm install ws// server.js const WebSocket require(ws); const wss new WebSocket.Server({ port: 8080 }); wss.on(connection, (ws, req) { console.log(client connected from, req.socket.remoteAddress); ws.on(message, (data) { const msg data.toString(); console.log(received:, msg); // 广播给所有客户端包括发送者自己 wss.clients.forEach((client) { if (client.readyState WebSocket.OPEN) { client.send(JSON.stringify({ sender: server, content: msg })); } }); }); ws.on(close, () console.log(client disconnected)); });运行node server.js后把上一节ChatSocket的url指向ws://localhost:8080页面里发送的消息会被服务器广播回来这样就能验证onmessage链路。wss.clients是 Set 结构遍历时只需要判断readyState为打开状态避免给已关闭的连接发送数据。这个本地服务不用于生产只是为了验证前端的收发和重连逻辑生产环境应该由网关层完成鉴权和消息路由。3. 用 HTML5 语义结构和 CSS3 布局搭出聊天室界面聊天室界面通常被分成三块顶部的标题栏、中间的消息流、底部的输入区。很多人一上来就div套div结果样式表有 800 行还理不清层级。用 HTML5 的语义化标签header、main、footer、section配合 CSS3 的 Flex 布局能让源码的结构和信息架构同时变清晰。再加上 CSS3 动画过渡消息气泡的入场、掉线提示的闪烁都比起用 JavaScript 去操作style.display要平滑。3.1 用 HTML5 语义标签搭建聊天室信息架构先写结构骨架这是源码里最容易被忽略的部分。聊天室页面可以这样组织header classchat-header h1JavaScript 聊天室/h1 span idonline-count classonline-badge1 人在线/span /header main idmessage-panel classchat-panel aria-livepolite /main footer classchat-input-bar input idmessage-input typetext placeholder输入消息Enter 发送 maxlength200 / button idsend-btn发送/button /footermain里用来动态渲染消息列表设置aria-livepolite可以让屏幕阅读器在新增消息时自然播报而不会打断用户正在进行的主操作。maxlength200在前端限制单条消息长度避免一条巨型消息把布局撑爆真正的后端也要做同样限制前端限制只是用户体验的一部分。输入框放在footer里语义上比放在div里更能表达“这是页面底部的一个补充操作区”。3.1.1 消息列表项的层级设计每条消息建议用article包裹里面分别放昵称、时间、正文这样浏览器能通过标签语义识别出“这是一条独立的内容”CSS 也能用后代选择器精准命中。示例article classmessage-item message-self div classmessage-meta span classmessage-author我/span time14:23/time /div div classmessage-bubble这是消息正文/div /articlemessage-self和message-other两个修饰类控制左右对齐和气泡颜色遵循“结构类名 状态类名”的命名规范。BEM 风格也可以但要保持整个源码里一致避免.left .content .text这种过深的层级。3.2 Flex 与 Grid 双方案实现消息区 输入区聊天室的整体高度是 100vh需要保证消息区能撑满剩余空间而不是让输入区被挤出去。最常见的做法是给整个页面设置 Flex 纵向排列html, body { height: 100%; margin: 0; } .chat-app { display: flex; flex-direction: column; height: 100vh; } .chat-panel { flex: 1 1 auto; overflow-y: auto; padding: 12px; } .chat-input-bar { flex: 0 0 auto; display: flex; gap: 8px; padding: 12px; border-top: 1px solid #e5e7eb; }flex: 1 1 auto让消息区占据所有剩余高度overflow-y: auto让消息过多时出现滚动条输入区用flex: 0 0 auto固定高度不参与伸缩。gap: 8px是 CSS3 flex 布局里很实用的间距属性替代了老式源码里对第一个子元素加margin-left: 0的 hack。输入框和按钮之间有了天然间距不用再单独写 margin。消息区内部的左右对齐可以用 Flex 的justify-content.message-item { display: flex; margin-bottom: 12px; } .message-self { justify-content: flex-end; } .message-other { justify-content: flex-start; } .message-bubble { max-width: 70%; padding: 8px 12px; border-radius: 12px; background: #e5e7eb; word-break: break-word; } .message-self .message-bubble { background: #3b82f6; color: white; }max-width: 70%防止气泡在宽屏上被拉成一整行阅读体验和视觉比例都更接近 IM 产品。如果你想让消息区和输入区在 Web 端达到类似手机聊天的居中效果可以用 Grid 把整体宽度约束在 640px.chat-app { display: grid; grid-template-rows: auto 1fr auto; height: 100vh; max-width: 640px; margin: 0 auto; }Grid 方案更直白三行分别是头、消息区、输入区1fr行占据剩余空间。两种方案二选一即可Flex 更灵活Grid 在“整体骨架固定三行”时语义更清楚。实际源码里我用 Grid 定外壳用 Flex 处理每一条消息内部的对齐互不冲突。3.3 CSS3 动画气泡进入延迟和完成后状态的保持消息气泡出现时如果直接插进 DOM视觉上会很生硬。CSS3 动画可以做到“消息滑入 透明度变化”并且通过animation-fill-mode: forwards让动画结束后的状态保持为最终状态。这里给出一个会给源码加分的配置.message-bubble { animation: bubble-in 0.25s ease-out both; } .message-item:nth-child(odd) .message-bubble { animation-delay: 0.05s; } keyframes bubble-in { from { opacity: 0; transform: translateY(8px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } }animation的第三个参数是 speed curveease-out让入场时快、结束时慢符合消息“弹出来”的观感。最后的both相当于把animation-fill-mode: backwards和forwards合并在动画未开始时应用from状态在动画结束后保持to状态。如果漏掉forwards动画结束的瞬间元素会变回opacity: 1前的默认状态闪烁一下。:nth-child(odd)给奇偶消息加了 50ms 的延迟差当多条消息同时出现时产生轻微错落但需要你自己控制延迟时间的绝对值延迟超过 0.2s 就会让人觉得卡。对于“对方正在输入”这类指示器用动画中的延迟和完成后状态保持同样很有效。可以做一个三圆点跳动动画每个圆点延迟 0 秒、0.15 秒、0.3 秒让跳动依次发生结束状态保持为静止避免无限循环打扰用户。这里写一个最小实现.typing-dot { display: inline-block; width: 6px; height: 6px; margin-right: 4px; border-radius: 50%; background: #9ca3af; animation: typing-bounce 1s ease-in-out infinite; } .typing-dot:nth-child(2) { animation-delay: 0.15s; } .typing-dot:nth-child(3) { animation-delay: 0.3s; } keyframes typing-bounce { 0%, 60%, 100% { transform: none; } 30% { transform: translateY(-4px); } }参数说明infinite让动画循环播放但动画时长 1 秒配合 0.15s 和 0.3s 的延迟看起来是连续的波浪。这类小动画务必控制在 0.5~1 秒之间超过 1 秒会显得输入状态拖沓。CSS3 动画的另一个价值是它由合成器处理transform和opacity的变化不会触发 reflow比用 JavaScript 定时器改style.top平滑得多。属性推荐值作用animation-fill-modeboth动画完成前保持起始状态完成后保持结束状态animation-delay0~300ms多元素错落入场超过 300ms 用户会感知卡顿flex1 1 auto让消息区伸展填满剩余空间max-width640px 或 70%防止气泡/页面在宽屏上拉得过长grid-template-rowsauto 1fr auto固定三行骨架中间行自适应4. JavaScript 消息渲染、合并与 XSS 防护布局确定后源码的 JavaScript 层主要做四件事发送、接收、渲染、状态提示。最容易写错的地方不在发送而在渲染直接把用户输入用innerHTML塞进消息区遇到img onerror或script就会被注入。聊天室的“设计源码”不只是界面漂亮更需要把用户内容当不可信数据来处理。这一章先讲消息对象设计再讲如何用原生 DOM API 高效渲染最后给一个低成本的转义方案。4.1 消息对象与合并配置从 socket 数据到 DOM 结构从 WebSocket 拿到的数据可能长这样{type:message,sender:nickname,content:hello,timestamp:1710000000}在 JavaScript 里建议先做一层规整把服务器字段映射成内部字段。如果你希望代码好扩展可以写一个工具函数function normalizeMessage(raw, selfId) { let data raw; if (typeof raw string) { try { data JSON.parse(raw); } catch (e) { data { content: raw }; } } const defaultMeta { sender: anonymous, content: , timestamp: Date.now() }; // 合并两个对象默认字段 实际消息字段 const msg { ...defaultMeta, ...data }; return { id: msg.id || ${msg.sender}-${msg.timestamp}-${Math.random().toString(16).slice(2)}, sender: msg.sender, content: msg.content, ts: new Date(msg.timestamp), isSelf: msg.sender selfId }; }defaultMeta和data用展开运算符合并成一个对象后面再叠加计算字段。这种方式比Object.assign可读性更好且不会修改原始数据。如果raw是格式错误的字符串捕获异常后降级为纯文本内容避免一条坏消息导致整个渲染流程崩溃。Math.random兜底生成 id 的方式只适合前端展示真正的消息 id 应该由服务端生成用来做消息去重和分页查询。在发送前可以把输入框的字符串和本地用户信息合成一个同构对象再JSON.stringify发送这样就保证“发送的数据”和“接收后渲染的数据”走的是同一个规范化路径。4.1.1 用 DocumentFragment 批量插入节点假设一次推送来了 20 条历史消息最慢的写法是循环里每次appendChild浏览器每插入一次就触发一次布局计算。合理做法是创建一个DocumentFragment把 20 条消息全部构建好再一次性插入DOM布局只计算一次function renderMessages(messages, container) { const fragment document.createDocumentFragment(); messages.forEach((msg) { const article document.createElement(article); article.className message-item ${msg.isSelf ? message-self : message-other}; const bubble document.createElement(div); bubble.className message-bubble; bubble.textContent msg.content; article.appendChild(bubble); fragment.appendChild(article); }); container.appendChild(fragment); }注意这里用textContent而不是innerHTML这一步同时完成了转义用户输入img srcx onerror...会被当成纯文本显示在气泡里而不是被浏览器解析成新的 DOM 节点。如果你的源码里已经用innerHTML渲染了用户昵称或内容需要立刻改成textContent否则任何访客都能往聊天室里塞脚本。4.2 转义函数与白名单校验防止聊天消息变成 XSS 攻击面textContent能防住内容注入但有些场景你确实需要让消息支持简单的表情符号或超链接比如把:)渲染成图片。此时就要在安全与体验之间取平衡。我一般会保留一个既有textContent逻辑、再对特定正则做安全替换的例子function safeLinkify(text) { const escaped text.replace(/[]/g, (c) ({ : amp;, : lt;, : gt;, : quot;, : #39; }[c])); return escaped.replace( /(https?:\/\/[^\s])/g, (url) a href${url} relnoopener noreferrer target_blank${url}/a ); }先转义 HTML 敏感字符再对 amp 转义后的文本执行 URL 匹配。URL 匹配放在转义之后确保正则不会撞上产生错误链接的href也被包在引号里避免属性注入。给链接统一加relnoopener noreferrer避免打开新页面时被window.opener反向控制。这个方法不是万无一失的完整 sanitize但它覆盖了聊天室 90% 的注入场景原文被纯文本展示链接被安全地变成可点击状态。title和name等属性同样要经过转义尤其是当你打算用innerHTML拼复杂模板的时候。如果团队有精力可以引入更成熟的 sanitize 库但纯原创源码里用上面这个函数就足够撑起演示原型。4.3 滚动到底部的时机requestAnimationFrame 与 ResizeObserver收到新消息后把滚动条拨到底部看似简单实际有坑。如果你在onmessage回调里立刻执行scrollTop scrollHeight此时新消息的 DOM 虽然已经插入但浏览器可能还没完成布局scrollHeight读到的还是旧值。稳妥做法是把滚动操作推迟一帧function scrollToBottom(panel) { requestAnimationFrame(() { panel.scrollTop panel.scrollHeight; }); }requestAnimationFrame会在下一次绘制之前执行回调此时布局已经被强制同步scrollHeight是真实高度。另一种影响滚动位置的情况是图片或视频异步加载后撑高了消息区用户往上翻历史时突然被拽到底部。解决办法是监听面板内图片load事件如果panel.scrollTop panel.clientHeight panel.scrollHeight - 80说明用户本来就在底部附近才自动滚动否则不打扰阅读。用ResizeObserver监听面板容器高度变化也可以做到同样的条件滚动const ro new ResizeObserver(() { const nearBottom panel.scrollTop panel.clientHeight panel.scrollHeight - 80; if (nearBottom) scrollToBottom(panel); }); ro.observe(panel);ResizeObserver的触发时机是元素内容盒尺寸变化相比window.resize事件更精确。注意设置 80px 的容差避免用户正处于底部边缘时反复被干预。这个细节决定了源码在真实使用中是否“乖巧”在聊天室源码评审里是很常见的加分项。5. 本地环境验证聊天室源码的关键指标与断线补偿聊天室源码写完不能只看“能弹气泡”至少要验证四件事刷新后历史是否还在、断线能否自动重连、大量消息是否卡顿、动画是否导致布局抖动。这四件事都能在本地环境用浏览器工具验证不需要复杂的测试框架。5.1 快速验证四件事的检查清单检查项操作期望结果WebSocket 握手打开浏览器 Network 面板过滤 WS能看到 101 Switching Protocols重连生效本地服务端 CtrlC观察页面状态状态变为 offline/reconnecting恢复服务后自动回到 online消息转义发送img srcx onerroralert(1)气泡中显示原文本不弹窗滚动策略向上翻历史时发消息滚动位置不跳动仅在底部时自动滚到底如果 Network 面板里看不到 101检查地址协议是否有误ws://和wss://混用是 HTTPS 页面常见的报错原因。前端源码里可以加一个console.log打印readyState0表示正在连接1表示已打开2表示正在关闭3表示已关闭排错时比看连不上更直观。5.2 进阶技巧消息去重与时间线修正当 WebSocket 掉线后重连服务端通常会做一次“补偿推送”把断线期间的消息补回来这可能导致同一条消息被渲染两次这是因为两端消息记录游标不一致。给每条消息分配服务端seq序号在前端用一个Set记录已经渲染的seqconst renderedSeq new Set(); function renderWithDedup(messages, container) { const uniq messages.filter(msg { if (msg.seq null) return true; if (renderedSeq.has(msg.seq)) return false; renderedSeq.add(msg.seq); return true; }); if (uniq.length) renderMessages(uniq, container); }seq可以由服务端单调递增客户端只处理更大序号的增量。本地存储历史时把renderedSeq也存入localStorage刷新后避免重复渲染。这一步让源码从“演示用”向“可接真实服务”踏出关键一步。最后可以在 devtools 的 Performance 面板里录制一段收到大量消息的过程如果 Rendering 耗时占比超过 15%优先检查是否在循环里强制读写布局属性把scrollHeight的读取交给requestAnimationFrame统一处理。本文还有配套的精品资源点击获取