現代 Web 應用標配非同步檔案上傳。本文使用 **jQuery + FormData + XMLHttpRequest (或 fetch)** 實作完整上傳元件,支援多檔、拖曳上傳、即時進度條、錯誤重試、伺服器端驗證與安全儲存,後端採用 PHP 處理。
<!-- HTML 結構 -->
<div id="uploader" class="uploader">
<div class="drop-zone" id="dropZone">
<span class="icon">☁️</span>
<p>拖曳檔案到此,或<button type="button" id="selectBtn">點選上傳</button></p>
<input type="file" id="fileInput" multiple hidden>
</div>
<div class="file-list" id="fileList"></div>
<button type="button" id="uploadBtn" class="btn-primary" disabled>開始上傳</button>
</div>
<style>
.uploader { max-width: 600px; margin: 2rem auto; font-family: system-ui; }
.drop-zone { border: 2px dashed #ccc; border-radius: 8px; padding: 3rem; text-align: center; transition: border-color .2s, background .2s; cursor: pointer; }
.drop-zone.drag-over { border-color: #007bff; background: #e7f1ff; }
.file-item { display: flex; align-items: center; gap: 1rem; padding: .75rem; border-bottom: 1px solid #eee; }
.file-item .preview { width: 40px; height: 40px; object-fit: cover; border-radius: 4px; background: #f5f5f5; }
.file-item .info { flex: 1; min-width: 0; }
.file-item .name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-item .progress-wrap { width: 150px; height: 6px; background: #eee; border-radius: 3px; overflow: hidden; }
.file-item .progress-bar { height: 100%; background: #007bff; width: 0%; transition: width .3s; }
.file-item .status { width: 80px; text-align: right; font-size: .85rem; color: #666; }
.file-item.error .status { color: #dc3545; }
.file-item.success .status { color: #28a745; }
.btn-primary { background: #007bff; color: #fff; border: none; padding: .75rem 1.5rem; border-radius: 4px; cursor: pointer; margin-top: 1rem; }
.btn-primary:disabled { background: #ccc; cursor: not-allowed; }
</style>
<!-- JavaScript (jQuery 3.6+) -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script>
class FileUploader {
constructor(options = {}) {
this.url = options.url || '/upload.php';
this.maxFiles = options.maxFiles || 10;
this.maxSize = options.maxSize || 50 * 1024 * 1024; // 50MB
this.allowedTypes = options.allowedTypes || ['image/', 'application/pdf'];
this.files = new Map(); // id -> {file, xhr, status, progress, element}
this.$dropZone = $('#dropZone');
this.$fileInput = $('#fileInput');
this.$fileList = $('#fileList');
this.$uploadBtn = $('#uploadBtn');
this.$selectBtn = $('#selectBtn');
this.bindEvents();
}
bindEvents() {
// 點選按鈕
this.$selectBtn.on('click', () => this.$fileInput.trigger('click'));
this.$fileInput.on('change', (e) => this.addFiles(e.target.files));
this.$fileInput.val(''); // 允許重複選同檔案
// 拖曳
this.$dropZone.on('dragover dragenter', (e) => {
e.preventDefault();
e.stopPropagation();
this.$dropZone.addClass('drag-over');
}).on('dragleave drop', (e) => {
e.preventDefault();
e.stopPropagation();
this.$dropZone.removeClass('drag-over');
if (e.type === 'drop') this.addFiles(e.originalEvent.dataTransfer.files);
});
// 上傳按鈕
this.$uploadBtn.on('click', () => this.uploadAll());
}
addFiles(fileList) {
[...fileList].forEach(file => {
if (this.files.size >= this.maxFiles) {
this.showToast(`最多上傳 ${this.maxFiles} 個檔案`);
return;
}
if (file.size > this.maxSize) {
this.showToast(`「${file.name}」超過 ${this.formatSize(this.maxSize)} 限制`);
return;
}
if (!this.allowedTypes.some(t => file.type.startsWith(t))) {
this.showToast(`「${file.name}」類型不允許`);
return;
}
const id = 'f_' + Date.now() + '_' + Math.random().toString(36).substr(2, 6);
this.files.set(id, {
file, xhr: null, status: 'pending', progress: 0,
element: this.createFileElement(id, file)
});
this.$fileList.append(this.files.get(id).element);
});
this.updateUploadButton();
}
createFileElement(id, file) {
const $el = $(`
<div class="file-item" data-id="${id}">
<img class="preview" src="${this.getPreviewUrl(file)}" alt="">
<div class="info">
<div class="name" title="${file.name}">${file.name}</div>
<div class="progress-wrap"><div class="progress-bar"></div></div>
</div>
<div class="status">等待中</div>
</div>
`);
return $el;
}
getPreviewUrl(file) {
if (file.type.startsWith('image/')) {
return URL.createObjectURL(file);
}
return 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiM5OTkiIHN0cm9rZS13aWR0aD0iMS41Ij48cGF0aCBkPSJNMTQgMkg2YTIgMiAwIDAgMCAyIDJ2MTZhMiAyIDAgMCAwIDIgMmg4Ii8+PHBhdGggZD0iTTE0IDJ2OGw4IDgiLz48L3N2Zz4=';
}
formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
const k = 1024, sizes = ['KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i-1];
}
updateUploadButton() {
const pending = [...this.files.values()].filter(f => f.status === 'pending').length;
this.$uploadBtn.prop('disabled', pending === 0).text(pending ? `上傳 ${pending} 個檔案` : '無待上傳檔案');
}
async uploadAll() {
const pendingFiles = [...this.files.entries()].filter(([,v]) => v.status === 'pending');
for (const [id, meta] of pendingFiles) {
await this.uploadFile(id, meta);
}
this.showToast('所有檔案上傳完成');
}
uploadFile(id, meta) {
return new Promise((resolve) => {
const formData = new FormData();
formData.append('file', meta.file);
const xhr = new XMLHttpRequest();
meta.xhr = xhr;
meta.status = 'uploading';
this.updateFileUI(id, meta);
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
meta.progress = Math.round((e.loaded / e.total) * 100);
this.updateFileUI(id, meta);
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const resp = JSON.parse(xhr.responseText);
if (resp.success) {
meta.status = 'success';
meta.serverPath = resp.path;
} else {
throw new Error(resp.message || '伺服器回傳失敗');
}
} catch (err) {
meta.status = 'error';
meta.error = '回應格式錯誤';
}
} else {
meta.status = 'error';
meta.error = `HTTP ${xhr.status}`;
}
this.updateFileUI(id, meta);
resolve();
});
xhr.addEventListener('error', () => {
meta.status = 'error';
meta.error = '網路錯誤';
this.updateFileUI(id, meta);
resolve();
});
xhr.addEventListener('abort', () => {
meta.status = 'pending';
this.updateFileUI(id, meta);
resolve();
});
xhr.open('POST', this.url, true);
xhr.send(formData);
});
}
updateFileUI(id, meta) {
const $el = meta.element;
$el.find('.progress-bar').css('width', meta.progress + '%');
$el.removeClass('error success');
switch (meta.status) {
case 'uploading':
$el.find('.status').text(meta.progress + '%');
break;
case 'success':
$el.find('.status').text('完成').addClass('success');
$el.addClass('success');
break;
case 'error':
$el.find('.status').text('失敗').addClass('error');
$el.addClass('error');
break;
default:
$el.find('.status').text('等待中');
}
}
showToast(msg) {
// 簡易 toast,實務建議用 toastr / notyf
console.log('[Toast]', msg);
alert(msg); // Demo only
}
}
// 初始化
$(function() {
window.uploader = new FileUploader({
url: 'upload.php',
maxFiles: 20,
maxSize: 100 * 1024 * 1024,
allowedTypes: ['image/', 'application/pdf', 'video/']
});
});
</script>
<?php
// upload.php
header('Content-Type: application/json; charset=UTF-8');
// 1. 設定
$config = [
'upload_dir' => __DIR__ . '/uploads/', // 必須可寫
'allowed_mime' => [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
'video/mp4', 'video/webm', 'video/quicktime',
],
'max_size' => 100 * 1024 * 1024, // 100MB
'url_prefix' => '/uploads/', // 前端存取路徑
];
// 2. 確保目錄存在
if (!is_dir($config['upload_dir'])) {
if (!mkdir($config['upload_dir'], 0755, true)) {
jsonResponse(false, '無法建立上傳目錄');
}
}
// 3. 檢查請求
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(false, '僅支援 POST');
}
if (!isset($_FILES['file'])) {
jsonResponse(false, '缺少 file 欄位');
}
$file = $_FILES['file'];
// 4. 基本驗證
if ($file['error'] !== UPLOAD_ERR_OK) {
$errors = [
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 => 'PHP 擴展阻止上傳',
];
jsonResponse(false, $errors[$file['error']] ?? '未知上傳錯誤');
}
if ($file['size'] > $config['max_size']) {
jsonResponse(false, '檔案大小超過限制');
}
// 5. MIME 類型驗證 (使用 finfo,比 $_FILES['type'] 可靠)
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if (!in_array($mime, $config['allowed_mime'], true)) {
jsonResponse(false, "檔案類型不允許: $mime");
}
// 6. 影像額外驗證 (防偽裝)
if (str_starts_with($mime, 'image/')) {
$imgInfo = @getimagesize($file['tmp_name']);
if (!$imgInfo) {
jsonResponse(false, '無效的影像檔案');
}
}
// 7. 產生安全檔名
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$ext = $ext ? '.' . strtolower($ext) : '';
$safeName = bin2hex(random_bytes(16)) . $ext;
$savePath = $config['upload_dir'] . $safeName;
// 8. 移動檔案
if (!move_uploaded_file($file['tmp_name'], $savePath)) {
jsonResponse(false, '檔案儲存失敗');
}
// 9. 設定權限 (Linux)
if (PHP_OS_FAMILY !== 'Windows') {
@chmod($savePath, 0644);
}
// 10. 回傳成功
jsonResponse(true, '上傳成功', [
'name' => $file['name'],
'size' => $file['size'],
'mime' => $mime,
'path' => $config['url_prefix'] . $safeName,
'saved_as' => $safeName,
]);
function jsonResponse(bool $success, string $message, array $data = []) {
echo json_encode([
'success' => $success,
'message' => $message,
'data' => $data,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
location /uploads { php_flag engine off; } 或 SetHandler default-handlerrandom_bytes() 產生不可預測檔名,防止覆蓋、遍歷finfo + getimagesize(),防偽裝php.ini (upload_max_filesize, post_max_size)本文提供生產級的非同步上傳完整方案:前端模組化元件支援拖曳、多檔、進度條、錯誤狀態;後端 PHP 嚴格驗證 MIME、大小、影像完整性、隨機化檔名、安全儲存。遵循安全清單與擴展建議,即可構建穩健、可擴展的檔案上傳系統。