HTML5 FormData + XMLHttpRequest/fetch 讓無刷新上傳變得極簡單,無需 iframe hack。本文涵蓋:FormData 建構三種方式、原生 XHR / jQuery / fetch 三種發送方式、進度條實作、PHP 端處理、多檔案、拖曳上傳、錯誤處理完整範例。
// 1. 手動 append (最靈活,無需 form 標籤)
const fd = new FormData();
fd.append('name', 'value');
fd.append('file', document.getElementById('file').files[0]);
fd.append('files[]', ...document.getElementById('multi').files); // 多檔案
// 2. 從現有 form 表單建構 (自動包含所有 input/select/textarea)
const form = document.getElementById('myForm');
const fd = new FormData(form);
// 仍可追加額外欄位
fd.append('extra', 'value');
// 3. 從 input files 直接建構 (單一檔案欄位)
const fd = new FormData();
fd.append('avatar', document.querySelector('input[type=file]').files[0]);
| 方式 | 優點 | 缺點 | 關鍵設定 |
|---|---|---|---|
| 原生 XMLHttpRequest | 支援 upload.onprogress 進度條、完整控制 |
代碼較多 | 無需設定 Content-Type,瀏覽器自動生成 multipart boundary |
| jQuery $.ajax | 簡潔、統一錯誤處理 | 需 processData: false, contentType: false |
同上 |
| fetch API | 現代 Promise、可搭配 Service Worker | **無原生進度事件** (需 Stream 讀取 body 實作) | 同上 |
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>FormData Upload</title>
<style>
.bar {width:300px;height:20px;background:#eee;border-radius:10px;overflow:hidden}
.bar > div {height:100%;background:#4caf50;width:0;transition:width .3s}
</style></head><body>
<form id="upForm">
<input type="file" id="file" name="file" required />
<input type="text" name="title" placeholder="標題" />
<button type="submit">上傳</button>
</form>
<div class="bar" id="progress" style="display:none"><div></div></div>
<div id="result"></div>
<script>
document.getElementById('upForm').addEventListener('submit', async e => {
e.preventDefault();
const fileInput = document.getElementById('file');
if (!fileInput.files.length) return alert('請選擇檔案');
const fd = new FormData(document.getElementById('upForm'));
const progressBar = document.getElementById('progress');
const progressInner = progressBar.querySelector('div');
const resultDiv = document.getElementById('result');
progressBar.style.display = 'block';
progressInner.style.width = '0%';
resultDiv.textContent = '';
try {
const resp = await uploadWithProgress('/upload.php', fd, (percent) => {
progressInner.style.width = percent + '%';
});
resultDiv.innerHTML = '<pre style="color:green">' + JSON.stringify(resp, null, 2) + '</pre>';
} catch (err) {
resultDiv.innerHTML = '<pre style="color:red">' + err.message + '</pre>';
} finally {
progressBar.style.display = 'none';
}
});
function uploadWithProgress(url, formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.upload.onprogress = e => {
if (e.lengthComputable) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try { resolve(JSON.parse(xhr.responseText)); }
catch { resolve(xhr.responseText); }
} else {
reject(new Error(`HTTP ${xhr.status}: ${xhr.responseText}`));
}
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.send(formData);
});
}
</script>
</body></html>
<?php
header('Content-Type: application/json; charset=utf-8');
$uploadDir = __DIR__ . '/uploads/';
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
// 基本驗證
$allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];
$maxSize = 10 * 1024 * 1024; // 10MB
$file = $_FILES['file'] ?? null;
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(['error' => '上傳失敗: ' . ($file['error'] ?? '無檔案')]);
exit;
}
if (!in_array($file['type'], $allowedTypes, true)) {
http_response_code(400);
echo json_encode(['error' => '不支援的檔案類型']);
exit;
}
if ($file['size'] > $maxSize) {
http_response_code(400);
echo json_encode(['error' => '檔案過大 (限制 10MB)']);
exit;
}
// 安全檔名
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$newName = bin2hex(random_bytes(16)) . '.' . $ext;
$dest = $uploadDir . $newName;
if (!move_uploaded_file($file['tmp_name'], $dest)) {
http_response_code(500);
echo json_encode(['error' => '儲存失敗']);
exit;
}
echo json_encode([
'success' => true,
'file' => [
'original' => $file['name'],
'saved' => $newName,
'url' => '/uploads/' . $newName,
'size' => $file['size'],
'type' => $file['type'],
],
'fields' => $_POST, // title 等其他欄位
], JSON_UNESCAPED_UNICODE);
?>
$('#upForm').on('submit', function(e) {
e.preventDefault();
const fd = new FormData(this);
$.ajax({
url: '/upload.php',
type: 'POST',
data: fd,
processData: false, // 關鍵:不要序列化 FormData
contentType: false, // 關鍵:讓瀏覽器自動設定 multipart boundary
xhr: () => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = e => {
if (e.lengthComputable) {
$('#progress div').css('width', Math.round(e.loaded/e.total*100)+'%');
}
};
return xhr;
},
}).done(res => $('#result').html(''+JSON.stringify(res,null,2)+'
'))
.fail(err => $('#result').html(''+err.responseText+'
'));
});
const dropZone = document.getElementById('dropZone');
['dragenter','dragover'].forEach(evt => dropZone.addEventListener(evt, e => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add('active'); }));
['dragleave','drop'].forEach(evt => dropZone.addEventListener(evt, e => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('active'); }));
dropZone.addEventListener('drop', e => {
document.getElementById('file').files = e.dataTransfer.files; // 同步到隱藏 input
document.getElementById('upForm').dispatchEvent(new Event('submit')); // 觸發上傳
});
$_FILES 為空 → 檢查 enctype="multipart/form-data"、contentType: false、Nginx client_max_body_size、PHP post_max_size/upload_max_filesize。xhr.upload.onprogress 綁定在 send() 之前、伺服器未啟用緩衝 (Nginx proxy_request_buffering off)。FormData 是現代無刷新上傳標準。**原生 XHR** 適合需進度條、**fetch** 適合簡單火後不理、**jQuery** 適合舊專案。PHP 端務必驗證型別、大小、副檔名、用隨機檔名儲存、回傳 JSON。拖曳上傳只需監聽 drop 事件同步檔案到隱藏 <input type=file> 即可複用現有表單邏輯。