傳統表單上傳需頁面跳轉,體驗差。利用 jQuery Form Plugin(ajaxSubmit())可輕鬆實現真正的非同步檔案上傳(非 iframe hack),配合 PHP 後端驗證、分類儲存、隨機命名,構建完整上傳體驗。本文提供前後端完整程式碼與安全強化指南。
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<title>非同步圖片上傳示範</title>
<style>
.upload-area { border: 2px dashed #ccc; padding: 20px; text-align: center; max-width: 500px; margin: 20px auto; border-radius: 8px; }
.upload-area.dragover { border-color: #4285f4; background: #f0f7ff; }
.progress-bar { width: 100%; height: 8px; background: #eee; border-radius: 4px; overflow: hidden; display: none; margin-top: 10px; }
.progress-bar .fill { height: 100%; width: 0%; background: #4285f4; transition: width 0.3s; }
.msg { text-align: center; margin-top: 10px; font-weight: 500; }
.msg.success { color: #34a853; }
.msg.error { color: #ea4335; }
.preview { max-width: 100%; max-height: 300px; margin-top: 15px; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); display: none; }
</style>
</head>
<body>
<div class="upload-area" id="dropZone">
<input type="file" id="fileInput" name="upfile" accept="image/*" style="display: none;">
<p>點擊或拖曳圖片到此處上傳</p>
<button type="button" id="selectBtn" class="btn">選擇檔案</button>
<div class="progress-bar" id="progressBar"><div class="fill"></div></div>
<div class="msg" id="msg"></div>
<img class="preview" id="preview" src="" alt="預覽">
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery-form@4.3.0/dist/jquery.form.min.js"></script>
<script>
$(function () {
const $dropZone = $('#dropZone');
const $fileInput = $('#fileInput');
const $progressBar = $('#progressBar');
const $progressFill = $progressBar.find('.fill');
const $msg = $('#msg');
const $preview = $('#preview');
// 點擊選擇
$('#selectBtn').on('click', () => $fileInput.trigger('click'));
// 拖曳上傳支援
$dropZone.on('dragover dragenter', e => {
e.preventDefault();
$dropZone.addClass('dragover');
}).on('dragleave drop', e => {
e.preventDefault();
$dropZone.removeClass('dragover');
}).on('drop', e => {
const files = e.originalEvent.dataTransfer.files;
if (files.length) {
$fileInput[0].files = files;
uploadFile();
}
});
// 檔案選擇變更
$fileInput.on('change', uploadFile);
function uploadFile() {
const file = $fileInput[0].files[0];
if (!file) return;
// 客戶端預檢
const maxSize = 5 * 1024 * 1024; // 5MB
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
showMsg('不支援的檔案格式,僅限 JPG/PNG/GIF/WebP', 'error');
return;
}
if (file.size > maxSize) {
showMsg(`檔案過大(${(file.size/1024/1024).toFixed(2)}MB),限制 5MB`, 'error');
return;
}
// 預覽
const reader = new FileReader();
reader.onload = e => {
$preview.attr('src', e.target.result).show();
};
reader.readAsDataURL(file);
// 非同步上傳
$progressBar.show();
$progressFill.css('width', '0%');
showMsg('上傳中...', '');
$('#uploadForm').ajaxSubmit({
url: 'upload.php',
type: 'POST',
dataType: 'json',
uploadProgress: (event, position, total, percentComplete) => {
$progressFill.css('width', percentComplete + '%');
},
success: (response) => {
$progressBar.hide();
if (response.success) {
showMsg('上傳成功!', 'success');
$preview.attr('src', response.url + '?t=' + Date.now());
} else {
showMsg('上傳失敗:' + response.message, 'error');
$preview.hide();
}
},
error: (xhr) => {
$progressBar.hide();
showMsg('網路錯誤:' + xhr.statusText, 'error');
$preview.hide();
},
resetForm: true
});
}
function showMsg(text, type) {
$msg.text(text).removeClass('success error').addClass(type);
}
});
</script>
<form id="uploadForm" action="upload.php" method="POST" enctype="multipart/form-data" style="display: none;">
<input type="file" name="upfile" id="fileInput">
</form>
</body>
</html>
<?php
// upload.php
header('Content-Type: application/json; charset=utf-8');
// 配置
$config = [
'maxSize' => 5 * 1024 * 1024, // 5MB
'allowedTypes' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
'allowedExts' => ['jpg', 'jpeg', 'png', 'gif', 'webp'],
'uploadRoot' => __DIR__ . '/uploads', // 儲存根目錄
'urlPrefix' => '/uploads', // 公開訪問 URL 前綴
];
// 回應函式
function respond(bool $success, string $message, array $extra = []): never
{
echo json_encode(array_merge(['success' => $success, 'message' => $message], $extra), JSON_UNESCAPED_UNICODE);
exit;
}
// 1. 檢查請求
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
respond(false, '僅支援 POST 請求');
}
if (!isset($_FILES['upfile']) || $_FILES['upfile']['error'] !== UPLOAD_ERR_OK) {
$errMsg = $_FILES['upfile']['error'] ?? '未知錯誤';
respond(false, "檔案上傳錯誤: $errMsg");
}
$file = $_FILES['upfile'];
// 2. 檔案大小驗證
if ($file['size'] > $config['maxSize']) {
respond(false, '檔案超過大小限制 (5MB)');
}
// 3. MIME 類型驗證(使用 finfo 避免偽造)
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
if (!in_array($mimeType, $config['allowedTypes'], true)) {
respond(false, '檔案類型不允許(偵測到: ' . $mimeType . ')');
}
// 4. 副檔名驗證
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $config['allowedExts'], true)) {
respond(false, '副檔名不允許');
}
// 5. 圖片完整性驗證
$imgInfo = @getimagesize($file['tmp_name']);
if ($imgInfo === false) {
respond(false, '非有效圖片檔案或圖片損毀');
}
// 可選:限制最大尺寸
// if ($imgInfo[0] > 4000 || $imgInfo[1] > 4000) { respond(false, '圖片尺寸過大'); }
// 6. 建立日期目錄
$dateDir = date('Y/m/d');
$saveDir = $config['uploadRoot'] . '/' . $dateDir;
if (!is_dir($saveDir)) {
if (!mkdir($saveDir, 0755, true) && !is_dir($saveDir)) {
respond(false, '無法建立上傳目錄');
}
}
// 7. 產生唯一檔名(時間戳 + 隨機字串 + 原始副檔名)
$randomStr = bin2hex(random_bytes(8));
$fileName = sprintf('%s_%s.%s', date('His'), $randomStr, $ext);
$savePath = $saveDir . '/' . $fileName;
$publicUrl = $config['urlPrefix'] . '/' . $dateDir . '/' . $fileName;
// 8. 移動檔案
if (!move_uploaded_file($file['tmp_name'], $savePath)) {
respond(false, '檔案儲存失敗(權限或磁碟空間不足)');
}
// 9. 設定檔案權限
chmod($savePath, 0644);
// 10. 可選:生成縮圖、水印、記錄資料庫...
// generateThumbnail($savePath, 300, 300);
// 成功回應
respond(true, '上傳成功', [
'url' => $publicUrl,
'fileName' => $fileName,
'size' => $file['size'],
'mime' => $mimeType,
'width' => $imgInfo[0],
'height' => $imgInfo[1],
]);
$_FILES['type'] 可偽造,必須用 finfo 或 getimagesize() 實際檢測檔案內容。.php、.phtml 等可執行腳本。location /uploads { php_flag engine off; } 或 AddHandler text/plain .php。upload_max_filesize、post_max_size、Nginx client_max_body_size 三層同步設定。imagecreatefromjpeg() → imagejpeg() 重新編碼,剝離 EXIF、隱寫、多幀攻擊載荷。
| 現象 | 可能原因 | 排查步驟 |
|---|---|---|
| 回應非 JSON | PHP 錯誤輸出 HTML、BOM、前置空白 | 檢查 php.ini display_errors=Off、檔案無 BOM、無前置輸出 |
| 進度條不動 | jQuery Form Plugin 未載入、瀏覽器不支援 XHR2 | Console 確認 $.fn.ajaxSubmit 存在、IE10+ 支援 |
| move_uploaded_file 失敗 | 目錄權限、磁碟滿、open_basedir 限制 | ls -ld /uploads、df -h、php -i | grep open_basedir |
| 上傳後圖片無法顯示 | Nginx 未配置 static location、MIME type 缺失 | 確認 location /uploads { alias /path/to/uploads; } |
掌握非同步上傳全流程,從前端體驗優化到後端安全防護,再到雲端架構演進,能為使用者提供流暢、安全、可擴展的檔案上傳服務。