雖然 HTTP/2 多路復用緩解了并發連接壓力,但許多環境仍運行在 HTTP/1.1 或需要兼容舊瀏覽器。合併靜態資源請求 (Combo) 能顯著減少:
🔗 TCP 握手/SSL 握手 次數
📦 HTTP Header 開銷 (每請求 ~500-800 bytes)
⏱ 隊首阻塞 等待時間
📊 典型頁面 10+ JS/CSS → 合併為 1-2 請求,首屏加載提速 30%-50%
前端引用: <script src="/combo.php?f=jquery,utils,app.js"></script>
│
▼
PHP 接收 f 參數 → 讀取文件內容 → 拼接輸出 → 設置緩存頭 → 瀏覽器執行
<?php
/**
* 靜態資源合併服務 (支援 JS/CSS)
* 特性:路徑安全、內容類型自動識別、ETag/Last-Modified、Gzip、錯誤容錯
*/
class ComboService {
private $rootDir;
private $maxFiles = 50; // 單次最大合併數
private $maxSize = 5 * 1024 * 1024; // 5MB 總大小限制
public function __construct(string $staticRoot) {
$this->rootDir = rtrim(realpath($staticRoot), DIRECTORY_SEPARATOR);
}
public function handle(): void {
$filesParam = $_GET['f'] ?? '';
if (!$filesParam) {
$this->error(400, 'Missing "f" parameter');
}
$fileList = $this->parseFileList($filesParam);
if (empty($fileList)) {
$this->error(400, 'No valid files');
}
// 確定輸出類型 (以第一個文件為準)
$firstExt = pathinfo($fileList[0], PATHINFO_EXTENSION);
$contentType = $this->getContentType($firstExt);
header("Content-Type: $contentType; charset=UTF-8");
// 緩存策略
$this->setCacheHeaders($fileList);
// 輸出合併內容
$output = '';
$totalSize = 0;
foreach ($fileList as $file) {
$fullPath = $this->resolvePath($file);
if (!$fullPath || !is_readable($fullPath)) {
error_log("Combo missing file: $file");
continue; // 靜默跳過,避免單個缺失導致整頁崩潰
}
$content = file_get_contents($fullPath);
$totalSize += strlen($content);
if ($totalSize > $this->maxSize) {
error_log("Combo size limit exceeded");
break;
}
$output .= $content . "\n"; // 換行隔離,防止無分號合併報錯
}
// 支持 Gzip/Deflate
if (isset($_SERVER['HTTP_ACCEPT_ENCODING']) && strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') !== false) {
header('Content-Encoding: gzip');
$output = gzencode($output, 6);
}
echo $output;
}
/** 解析並去重文件列表 */
private function parseFileList(string $param): array {
$files = array_unique(array_map('trim', explode(',', $param)));
return array_slice(array_filter($files, fn($f) => $f !== ''), 0, $this->maxFiles);
}
/** 路徑解析與安全檢查 (防止目錄遍歷) */
private function resolvePath(string $file): ?string {
// 規範化:移除多重斜杠、. 、..
$file = ltrim($file, '/');
$fullPath = $this->rootDir . DIRECTORY_SEPARATOR . $file;
$realRoot = realpath($this->rootDir);
$realFile = realpath($fullPath);
// 必須在根目錄內、文件存在、擴展名白名單
if (!$realFile || strpos($realFile, $realRoot) !== 0) {
return null;
}
$ext = strtolower(pathinfo($realFile, PATHINFO_EXTENSION));
if (!in_array($ext, ['js', 'css', 'map'])) {
return null;
}
return $realFile;
}
/** 內容類型映射 */
private function getContentType(string $ext): string {
return match(strtolower($ext)) {
'js' => 'application/javascript',
'css' => 'text/css',
'map' => 'application/json',
default => 'text/plain'
};
}
/** 緩存頭:ETag 基于文件列表+修改時間,Last-Modified 取最新 */
private function setCacheHeaders(array $files): void {
$mtimes = [];
foreach ($files as $file) {
$path = $this->resolvePath($file);
if ($path) $mtimes[] = filemtime($path);
}
$latest = $mtimes ? max($mtimes) : time();
$etag = '"' . md5(implode(',', $files) . $latest) . '"';
header('Cache-Control: public, max-age=31536000, immutable'); // 1 年,配合文件名哈希版本
header('Expires: ' . gmdate('D, d M Y H:i:s', time() + 31536000) . ' GMT');
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $latest) . ' GMT');
header('ETag: ' . $etag);
header('Vary: Accept-Encoding');
// 304 Not Modified
if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && $_SERVER['HTTP_IF_NONE_MATCH'] === $etag) {
http_response_code(304);
exit;
}
if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) >= $latest) {
http_response_code(304);
exit;
}
}
private function error(int $code, string $msg): void {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode(['error' => $msg]);
exit;
}
}
// ========== 入口文件 combo.php ==========
require_once __DIR__ . '/ComboService.php';
$combo = new ComboService(__DIR__ . '/public/static'); // 靜態資源根目錄
$combo->handle();
<!-- HTML -->
<script src="/combo.php?f=lib/jquery-3.6.0.min.js,lib/vue.min.js,app/main.js,app/utils.js"></script>
<link rel="stylesheet" href="/combo.php?f=css/reset.css,css/layout.css,css/theme.css">
運行時 PHP 合併有 CPU/IO 開銷,**構建階段合併** 才是終極方案:
| 工具 | 特點 | 適用場景 |
|---|---|---|
| Webpack / Vite / Rollup | 依賴圖分析、Tree Shaking、代碼分割、Hash 緩存 | 現代前端項目 (Vue/React/Svelte) |
| Gulp / Grunt + concat/uglifycss | 簡單配置、流式處理 | 傳統多頁應用、jQuery 時代遺留項目 |
| Nginx ngx_http_concat_module | 模塊級高性能、無 PHP 開銷 | 純靜態資源、不想引入構建工具 |
| PHP Combo (本文方案) | 零構建依賴、動態組合、適合老系統快速接入 | legacy 系統、臨時優化、動態主題加載 |
# 編譯 Nginx 時添加 --add-module=../ngx_http_concat_module
location /static/ {
concat on;
concat_max_files 20;
concat_unique off; # 允許不同目錄文件合併
concat_types text/css application/javascript;
concat_delimiter "\n/* --- file boundary --- */\n";
}
# 前端調用:/static/??lib/jquery.js,lib/vue.js,app/main.js
Access-Control-Allow-OriginPHP 運行時合併是 「零重構、立竿見影」 的性能優化手段,適合存量系統快速提速。長期演進應遷移至 **構建時打包 + HTTP/2/3 + 瀏覽器緩存策略** 的現代化工程體系。
核心記住三點:
✅ 安全第一:路徑遍歷防護、擴展名白名單
✅ 緩存為王:ETag/Last-Modified + 長期強緩存 + 版本化 URL
✅ 監控到位:記錄合併請求耗時、命中率、錯誤率,持續優化