简介:这是一款基于PHP5.6+环境运行的轻量级聊天室源码,适合小型社区、企业内网或教育培训场景快速搭建即时通讯工具。采用纯TXT文件存储消息,无需MySQL数据库,部署仅需1分钟;移动端与PC端自适应,并兼容主流浏览器。核心代码仅28KB,配合jQuery和Ajax轮询技术,实测消息延时低于1.5秒,支持Emoji表情。包内共8个文件,包含3个PHP逻辑文件、2个JavaScript交互脚本、1个CSS样式表、1个HTML入口页及1个TXT数据文件,整体压缩包仅40KB,结构清晰便于二次开发。已有172人学习下载。资源附带防IP刷屏、Base64编码防XSS、环形队列自动清理历史消息等安全与效率设计,同时提供自定义昵称、回车快捷发送、消息气泡动态加载等交互细节,适合PHP初学者研究极简聊天室架构,也可直接部署用作内网即时沟通工具。
1. 为什么“PHP轻量级聊天室源码”值得自己装一套:三类需求说的其实是同一种选择
如果你正在找一套 PHP 轻量级聊天室源码,多半是想在两三小时内把它跑起来,放到小服务器上给团队内部、课程展示或临时活动用,而不是再搭一套重型即时通讯框架。这类源码的价值不在“能聊天”这三个字,而在它足够小、逻辑能看懂、部署没有复杂依赖。典型的提问者是三类人:刚接触 PHP 的学生想拿一份能跑的代码研究会话和 AJAX;外包开发者需要一个交付给客户的临时在线沟通页;还有运维想在小内存 VPS 上放一个不占资源的聊天入口。三者有一个共同点:要的是一份能下载、能改、能排错的源码,而不是 100 个文件起步的框架。所以这篇笔记不推“全家桶”,只讲常见做法,从最省事的轮询版开始,再往 WebSocket 升级,并给出能直接套用的代码块和具体坑位。
2. 三种实时方案怎么选:轮询、长轮询、WebSocket 的适用边界
聊天室的消息从浏览器到另一个浏览器,本质上就是“拉”和“推”的问题。PHP 最常见的执行模型是“请求来了才跑一次”,这让它天然擅长被浏览器主动拉取。所以方案选型,先要看清你在拉还是在推。
2.1 轮询方案:最省事的 PHP 原生实现,每 2 秒拉一次新消息
轮询是最容易理解的方案。浏览器用一个定时器,比如每 2 秒发起一次 HTTP 请求,问“有没有比 last_id 更大的新消息”。PHP 收到请求后查一次数据库,有就返回 JSON,没有就返回空数组。请求处理完,PHP 进程就退出,不占用任何连接。这种模式的好处是:没有常驻进程,不需要额外端口,和普通 PHP 站点一样部署在 Web 服务器下,会话、CSRF、登录状态这些现成机制都能直接用。
轮询的核心逻辑其实只有几行:
// 示意代码,生产实现见第 3 章 $lastId = (int)$_GET['last_id']; $rows = $pdo->query( 'SELECT id, sender, content FROM chat_msg WHERE id > ' . $lastId . ' ORDER BY id ASC' )->fetchAll(); echo json_encode($rows);注意这里为了示意把参数直接拼进了 SQL,真实代码必须用预处理绑定参数,否则一个小小的接口就是 SQL 注入重灾区。用last_id做游标,而不是用时间戳,是因为消息表里的自增主键严格递增且不会重复,而时间戳在并发量稍高时可能重复,导致消息漏拉或重复拉。
轮询的成本也很清楚:延迟和请求量。50 人在线,每 2 秒就有 25 个请求,每个请求一次 SQL 查询,小服务器完全扛得住。500 人时每 2 秒 250 个请求,数据库压力开始明显。但轻量级聊天室的定位就是小规模,所以轮询并不过时。我一般会把它当作第一版,因为代码少、排错快,后续要升级再动底层。
2.2 长轮询和 WebSocket:实时性更强,但服务端要先学会“挂起”
长轮询是轮询的改良:浏览器发一个请求后,PHP 不立刻返回,而是进入一个循环,每隔 1 秒查一次新消息,最长挂起 30~60 秒;有消息才返回,没消息就超时。这样消息延迟从 2 秒降到 1 秒内,但代价很大:每个在线用户都要占住一个 PHP 进程。Web 服务器能撑的并发进程数是有限的,所以长轮询更适合连接数少但消息频率高的场景,不适合聊天室这种人更多的场景。
WebSocket 走另一条路:浏览器和服务端先通过 HTTP 完成一次 101 握手,之后双方共用一个持续连接,双向随意发消息。服务端必须有一个常驻进程,不能“请求完就退出”。PHP 做这件事有天然门槛,但也不是不行:CLI 模式启动一个进程,监听 8080 端口,用事件循环管理连接。常见做法是引入一个 WebSocket Server 包,把连接管理、握手、帧解析都交给它,你只写 onConnect、onMessage、onClose 三个回调。
需要特别注意的是:WebSocket 服务端没有“一次请求”的边界,连接可能挂几个小时。消息广播时,一个用户网线断了,服务端要能在onClose里及时把它从连接列表里摘出去,否则广播会向一个死 socket 写数据,轻则报错,重则拖慢主循环。这就是常驻进程维护连接状态最基础、也最容易翻车的地方。
2.3 选型对照:延迟、部署复杂度、服务器开销和适合人数
| 方案 | 消息延迟 | 部署复杂度 | 服务器开销 | 适合在线人数 | 典型场景 |
|---|---|---|---|---|---|
| 短轮询 | 2 秒以上 | 低,纯 PHP/数据库 | 低,每请求一次查询 | 50~200 | 课程作业、内部工具、主页右下角客服 |
| 长轮询 | 0.5~1 秒 | 高,要处理挂起和超时 | 高,占用大量 PHP 进程 | 50~150 | 消息频率低,但要求不刷新的小众系统 |
| WebSocket | 100ms 级 | 中高,需常驻进程和端口 | 中,一个连接少量内存 | 500+ | 对外演示、社区聊天室、需要实时刷新的产品 |
选型建议:第一次做,优先短轮询。它能覆盖绝大多数“轻量级聊天室”的诉求,代码可读性好。如果体验要求已经明确到“消息必须在 1 秒内出现”,再上 WebSocket。长轮询我基本不推荐,原因是排错麻烦:既要控制并发,还要处理 PHP 连接超时,收益却不大。长轮询最怕连接数一多,Web 服务器进程池被占满,其他普通 PHP 页面也跟着卡死,这种“一挂挂一片”的问题在轻量应用里特别不值得背。之后的章节先给你完整短轮询源码,再讲升级 WebSocket 的最短路径。
3. 轮询版源码最小实现:建表、发送接口、拉取接口和前端循环
这一章给出可以放进目录直接用的最小三件套:一张表、两个 PHP 接口、一个前端页面。
3.1 数据库只留一张消息表:字段越少越容易搬
先说设计。轻量级不需要用户表,把昵称直接存在每条消息里。好处是减少一次关联查询;代价是不能改昵称、没有头像和权限体系。这对小聊天室完全可以接受。如果你以后要加禁言,再拆用户表也不迟。
建表 SQL:
CREATE TABLE `chat_msg` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `sender` VARCHAR(20) NOT NULL COMMENT '发送者昵称', `content` TEXT NOT NULL, `create_at` INT UNSIGNED NOT NULL COMMENT '发送时间戳', PRIMARY KEY (`id`), KEY `idx_create_at` (`create_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='聊天消息表';逻辑说明:id自增主键同时充当“消息游标”,轮询接口只要记住最后一条消息的id,就能增量拉取。content用 TEXT 而不用 VARCHAR,是给长句留余地;但接口层仍要做长度限制。idx_create_at目前不是必须的,但消息多了以后,按时间清理历史数据会用到。utf8mb4是硬性要求,否则 emoji 和特殊字符会变成乱码。字段越少,这个文件越容易复制到别的项目里。
3.2 后端两个接口搞定收发:send.php 和 poll.php 的完整代码与参数说明
发送接口send.php:
<?php // send.php —— 接收前端提交的消息并写入数据库 error_reporting(0); header('Content-Type: application/json; charset=utf-8'); $pdo = new PDO( 'mysql:host=127.0.0.1;dbname=chat_room;charset=utf8mb4', 'root', 'your_password', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] ); $sender = trim($_POST['sender'] ?? ''); $content = trim($_POST['content'] ?? ''); if ($sender === '' || $content === '' || mb_strlen($content) > 500) { echo json_encode(['code' => 1, 'msg' => '参数不合法']); exit; } $stmt = $pdo->prepare('INSERT INTO chat_msg (sender, content, create_at) VALUES (?, ?, ?)'); $stmt->execute([$sender, $content, time()]); echo json_encode([ 'code' => 0, 'id' => (int)$pdo->lastInsertId(), 'msg' => 'ok' ]);逻辑说明:error_reporting(0)是为了防止 PHP 警告被当成 JSON 输出,调试阶段可以去掉;接口返回统一 JSON 结构,code=0表示成功。mb_strlen($content) > 500限制单条长度,防止有人灌几十万字。charset=utf8mb4出现在 DSN 里,这是中文不乱码的第一个关键点。PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION让 SQL 错误抛出异常,方便写日志,而不是静默失败。
拉取接口poll.php:
<?php // poll.php —— 按 last_id 拉取增量消息,last_id=0 时返回最近 50 条 header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-store, max-age=0'); $pdo = new PDO( 'mysql:host=127.0.0.1;dbname=chat_room;charset=utf8mb4', 'root', 'your_password' ); $lastId = (int)($_GET['last_id'] ?? 0); if ($lastId === 0) { $rows = $pdo->query( 'SELECT id, sender, content, create_at FROM chat_msg ORDER BY id DESC LIMIT 50' )->fetchAll(PDO::FETCH_ASSOC); $rows = array_reverse($rows); } else { $stmt = $pdo->prepare( 'SELECT id, sender, content, create_at FROM chat_msg WHERE id > ? ORDER BY id ASC LIMIT 100' ); $stmt->execute([$lastId]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); } $lastIdNow = $rows ? (int)$rows[count($rows) - 1]['id'] : $lastId; echo json_encode(['code' => 0, 'list' => $rows, 'last_id' => $lastIdNow]);逻辑说明:last_id=0表示首次进入,此时不能从 1 开始查,否则历史消息会把页面刷爆。所以做了个分支:按id DESC取最近 50 条,再array_reverse回正序。非首次进入时,id > last_id且ORDER BY id ASC保证增序返回;LIMIT 100防止一次性返回太多。最后返回服务端最新的last_id,前端保存下来,下一次请求继续用它。注意lastIdNow的求法要取反转后数组最后一个元素的id,这里用count($rows)-1比end($rows)更直白,也避免操作内部指针。Cache-Control: no-store这一行很关键,有些浏览器会缓存 GET 请求的 JSON 响应,导致轮询拿到旧数据。
3.3 前端轮询循环:setInterval + fetch,别忘掉 XSS 转义
前端index.html:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>PHP 轻量聊天室</title> <style> #msgs { max-width: 640px; margin: 20px auto; border: 1px solid #ccc; height: 400px; overflow-y: auto; padding: 10px; } .row { margin-bottom: 8px; } </style> </head> <body> <div id="msgs"></div> <div style="max-width:640px;margin:10px auto;"> <input id="sender" placeholder="昵称" maxlength="20"> <input id="content" placeholder="说点什么" maxlength="500"> <button id="sendBtn">发送</button> </div> <script> let lastId = 0; async function loadMessages() { const resp = await fetch('poll.php?last_id=' + encodeURIComponent(lastId)); const data = await resp.json(); if (data.code !== 0) return; if (data.list.length) { data.list.forEach(appendMsg); lastId = data.last_id; } } function appendMsg(msg) { const box = document.getElementById('msgs'); const div = document.createElement('div'); div.className = 'row'; div.innerHTML = '<strong>' + escapeHtml(msg.sender) + '</strong>:' + escapeHtml(msg.content); box.appendChild(div); while (box.children.length > 200) { box.removeChild(box.firstChild); } box.scrollTop = box.scrollHeight; } function escapeHtml(text) { const t = document.createElement('div'); t.textContent = text; return t.innerHTML; } async function sendMessage() { const sender = document.getElementById('sender').value.trim() || '匿名'; const content = document.getElementById('content').value.trim(); if (!content) return; const body = new URLSearchParams(); body.append('sender', sender); body.append('content', content); await fetch('send.php', { method: 'POST', body }); document.getElementById('content').value = ''; loadMessages(); } document.getElementById('sendBtn').addEventListener('click', sendMessage); setInterval(loadMessages, 2000); loadMessages(); </script> </body> </html>逻辑说明:setInterval(loadMessages, 2000)是核心,每 2 秒拉一次。lastId从 0 开始,首次加载会拿到最近 50 条历史。appendMsg把新消息拼到 DOM,并限制只有最近 200 条,防止页面越来越卡。escapeHtml必须做:用户输入的内容如果直接插innerHTML,就是存储型 XSS,别人一发言就能偷走你的登录态。URLSearchParams比FormData更轻量,POST 时不需要额外设置请求头,fetch会自动编成表单格式。
参数怎么调:前端把2000改成1000,延迟更低但服务器请求数翻倍;改成3000则相反。如果聊天室同时在线不到 30 人,2000是很合理的平衡点。发送成功后立刻手动调一次loadMessages(),是为了让“我自己发出去的消息”不用等下一个轮询周期才显示。
4. 升级 WebSocket 版:用 PHP 常驻进程做服务端,让消息秒到
如果你的聊天室要在活动页面上对上百人开放,2 秒延迟会明显拉低体验。这时候升级 WebSocket,但先理解它。
4.1 先补一课:WebSocket 协议里握手和帧是两件事
WebSocket 的第一次连接仍然走 HTTP:浏览器发一个带Upgrade: websocket的 GET 请求,服务端校验后返回101 Switching Protocols,这时 TCP 连接升级为 WebSocket。之后双方发送的数据不再有 HTTP 头,而是按二进制帧格式封装。文本消息通常是一个以0x81开头的帧,包含长度、掩码和 payload。客户端发给服务端的帧必须加掩码,服务端返回给客户端的帧不需要。
这意味着 PHP 做 WebSocket 服务端时,不能像普通 HTTP 接口那样“跑完就退出”,必须有一个进程一直等在那里,读帧、读 socket、写 socket。PHP CLI 模式下可以做到,但用手写stream_socket_server加帧解析,代码量不小,且边界情况容易翻车。常见做法是引入一个 WebSocket Server 包,把握手、帧解析、连接状态都封装好。你需要的只是三个回调:onConnect、onMessage、onClose。
4.2 用 PHP 常驻进程框架写服务端:连接管理、广播和最小启动命令
下面这段用框架无关的回调风格写,你选定的包基本都会提供类似 API:
<?php // chat_server.php(以你选择的 WebSocket Server 包为例) $server = new WebSocketServer('0.0.0.0:8080'); $clients = []; $server->onConnect = function ($conn) use (&$clients) { $clients[$conn->id] = $conn; $conn->send(json_encode([ 'type' => 'system', 'content' => '已连接聊天室' ])); }; $server->onMessage = function ($conn, $data) use (&$clients) { $payload = json_decode($data, true); if (!is_array($payload)) return; $msg = [ 'type' => 'chat', 'sender' => trim($payload['sender'] ?? '匿名'), 'content' => trim($payload['content'] ?? ''), 'time' => date('H:i:s') ]; foreach ($clients as $client) { $client->send(json_encode($msg)); } }; $server->onClose = function ($conn) use (&$clients) { unset($clients[$conn->id]); }; $server->run();启动命令:
# 1. 安装你选择的 PHP WebSocket Server 包 composer require your/websocket-server-package # 2. 以 CLI 模式启动常驻进程 php chat_server.php start # 3. 确认端口在监听 netstat -tlnp | grep 8080逻辑说明:$clients数组保存所有在线连接,收到消息后遍历广播,这是聊天室最基本的服务端行为。conn->id是包内为每个连接分配的标识,临时变量形式可能略有差异,但思路一样。json_encode统一返回结构化消息,前端靠type字段区分聊天消息和系统提示。
这里有个性能边界要清楚:foreach ($clients as $client)会把所有在线用户扫一遍,几百人完全没问题;但如果在线几千人,一次广播要发几千个 JSON,某条慢连接会拖慢整个循环。常见做法是引入发送队列,每 50ms 批量推送一次。轻量级聊天室先不用管,但你要知道这个边界,别等线上卡住再去改架构。
前端升级为 WebSocket 客户端,去掉 setInterval:
let ws = null; let reconnectTimer = null; function connect() { ws = new WebSocket('ws://' + location.hostname + ':8080'); ws.onopen = () => { document.getElementById('status').textContent = '在线'; }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'chat') appendMsg(msg); }; ws.onclose = () => { document.getElementById('status').textContent = '掉线,重连中…'; clearTimeout(reconnectTimer); reconnectTimer = setTimeout(connect, 3000); }; ws.onerror = () => ws.close(); } connect();发送消息:
document.getElementById('sendBtn').addEventListener('click', () => { const sender = document.getElementById('sender').value.trim() || '匿名'; const content = document.getElementById('content').value.trim(); if (!content) return; ws.send(JSON.stringify({ sender, content })); document.getElementById('content').value = ''; });逻辑说明:ws://的地址拼location.hostname,不要直接拼location.host,否则会带上当前页面端口,导致端口重复。onclose里做 3 秒重连,是 WebSocket 生产环境的必备操作,因为你无法保证网络不闪断。历史消息仍可以保留poll.php拉取,进入页面时先 HTTP 拉一次last_id=0的历史,再建立 WebSocket,两套接口并存并不冲突。
5. 部署排查手册:5 个常见坑和对应解法(避坑章节)
轮询版和 WebSocket 版各有一些高频坑,下面五条是我见过最多的。
5.1 轮询接口 404、空输出和中文乱码的排查
坑 1:现象是poll.php在浏览器里直接访问显示“File not found”,但index.html正常。原因是 Web 服务器的 PHP 请求没有正确交给 PHP 解释器,常见是缺少location ~ \.php$配置,或者fastcgi_param SCRIPT_FILENAME路径写错。解决方法是把 PHP 请求交给解释器处理,下面是一份最小配置,放进站点配置后记得重载:
location ~ \.php$ { try_files $uri =404; include fastcgi_params; fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; }如果仍然 404,再看两处:PHP 解释器有没有启动,监听地址是不是 9000;站点根目录是否写到了chat目录而不是上一层。日志是最快的定位方式,Web 服务器错误日志会直接告诉你文件路径去错了哪里。
坑 2:现象是接口返回 JSON 但中文变?或\u00e6,名场面叫“乱码”。原因多半是三处不一致:数据库表用了utf8而不是utf8mb4;PDO 连接串没写charset=utf8mb4;PHP 文件没有声明 UTF-8 输出。解决方法是统一改成utf8mb4,在send.php和poll.php的开头都加header('Content-Type: application/json; charset=utf-8')。改完之后清一下浏览器缓存,乱码基本绝迹。
坑 3:现象是轮询第一次进入时聊天记录只显示最近几条,或者顺序是反的。原因是last_id=0的处理写成了WHERE id > 0 ORDER BY id ASC,这会从第一条历史开始返回,又慢又凌乱。解决方法是先查最近 50 条再反转,也就是第 3 章里poll.php的分支写法。这条容易发生在你复制别人代码时,只看到后半段增量逻辑。
5.2 WebSocket 握手 400、HTTPS 页面拒绝和常驻进程被禁用的排查
坑 4:现象是浏览器 WebSocket 一直报连接失败,服务端那边没有任何消息进来,Network 面板里握手返回 400 Bad Request。原因通常在于 Web 服务器反代设置了端口转发,却没有转发Upgrade头;WebSocket 服务端把请求当成普通 HTTP,于是拒绝。解决方法是给 WebSocket 路径加一个独立配置,保证 HTTP 版本和升级头正确:
location /ws { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }注意proxy_http_version 1.1不能省,HTTP/1.0 不支持升级协议。
坑 5:现象是页面已启用 HTTPS,但浏览器控制台直接禁止连接,提示不安全的 WebSocket。原因是前端代码写了ws://,HTTPS 页面只能放行wss://。解决方式有两种。最简单的,让当前页面也是ws://(仅限纯 HTTP 场景);更规范的是在 Web 服务器的 443 配置块里加上面那段/ws反代,然后前端把地址写成wss://你的域名/ws。这样 TLS 握手由 Web 服务器处理,后端的 PHP 服务仍然监听普通 TCP 端口,不用处理证书。
坑 6:现象是php chat_server.php start启动时报pcntl_fork() has been disabled或类似函数不存在。原因是常驻进程框架依赖进程控制和信号函数,而你用的 PHP 版本在编译或配置时禁用了这些函数。解决方法是先执行php -m | grep -i pcntl确认;没有就安装或启用 pcntl、posix 两个扩展。另外,常驻服务必须用 CLI 启动,不要把它挂在 Web 服务器的 PHP 请求里跑,否则只能服务化一次请求,进程根本驻留不住。
6. 生产级小技巧:消息去重、心跳检测和房间隔离
到这里,一套能跑的两版源码已经有了,剩下是让它在真实环境中少被投诉的几个习惯。
6.1 消息去重和心跳:让 UI 不翻车,连接不假死
轮询版最大的重复来源是last_id没更新成功时,下一次轮询又拉回同样的消息。WebSocket 版则是断线重连后,服务端可能把历史消息重放回来。前端可以加一个消息 ID 缓存:
const seen = new Set(); function appendMsg(msg) { if (seen.has(msg.id)) return; seen.add(msg.id); // 后续渲染逻辑照旧 }如果消息没有服务端 ID(纯 WebSocket 广播不带 ID),可以改用内容加时间戳做指纹,但不如在服务端生成id稳定。心跳是另一个必备动作:WebSocket 连接长时间没有数据,某层代理或防火墙会悄悄断开它,双方都还认为连接在。浏览器端每 25 秒发一条心跳:
setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping' })); } }, 25000);服务端在onMessage里遇到type=ping就回一条pong,不做广播。这样连接是否存活就由两端共同确认,掉线能更快被发现。
6.2 房间隔离:一个端口跑多个聊天室,不比再加一套源码
很多场景要求同时开多个房间,比如技术群、闲聊群。最简单的做法不是复制一份源码,而是在连接元数据里加roomId。服务端把连接按房间分组:
$rooms[$roomId][$conn->id] = $conn;广播时只遍历当前房间里的连接:
foreach ($rooms[$roomId] as $client) { $client->send(json_encode($msg)); }前端进入页面时通过 URL 参数指定房间:
const roomId = new URLSearchParams(location.search).get('room') || 'default';建立 WebSocket 后第一条消息就把roomId告诉服务端,服务端将该连接归入对应分组。这样改动量不大,却把一个聊天室变成了聊天室集群,后续加禁言、加表情也只是在房间维度上加判断。
我自己的上线习惯是:先把 Web 服务器错误日志和 PHP 错误日志同时打开,改一行、测一次、看一遍日志。多数“玄学”问题其实是日志没开,多数“翻车”都发生在改完代码没重载配置。把这两点养成习惯,这套源码基本能平稳跑起来。希望帮到你。
本文还有配套的精品资源,点击获取