简介这是基于PHP打造的在线聊天室源码采用单文件与无数据库设计面向需要快速部署轻量聊天功能的开发者同时适合PHP初学者通过实际代码学习服务器端脚本、会话维持与异步刷新机制。压缩包总共包含1个PHP文件体积仅8KB无需配置数据库上传到支持PHP的服务器即可直接使用对临时项目、内部测试或教学演示尤为便利。源码在服务器内存中暂存聊天记录借助轮询或长轮询实现新消息的实时获取同时提供在线用户列表、自定义字体等基础交互由于没有持久化存储页面刷新后聊天记录会丢失但这一取舍也明显降低了系统复杂性。代码同时兼容PHP5与PHP7兼顾不同服务器环境。已有1873人下载学习。对于想弄清无数据库状态下如何维护状态、理解Ajax轮询本质或借鉴单文件结构组织小型应用的人来说这份源码提供了简洁完整且可直接运行的参考。1. 单文件无数据库聊天室解压后一条命令就能跑起来有人问我“PHP 聊天室源码”下载下来是不是还要装 MySQL我说不用。这份单文件无数据库版整个项目就一个.php文件解压后只要本机装了 PHP 环境一条php -S 0.0.0.0:8080就能把聊天室跑起来。它没有 Redis、没有 Composer 依赖连聊天记录都不落数据库而是写进文本文件刷新页面消息还在。适合临时内网讨论、课堂教学演示、个人项目练手也适合想在几分钟内看懂 PHP 轮询实现的人。对想二次开发的熟手来说它没有框架黑匣子改起来能看见每一行代码算是一个很干净的解剖对象。2. 结构拆解单文件里的入口、数据文件与动作分发第一次打开这个压缩包的人大多会愣一下怎么就一个index.php但当我把文件从头到尾扫一遍会发现它的分区其实非常清晰不是一个把所有逻辑都塞在一起的“大泥球”。2.1 主文件的分区结构入口、函数、视图、前端脚本单文件不等于无组织。这个项目在同一个 PHP 文件里把四个区域分得很清楚顺序大致如下index.php ├── 顶部常量、数据文件路径、公共函数 ├── 中部action 动作分发send / poll / heartbeat / html ├── 函数区写消息、读消息、在线状态维护 └── 底部HTML 模板 前端 JavaScript代码块里能看到删掉注释后整个文件相当于一个小型 MVC 的压缩形态页面渲染在底部接口动作在中间数据存储在函数区。之所以这样安排是因为无框架项目没有路由表请求进来只能靠 URL 参数区分动作。你访问index.php时它输出 HTML 页面你提交index.php?actionsend时它去处理发消息index.php?actionpoll则是拉取新消息。这种自包含结构最大的好处是部署简单所有相对路径都以index.php所在目录为基准不会出现多文件项目里常见的 include 路径漂移。缺点是文件体积会膨胀改前端 JS 时要在字符串拼接里翻来翻去。源码里 HTML 部分用的是 PHP 字符串拼接输出动前端模板时要注意引号转义这是单文件项目最常翻车的地方。2.2 消息存储JSON Lines 追加文件 文件锁无数据库不代表无状态聊天记录总得有个落盘的地方。这个项目用的是 JSON Lines 文本文件也就是每一行一条消息 JSON 对象新消息直接追加到文件末尾。没有用 MySQL也没有用 SQLite因为很多精简 PHP 环境不一定开了 SQLite 扩展而文本文件在任何 PHP 版本上都能跑。$fp fopen(__DIR__ . /runtime/chat.jsonl, a); flock($fp, LOCK_EX); fwrite($fp, json_encode($msg, JSON_UNESCAPED_UNICODE) . PHP_EOL); fflush($fp); flock($fp, LOCK_UN); fclose($fp);这里fopen($fp, a)的a表示追加模式写入永远发生在文件末尾不会覆盖已有消息。flock($fp, LOCK_EX)加的是排他写锁避免两个用户同时发言时互相打断。JSON_UNESCAPED_UNICODE参数很关键不加的话中文会被转成\uXXXX形式的转义序列文件能读但可读性很差排错时容易看花眼。消息文件的单条记录结构类似这样{id:1,nickname:张三,text:大家好,time:14:30:00}。每行独立成一条消息读取时用fgets逐行解析天然就有增量边界。这种设计其实就是聊天室的提交日志每次追加都是 O(1) 操作崩溃时最多丢最后一条不会把整份记录搞坏。2.3 运行时目录与权限解压后初始情况是零数据文件第一次运行才自动生成 runtime 目录解压目录/ ├── index.php # 唯一源码聊天室全部逻辑 └── runtime/ # 首次运行自动生成 ├── chat.jsonl # 聊天消息按行追加 └── online.json # 在线用户状态把数据文件放在runtime/子目录而不是和index.php平级是为了避免聊天记录被浏览器直接访问下载。部署到服务器时需要保证 PHP 进程对该目录有写权限。如果主机有目录权限面板让运行 PHP 的系统用户对runtime可写即可如果使用只读的静态托管环境这个方案就没法跑这也是所有文件存储类 PHP 项目的共同前提。我一般建议直接把runtime目录放到 Web 根目录之外做到物理隔离。比如站点根目录是www/就把index.php放进去把 runtime 放到www/../data/然后在源码里把数据文件路径改成绝对路径。这样即使服务器没配置任何访问规则聊天记录也不会被下载。2.4 无数据库方案的边界在哪里这套架构的瓶颈很清楚文件锁。所有写操作最终都串行化到同一个文件上所以它适合几十人、每秒几条消息的场景。我做过简单估算如果一百人同时在线每人 1 秒轮询一次每分钟至少有几千次请求打到服务器上PHP-FPM 进程会被占满poll 接口开始批量超时。这不是代码写得差而是文件轮询方案的物理上限。所以拿到这份源码第一件事不是改功能而是确认使用场景。能接受几秒延迟、峰值在线不奔百人文本文件方案非常舒服如果预期是几百人同时聊天就老老实实换成 WebSocket 或者上长轮询不要硬用文件方案扛。无数据库版的价值在于快速可用不在于高并发。3. 核心代码拆解一条消息从输入到上屏的完整链路这个聊天室的运行时链路可以概括成一句话浏览器 A 每隔一秒轮询一次新消息浏览器 B 发消息时把内容写进文件A 下一次轮询就把 B 的消息拉回来了。没有 WebSocket没有长连接靠的是“轮询”这个最朴素的手段。下面按入口、轮询、发送、心跳四段来拆。3.1 入口分发同一个 index.php 输出 HTML 或 JSON?php declare(strict_types1); $action $_GET[action] ?? html; // API 请求统一返回 JSON页面请求返回 HTML if (in_array($action, [send, poll, heartbeat], true)) { header(Content-Type: application/json; charsetutf-8); } switch ($action) { case send: handleSend(); break; case poll: handlePoll(); break; case heartbeat: handleHeartbeat(); break; default: renderPage(); }action参数通过 URL 的 GET 传递决定了当前请求是“渲染页面”还是“处理接口”。比如前端轮询时请求的是index.php?actionpolllast_id12发消息时 POST 到index.php?actionsend。页面渲染则是默认分支当 URL 不带action时输出整个聊天室 HTML。代码里每个case处理完业务后直接用exit或return结束避免后续 HTML 模板输出混进 JSON 响应里。这是单文件项目最容易忽略的细节接口和页面在同一个文件里如果不在接口分支结束后中断JSON 后面会跟着一大段 HTML前端res.json()直接报错而且这种报错在浏览器里很难一眼看出来。3.2 增量拉消息last_id 就是聊天室的游标function handlePoll(): void { $lastId (int)($_GET[last_id] ?? 0); $fp fopen(DATA_FILE, r); $messages []; while (($line fgets($fp)) ! false) { $line trim($line); if ($line ) { continue; } $msg json_decode($line, true); if (!is_array($msg)) { continue; } if ((int)$msg[id] $lastId) { $messages[] $msg; } } fclose($fp); echo json_encode([ code 0, messages $messages, ], JSON_UNESCAPED_UNICODE); }这段代码是整个聊天室的“游标设计”。客户端每次轮询时带上自己最后看到的消息 ID服务端扫描消息文件只返回id lastId的记录。第一次进聊天室时lastId是 0服务端会把文件里所有消息都返回之后每次轮询都是增量返回传输量很小。逐行解析时如果某一行 JSON 解析失败会直接跳过不会让接口崩掉。这个容错很有必要因为并发写得快时读端可能正好读到半行数据json_decode返回null跳过这行等下一次轮询就能补回来。这里用的是线性扫描消息量几百上千条时性能完全没问题等到文件有几万行就可以用fseek从文件尾部倒着读只解析最后一段。二次开发时可以按这个方向优化但初期没必要。3.3 发送消息加锁、过滤、限长function handleSend(): void { $text trim((string)($_POST[text] ?? )); $nickname trim((string)($_POST[nickname] ?? 匿名用户)); if ($text || mb_strlen($text) 500) { echo json_encode([code 1, message 消息不能为空且不能超过500字]); return; } $fp fopen(DATA_FILE, a); if (!$fp) { echo json_encode([code 1, message 消息存储不可写]); return; } if (flock($fp, LOCK_EX)) { $nextId getLastMessageId() 1; fwrite($fp, json_encode([ id $nextId, nickname htmlspecialchars(mb_substr($nickname, 0, 20), ENT_QUOTES, UTF-8), text htmlspecialchars($text, ENT_QUOTES, UTF-8), time date(H:i:s), ], JSON_UNESCAPED_UNICODE) . PHP_EOL); fflush($fp); flock($fp, LOCK_UN); } fclose($fp); echo json_encode([code 0, id $nextId]); }发送接口做了三件事参数校验、内容过滤、加锁追加。消息长度限制在 500 字昵称限制在 20 字超出就直接返回错误码这是聊天室项目最基本的护城河不然一条消息能把整个页面布局撑坏。重点看getLastMessageId() 1为什么要放在锁内。我一开始拆这个源码时想省事直接用count(file(DATA_FILE))数行数来算下一条消息的 ID结果并发一上来就出现两条消息 ID 相同的情况。原因很简单两个请求同时读到“当前文件有 9 行”各自把消息写成第 10 条ID 就重复了。放进锁内之后同一时刻只有一个进程能进入临界区读完再写是原子操作。fflush是为了把缓冲区数据强制刷到磁盘防止进程结束时数据还在缓冲区里没落盘。htmlspecialchars这里做了双向防护昵称和内容统一转义 HTML 实体ENT_QUOTES连单引号也一起转义。这是聊天室的标配否则用户发一个img onerror...就能在别人的浏览器里执行脚本。哪怕前端已经做了转义后端也要再做一层永远不要相信客户端提交的数据。3.4 在线列表与心跳30 秒无动作就下播function handleHeartbeat(): void { $uid $_POST[uid] ?? ; $nickname trim($_POST[nickname] ?? 匿名用户); $now time(); $online json_decode((string)file_get_contents(ONLINE_FILE), true); if (!is_array($online)) { $online []; } if ($uid ! ) { $online[$uid] [nickname $nickname, last_seen $now]; } // 30 秒没心跳就当成离线 $online array_filter($online, static function ($row) use ($now) { return $now - (int)$row[last_seen] 30; }); file_put_contents(ONLINE_FILE, json_encode($online, JSON_UNESCAPED_UNICODE), LOCK_EX); echo json_encode([code 0, online array_values($online)]); }在线列表不是靠 WebSocket 实时维护的而是客户端每隔一段时间主动上报一次“我还活着”。每次心跳把当前用户的uid和昵称写进online.json同时把超过 30 秒没有更新的用户过滤掉。所以在线列表的“在线”定义是最近 30 秒内至少发过一次心跳。如果想更准确把 30 秒改成 10 秒同时让前端心跳间隔也缩到 10 秒以内这里没有玄学就是一个超时窗口问题。回调前端拿到online数组后渲染右侧在线用户列表。整个在线表每次都是全量读、全量写几十人没问题上千人会非常吃力这个机制本身就是为轻量场景设计的。4. 三种启动方式本机调试、局域网联调、服务器部署聊完代码下面说怎么跑。这个项目最友好的地方就是启动方式多而且都不涉及数据库初始化。我在不同环境里都试过一轮步骤很固定。4.1 本机最快路径php -S 一条命令前提是机器上已经装了 PHP 7.4 或更高版本命令行执行php -v能看到版本号。然后在解压目录打开终端执行php -S 0.0.0.0:8080-S是 PHP 内置开发服务器的启动参数0.0.0.0表示监听本机所有网卡8080是端口。启动后浏览器访问http://127.0.0.1:8080就能看到聊天室页面。如果端口被占用改成php -S 0.0.0.0:8088换个端口即可源码里没有写死端口不用担心端口不匹配的问题。注意这里绑定的是0.0.0.0不是127.0.0.1。我的习惯是开发阶段直接绑0.0.0.0这样局域网里的人也能随时加进来测试如果只想本机看绑127.0.0.1更安全。第一次跑失败最常见的原因是 PHP 不在 PATH 环境变量里Windows 下要把 PHP 安装目录加进 PATH或者直接切换到 PHP 所在目录执行命令。4.2 局域网联调想用手机或另一台电脑加入聊天让设备连同一个局域网然后访问电脑的局域网 IP。比如电脑 IP 是192.168.1.23手机上访问http://192.168.1.23:8080。如果访问不通优先检查防火墙是否放行了 8080 端口这一步在 Windows 和 Linux 上表现不一样Windows 弹防火墙提示时要允许专用网络访问。这个单文件结构在局域网联调时有个天然优势页面和接口同源不存在跨域问题。只要前端请求用的是相对路径index.php?actionpoll不管从哪个 IP 打开页面都能正常工作。有些多文件项目把接口地址写成了localhost换设备访问就直接废掉这份源码没有这个坑。我一般会开两个浏览器窗口一个用电脑一个用手机同时登录观察消息同步。如果手机上发了消息电脑看不到先看浏览器控制台网络请求是不是 500多半是 runtime 目录没有写权限如果请求被拒绝那就是防火墙问题跟代码无关。4.3 正式 Web 服务器部署要放到正式 Web 服务器上可以让 PHP 请求统一由 PHP-FPM 处理。不需要开启伪静态因为这个项目没有路由重写需求所有动作都走带参数的index.php?actionxxx。部署时只需要关注三件事默认文档设置为index.php、.php请求转给 PHP-FPM、runtime 目录可写。如果服务器面板支持一键部署 PHP 站点直接指定站点根目录、PHP 版本选 7.4 以上上传index.php后访问域名就能看到聊天室页面。我习惯部署后先发一条测试消息然后直接查看runtime/chat.jsonl内容确认写入成功再检查runtime目录是否泄漏到外网访问路径。一个容易被忽视的点如果站点根目录下有多个目录要确保 PHP 进程对runtime有写权限对目录里其他文件没有写权限也没关系。读取.html静态资源不需要动 runtime但聊天室每个消息都在写文件这个权限缺了所有发送都会失败。5. 避坑记录文件锁、中文乱码、轮询风暴的现场复盘下面这五条是跑这个项目时最容易踩的坑我在不同机器上反复遇到过每条都按“现象、原因、解决”展开。5.1 并发写坏消息文件现象几个人同时发消息过一会儿聊天记录里出现半行 JSON或者某条消息的 ID 跳号重复。原因写的速度跟不上的时候两个 PHP 进程同时操作同一个文件没有锁保护就会互相覆盖。早期版本如果用file_put_contents默认模式截断和追加之间发生交错文件内容就整个乱掉。解决统一用fopen($fp, a)配合flock(LOCK_EX)追加写入读取端遇到解析失败的行直接跳过等下一次轮询再读。写入必须用a追加模式千万不能用w模式w会从文件开头截断两个请求一交错就清空记录。5.2 中文乱码现象本地测试中文正常上传到服务器后全部变成问号或方块。原因三选一——编辑器把文件保存成了非 UTF-8 编码PHP 页面响应头里没有声明charsetutf-8文件头部被加上了 BOM 字符导致header()还没执行就已经输出了 3 个字节的 BOM响应头设置失效。解决代码开头统一使用header(Content-Type: text/html; charsetutf-8);编辑器保存时强制选 “UTF-8 无 BOM”。这个项目没有数据库所以不存在 MySQL 连接字符集的问题比多文件项目排查起来省事很多。如果服务器上已经乱码把文件重新用 UTF-8 无 BOM 保存覆盖一遍即可。5.3 轮询风暴把浏览器拖垮现象开着聊天室页面一段时间后标签页 CPU 占用拉满浏览器越来越卡网络请求面板里全是 pending 的 poll 请求。原因前端用了setInterval(poll, 1000)网络稍慢时上一次请求还没完成下一次又启动了请求不断堆积越积越卡。解决改成链式setTimeout等上一个请求彻底结束再安排下一次。let lastId 0; let timer null; function poll() { fetch(index.php?actionpolllast_id lastId) .then(res res.json()) .then(data { if (data.messages data.messages.length) { appendMessages(data.messages); lastId data.messages[data.messages.length - 1].id; } timer setTimeout(poll, 1000); }) .catch(() { timer setTimeout(poll, 2000); }); } poll();这段代码的核心是setTimeout放在回调里请求失败时自动退避到 2 秒再重试。页面切换到后台时还可以加一个document.hidden判断暂停轮询省下无意义的请求量。这个坑也是很多新手把“轮询”写成“轮询风暴”的根源。5.4 刷新页面后昵称变成匿名用户现象F5 刷新后自己明明设置了昵称在线列表里显示的名字却变成了“匿名用户”。原因这个轻量版聊天室把昵称存在前端localStorage里服务端只是在收到发送或心跳请求时才拿到昵称。刷新后的第一次心跳如果没有带上localStorage里的昵称服务端就只能记录成“匿名用户”。解决前端每次心跳都从localStorage读取昵称和uid随请求一起 POST 给服务端。如果服务端发现uid已存在但昵称不同就用新昵称覆盖旧记录。这里不建议刷新时换新uid否则在线列表会出现一个旧身份残留又一个 30 秒后才被清理掉。5.5 聊天记录被浏览器直接下载现象把部署地址发给别人对方访问http://域名/runtime/chat.jsonl直接看到了所有明文聊天记录。原因数据文件放在 Web 可访问目录里而.jsonl后缀没有被服务器拦截浏览器就直接当成普通文件下载了。解决把runtime目录挪到 Web 根目录之外最省事如果已经上线、暂时不想动目录结构就在 runtime 目录下放一条服务器拒绝规则禁止外部访问该目录。单文件项目能自带目录拒绝规则最好这样部署时少一项人工配置。6. 改造思路让“能聊”变成“能留存”6.1 按天切分消息文件控制单文件体积聊天室跑久了chat.jsonl会越来越大扫描也越来越慢。改造时可以按天生成消息文件把文件路径定义成带日期define(DATA_FILE, __DIR__ . /runtime/chat- . date(Ymd) . .jsonl);poll 默认只读当天文件历史记录单独做一个actionhistory按日期读取。再配合一个定时任务删除 30 天前的文件就完成了轻量级归档。注意DATA_FILE不能在一个请求过程里多次重复定义否则 send 和 poll 各写各的文件会对不上。6.2 保守升级先换 SSE 再考虑 WebSocket很多人拿到手就想上 WebSocket但一旦引入常驻进程部署复杂度直接翻倍。折中做法是加一个actionsse服务端每次循环读新消息用echo data: ...\n\n推给浏览器前端用EventSource接收。本质还是文件轮询但省掉了每个请求的 HTTP 头开销几十人场景体验接近实时。要清醒的是每个 SSE 连接会占一个 PHP-FPM 进程所以它是过渡方案不是终极解。6.3 加房间隔离最小改动给消息体增加room字段数据文件按房间拆分chat-{room}.jsonl。send 和 poll 都接收room参数每个房间独立读写文件竞争天然被摊开。加一个固定房间列表参数或者进入页面时自动分配随机房间就能从“一个公共聊天室”变成“多人房间聊天室”。这套改造我建议按固定顺序走先加 action再动存储层最后改前端 JS。跑通一个功能后再回头检查一遍锁和 flush。因为一开始我也干过直接改前端、后端没加锁就上线导致 ID 重复的蠢事从那以后每个单文件项目我都强制先画数据流图再动手。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?