傳統輪詢雖非 WebSocket 等級的即時性,但無需額外服務、部署簡單、相容性極佳,適合中小型聊天室、客服系統、內部通訊工具。本文實作一個功能完整的單檔案聊天室,支援:多房間、按日分檔儲存、線上用戶偵測、表情符號、字體樣式、私有訊息、敏感詞過濾、XSS 防護。
chat.php 處理讀/寫/線上狀態,資料存儲於 rooms/YYYY-MM-DD.dat.php(含 PHP 死結構防直接存取)。setInterval 輪詢(預設 2 秒),jQuery 可選。時間戳|發送者:內容(含HTML),線上用戶檔 rooms/YYYY-MM-DD.php 記錄 時間戳|最後活動|用戶名|IP。<?php
// chat.php - 完整聊天室後端
header('Content-Type: application/json; charset=UTF-8');
// ===== 配置區 =====
const ROOM_DIR = __DIR__ . '/rooms/';
const MAX_DISPLAY = 200; // 客戶端最大顯示條數
const LEAST_NUM = 30; // 初次載入歷史條數
const ONLINE_TIMEOUT = 20; // 線上判定秒數
const CLEANUP_INTERVAL = 1800; // 檔案清理間隔(秒)
const CHARSET = 'UTF-8';
// 敏感詞過濾
const BAN_WORDS = ['法輪功', '共產黨', '李洪志', 'fuck', '叼', '你媽的', '他媽的'];
// 表情映射
const EMOTICONS = [
'[:ani_wink:]' => '😉', '[:big_eyes:]' => '😳', '[:cool:]' => '😎',
'[:cry:]' => '😭', '[:eye_roll:]' => '🙄', '[:grin:]' => '😁',
'[:happy:]' => '😊', '[:not_impressed:]' => '😒', '[:smile:]' => '🙂',
'[:smile_eyes:]' => '😍', '[:stickout:]' => '😛', '[:straight:]' => '😐',
'[:surprised:]' => '😲', '[:unhappy:]' => '😞', '[:wink:]' => '😉',
];
// ===== 工具函數 =====
function getRoomFile(string $room): string {
$room = preg_replace('/[^a-zA-Z0-9_\-]/', '', $room);
return ROOM_DIR . $room . '.dat.php';
}
function getOnlineFile(string $room): string {
$room = preg_replace('/[^a-zA-Z0-9_\-]/', '', $room);
return ROOM_DIR . $room . '.php';
}
function getClientIp(): string {
$headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_CLIENT_IP', 'REMOTE_ADDR'];
foreach ($headers as $h) {
if (!empty($_SERVER[$h])) {
$ips = explode(',', $_SERVER[$h]);
return trim($ips[0]);
}
}
return 'unknown';
}
function sanitize(string $str): string {
return htmlspecialchars($str, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
function ensureRoomDir(): void {
if (!is_dir(ROOM_DIR)) {
mkdir(ROOM_DIR, 0755, true);
}
}
function initRoomFiles(string $room): void {
$dataFile = getRoomFile($room);
$onlineFile = getOnlineFile($room);
if (!file_exists($dataFile)) {
file_put_contents($dataFile, "\n" . time() . "|歡迎來到聊天室!\n");
}
if (!file_exists($onlineFile)) {
file_put_contents($onlineFile, "\n");
}
}
// ===== 請求路由 =====
$action = $_GET['action'] ?? '';
$room = $_GET['room'] ?? date('Y-m-d');
$room = preg_replace('/[^a-zA-Z0-9_\-]/', '', $room);
ensureRoomDir();
initRoomFiles($room);
$dataFile = getRoomFile($room);
$onlineFile = getOnlineFile($room);
switch ($action) {
case 'write':
handleWrite($dataFile, $onlineFile);
break;
case 'read':
handleRead($dataFile, $onlineFile);
break;
default:
// 首頁輸出 HTML
outputHtml($room);
break;
}
// ===== 寫入處理 =====
function handleWrite(string $dataFile, string $onlineFile): void {
$input = file('php://input');
if (count($input) < 2) {
jsonOut(['ok' => false, 'msg' => '無內容']);
}
$name = sanitize(trim($input[0]));
$ip = getClientIp();
if (!$name) {
jsonOut(['ok' => false, 'msg' => '請輸入暱稱']);
}
// 線上用戶註冊
$onlineData = file_get_contents($onlineFile);
$marker = "|{$name}|{$ip}|";
if (strpos($onlineData, $marker) === false) {
if (strpos($onlineData, "|{$name}|") !== false) {
jsonOut(['ok' => false, 'msg' => '暱稱已被使用']);
}
$line = time() . "|" . time() . $marker . "\n";
file_put_contents($onlineFile, $line, FILE_APPEND | LOCK_EX);
}
// 處理消息內容
$style = buildStyleFromInput();
$messages = [];
for ($i = 1; $i < count($input); $i++) {
$content = sanitize(trim($input[$i]));
if ($content === '') continue;
// 圖片標籤處理
$content = preg_replace('!
!i', '[img $1/]', $content);
$content = str_replace(['<', '>'], ['<', '>'], $content);
$content = preg_replace('!\[img (.*?)/\]!i', '
', $content);
// 敏感詞過濾
$content = str_ireplace(BAN_WORDS, '***', $content);
// 表情符號替換
$content = str_replace(array_keys(EMOTICONS), array_values(EMOTICONS), $content);
if ($style) {
$content = "{$content}";
}
$messages[] = time() . "|{$name}:{$content}";
}
if (empty($messages)) {
jsonOut(['ok' => false, 'msg' => '無有效內容']);
}
$content = implode("\n", $messages) . "\n";
file_put_contents($dataFile, $content, FILE_APPEND | LOCK_EX);
jsonOut(['ok' => true]);
}
function buildStyleFromInput(): string {
$style = '';
$font = $_GET['font'] ?? '';
$fontMap = ['songti' => 'SimSun', 'heiti' => 'SimHei', 'kaiti' => 'KaiTi'];
if (isset($fontMap[$font])) {
$style .= "font-family:{$fontMap[$font]};";
}
if (!empty($_GET['bold'])) $style .= 'font-weight:bold;';
$color = $_GET['color'] ?? '';
if (preg_match('/^[0-9a-fA-F]{6}$/', $color)) $style .= "color:#{$color};";
$size = (int)($_GET['size'] ?? 0);
if ($size >= 10 && $size <= 48) $style .= "font-size:{$size}px;";
return $style;
}
// ===== 讀取處理 =====
function handleRead(string $dataFile, string $onlineFile): void {
$first = isset($_GET['first']);
$lastMod = (int)($_GET['lastmod'] ?? 0);
$currentMod = @filemtime($dataFile);
$name = sanitize(trim(file_get_contents('php://input')));
$ip = getClientIp();
$response = [
'lastmod' => $currentMod,
'lines' => [],
'onlines' => [],
];
// 新消息
if ($currentMod > $lastMod && !$first) {
$lines = file($dataFile);
foreach ($lines as $line) {
$line = trim($line);
if (!$line || strpos($line, '|') === false) continue;
[$ts, $msg] = explode('|', $line, 2);
if ((int)$ts > $lastMod) {
$response['lines'][] = [
'time' => date('H:i:s', (int)$ts),
'word' => stripslashes($msg),
];
}
}
}
// 首次載入歷史
elseif ($first) {
$lines = file($dataFile);
$total = count($lines);
$start = max(0, $total - LEAST_NUM);
$history = [];
for ($i = $start; $i < $total; $i++) {
$line = trim($lines[$i]);
if (!$line || strpos($line, '|') === false) continue;
[$ts, $msg] = explode('|', $line, 2);
$history[] = [
'time' => (date('m-d') == date('m-d', (int)$ts)) ? date('H:i:s', (int)$ts) : date('m-d H:i', (int)$ts),
'word' => stripslashes($msg),
];
}
$response['lines'] = array_reverse($history);
}
// 更新線上用戶
$now = time();
$onlineLines = file($onlineFile);
$newOnlineContent = '';
$onlines = [];
foreach ($onlineLines as $line) {
$line = trim($line);
if (!$line || strpos($line, '|') === false) continue;
[$ts, $lastActive, $uname, $uip] = explode('|', $line);
if ($now - (int)$lastActive < ONLINE_TIMEOUT * 2) {
if ($uname === $name && $uip === $ip) {
$newOnlineContent .= "{$ts}|{$now}|{$uname}|{$uip}|\n";
} else {
$newOnlineContent .= $line . "\n";
}
$onlines[] = $uname;
}
}
file_put_contents($onlineFile, $newOnlineContent, LOCK_EX);
$response['onlines'] = $onlines;
jsonOut($response);
}
function jsonOut(array $data): void {
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
// ===== HTML 前端 =====
function outputHtml(string $room): void {
$roomJs = json_encode($room, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
?>
PHP AJAX 聊天室
💬 PHP AJAX 聊天室
😀
線上用戶 0
部署與運行
- 建立目錄結構:
chat.php + rooms/(可寫權限)
- 瀏覽器開啟
chat.php 即可使用
- 多用戶測試:不同瀏覽器/無痕模式輸入不同暱稱
擴展方向
| 功能 | 實作方向 |
|---|---|
| WebSocket 升級 | 引入 Workerman/Swoole,替換輪詢為長連線 |
| 私聊/群組 | 房間機制擴展,增加 @用戶 解析與推送 |
| 訊息持久化 | 改用 SQLite/MySQL,支援搜尋、匯出、審計 |
| 檔案/圖片上傳 | 前端 FormData 上傳,後端驗證類型/大小,生成縮圖 |
| 管理員功能 | 踢人、禁言、公告、日誌查看 |
本文實作的聊天室雖基於簡單的檔案儲存與輪詢,但涵蓋了即時通訊的核心要素:狀態同步、併發控制(LOCK_EX)、XSS 防護、敏感詞過濾、表情符號、樣式自定義、線上偵測。代碼結構清晰,可作為學習 PHP 即時通訊基礎、快速原型或內部工具的起點。生產環境建議遷移至資料庫儲存並引入 WebSocket 以獲得更佳體驗。