首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 相關知識

PHP + jQuery Ajax 非同步檔案上傳完整實作 (含進度條、多檔、拖曳)

     編輯:PHP 相關知識

現代 Web 應用標配非同步檔案上傳。本文使用 **jQuery + FormData + XMLHttpRequest (或 fetch)** 實作完整上傳元件,支援多檔、拖曳上傳、即時進度條、錯誤重試、伺服器端驗證與安全儲存,後端採用 PHP 處理。

一、前端完整實作 (ES6 模組化)

<!-- 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)

<?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;
}

三、安全性強化清單

  1. 上傳目錄不可執行腳本:Nginx/Apache 設定 location /uploads { php_flag engine off; } 或 SetHandler default-handler
  2. 檔名隨機化:使用 random_bytes() 產生不可預測檔名,防止覆蓋、遍歷
  3. MIME 雙重驗證:finfo + getimagesize(),防偽裝
  4. 大小限制雙層:前端 JS + 後端 PHP + php.ini (upload_max_filesize, post_max_size)
  5. 病毒掃描:生產環境整合 ClamAV 掃描上傳檔案
  6. CSRF 保護:AJAX 請求攜帶 Token (Header 或表單欄位)
  7. 權限最小化:上傳目錄僅給 PHP-FPM 用戶寫入,Nginx/Apache 唯讀

四、進階功能擴展

  • 斷點續傳:前端使用 tus-js-client,後端實作 tusd 或自建分片合併邏輯
  • 圖片即時壓縮/裁剪:前端 compressorjs 壓縮後再上傳,或後端 Intervention Image 處理
  • 雲端儲存:上傳至 S3/OSS/COS,後端回傳預簽名 URL,減輕伺服器頻寬
  • Web Workers:大檔案分片計算 Hash (SHA-256) 於 Worker 執行,不阻塞主執行緒

五、總結

本文提供生產級的非同步上傳完整方案:前端模組化元件支援拖曳、多檔、進度條、錯誤狀態;後端 PHP 嚴格驗證 MIME、大小、影像完整性、隨機化檔名、安全儲存。遵循安全清單與擴展建議,即可構建穩健、可擴展的檔案上傳系統。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved