歷史背景與技術演進
| 時代 | 技術方案 | 核心原理 | 缺點 |
| ~2010 | 隱藏 <iframe> + Form 提交 | 表單 target 指向 iframe,服務端返回 JS 腳本操作父頁面 DOM | 體驗差、調試難、無進度、跨域受限 |
| 2010-2015 | Flash/Silverlight 上傳組件 | 插件提供文件系統訪問、多文件、進度條 | 需插件、移動端不支持、安全漏洞多 |
| 2012+ | XMLHttpRequest Level 2 + FormData | 原生支持二進制、進度事件、跨域(CORS) | IE9 以下不支持 |
| 2015+ | Fetch API + FormData | Promise 化、流式處理、Service Worker 友好 | 無原生進度事件、需 polyfill |
| 2020+ | 現代 Uploader 庫 | 分片上傳、斷點續傳、並發控制、預簽名直傳 OSS | 引入依賴、配置複雜 |
方案一:現代原生 Fetch + FormData(推薦新項目)
前端核心代碼
<!-- HTML -->
<input type="file" id="fileInput" multiple accept="image/*,application/pdf">
<button id="uploadBtn">上傳</button>
<div id="progress" style="display:none;">
<progress id="progressBar" max="100" value="0"></progress>
<span id="progressText">0%</span>
</div>
<div id="fileList"></div>
<script>
document.getElementById('uploadBtn').addEventListener('click', async () => {
const files = document.getElementById('fileInput').files;
if (!files.length) return alert('請選擇文件');
const formData = new FormData();
for (const file of files) {
formData.append('files[]', file);
}
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const fileList = document.getElementById('fileList');
document.getElementById('progress').style.display = 'block';
try {
// 使用 XMLHttpRequest 獲取上傳進度(Fetch 目前無原生進度)
const response = await uploadWithProgress('/api/upload.php', formData, (percent) => {
progressBar.value = percent;
progressText.textContent = `${percent}%`;
});
const result = await response.json();
if (result.success) {
fileList.innerHTML = result.data.map(f =>
`✅ ${f.name} (${(f.size/1024).toFixed(1)} KB) -
查看 `
).join('');
} else {
throw new Error(result.message);
}
} catch (err) {
fileList.innerHTML = `❌ ${err.message}
`;
} finally {
document.getElementById('progress').style.display = 'none';
}
});
// 封裝:XHR 上傳 + 進度回調 + 返回 Promise
function uploadWithProgress(url, formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url);
// 上傳進度事件
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve({ json: () => Promise.resolve(JSON.parse(xhr.responseText)) });
} else {
reject(new Error(`HTTP ${xhr.status}`));
}
});
xhr.addEventListener('error', () => reject(new Error('網絡錯誤')));
xhr.addEventListener('abort', () => reject(new Error('已取消')));
xhr.send(formData);
});
}
</script>
後端 PHP 處理(支持多文件、驗證、安全存儲)
<?php
// api/upload.php
header('Content-Type: application/json; charset=utf-8');
// 配置
$config = [
'upload_dir' => __DIR__ . '/../storage/uploads/',
'allowed_mime' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'],
'max_size' => 5 * 1024 * 1024, // 5MB
'url_prefix' => '/storage/uploads/',
];
// 確保目錄存在
if (!is_dir($config['upload_dir'])) {
mkdir($config['upload_dir'], 0755, true);
}
$results = [];
$errors = [];
foreach ($_FILES['files']['tmp_name'] as $idx => $tmpName) {
$error = $_FILES['files']['error'][$idx];
if ($error !== UPLOAD_ERR_OK) {
$errors[] = "文件 {$_FILES['files']['name'][$idx]}: " . uploadErrorMsg($error);
continue;
}
$name = $_FILES['files']['name'][$idx];
$size = $_FILES['files']['size'][$idx];
$mime = mime_content_type($tmpName); // 真實 MIME,不可信 $_FILES['type']
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
// 驗證
if (!in_array($mime, $config['allowed_mime'], true)) {
$errors[] = "{$name}: 類型不允許 ({$mime})";
continue;
}
if ($size > $config['max_size']) {
$errors[] = "{$name}: 超過大小限制";
continue;
}
// 生成安全文件名
$safeName = bin2hex(random_bytes(16)) . ".{$ext}";
$destPath = $config['upload_dir'] . $safeName;
if (!move_uploaded_file($tmpName, $destPath)) {
$errors[] = "{$name}: 移動失敗";
continue;
}
// 設置權限
chmod($destPath, 0644);
$results[] = [
'name' => $name,
'size' => $size,
'mime' => $mime,
'url' => $config['url_prefix'] . $safeName,
'path' => $destPath,
];
}
echo json_encode([
'success' => empty($errors),
'message' => $errors ? implode('; ', $errors) : '上傳成功',
'data' => $results,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
function uploadErrorMsg(int $code): string {
return match($code) {
UPLOAD_ERR_INI_SIZE => '超過 php.ini upload_max_filesize',
UPLOAD_ERR_FORM_SIZE => '超過表單 MAX_FILE_SIZE',
UPLOAD_ERR_PARTIAL => '僅部分上傳',
UPLOAD_ERR_NO_FILE => '無文件',
UPLOAD_ERR_NO_TMP_DIR => '缺少臨時目錄',
UPLOAD_ERR_CANT_WRITE => '寫入磁盤失敗',
UPLOAD_ERR_EXTENSION => '被擴展阻止',
default => '未知錯誤',
};
}
方案二:大文件分片上傳(斷點續傳、秒傳)
核心流程
- 前端:文件分片(如 5MB/片)→ 計算全文件 hash (SparkMD5) → 檢查服務端是否已存在 → 並發上傳分片
- 服務端:接收分片 → 臨時目錄存儲 → 全部分片到齊 → 合併 → 驗證 hash → 移動到正式目錄
- 關鍵接口:
POST /api/upload/check:檢查文件/分片是否存在(秒傳/斷點續傳)
POST /api/upload/chunk:上傳單個分片
POST /api/upload/merge:合併分片
推薦成熟庫(避免重複造輪子)
- Uppy:模塊化、React/Vue/Svelte 組件、支持 Tus/HTTP/S3 多協議
- FilePond:輕量、美觀、圖片預覽/編輯、插件化
- Dropzone.js:經典、拖拽上傻、進度、縮略圖
- Plupload:支持分片、多運行時、較老但穩定
- Tus.io:開放協議、標準化斷點續傳、多語言服務端實現
安全加固清單(無論哪種方案)
| 層面 | 措施 |
| 文件驗證 | MIME 白名單 + 文件頭魔數檢測 + 副檢查副檔名 |
| 存儲安全 | 隨機文件名 + 非 Web 可訪問目錄 + Nginx 禁用執行 + 防盜鏈 |
| 大小限制 | 前端 + PHP(upload_max_filesize/post_max_size) + Nginx(client_max_body_size) 三層 |
| 權限控制 | 登錄態驗證 + 業務權限(如僅作者可上傳封面)+ CSRF Token |
| 病毒掃描 | ClamAV 異步掃描、可疑文件隔離 |
| 日誌審計 | 記錄用戶、IP、文件信息、操作結果,保留 6 個月 |
選型建議:小項目用原生 Fetch + FormData(零依賴、現代瀏覽器全支持);中型項目引入 Uppy/FilePond(體驗好、維護省);大文件/斷點續傳必須用分片上傳(Tus 協議或 Uppy Tus 插件);生產環境務必做三層大小限制 + MIME 雙重驗證 + 存儲目錄隔離。