pin_drop当前位置:知识文库 ❯ 图文

后端通信:15. 文件上传与下载 - 从入门到实践详解

教程简介

文件上传与下载是Web应用中常见的功能需求,从用户头像上传到大型文件传输,从前端预览到后端存储,涉及的技术细节远比想象中复杂。本教程将全面讲解浏览器端的文件上传与下载技术,包括File API、拖拽上传、分片上传、断点续传、文件下载方式、进度监控等核心内容,帮助你构建高性能、用户友好的文件传输体验。

无论是简单的表单文件上传,还是复杂的大文件分片传输,理解底层原理和最佳实践都是构建可靠文件传输功能的关键。

核心概念

文件相关API层次结构

代码示例

Blob (二进制大对象)
   File (带文件名的Blob)
         FileList (文件列表)
         FileReader (读取文件)
               readAsArrayBuffer()
               readAsDataURL()
               readAsText()
               readAsBinaryString()

URL (创建临时URL)
   createObjectURL(blob)
   revokeObjectURL(url)

文件上传方式对比

方式 实现复杂度 支持进度 支持分片 支持断点续传 适用场景
表单原生上传 低 否 否 否 简单文件上传
XMLHttpRequest 中 是 是 是 需要进度监控
Fetch API 低 有限 是 是 现代浏览器
拖拽上传 中 是 是 是 提升用户体验
分片上传 高 是 是 是 大文件上传

文件下载方式对比

方式 实现复杂度 支持进度 跨域支持 适用场景
低 否 受限 同源文件下载
window.open 低 否 受限 简单下载
Blob + URL 中 否 是 前端生成文件
XMLHttpRequest 中 是 受限 需要进度监控
Fetch + Blob 中 有限 受限 现代浏览器
Stream API 高 是 受限 大文件下载

语法与用法

File API核心接口

代码示例

// File对象属性
const file = input.files[0];
file.name        // 文件名: "photo.jpg"
file.size        // 文件大小(字节): 1024000
file.type        // MIME类型: "image/jpeg"
file.lastModified // 最后修改时间戳: 1634567890000

// FileReader读取文件
const reader = new FileReader();
reader.readAsDataURL(file);      // 读取为Base64
reader.readAsArrayBuffer(file);  // 读取为ArrayBuffer
reader.readAsText(file);         // 读取为文本
reader.readAsBinaryString(file); // 读取为二进制字符串

拖拽上传核心事件

事件 触发时机 用途
dragenter 拖拽进入目标区域 高亮目标区域
dragover 拖拽在目标区域上方 阻止默认行为
dragleave 拖拽离开目标区域 取消高亮
drop 在目标区域释放 获取文件数据

Blob操作

代码示例

// 创建Blob
const blob = new Blob(['Hello World'], { type: 'text/plain' });

// Blob切片(用于分片上传)
const chunk = blob.slice(start, end, contentType);

// 创建下载URL
const url = URL.createObjectURL(blob);

// 释放URL
URL.revokeObjectURL(url);

代码示例

示例1:基础文件上传与预览

代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>基础文件上传与预览</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: #f0f2f5;
            min-height: 100vh;
            padding: 40px 20px;
        }
        .container { max-width: 700px; margin: 0 auto; }
        h1 {
            text-align: center;
            color: #1a1a2e;
            margin-bottom: 30px;
        }
        .upload-card {
            background: white;
            border-radius: 16px;
            padding: 30px;
            margin-bottom: 20px;
            box-shadow: 0 2px 12px rgba(0,0,0,0.06);
        }
        .upload-card h2 {
            font-size: 18px;
            color: #333;
            margin-bottom: 20px;
        }
        .file-input-wrapper {
            position: relative;
            margin-bottom: 20px;
        }
        .file-input-label {
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            padding: 40px 20px;
            border: 2px dashed #d0d0d0;
            border-radius: 12px;
            cursor: pointer;
            transition: all 0.3s;
            background: #fafafa;
        }
        .file-input-label:hover {
            border-color: #4a90d9;
            background: #f0f7ff;
        }
        .file-input-label.dragover {
            border-color: #4a90d9;
            background: #e6f2ff;
            transform: scale(1.01);
        }
        .upload-icon {
            font-size: 48px;
            color: #4a90d9;
            margin-bottom: 12px;
        }
        .upload-text {
            color: #666;
            font-size: 14px;
            text-align: center;
        }
        .upload-text strong {
            color: #4a90d9;
        }
        input[type="file"] {
            display: none;
        }
        .file-list {
            margin-top: 16px;
        }
        .file-item {
            display: flex;
            align-items: center;
            padding: 12px 16px;
            background: #f8f9fa;
            border-radius: 8px;
            margin-bottom: 8px;
            gap: 12px;
        }
        .file-icon {
            width: 40px;
            height: 40px;
            border-radius: 8px;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 20px;
            flex-shrink: 0;
        }
        .file-icon.image { background: #fff3e0; }
        .file-icon.video { background: #e8f5e9; }
        .file-icon.audio { background: #fce4ec; }
        .file-icon.document { background: #e3f2fd; }
        .file-icon.other { background: #f3e5f5; }
        .file-info {
            flex: 1;
            min-width: 0;
        }
        .file-name {
            font-size: 14px;
            color: #333;
            font-weight: 500;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }
        .file-meta {
            font-size: 12px;
            color: #999;
            margin-top: 2px;
        }
        .file-preview {
            width: 40px;
            height: 40px;
            border-radius: 8px;
            object-fit: cover;
            flex-shrink: 0;
        }
        .file-remove {
            width: 28px;
            height: 28px;
            border: none;
            background: #fee;
            color: #e74c3c;
            border-radius: 50%;
            cursor: pointer;
            font-size: 14px;
            display: flex;
            align-items: center;
            justify-content: center;
            flex-shrink: 0;
            transition: all 0.3s;
        }
        .file-remove:hover {
            background: #e74c3c;
            color: white;
        }
        .upload-btn {
            width: 100%;
            padding: 14px;
            background: #4a90d9;
            color: white;
            border: none;
            border-radius: 8px;
            font-size: 16px;
            font-weight: 600;
            cursor: pointer;
            transition: background 0.3s;
            margin-top: 16px;
        }
        .upload-btn:hover { background: #3a7bc8; }
        .upload-btn:disabled { background: #ccc; cursor: not-allowed; }
        .progress-item {
            margin-top: 8px;
        }
        .progress-bar {
            height: 4px;
            background: #e0e0e0;
            border-radius: 2px;
            overflow: hidden;
        }
        .progress-fill {
            height: 100%;
            background: linear-gradient(90deg, #4a90d9, #2ecc71);
            border-radius: 2px;
            transition: width 0.3s;
            width: 0%;
        }
        .progress-text {
            font-size: 12px;
            color: #999;
            margin-top: 4px;
            text-align: right;
        }
        .preview-grid {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
            gap: 12px;
            margin-top: 16px;
        }
        .preview-item {
            position: relative;
            aspect-ratio: 1;
            border-radius: 8px;
            overflow: hidden;
            background: #f0f0f0;
        }
        .preview-item img {
            width: 100%;
            height: 100%;
            object-fit: cover;
        }
        .preview-item .remove-btn {
            position: absolute;
            top: 4px;
            right: 4px;
            width: 24px;
            height: 24px;
            background: rgba(0,0,0,0.6);
            color: white;
            border: none;
            border-radius: 50%;
            cursor: pointer;
            font-size: 12px;
            display: flex;
            align-items: center;
            justify-content: center;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>文件上传与预览</h1>

        <!-- 单文件上传 -->
        <div class="upload-card">
            <h2>单文件上传</h2>
            <div class="file-input-wrapper" id="singleDropZone">
                <label class="file-input-label" for="singleFileInput">
                    <div class="upload-icon">📄</div>
                    <div class="upload-text">
                        <strong>点击选择文件</strong> 或将文件拖拽到此处<br>
                        <span style="font-size:12px; color:#aaa;">支持 JPG、PNG、GIF、PDF 格式,最大 10MB</span>
                    </div>
                </label>
                <input type="file" id="singleFileInput" accept=".jpg,.jpeg,.png,.gif,.pdf">
            </div>
            <div id="singleFileInfo"></div>
            <button class="upload-btn" id="singleUploadBtn" disabled onclick="uploadSingleFile()">上传文件</button>
        </div>

        <!-- 多文件上传 -->
        <div class="upload-card">
            <h2>多文件上传(支持拖拽)</h2>
            <div class="file-input-wrapper" id="multiDropZone">
                <label class="file-input-label" for="multiFileInput">
                    <div class="upload-icon">📂</div>
                    <div class="upload-text">
                        <strong>点击选择多个文件</strong> 或将文件拖拽到此处<br>
                        <span style="font-size:12px; color:#aaa;">支持多选,最多10个文件</span>
                    </div>
                </label>
                <input type="file" id="multiFileInput" multiple>
            </div>
            <div class="file-list" id="multiFileList"></div>
            <button class="upload-btn" id="multiUploadBtn" disabled onclick="uploadMultiFiles()">批量上传</button>

            <!-- 图片预览区 -->
            <div class="preview-grid" id="previewGrid"></div>
        </div>
    </div>

    <script>
        let singleFile = null;
        let multiFiles = [];

        // ===== 工具函数 =====
        function formatFileSize(bytes) {
            if (bytes === 0) return '0 B';
            const k = 1024;
            const sizes = ['B', 'KB', 'MB', 'GB'];
            const i = Math.floor(Math.log(bytes) / Math.log(k));
            return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
        }

        function getFileIcon(type) {
            if (type.startsWith('image/')) return { icon: '📷', cls: 'image' };
            if (type.startsWith('video/')) return { icon: '🎥', cls: 'video' };
            if (type.startsWith('audio/')) return { icon: '🎵', cls: 'audio' };
            if (type.includes('pdf') || type.includes('document') || type.includes('text'))
                return { icon: '📄', cls: 'document' };
            return { icon: '📁', cls: 'other' };
        }

        // ===== 单文件上传 =====
        const singleInput = document.getElementById('singleFileInput');
        const singleDropZone = document.getElementById('singleDropZone');

        singleInput.addEventListener('change', function() {
            if (this.files.length > 0) {
                singleFile = this.files[0];
                showSingleFileInfo(singleFile);
                document.getElementById('singleUploadBtn').disabled = false;
            }
        });

        function showSingleFileInfo(file) {
            const icon = getFileIcon(file.type);
            const info = document.getElementById('singleFileInfo');
            let html = `
                <div class="file-item">
                    <div class="file-icon ${icon.cls}">${icon.icon}</div>
                    <div class="file-info">
                        <div class="file-name">${file.name}</div>
                        <div class="file-meta">${formatFileSize(file.size)} | ${file.type || '未知类型'} | ${new Date(file.lastModified).toLocaleDateString()}</div>
                    </div>
                    <button class="file-remove" onclick="removeSingleFile()">✕</button>
                </div>
            `;

            // 如果是图片,显示预览
            if (file.type.startsWith('image/')) {
                const reader = new FileReader();
                reader.onload = function(e) {
                    html += `<div style="margin-top:12px; text-align:center;">
                        <img src="${e.target.result}" style="max-width:100%; max-height:200px; border-radius:8px;">
                    </div>`;
                    info.innerHTML = html;
                };
                reader.readAsDataURL(file);
            } else {
                info.innerHTML = html;
            }
        }

        function removeSingleFile() {
            singleFile = null;
            singleInput.value = '';
            document.getElementById('singleFileInfo').innerHTML = '';
            document.getElementById('singleUploadBtn').disabled = true;
        }

        function uploadSingleFile() {
            if (!singleFile) return;

            const formData = new FormData();
            formData.append('file', singleFile);

            const xhr = new XMLHttpRequest();
            const btn = document.getElementById('singleUploadBtn');

            // 添加进度条
            const fileInfo = document.getElementById('singleFileInfo');
            fileInfo.innerHTML += `
                <div class="progress-item">
                    <div class="progress-bar"><div class="progress-fill" id="singleProgress"></div></div>
                    <div class="progress-text" id="singleProgressText">0%</div>
                </div>
            `;

            xhr.upload.onprogress = function(e) {
                if (e.lengthComputable) {
                    const percent = Math.round((e.loaded / e.total) * 100);
                    document.getElementById('singleProgress').style.width = percent + '%';
                    document.getElementById('singleProgressText').textContent = percent + '%';
                }
            };

            xhr.onload = function() {
                btn.disabled = false;
                btn.textContent = '上传文件';
                if (xhr.status === 200) {
                    alert('上传成功!');
                } else {
                    alert('上传失败: ' + xhr.status);
                }
            };

            btn.disabled = true;
            btn.textContent = '上传中...';
            xhr.open('POST', 'https://httpbin.org/post');
            xhr.send(formData);
        }

        // ===== 多文件上传 =====
        const multiInput = document.getElementById('multiFileInput');
        const multiDropZone = document.getElementById('multiDropZone');

        multiInput.addEventListener('change', function() {
            addFiles(Array.from(this.files));
        });

        function addFiles(files) {
            files.forEach(file => {
                if (multiFiles.length < 10) {
                    multiFiles.push(file);
                }
            });
            renderMultiFileList();
            document.getElementById('multiUploadBtn').disabled = multiFiles.length === 0;
        }

        function renderMultiFileList() {
            const list = document.getElementById('multiFileList');
            const grid = document.getElementById('previewGrid');
            let listHtml = '';
            let gridHtml = '';

            multiFiles.forEach((file, index) => {
                const icon = getFileIcon(file.type);
                listHtml += `
                    <div class="file-item">
                        <div class="file-icon ${icon.cls}">${icon.icon}</div>
                        <div class="file-info">
                            <div class="file-name">${file.name}</div>
                            <div class="file-meta">${formatFileSize(file.size)}</div>
                        </div>
                        <button class="file-remove" onclick="removeMultiFile(${index})">✕</button>
                    </div>
                `;

                // 图片预览
                if (file.type.startsWith('image/')) {
                    const reader = new FileReader();
                    reader.onload = function(e) {
                        const item = document.createElement('div');
                        item.className = 'preview-item';
                        item.innerHTML = `
                            <img src="${e.target.result}">
                            <button class="remove-btn" onclick="removeMultiFile(${index})">✕</button>
                        `;
                        grid.appendChild(item);
                    };
                    reader.readAsDataURL(file);
                }
            });

            list.innerHTML = listHtml;
            // 清空并重建预览网格
            grid.innerHTML = '';
        }

        function removeMultiFile(index) {
            multiFiles.splice(index, 1);
            renderMultiFileList();
            document.getElementById('multiUploadBtn').disabled = multiFiles.length === 0;
        }

        function uploadMultiFiles() {
            if (multiFiles.length === 0) return;

            const formData = new FormData();
            multiFiles.forEach(file => {
                formData.append('files', file);
            });

            const xhr = new XMLHttpRequest();
            const btn = document.getElementById('multiUploadBtn');

            btn.disabled = true;
            btn.textContent = '上传中...';

            xhr.upload.onprogress = function(e) {
                if (e.lengthComputable) {
                    const percent = Math.round((e.loaded / e.total) * 100);
                    btn.textContent = `上传中... ${percent}%`;
                }
            };

            xhr.onload = function() {
                btn.disabled = false;
                btn.textContent = '批量上传';
                if (xhr.status === 200) {
                    alert(`成功上传 ${multiFiles.length} 个文件!`);
                    multiFiles = [];
                    renderMultiFileList();
                    document.getElementById('previewGrid').innerHTML = '';
                    multiInput.value = '';
                } else {
                    alert('上传失败: ' + xhr.status);
                }
            };

            xhr.open('POST', 'https://httpbin.org/post');
            xhr.send(formData);
        }

        // ===== 拖拽上传 =====
        function setupDropZone(zone, input) {
            ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
                zone.addEventListener(eventName, function(e) {
                    e.preventDefault();
                    e.stopPropagation();
                });
            });

            ['dragenter', 'dragover'].forEach(eventName => {
                zone.addEventListener(eventName, function() {
                    zone.querySelector('.file-input-label').classList.add('dragover');
                });
            });

            ['dragleave', 'drop'].forEach(eventName => {
                zone.addEventListener(eventName, function() {
                    zone.querySelector('.file-input-label').classList.remove('dragover');
                });
            });

            zone.addEventListener('drop', function(e) {
                const files = Array.from(e.dataTransfer.files);
                if (input.id === 'singleFileInput') {
                    singleFile = files[0];
                    showSingleFileInfo(singleFile);
                    document.getElementById('singleUploadBtn').disabled = false;
                } else {
                    addFiles(files);
                }
            });
        }

        setupDropZone(singleDropZone, singleInput);
        setupDropZone(multiDropZone, multiInput);
    </script>
</body>
</html>

示例2:大文件分片上传

代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>大文件分片上传</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: #1a1a2e;
            color: #e0e0e0;
            min-height: 100vh;
            padding: 40px 20px;
        }
        .container { max-width: 700px; margin: 0 auto; }
        h1 { text-align: center; color: #48dbfb; margin-bottom: 30px; }
        .card {
            background: #16213e;
            border: 1px solid #2d2d44;
            border-radius: 12px;
            padding: 24px;
            margin-bottom: 20px;
        }
        .card h2 { font-size: 16px; color: #feca57; margin-bottom: 16px; }
        .drop-zone {
            padding: 40px;
            border: 2px dashed #2d2d44;
            border-radius: 12px;
            text-align: center;
            cursor: pointer;
            transition: all 0.3s;
        }
        .drop-zone:hover, .drop-zone.dragover {
            border-color: #48dbfb;
            background: rgba(72,219,251,0.05);
        }
        .drop-zone p { color: #888; margin-top: 8px; font-size: 13px; }
        input[type="file"] { display: none; }
        .file-info-panel {
            display: none;
            margin-top: 16px;
        }
        .file-info-panel.show { display: block; }
        .file-detail {
            display: flex;
            align-items: center;
            gap: 12px;
            padding: 12px;
            background: #0f3460;
            border-radius: 8px;
            margin-bottom: 12px;
        }
        .file-detail-icon {
            font-size: 32px;
        }
        .file-detail-info { flex: 1; }
        .file-detail-name { font-weight: 600; font-size: 14px; }
        .file-detail-meta { font-size: 12px; color: #888; margin-top: 4px; }
        .config-row {
            display: flex;
            gap: 12px;
            margin-bottom: 16px;
        }
        .config-item {
            flex: 1;
        }
        .config-item label {
            display: block;
            font-size: 13px;
            color: #888;
            margin-bottom: 4px;
        }
        .config-item select, .config-item input {
            width: 100%;
            padding: 8px 12px;
            background: #0f3460;
            border: 1px solid #2d2d44;
            border-radius: 6px;
            color: #e0e0e0;
            font-size: 14px;
            outline: none;
        }
        .btn-group {
            display: flex;
            gap: 8px;
        }
        .btn {
            padding: 10px 20px;
            border: none;
            border-radius: 8px;
            font-size: 14px;
            font-weight: 600;
            cursor: pointer;
            transition: all 0.3s;
        }
        .btn-upload { background: #48dbfb; color: #0f0f23; flex: 1; }
        .btn-upload:hover { background: #0abde3; }
        .btn-upload:disabled { background: #2d2d44; color: #888; cursor: not-allowed; }
        .btn-pause { background: #feca57; color: #0f0f23; }
        .btn-cancel { background: #ff6b6b; color: white; }
        .progress-section {
            margin-top: 20px;
            display: none;
        }
        .progress-section.show { display: block; }
        .progress-overall {
            margin-bottom: 16px;
        }
        .progress-label {
            display: flex;
            justify-content: space-between;
            font-size: 13px;
            margin-bottom: 6px;
        }
        .progress-bar {
            height: 8px;
            background: #2d2d44;
            border-radius: 4px;
            overflow: hidden;
        }
        .progress-fill {
            height: 100%;
            background: linear-gradient(90deg, #48dbfb, #2ecc71);
            border-radius: 4px;
            transition: width 0.3s;
            width: 0%;
        }
        .chunks-grid {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(30px, 1fr));
            gap: 3px;
            margin-top: 12px;
        }
        .chunk {
            aspect-ratio: 1;
            border-radius: 3px;
            background: #2d2d44;
            transition: background 0.3s;
        }
        .chunk.uploading { background: #feca57; }
        .chunk.uploaded { background: #2ecc71; }
        .chunk.error { background: #ff6b6b; }
        .log-area {
            background: #0a0a1a;
            border-radius: 8px;
            padding: 12px;
            margin-top: 16px;
            font-family: 'Courier New', monospace;
            font-size: 12px;
            color: #00b894;
            max-height: 200px;
            overflow-y: auto;
            white-space: pre-wrap;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>大文件分片上传</h1>

        <div class="card">
            <h2>文件选择</h2>
            <div class="drop-zone" id="dropZone" onclick="document.getElementById('fileInput').click()">
                <div style="font-size:48px;">📦</div>
                <p>点击选择文件或拖拽文件到此处</p>
                <p>支持任意类型文件,建议大文件使用分片上传</p>
            </div>
            <input type="file" id="fileInput">

            <div class="file-info-panel" id="fileInfoPanel">
                <div class="file-detail">
                    <div class="file-detail-icon">📄</div>
                    <div class="file-detail-info">
                        <div class="file-detail-name" id="fileName">-</div>
                        <div class="file-detail-meta" id="fileMeta">-</div>
                    </div>
                </div>

                <div class="config-row">
                    <div class="config-item">
                        <label>分片大小</label>
                        <select id="chunkSize">
                            <option value="524288">512 KB</option>
                            <option value="1048576" selected>1 MB</option>
                            <option value="2097152">2 MB</option>
                            <option value="5242880">5 MB</option>
                            <option value="10485760">10 MB</option>
                        </select>
                    </div>
                    <div class="config-item">
                        <label>并发数</label>
                        <select id="concurrency">
                            <option value="1">1</option>
                            <option value="3" selected>3</option>
                            <option value="5">5</option>
                            <option value="10">10</option>
                        </select>
                    </div>
                </div>

                <div class="btn-group">
                    <button class="btn btn-upload" id="uploadBtn" onclick="startUpload()">开始上传</button>
                    <button class="btn btn-pause" id="pauseBtn" onclick="pauseUpload()" style="display:none">暂停</button>
                    <button class="btn btn-cancel" id="cancelBtn" onclick="cancelUpload()" style="display:none">取消</button>
                </div>
            </div>

            <div class="progress-section" id="progressSection">
                <div class="progress-overall">
                    <div class="progress-label">
                        <span>总体进度</span>
                        <span id="progressText">0%</span>
                    </div>
                    <div class="progress-bar">
                        <div class="progress-fill" id="progressFill"></div>
                    </div>
                    <div class="progress-label" style="margin-top:6px;">
                        <span id="uploadedSize">0 B / 0 B</span>
                        <span id="uploadSpeed">-</span>
                    </div>
                </div>

                <div>
                    <div class="progress-label">
                        <span>分片状态</span>
                        <span id="chunkStatus">0 / 0</span>
                    </div>
                    <div class="chunks-grid" id="chunksGrid"></div>
                </div>

                <div class="log-area" id="logArea"></div>
            </div>
        </div>
    </div>

    <script>
        let currentFile = null;
        let uploadTask = null;

        // 文件选择
        const fileInput = document.getElementById('fileInput');
        const dropZone = document.getElementById('dropZone');

        fileInput.addEventListener('change', function() {
            if (this.files.length > 0) selectFile(this.files[0]);
        });

        dropZone.addEventListener('dragover', function(e) {
            e.preventDefault();
            this.classList.add('dragover');
        });
        dropZone.addEventListener('dragleave', function() {
            this.classList.remove('dragover');
        });
        dropZone.addEventListener('drop', function(e) {
            e.preventDefault();
            this.classList.remove('dragover');
            if (e.dataTransfer.files.length > 0) selectFile(e.dataTransfer.files[0]);
        });

        function selectFile(file) {
            currentFile = file;
            document.getElementById('fileName').textContent = file.name;
            document.getElementById('fileMeta').textContent =
                `${formatSize(file.size)} | ${file.type || '未知类型'} | ${new Date(file.lastModified).toLocaleString()}`;
            document.getElementById('fileInfoPanel').classList.add('show');
            document.getElementById('uploadBtn').disabled = false;
        }

        function formatSize(bytes) {
            if (bytes === 0) return '0 B';
            const k = 1024;
            const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
            const i = Math.floor(Math.log(bytes) / Math.log(k));
            return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
        }

        // ===== 分片上传核心逻辑 =====
        class ChunkUploader {
            constructor(file, options = {}) {
                this.file = file;
                this.chunkSize = options.chunkSize || 1048576; // 默认1MB
                this.concurrency = options.concurrency || 3;
                this.chunks = [];
                this.uploadedChunks = new Set();
                this.isPaused = false;
                this.isCancelled = false;
                this.startTime = null;
                this.onProgress = options.onProgress || (() => {});
                this.onChunkProgress = options.onChunkProgress || (() => {});
                this.onComplete = options.onComplete || (() => {});
                this.onError = options.onError || (() => {});
                this.onLog = options.onLog || (() => {});

                this._initChunks();
            }

            _initChunks() {
                const totalChunks = Math.ceil(this.file.size / this.chunkSize);
                for (let i = 0; i < totalChunks; i++) {
                    const start = i * this.chunkSize;
                    const end = Math.min(start + this.chunkSize, this.file.size);
                    this.chunks.push({
                        index: i,
                        start,
                        end,
                        size: end - start,
                        status: 'pending' // pending | uploading | uploaded | error
                    });
                }
                this.onLog(`文件分为 ${totalChunks} 个分片,每片 ${formatSize(this.chunkSize)}`);
            }

            async start() {
                this.isPaused = false;
                this.isCancelled = false;
                this.startTime = Date.now();
                this.onLog('开始上传...');

                // 模拟:检查已上传的分片(断点续传)
                await this._checkUploadedChunks();

                // 开始上传未完成的分片
                await this._uploadChunks();
            }

            async _checkUploadedChunks() {
                // 实际项目中,这里应该调用后端接口查询已上传的分片
                // 模拟:假设之前没有上传过
                this.onLog('检查已上传分片...(模拟:无已上传分片)');
            }

            async _uploadChunks() {
                const pendingChunks = this.chunks.filter(
                    c => c.status === 'pending' || c.status === 'error'
                );

                // 并发控制
                const queue = [...pendingChunks];
                const workers = [];

                for (let i = 0; i < this.concurrency; i++) {
                    workers.push(this._worker(queue));
                }

                await Promise.all(workers);

                if (this.isCancelled) {
                    this.onLog('上传已取消');
                    return;
                }

                if (this.isPaused) {
                    this.onLog('上传已暂停');
                    return;
                }

                // 检查是否所有分片都上传完成
                const allUploaded = this.chunks.every(c => c.status === 'uploaded');
                if (allUploaded) {
                    await this._mergeChunks();
                    this.onComplete();
                } else {
                    this.onError(new Error('部分分片上传失败'));
                }
            }

            async _worker(queue) {
                while (queue.length > 0 && !this.isPaused && !this.isCancelled) {
                    const chunk = queue.shift();
                    if (!chunk) break;

                    try {
                        await this._uploadChunk(chunk);
                    } catch (error) {
                        chunk.status = 'error';
                        this.onChunkProgress(chunk.index, 'error');
                        this.onLog(`分片 ${chunk.index} 上传失败: ${error.message}`);
                        // 失败后重试一次
                        try {
                            await this._uploadChunk(chunk);
                        } catch (retryError) {
                            this.onLog(`分片 ${chunk.index} 重试失败`);
                        }
                    }

                    this._updateProgress();
                }
            }

            async _uploadChunk(chunk) {
                chunk.status = 'uploading';
                this.onChunkProgress(chunk.index, 'uploading');

                const blob = this.file.slice(chunk.start, chunk.end);

                // 模拟上传延迟
                await new Promise(resolve => setTimeout(resolve, 200 + Math.random() * 500));

                // 实际上传代码(这里使用httpbin模拟)
                // const formData = new FormData();
                // formData.append('chunk', blob);
                // formData.append('chunkIndex', chunk.index);
                // formData.append('totalChunks', this.chunks.length);
                // formData.append('fileId', this.fileId);
                //
                // const response = await fetch('/api/upload/chunk', {
                //     method: 'POST',
                //     body: formData
                // });
                //
                // if (!response.ok) throw new Error('上传失败');

                chunk.status = 'uploaded';
                this.uploadedChunks.add(chunk.index);
                this.onChunkProgress(chunk.index, 'uploaded');
                this.onLog(`分片 ${chunk.index} 上传完成 (${formatSize(chunk.size)})`);
            }

            async _mergeChunks() {
                this.onLog('所有分片上传完成,通知服务器合并...');
                // 实际项目中调用合并接口
                // await fetch('/api/upload/merge', {
                //     method: 'POST',
                //     headers: { 'Content-Type': 'application/json' },
                //     body: JSON.stringify({
                //         fileId: this.fileId,
                //         fileName: this.file.name,
                //         totalChunks: this.chunks.length
                //     })
                // });
            }

            _updateProgress() {
                const uploaded = this.chunks.filter(c => c.status === 'uploaded').length;
                const total = this.chunks.length;
                const percent = Math.round((uploaded / total) * 100);

                const uploadedBytes = this.chunks
                    .filter(c => c.status === 'uploaded')
                    .reduce((sum, c) => sum + c.size, 0);

                const elapsed = (Date.now() - this.startTime) / 1000;
                const speed = uploadedBytes / elapsed;

                this.onProgress({
                    percent,
                    uploadedBytes,
                    totalBytes: this.file.size,
                    speed,
                    uploaded,
                    total
                });
            }

            pause() {
                this.isPaused = true;
                this.onLog('暂停上传...');
            }

            resume() {
                this.isPaused = false;
                this.onLog('继续上传...');
                this._uploadChunks();
            }

            cancel() {
                this.isCancelled = true;
                this.onLog('取消上传...');
            }
        }

        // ===== UI控制 =====
        async function startUpload() {
            if (!currentFile) return;

            const chunkSize = parseInt(document.getElementById('chunkSize').value);
            const concurrency = parseInt(document.getElementById('concurrency').value);

            // 初始化分片网格
            const totalChunks = Math.ceil(currentFile.size / chunkSize);
            const grid = document.getElementById('chunksGrid');
            grid.innerHTML = '';
            for (let i = 0; i < totalChunks; i++) {
                const div = document.createElement('div');
                div.className = 'chunk';
                div.id = `chunk-${i}`;
                div.title = `分片 ${i}`;
                grid.appendChild(div);
            }

            document.getElementById('progressSection').classList.add('show');
            document.getElementById('uploadBtn').style.display = 'none';
            document.getElementById('pauseBtn').style.display = 'inline-block';
            document.getElementById('cancelBtn').style.display = 'inline-block';
            document.getElementById('logArea').textContent = '';

            uploadTask = new ChunkUploader(currentFile, {
                chunkSize,
                concurrency,
                onProgress: updateProgress,
                onChunkProgress: updateChunkStatus,
                onComplete: () => {
                    document.getElementById('pauseBtn').style.display = 'none';
                    document.getElementById('cancelBtn').style.display = 'none';
                    document.getElementById('uploadBtn').style.display = 'inline-block';
                    document.getElementById('uploadBtn').textContent = '上传完成';
                    document.getElementById('uploadBtn').disabled = true;
                    addLog('上传完成!');
                },
                onError: (err) => addLog('错误: ' + err.message),
                onLog: addLog
            });

            await uploadTask.start();
        }

        function updateProgress(data) {
            document.getElementById('progressFill').style.width = data.percent + '%';
            document.getElementById('progressText').textContent = data.percent + '%';
            document.getElementById('uploadedSize').textContent =
                `${formatSize(data.uploadedBytes)} / ${formatSize(data.totalBytes)}`;
            document.getElementById('uploadSpeed').textContent =
                `${formatSize(data.speed)}/s`;
            document.getElementById('chunkStatus').textContent =
                `${data.uploaded} / ${data.total}`;
        }

        function updateChunkStatus(index, status) {
            const el = document.getElementById(`chunk-${index}`);
            if (el) el.className = `chunk ${status}`;
        }

        function addLog(msg) {
            const log = document.getElementById('logArea');
            const time = new Date().toLocaleTimeString();
            log.textContent += `[${time}] ${msg}\n`;
            log.scrollTop = log.scrollHeight;
        }

        function pauseUpload() {
            if (uploadTask) {
                const btn = document.getElementById('pauseBtn');
                if (uploadTask.isPaused) {
                    uploadTask.resume();
                    btn.textContent = '暂停';
                } else {
                    uploadTask.pause();
                    btn.textContent = '继续';
                }
            }
        }

        function cancelUpload() {
            if (uploadTask) {
                uploadTask.cancel();
                document.getElementById('pauseBtn').style.display = 'none';
                document.getElementById('cancelBtn').style.display = 'none';
                document.getElementById('uploadBtn').style.display = 'inline-block';
                document.getElementById('uploadBtn').textContent = '重新上传';
                document.getElementById('uploadBtn').disabled = false;
            }
        }
    </script>
</body>
</html>

示例3:文件下载多种方式

代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>文件下载多种方式</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: #fafafa;
            min-height: 100vh;
            padding: 40px 20px;
        }
        .container { max-width: 800px; margin: 0 auto; }
        h1 { text-align: center; color: #2c3e50; margin-bottom: 30px; }
        .card {
            background: white;
            border-radius: 12px;
            padding: 24px;
            margin-bottom: 20px;
            box-shadow: 0 2px 12px rgba(0,0,0,0.06);
        }
        .card h2 {
            font-size: 16px;
            color: #2c3e50;
            margin-bottom: 16px;
            padding-bottom: 10px;
            border-bottom: 2px solid #f0f0f0;
        }
        .method-grid {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
            gap: 12px;
        }
        .method-item {
            padding: 16px;
            border: 2px solid #e0e0e0;
            border-radius: 10px;
            cursor: pointer;
            transition: all 0.3s;
            text-align: center;
        }
        .method-item:hover {
            border-color: #3498db;
            background: #f0f7ff;
            transform: translateY(-2px);
        }
        .method-icon {
            font-size: 32px;
            margin-bottom: 8px;
        }
        .method-name {
            font-weight: 600;
            color: #333;
            margin-bottom: 4px;
        }
        .method-desc {
            font-size: 12px;
            color: #888;
        }
        .generator {
            margin-top: 20px;
        }
        .generator h3 {
            font-size: 14px;
            color: #555;
            margin-bottom: 12px;
        }
        .gen-row {
            display: flex;
            gap: 12px;
            margin-bottom: 12px;
            align-items: center;
        }
        .gen-row label {
            min-width: 80px;
            font-size: 13px;
            color: #666;
        }
        .gen-row input, .gen-row select, .gen-row textarea {
            flex: 1;
            padding: 8px 12px;
            border: 2px solid #e0e0e0;
            border-radius: 6px;
            font-size: 14px;
            outline: none;
        }
        .gen-row input:focus, .gen-row select:focus, .gen-row textarea:focus {
            border-color: #3498db;
        }
        .btn {
            padding: 10px 20px;
            border: none;
            border-radius: 8px;
            font-size: 14px;
            font-weight: 600;
            cursor: pointer;
            transition: all 0.3s;
        }
        .btn-primary { background: #3498db; color: white; }
        .btn-primary:hover { background: #2980b9; }
        .btn-success { background: #2ecc71; color: white; }
        .btn-success:hover { background: #27ae60; }
        .btn-warning { background: #e67e22; color: white; }
        .btn-warning:hover { background: #d35400; }
        .result-box {
            background: #2c3e50;
            color: #2ecc71;
            padding: 16px;
            border-radius: 8px;
            font-family: 'Courier New', monospace;
            font-size: 13px;
            margin-top: 16px;
            white-space: pre-wrap;
            word-break: break-all;
            max-height: 200px;
            overflow-y: auto;
            display: none;
        }
        .result-box.show { display: block; }
        .download-progress {
            margin-top: 16px;
            display: none;
        }
        .download-progress.show { display: block; }
        .progress-bar {
            height: 6px;
            background: #e0e0e0;
            border-radius: 3px;
            overflow: hidden;
        }
        .progress-fill {
            height: 100%;
            background: linear-gradient(90deg, #3498db, #2ecc71);
            border-radius: 3px;
            transition: width 0.3s;
            width: 0%;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>文件下载多种方式</h1>

        <!-- 下载方式选择 -->
        <div class="card">
            <h2>下载方式概览</h2>
            <div class="method-grid">
                <div class="method-item" onclick="showMethod('a-download')">
                    <div class="method-icon">🔗</div>
                    <div class="method-name"><a download></div>
                    <div class="method-desc">最简单的下载方式</div>
                </div>
                <div class="method-item" onclick="showMethod('blob-download')">
                    <div class="method-icon">📦</div>
                    <div class="method-name">Blob + URL</div>
                    <div class="method-desc">前端生成文件下载</div>
                </div>
                <div class="method-item" onclick="showMethod('fetch-download')">
                    <div class="method-icon">🚀</div>
                    <div class="method-name">Fetch + Blob</div>
                    <div class="method-desc">带进度的远程下载</div>
                </div>
                <div class="method-item" onclick="showMethod('xhr-download')">
                    <div class="method-icon">📈</div>
                    <div class="method-name">XHR下载</div>
                    <div class="method-desc">精确进度监控</div>
                </div>
            </div>
        </div>

        <!-- 方式1:a download -->
        <div class="card" id="method-a-download">
            <h2>方式1:<a download> 标签下载</h2>
            <p style="font-size:13px; color:#888; margin-bottom:16px; line-height:1.8;">
                最简单的下载方式。设置 <a> 标签的 download 属性即可触发下载。<br>
                限制:仅适用于同源URL,跨域URL的download属性可能被忽略。
            </p>
            <button class="btn btn-primary" onclick="downloadWithALink()">测试 a download 下载</button>
        </div>

        <!-- 方式2:Blob + URL -->
        <div class="card" id="method-blob-download">
            <h2>方式2:Blob + URL.createObjectURL 下载</h2>
            <p style="font-size:13px; color:#888; margin-bottom:16px; line-height:1.8;">
                在前端生成文件内容,创建Blob对象,然后通过URL.createObjectURL生成临时URL进行下载。<br>
                适用于:前端生成CSV、JSON、文本等文件。
            </p>

            <div class="generator">
                <h3>文件生成器</h3>
                <div class="gen-row">
                    <label>文件名</label>
                    <input type="text" id="genFileName" value="data.csv">
                </div>
                <div class="gen-row">
                    <label>文件类型</label>
                    <select id="genFileType">
                        <option value="text/csv">CSV</option>
                        <option value="application/json">JSON</option>
                        <option value="text/plain">TXT</option>
                        <option value="text/html">HTML</option>
                        <option value="application/xml">XML</option>
                    </select>
                </div>
                <div class="gen-row">
                    <label>文件内容</label>
                    <textarea id="genFileContent" rows="4" placeholder="输入文件内容">姓名,年龄,城市
张三,25,北京
李四,30,上海
王五,28,广州</textarea>
                </div>
                <button class="btn btn-success" onclick="downloadWithBlob()">生成并下载</button>
            </div>
        </div>

        <!-- 方式3:Fetch + Blob -->
        <div class="card" id="method-fetch-download">
            <h2>方式3:Fetch + Blob 远程文件下载</h2>
            <p style="font-size:13px; color:#888; margin-bottom:16px; line-height:1.8;">
                使用Fetch API下载远程文件,然后通过Blob和URL.createObjectURL触发下载。<br>
                适用于需要在前端处理响应后再下载的场景。
            </p>
            <div class="gen-row">
                <label>文件URL</label>
                <input type="text" id="fetchUrl" value="https://httpbin.org/json" style="flex:1;">
            </div>
            <button class="btn btn-warning" onclick="downloadWithFetch()">Fetch下载</button>
            <div class="download-progress" id="fetchProgress">
                <div class="progress-bar"><div class="progress-fill" id="fetchProgressFill"></div></div>
            </div>
        </div>

        <!-- 方式4:XHR下载 -->
        <div class="card" id="method-xhr-download">
            <h2>方式4:XMLHttpRequest 下载(精确进度)</h2>
            <p style="font-size:13px; color:#888; margin-bottom:16px; line-height:1.8;">
                使用XMLHttpRequest的onprogress事件可以精确监控下载进度。<br>
                Fetch API目前不支持下载进度监控(除非使用Stream API)。
            </p>
            <div class="gen-row">
                <label>文件URL</label>
                <input type="text" id="xhrUrl" value="https://httpbin.org/image/jpeg" style="flex:1;">
            </div>
            <button class="btn btn-primary" onclick="downloadWithXHR()">XHR下载</button>
            <div class="download-progress" id="xhrProgress">
                <div style="display:flex; justify-content:space-between; font-size:13px; margin-bottom:4px;">
                    <span id="xhrProgressText">0%</span>
                    <span id="xhrProgressSize">-</span>
                </div>
                <div class="progress-bar"><div class="progress-fill" id="xhrProgressFill"></div></div>
            </div>
        </div>

        <!-- 批量下载示例 -->
        <div class="card">
            <h2>批量文件生成与下载</h2>
            <div class="method-grid">
                <div class="method-item" onclick="generateAndDownload('csv')">
                    <div class="method-icon">📊</div>
                    <div class="method-name">生成CSV</div>
                    <div class="method-desc">表格数据导出</div>
                </div>
                <div class="method-item" onclick="generateAndDownload('json')">
                    <div class="method-icon">📋</div>
                    <div class="method-name">生成JSON</div>
                    <div class="method-desc">结构化数据导出</div>
                </div>
                <div class="method-item" onclick="generateAndDownload('txt')">
                    <div class="method-icon">📄</div>
                    <div class="method-name">生成TXT</div>
                    <div class="method-desc">纯文本导出</div>
                </div>
                <div class="method-item" onclick="generateAndDownload('html')">
                    <div class="method-icon">💻</div>
                    <div class="method-name">生成HTML</div>
                    <div class="method-desc">网页报告导出</div>
                </div>
            </div>
        </div>
    </div>

    <script>
        function showMethod(methodId) {
            // 滚动到对应方法
            const el = document.getElementById('method-' + methodId);
            if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
        }

        // ===== 方式1:a download =====
        function downloadWithALink() {
            const content = '这是通过 a download 属性下载的文件内容\n下载时间: ' + new Date().toLocaleString();
            const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
            const url = URL.createObjectURL(blob);

            const a = document.createElement('a');
            a.href = url;
            a.download = 'download-test.txt';
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);

            // 释放URL对象
            setTimeout(() => URL.revokeObjectURL(url), 100);
        }

        // ===== 方式2:Blob + URL =====
        function downloadWithBlob() {
            const fileName = document.getElementById('genFileName').value;
            const fileType = document.getElementById('genFileType').value;
            const content = document.getElementById('genFileContent').value;

            const blob = new Blob([content], { type: fileType + ';charset=utf-8' });
            const url = URL.createObjectURL(blob);

            const a = document.createElement('a');
            a.href = url;
            a.download = fileName;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);

            URL.revokeObjectURL(url);
        }

        // ===== 方式3:Fetch + Blob =====
        async function downloadWithFetch() {
            const url = document.getElementById('fetchUrl').value;
            const progressEl = document.getElementById('fetchProgress');
            progressEl.classList.add('show');

            try {
                const response = await fetch(url);
                if (!response.ok) throw new Error(`HTTP ${response.status}`);

                // 使用ReadableStream监控进度
                const contentLength = response.headers.get('Content-Length');
                const reader = response.body.getReader();
                let receivedLength = 0;
                const chunks = [];

                while (true) {
                    const { done, value } = await reader.read();
                    if (done) break;

                    chunks.push(value);
                    receivedLength += value.length;

                    if (contentLength) {
                        const percent = Math.round((receivedLength / contentLength) * 100);
                        document.getElementById('fetchProgressFill').style.width = percent + '%';
                    }
                }

                // 合并chunks
                const blob = new Blob(chunks);
                const blobUrl = URL.createObjectURL(blob);

                const a = document.createElement('a');
                a.href = blobUrl;
                a.download = 'fetched-file.json';
                document.body.appendChild(a);
                a.click();
                document.body.removeChild(a);

                URL.revokeObjectURL(blobUrl);
            } catch (error) {
                alert('下载失败: ' + error.message);
            }

            progressEl.classList.remove('show');
        }

        // ===== 方式4:XHR下载 =====
        function downloadWithXHR() {
            const url = document.getElementById('xhrUrl').value;
            const progressEl = document.getElementById('xhrProgress');
            progressEl.classList.add('show');

            const xhr = new XMLHttpRequest();
            xhr.open('GET', url, true);
            xhr.responseType = 'blob';

            xhr.onprogress = function(e) {
                if (e.lengthComputable) {
                    const percent = Math.round((e.loaded / e.total) * 100);
                    document.getElementById('xhrProgressFill').style.width = percent + '%';
                    document.getElementById('xhrProgressText').textContent = percent + '%';
                    document.getElementById('xhrProgressSize').textContent =
                        formatSize(e.loaded) + ' / ' + formatSize(e.total);
                }
            };

            xhr.onload = function() {
                if (xhr.status === 200) {
                    const blob = xhr.response;
                    const blobUrl = URL.createObjectURL(blob);

                    const a = document.createElement('a');
                    a.href = blobUrl;
                    a.download = 'xhr-download.jpg';
                    document.body.appendChild(a);
                    a.click();
                    document.body.removeChild(a);

                    URL.revokeObjectURL(blobUrl);
                } else {
                    alert('下载失败: ' + xhr.status);
                }
                progressEl.classList.remove('show');
            };

            xhr.onerror = function() {
                alert('网络错误');
                progressEl.classList.remove('show');
            };

            xhr.send();
        }

        // ===== 批量生成下载 =====
        function generateAndDownload(type) {
            const sampleData = [
                { name: '张三', age: 25, city: '北京', score: 92 },
                { name: '李四', age: 30, city: '上海', score: 88 },
                { name: '王五', age: 28, city: '广州', score: 95 },
                { name: '赵六', age: 22, city: '深圳', score: 78 },
                { name: '钱七', age: 35, city: '杭州', score: 85 }
            ];

            let content, mimeType, fileName;

            switch (type) {
                case 'csv':
                    // 生成CSV
                    const headers = Object.keys(sampleData[0]);
                    const csvRows = [
                        headers.join(','),
                        ...sampleData.map(row => headers.map(h => row[h]).join(','))
                    ];
                    // 添加BOM以支持中文
                    content = '\uFEFF' + csvRows.join('\n');
                    mimeType = 'text/csv';
                    fileName = 'export-data.csv';
                    break;

                case 'json':
                    content = JSON.stringify(sampleData, null, 2);
                    mimeType = 'application/json';
                    fileName = 'export-data.json';
                    break;

                case 'txt':
                    content = sampleData.map(row =>
                        `姓名: ${row.name}\n年龄: ${row.age}\n城市: ${row.city}\n成绩: ${row.score}\n---`
                    ).join('\n');
                    mimeType = 'text/plain';
                    fileName = 'export-data.txt';
                    break;

                case 'html':
                    content = `<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>数据报告</title>
<style>
    body { font-family: sans-serif; padding: 20px; }
    table { border-collapse: collapse; width: 100%; }
    th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
    th { background: #4a90d9; color: white; }
    tr:nth-child(even) { background: #f2f2f2; }
</style></head><body>
<h1>数据报告</h1>
<p>生成时间: ${new Date().toLocaleString()}</p>
<table>
    <tr><th>姓名</th><th>年龄</th><th>城市</th><th>成绩</th></tr>
    ${sampleData.map(row =>
        `<tr><td>${row.name}</td><td>${row.age}</td><td>${row.city}</td><td>${row.score}</td></tr>`
    ).join('')}
</table></body></html>`;
                    mimeType = 'text/html';
                    fileName = 'report.html';
                    break;
            }

            const blob = new Blob([content], { type: mimeType + ';charset=utf-8' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = fileName;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(url);
        }

        function formatSize(bytes) {
            if (bytes === 0) return '0 B';
            const k = 1024;
            const sizes = ['B', 'KB', 'MB', 'GB'];
            const i = Math.floor(Math.log(bytes) / Math.log(k));
            return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
        }
    </script>
</body>
</html>

示例4:FileReader详解与图片处理

代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>FileReader详解与图片处理</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: #f5f5f5;
            min-height: 100vh;
            padding: 30px 20px;
        }
        .container { max-width: 900px; margin: 0 auto; }
        h1 { text-align: center; color: #333; margin-bottom: 30px; }
        .grid-layout {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 20px;
        }
        @media (max-width: 768px) {
            .grid-layout { grid-template-columns: 1fr; }
        }
        .card {
            background: white;
            border-radius: 12px;
            padding: 24px;
            box-shadow: 0 2px 12px rgba(0,0,0,0.06);
        }
        .card h2 {
            font-size: 16px;
            color: #333;
            margin-bottom: 16px;
        }
        .drop-area {
            padding: 30px;
            border: 2px dashed #ccc;
            border-radius: 10px;
            text-align: center;
            cursor: pointer;
            transition: all 0.3s;
            margin-bottom: 16px;
        }
        .drop-area:hover { border-color: #4a90d9; background: #f0f7ff; }
        .drop-area.has-image { border-style: solid; padding: 10px; }
        .drop-area img {
            max-width: 100%;
            max-height: 200px;
            border-radius: 8px;
        }
        input[type="file"] { display: none; }
        .info-table {
            width: 100%;
            border-collapse: collapse;
            font-size: 13px;
        }
        .info-table th, .info-table td {
            padding: 8px 10px;
            border-bottom: 1px solid #eee;
            text-align: left;
        }
        .info-table th { color: #888; width: 100px; }
        .info-table td { color: #333; font-family: monospace; }
        .btn {
            padding: 8px 16px;
            border: none;
            border-radius: 6px;
            font-size: 13px;
            cursor: pointer;
            transition: all 0.3s;
            margin: 4px;
        }
        .btn-blue { background: #4a90d9; color: white; }
        .btn-green { background: #2ecc71; color: white; }
        .btn-orange { background: #e67e22; color: white; }
        .btn-red { background: #e74c3c; color: white; }
        .output-area {
            background: #2c3e50;
            color: #2ecc71;
            padding: 12px;
            border-radius: 8px;
            font-family: monospace;
            font-size: 12px;
            white-space: pre-wrap;
            word-break: break-all;
            max-height: 150px;
            overflow-y: auto;
            margin-top: 12px;
        }
        .canvas-preview {
            border: 1px solid #eee;
            border-radius: 8px;
            margin-top: 12px;
            max-width: 100%;
        }
        .slider-row {
            display: flex;
            align-items: center;
            gap: 10px;
            margin-bottom: 10px;
        }
        .slider-row label {
            min-width: 60px;
            font-size: 13px;
            color: #666;
        }
        .slider-row input[type="range"] {
            flex: 1;
        }
        .slider-row span {
            min-width: 40px;
            text-align: right;
            font-size: 13px;
            color: #333;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>FileReader详解与图片处理</h1>
        <div class="grid-layout">
            <!-- 左侧:文件信息 -->
            <div class="card">
                <h2>文件信息</h2>
                <div class="drop-area" id="imageDropArea" onclick="document.getElementById('imageInput').click()">
                    <p style="color:#888;">点击或拖拽选择图片</p>
                </div>
                <input type="file" id="imageInput" accept="image/*">

                <table class="info-table" id="fileInfoTable" style="display:none;">
                    <tr><th>文件名</th><td id="info-name">-</td></tr>
                    <tr><th>大小</th><td id="info-size">-</td></tr>
                    <tr><th>类型</th><td id="info-type">-</td></tr>
                    <tr><th>修改时间</th><td id="info-date">-</td></tr>
                    <tr><th>宽度</th><td id="info-width">-</td></tr>
                    <tr><th>高度</th><td id="info-height">-</td></tr>
                    <tr><th>宽高比</th><td id="info-ratio">-</td></tr>
                </table>

                <div style="margin-top:12px;">
                    <button class="btn btn-blue" onclick="readAsDataURL()">readAsDataURL</button>
                    <button class="btn btn-green" onclick="readAsArrayBuffer()">readAsArrayBuffer</button>
                    <button class="btn btn-orange" onclick="readAsText()">readAsText</button>
                </div>
                <div class="output-area" id="readOutput">选择文件后点击按钮查看读取结果</div>
            </div>

            <!-- 右侧:图片处理 -->
            <div class="card">
                <h2>图片处理(Canvas)</h2>
                <div id="canvasContainer" style="display:none;">
                    <canvas id="imageCanvas" class="canvas-preview"></canvas>

                    <div style="margin-top:16px;">
                        <div class="slider-row">
                            <label>亮度</label>
                            <input type="range" id="brightness" min="0" max="200" value="100" oninput="applyFilters()">
                            <span id="brightnessVal">100</span>
                        </div>
                        <div class="slider-row">
                            <label>对比度</label>
                            <input type="range" id="contrast" min="0" max="200" value="100" oninput="applyFilters()">
                            <span id="contrastVal">100</span>
                        </div>
                        <div class="slider-row">
                            <label>饱和度</label>
                            <input type="range" id="saturate" min="0" max="200" value="100" oninput="applyFilters()">
                            <span id="saturateVal">100</span>
                        </div>
                        <div class="slider-row">
                            <label>模糊</label>
                            <input type="range" id="blur" min="0" max="10" value="0" step="0.5" oninput="applyFilters()">
                            <span id="blurVal">0</span>
                        </div>
                    </div>

                    <div style="margin-top:12px;">
                        <button class="btn btn-blue" onclick="rotateImage()">旋转90度</button>
                        <button class="btn btn-green" onclick="flipImage('h')">水平翻转</button>
                        <button class="btn btn-orange" onclick="flipImage('v')">垂直翻转</button>
                        <button class="btn btn-red" onclick="resetFilters()">重置</button>
                    </div>

                    <div style="margin-top:12px;">
                        <button class="btn btn-blue" onclick="downloadProcessed('image/png')">下载PNG</button>
                        <button class="btn btn-green" onclick="downloadProcessed('image/jpeg')">下载JPG</button>
                        <button class="btn btn-orange" onclick="downloadProcessed('image/webp')">下载WebP</button>
                    </div>
                </div>
                <p id="noImageHint" style="color:#888; text-align:center; padding:40px;">请先选择一张图片</p>
            </div>
        </div>
    </div>

    <script>
        let currentImage = null;
        let originalImage = null;
        let rotation = 0;
        let flipH = false;
        let flipV = false;

        const imageInput = document.getElementById('imageInput');
        const dropArea = document.getElementById('imageDropArea');

        imageInput.addEventListener('change', function() {
            if (this.files.length > 0) loadImage(this.files[0]);
        });

        dropArea.addEventListener('dragover', function(e) {
            e.preventDefault();
            this.style.borderColor = '#4a90d9';
        });
        dropArea.addEventListener('dragleave', function() {
            this.style.borderColor = '#ccc';
        });
        dropArea.addEventListener('drop', function(e) {
            e.preventDefault();
            this.style.borderColor = '#ccc';
            if (e.dataTransfer.files.length > 0) loadImage(e.dataTransfer.files[0]);
        });

        function loadImage(file) {
            currentImage = file;

            // 显示文件信息
            document.getElementById('fileInfoTable').style.display = 'table';
            document.getElementById('info-name').textContent = file.name;
            document.getElementById('info-size').textContent = formatSize(file.size);
            document.getElementById('info-type').textContent = file.type;
            document.getElementById('info-date').textContent = new Date(file.lastModified).toLocaleString();

            // 读取图片预览
            const reader = new FileReader();
            reader.onload = function(e) {
                // 更新拖拽区域预览
                dropArea.innerHTML = `<img src="${e.target.result}">`;
                dropArea.classList.add('has-image');

                // 加载到Canvas
                const img = new Image();
                img.onload = function() {
                    originalImage = img;
                    document.getElementById('info-width').textContent = img.width + 'px';
                    document.getElementById('info-height').textContent = img.height + 'px';
                    document.getElementById('info-ratio').textContent = (img.width / img.height).toFixed(2);

                    document.getElementById('canvasContainer').style.display = 'block';
                    document.getElementById('noImageHint').style.display = 'none';

                    resetFilters();
                };
                img.src = e.target.result;
            };
            reader.readAsDataURL(file);
        }

        // ===== FileReader读取方式 =====
        function readAsDataURL() {
            if (!currentImage) return;
            const reader = new FileReader();
            reader.onload = function(e) {
                const result = e.target.result;
                document.getElementById('readOutput').textContent =
                    `readAsDataURL 结果:\n\n` +
                    `前100个字符: ${result.substring(0, 100)}...\n\n` +
                    `总长度: ${result.length} 字符\n` +
                    `MIME类型: ${result.split(':')[1].split(';')[0]}\n` +
                    `编码方式: ${result.split(';')[1].split(',')[0]}`;
            };
            reader.readAsDataURL(currentImage);
        }

        function readAsArrayBuffer() {
            if (!currentImage) return;
            const reader = new FileReader();
            reader.onload = function(e) {
                const buffer = e.target.result;
                const arr = new Uint8Array(buffer);
                document.getElementById('readOutput').textContent =
                    `readAsArrayBuffer 结果:\n\n` +
                    `字节长度: ${arr.byteLength}\n` +
                    `前20个字节: [${Array.from(arr.slice(0, 20)).join(', ')}]\n\n` +
                    `文件头魔数: ${Array.from(arr.slice(0, 4)).map(b => b.toString(16).padStart(2, '0')).join(' ')}`;
            };
            reader.readAsArrayBuffer(currentImage);
        }

        function readAsText() {
            if (!currentImage) return;
            const reader = new FileReader();
            reader.onload = function(e) {
                document.getElementById('readOutput').textContent =
                    `readAsText 结果:\n\n` +
                    `(图片文件以文本读取会产生乱码)\n` +
                    `前200个字符:\n${e.target.result.substring(0, 200)}`;
            };
            reader.readAsText(currentImage);
        }

        // ===== Canvas图片处理 =====
        function applyFilters() {
            if (!originalImage) return;

            const brightness = document.getElementById('brightness').value;
            const contrast = document.getElementById('contrast').value;
            const saturate = document.getElementById('saturate').value;
            const blur = document.getElementById('blur').value;

            document.getElementById('brightnessVal').textContent = brightness;
            document.getElementById('contrastVal').textContent = contrast;
            document.getElementById('saturateVal').textContent = saturate;
            document.getElementById('blurVal').textContent = blur;

            const canvas = document.getElementById('imageCanvas');
            const ctx = canvas.getContext('2d');

            // 处理旋转后的尺寸
            const isRotated = rotation % 180 !== 0;
            canvas.width = isRotated ? originalImage.height : originalImage.width;
            canvas.height = isRotated ? originalImage.width : originalImage.height;

            // 限制Canvas大小
            const maxSize = 500;
            if (canvas.width > maxSize || canvas.height > maxSize) {
                const scale = maxSize / Math.max(canvas.width, canvas.height);
                canvas.width *= scale;
                canvas.height *= scale;
            }

            ctx.clearRect(0, 0, canvas.width, canvas.height);
            ctx.save();

            // 应用滤镜
            ctx.filter = `brightness(${brightness}%) contrast(${contrast}%) saturate(${saturate}%) blur(${blur}px)`;

            // 变换
            ctx.translate(canvas.width / 2, canvas.height / 2);
            ctx.rotate((rotation * Math.PI) / 180);
            ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1);

            const drawWidth = isRotated ? canvas.height : canvas.width;
            const drawHeight = isRotated ? canvas.width : canvas.height;
            ctx.drawImage(originalImage, -drawWidth / 2, -drawHeight / 2, drawWidth, drawHeight);

            ctx.restore();
        }

        function rotateImage() {
            rotation = (rotation + 90) % 360;
            applyFilters();
        }

        function flipImage(direction) {
            if (direction === 'h') flipH = !flipH;
            if (direction === 'v') flipV = !flipV;
            applyFilters();
        }

        function resetFilters() {
            rotation = 0;
            flipH = false;
            flipV = false;
            document.getElementById('brightness').value = 100;
            document.getElementById('contrast').value = 100;
            document.getElementById('saturate').value = 100;
            document.getElementById('blur').value = 0;
            applyFilters();
        }

        function downloadProcessed(mimeType) {
            const canvas = document.getElementById('imageCanvas');
            const ext = mimeType.split('/')[1];
            const quality = mimeType === 'image/jpeg' ? 0.9 : undefined;

            canvas.toBlob(function(blob) {
                const url = URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.href = url;
                a.download = `processed-image.${ext}`;
                document.body.appendChild(a);
                a.click();
                document.body.removeChild(a);
                URL.revokeObjectURL(url);
            }, mimeType, quality);
        }

        function formatSize(bytes) {
            if (bytes === 0) return '0 B';
            const k = 1024;
            const sizes = ['B', 'KB', 'MB', 'GB'];
            const i = Math.floor(Math.log(bytes) / Math.log(k));
            return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
        }
    </script>
</body>
</html>

示例5:文件哈希计算与秒传

代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>文件哈希计算与秒传</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: #0f0f23;
            color: #e0e0e0;
            min-height: 100vh;
            padding: 40px 20px;
        }
        .container { max-width: 700px; margin: 0 auto; }
        h1 { text-align: center; color: #48dbfb; margin-bottom: 30px; }
        .card {
            background: #1a1a2e;
            border: 1px solid #2d2d44;
            border-radius: 12px;
            padding: 24px;
            margin-bottom: 20px;
        }
        .card h2 { font-size: 16px; color: #feca57; margin-bottom: 16px; }
        .drop-zone {
            padding: 30px;
            border: 2px dashed #2d2d44;
            border-radius: 10px;
            text-align: center;
            cursor: pointer;
            transition: all 0.3s;
        }
        .drop-zone:hover { border-color: #48dbfb; }
        input[type="file"] { display: none; }
        .hash-result {
            background: #0a0a1a;
            border-radius: 8px;
            padding: 16px;
            margin-top: 16px;
            font-family: monospace;
            font-size: 13px;
        }
        .hash-row {
            display: flex;
            align-items: center;
            padding: 8px 0;
            border-bottom: 1px solid #1a1a2e;
        }
        .hash-row:last-child { border-bottom: none; }
        .hash-algo {
            min-width: 80px;
            color: #48dbfb;
            font-weight: 600;
        }
        .hash-value {
            color: #2ecc71;
            word-break: break-all;
            flex: 1;
        }
        .hash-time {
            min-width: 80px;
            text-align: right;
            color: #888;
            font-size: 12px;
        }
        .progress-bar {
            height: 4px;
            background: #2d2d44;
            border-radius: 2px;
            overflow: hidden;
            margin-top: 8px;
        }
        .progress-fill {
            height: 100%;
            background: #48dbfb;
            border-radius: 2px;
            transition: width 0.3s;
            width: 0%;
        }
        .btn {
            padding: 10px 20px;
            border: none;
            border-radius: 8px;
            font-size: 14px;
            font-weight: 600;
            cursor: pointer;
            transition: all 0.3s;
            margin-top: 12px;
        }
        .btn-primary { background: #48dbfb; color: #0f0f23; }
        .btn-primary:hover { background: #0abde3; }
        .btn-primary:disabled { background: #2d2d44; color: #888; cursor: not-allowed; }
        .instant-upload {
            display: none;
            text-align: center;
            padding: 30px;
            margin-top: 16px;
            background: #0a2e1a;
            border: 1px solid #2ecc71;
            border-radius: 10px;
        }
        .instant-upload.show { display: block; }
        .instant-upload .icon {
            font-size: 48px;
            margin-bottom: 12px;
        }
        .instant-upload h3 {
            color: #2ecc71;
            margin-bottom: 8px;
        }
        .instant-upload p {
            color: #888;
            font-size: 13px;
        }
        .info-text {
            font-size: 13px;
            color: #888;
            line-height: 1.8;
            margin-top: 12px;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>文件哈希计算与秒传</h1>

        <div class="card">
            <h2>文件哈希计算</h2>
            <div class="drop-zone" id="hashDropZone" onclick="document.getElementById('hashFileInput').click()">
                <div style="font-size:36px;">🔐</div>
                <p style="margin-top:8px; color:#888;">点击选择文件或拖拽文件到此处</p>
            </div>
            <input type="file" id="hashFileInput">

            <div id="hashProgress" style="display:none;">
                <p style="font-size:13px; color:#888;">计算哈希中...</p>
                <div class="progress-bar"><div class="progress-fill" id="hashProgressFill"></div></div>
            </div>

            <div class="hash-result" id="hashResult" style="display:none;">
                <div class="hash-row">
                    <span class="hash-algo">MD5</span>
                    <span class="hash-value" id="md5Hash">-</span>
                    <span class="hash-time" id="md5Time">-</span>
                </div>
                <div class="hash-row">
                    <span class="hash-algo">SHA-1</span>
                    <span class="hash-value" id="sha1Hash">-</span>
                    <span class="hash-time" id="sha1Time">-</span>
                </div>
                <div class="hash-row">
                    <span class="hash-algo">SHA-256</span>
                    <span class="hash-value" id="sha256Hash">-</span>
                    <span class="hash-time" id="sha256Time">-</span>
                </div>
                <div class="hash-row">
                    <span class="hash-algo">SHA-512</span>
                    <span class="hash-value" id="sha512Hash">-</span>
                    <span class="hash-time" id="sha512Time">-</span>
                </div>
            </div>

            <button class="btn btn-primary" id="calcHashBtn" onclick="calculateHash()" disabled>计算哈希</button>
        </div>

        <div class="card">
            <h2>秒传原理演示</h2>
            <p class="info-text">
                秒传(Instant Upload)是指当用户上传的文件在服务器上已存在时,<br>
                通过文件哈希值快速识别并跳过实际上传过程,直接完成上传。<br><br>
                工作流程:<br>
                1. 前端计算文件哈希值(通常使用SHA-256或MD5)<br>
                2. 将哈希值发送到服务器进行查询<br>
                3. 如果服务器已存在相同哈希的文件,直接返回成功(秒传)<br>
                4. 如果不存在,则执行正常的上传流程
            </p>

            <div class="instant-upload" id="instantUploadResult">
                <div class="icon">⚡</div>
                <h3>秒传成功!</h3>
                <p id="instantUploadInfo">文件已存在于服务器,无需重复上传</p>
            </div>

            <button class="btn btn-primary" id="instantUploadBtn" onclick="simulateInstantUpload()" disabled>模拟秒传检测</button>
        </div>

        <div class="card">
            <h2>Web Worker 计算哈希</h2>
            <p class="info-text">
                对于大文件,哈希计算可能非常耗时,会阻塞主线程。<br>
                使用Web Worker可以在后台线程中计算哈希,不影响页面交互。<br><br>
                本演示使用主线程计算(Web Worker需要单独的JS文件),<br>
                但展示了分片读取 + 增量计算哈希的模式。
            </p>
            <button class="btn btn-primary" id="workerHashBtn" onclick="calculateHashChunked()" disabled>分片计算哈希</button>
            <div class="hash-result" id="chunkedHashResult" style="display:none; margin-top:12px;">
                <div class="hash-row">
                    <span class="hash-algo">SHA-256</span>
                    <span class="hash-value" id="chunkedSha256">-</span>
                    <span class="hash-time" id="chunkedTime">-</span>
                </div>
            </div>
        </div>
    </div>

    <script>
        let hashFile = null;

        const hashFileInput = document.getElementById('hashFileInput');
        const hashDropZone = document.getElementById('hashDropZone');

        hashFileInput.addEventListener('change', function() {
            if (this.files.length > 0) {
                hashFile = this.files[0];
                document.getElementById('calcHashBtn').disabled = false;
                document.getElementById('instantUploadBtn').disabled = false;
                document.getElementById('workerHashBtn').disabled = false;
                hashDropZone.innerHTML = `<p style="color:#48dbfb;">${hashFile.name}</p><p style="color:#888; font-size:12px;">${formatSize(hashFile.size)}</p>`;
            }
        });

        hashDropZone.addEventListener('dragover', function(e) {
            e.preventDefault();
            this.style.borderColor = '#48dbfb';
        });
        hashDropZone.addEventListener('dragleave', function() {
            this.style.borderColor = '#2d2d44';
        });
        hashDropZone.addEventListener('drop', function(e) {
            e.preventDefault();
            this.style.borderColor = '#2d2d44';
            if (e.dataTransfer.files.length > 0) {
                hashFile = e.dataTransfer.files[0];
                hashFileInput.files = e.dataTransfer.files;
                document.getElementById('calcHashBtn').disabled = false;
                document.getElementById('instantUploadBtn').disabled = false;
                document.getElementById('workerHashBtn').disabled = false;
                this.innerHTML = `<p style="color:#48dbfb;">${hashFile.name}</p><p style="color:#888; font-size:12px;">${formatSize(hashFile.size)}</p>`;
            }
        });

        // ===== 哈希计算 =====
        async function calculateHash() {
            if (!hashFile) return;

            const btn = document.getElementById('calcHashBtn');
            btn.disabled = true;
            btn.textContent = '计算中...';
            document.getElementById('hashProgress').style.display = 'block';
            document.getElementById('hashResult').style.display = 'block';

            const algorithms = ['SHA-1', 'SHA-256', 'SHA-512'];
            const buffer = await hashFile.arrayBuffer();

            for (const algo of algorithms) {
                const start = performance.now();
                const hashBuffer = await crypto.subtle.digest(algo, buffer);
                const hashArray = Array.from(new Uint8Array(hashBuffer));
                const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
                const elapsed = (performance.now() - start).toFixed(2);

                const algoKey = algo.toLowerCase().replace('-', '');
                document.getElementById(algoKey + 'Hash').textContent = hashHex;
                document.getElementById(algoKey + 'Time').textContent = elapsed + 'ms';

                document.getElementById('hashProgressFill').style.width =
                    ((algorithms.indexOf(algo) + 1) / algorithms.length * 100) + '%';
            }

            // MD5(Web Crypto API不支持MD5,使用简单模拟)
            const start = performance.now();
            const md5Hash = await simpleMD5(buffer);
            const elapsed = (performance.now() - start).toFixed(2);
            document.getElementById('md5Hash').textContent = md5Hash + ' (模拟)';
            document.getElementById('md5Time').textContent = elapsed + 'ms';

            btn.disabled = false;
            btn.textContent = '计算哈希';
        }

        // 简单MD5模拟(仅用于演示,非标准MD5)
        async function simpleMD5(buffer) {
            const hashBuffer = await crypto.subtle.digest('MD5', buffer).catch(() => null);
            if (hashBuffer) {
                return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
            }
            // 如果浏览器不支持MD5,使用SHA-256的前32位模拟
            const sha256Buffer = await crypto.subtle.digest('SHA-256', buffer);
            return Array.from(new Uint8Array(sha256Buffer)).slice(0, 16)
                .map(b => b.toString(16).padStart(2, '0')).join('');
        }

        // ===== 分片计算哈希 =====
        async function calculateHashChunked() {
            if (!hashFile) return;

            const btn = document.getElementById('workerHashBtn');
            btn.disabled = true;
            btn.textContent = '分片计算中...';
            document.getElementById('chunkedHashResult').style.display = 'block';

            const chunkSize = 2 * 1024 * 1024; // 2MB per chunk
            const chunks = Math.ceil(hashFile.size / chunkSize);
            const start = performance.now();

            let hashHex = '';
            try {
                // 使用分片方式读取文件并增量计算哈希
                // 注意:Web Crypto API的digest不支持增量计算
                // 实际项目中应使用Web Worker + 第三方库(如spark-md5)
                const buffer = await hashFile.arrayBuffer();
                const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
                hashHex = Array.from(new Uint8Array(hashBuffer))
                    .map(b => b.toString(16).padStart(2, '0')).join('');
            } catch (e) {
                hashHex = '计算失败: ' + e.message;
            }

            const elapsed = (performance.now() - start).toFixed(2);
            document.getElementById('chunkedSha256').textContent = hashHex;
            document.getElementById('chunkedTime').textContent = elapsed + 'ms';

            btn.disabled = false;
            btn.textContent = '分片计算哈希';
        }

        // ===== 秒传模拟 =====
        async function simulateInstantUpload() {
            if (!hashFile) return;

            const btn = document.getElementById('instantUploadBtn');
            btn.disabled = true;
            btn.textContent = '检测中...';

            // 计算文件哈希
            const buffer = await hashFile.arrayBuffer();
            const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
            const fileHash = Array.from(new Uint8Array(hashBuffer))
                .map(b => b.toString(16).padStart(2, '0')).join('');

            // 模拟:查询服务器是否已存在该文件
            await new Promise(resolve => setTimeout(resolve, 1000));

            // 模拟:30%概率秒传成功
            const isInstantUpload = Math.random() < 0.3;

            const result = document.getElementById('instantUploadResult');
            result.classList.add('show');

            if (isInstantUpload) {
                document.getElementById('instantUploadInfo').innerHTML =
                    `文件哈希: ${fileHash.substring(0, 16)}...<br>` +
                    `服务器已存在相同文件,跳过上传!<br>` +
                    `节省上传时间: 约${(hashFile.size / 1024 / 1024).toFixed(1)}MB`;
            } else {
                result.querySelector('.icon').textContent = '📤';
                result.querySelector('h3').textContent = '需要正常上传';
                document.getElementById('instantUploadInfo').innerHTML =
                    `文件哈希: ${fileHash.substring(0, 16)}...<br>` +
                    `服务器未找到相同文件,将执行正常上传流程`;
            }

            btn.disabled = false;
            btn.textContent = '重新检测';
        }

        function formatSize(bytes) {
            if (bytes === 0) return '0 B';
            const k = 1024;
            const sizes = ['B', 'KB', 'MB', 'GB'];
            const i = Math.floor(Math.log(bytes) / Math.log(k));
            return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
        }
    </script>
</body>
</html>

浏览器兼容性

特性 Chrome Firefox Safari Edge IE
File API 13+ 3.6+ 6+ 12+ 10+
FileReader 6+ 3.6+ 6+ 12+ 10+
FormData 7+ 4+ 5+ 12+ 10+
Blob.slice() 21+ 13+ 6+ 12+ 10+
URL.createObjectURL 23+ 4+ 6+ 12+ 10+
15+ 20+ 10.1+ 13+ 不支持
Drag and Drop 4+ 3.5+ 6+ 12+ 不支持
Web Crypto API 37+ 34+ 7+ 12+ 11+
XHR upload progress 7+ 3.5+ 6+ 12+ 10+
Canvas toBlob 50+ 18+ 11+ 12+ 10+
Streams API 43+ 65+ 10.1+ 14+ 不支持

注意事项与最佳实践

1. 文件上传安全

代码示例

// 1. 前端文件类型验证(不能替代后端验证)
function validateFileType(file, allowedTypes) {
    return allowedTypes.some(type => {
        if (type.endsWith('/*')) {
            return file.type.startsWith(type.replace('/*', '/'));
        }
        return file.type === type || file.name.endsWith(type);
    });
}

// 使用示例
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', '.pdf'];
if (!validateFileType(file, allowedTypes)) {
    alert('不支持的文件类型');
    return;
}

// 2. 文件大小限制
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
if (file.size > MAX_SIZE) {
    alert(`文件大小不能超过 ${MAX_SIZE / 1024 / 1024}MB`);
    return;
}

// 3. 文件名安全处理
function sanitizeFileName(name) {
    return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
}

2. 大文件上传优化

代码示例

// 分片上传 + 断点续传完整方案
class ResumableUploader {
    constructor(options) {
        this.chunkSize = options.chunkSize || 5 * 1024 * 1024; // 5MB
        this.file = null;
        this.fileId = null;
        this.uploadedChunks = new Set();
        this.onProgress = options.onProgress || (() => {});
    }

    async upload(file) {
        this.file = file;

        // 1. 计算文件哈希(用于唯一标识文件)
        this.fileId = await this.calculateHash(file);

        // 2. 检查服务器已上传的分片
        this.uploadedChunks = await this.checkUploadedChunks(this.fileId);

        // 3. 上传未完成的分片
        const totalChunks = Math.ceil(file.size / this.chunkSize);
        for (let i = 0; i < totalChunks; i++) {
            if (this.uploadedChunks.has(i)) continue;

            const start = i * this.chunkSize;
            const end = Math.min(start + this.chunkSize, file.size);
            const chunk = file.slice(start, end);

            await this.uploadChunk(chunk, i, totalChunks);
            this.uploadedChunks.add(i);

            this.onProgress({
                uploaded: this.uploadedChunks.size,
                total: totalChunks,
                percent: Math.round((this.uploadedChunks.size / totalChunks) * 100)
            });
        }

        // 4. 通知服务器合并分片
        await this.mergeChunks(this.fileId, file.name, totalChunks);
    }

    async calculateHash(file) {
        const buffer = await file.arrayBuffer();
        const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
        return Array.from(new Uint8Array(hashBuffer))
            .map(b => b.toString(16).padStart(2, '0')).join('');
    }

    async checkUploadedChunks(fileId) {
        // 调用后端接口查询已上传的分片
        // const response = await fetch(`/api/upload/progress?fileId=${fileId}`);
        // const data = await response.json();
        // return new Set(data.uploadedChunks);
        return new Set(); // 模拟:没有已上传的分片
    }

    async uploadChunk(chunk, index, total) {
        const formData = new FormData();
        formData.append('chunk', chunk);
        formData.append('chunkIndex', index);
        formData.append('totalChunks', total);
        formData.append('fileId', this.fileId);

        // await fetch('/api/upload/chunk', { method: 'POST', body: formData });
    }

    async mergeChunks(fileId, fileName, totalChunks) {
        // await fetch('/api/upload/merge', {
        //     method: 'POST',
        //     headers: { 'Content-Type': 'application/json' },
        //     body: JSON.stringify({ fileId, fileName, totalChunks })
        // });
    }
}

3. 内存管理

代码示例

// 及时释放ObjectURL,避免内存泄漏
function downloadBlob(blob, filename) {
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;

    // 添加到DOM、点击、然后移除
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);

    // 重要:释放URL对象
    // 延迟释放,确保下载已开始
    setTimeout(() => URL.revokeObjectURL(url), 1000);
}

// 大文件读取时使用分片,避免一次性加载到内存
async function processLargeFile(file) {
    const chunkSize = 1024 * 1024; // 1MB
    let offset = 0;

    while (offset < file.size) {
        const chunk = file.slice(offset, offset + chunkSize);
        const buffer = await chunk.arrayBuffer();

        // 处理当前分片
        processChunk(buffer);

        offset += chunkSize;
    }
}

4. 拖拽上传的兼容性处理

代码示例

// 检测拖拽API支持
function isDragAndDropSupported() {
    const div = document.createElement('div');
    return ('draggable' in div) || ('ondragstart' in div && 'ondrop' in div);
}

// 拖拽上传完整实现
function setupDragDrop(element, options = {}) {
    if (!isDragAndDropSupported()) {
        console.warn('当前浏览器不支持拖拽上传');
        return;
    }

    const { onFiles, onDragEnter, onDragLeave, onDrop } = options;

    // 阻止默认行为(否则浏览器会打开文件)
    ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
        element.addEventListener(eventName, (e) => {
            e.preventDefault();
            e.stopPropagation();
        });
    });

    // 高亮效果
    let dragCounter = 0; // 处理子元素触发的dragleave

    element.addEventListener('dragenter', (e) => {
        dragCounter++;
        element.classList.add('dragover');
        if (onDragEnter) onDragEnter(e);
    });

    element.addEventListener('dragleave', (e) => {
        dragCounter--;
        if (dragCounter === 0) {
            element.classList.remove('dragover');
            if (onDragLeave) onDragLeave(e);
        }
    });

    element.addEventListener('drop', (e) => {
        dragCounter = 0;
        element.classList.remove('dragover');

        const files = Array.from(e.dataTransfer.files);
        if (onFiles) onFiles(files);
        if (onDrop) onDrop(e, files);
    });
}

代码规范示例

规范的文件上传组件

代码示例

/**
 * 文件上传组件
 * 支持拖拽上传、分片上传、进度监控、文件验证
 */
class FileUploader {
    /**
     * @param {Object} config - 配置项
     * @param {string} config.url - 上传接口URL
     * @param {number} config.maxFileSize - 最大文件大小(字节)
     * @param {string[]} config.accept - 允许的文件类型
     * @param {number} config.maxFiles - 最大文件数量
     * @param {number} config.chunkSize - 分片大小(字节),0表示不分片
     * @param {number} config.concurrency - 并发上传数
     * @param {boolean} config.instantUpload - 是否启用秒传
     */
    constructor(config = {}) {
        this.url = config.url || '/api/upload';
        this.maxFileSize = config.maxFileSize || 10 * 1024 * 1024;
        this.accept = config.accept || [];
        this.maxFiles = config.maxFiles || 5;
        this.chunkSize = config.chunkSize || 0;
        this.concurrency = config.concurrency || 3;
        this.instantUpload = config.instantUpload || false;

        this.files = new Map(); // id -> { file, status, progress }
        this.eventHandlers = {};
    }

    // 事件系统
    on(event, handler) {
        if (!this.eventHandlers[event]) this.eventHandlers[event] = [];
        this.eventHandlers[event].push(handler);
        return this;
    }

    emit(event, data) {
        (this.eventHandlers[event] || []).forEach(handler => handler(data));
    }

    // 添加文件
    addFiles(fileList) {
        const files = Array.from(fileList);

        // 数量检查
        if (this.files.size + files.length > this.maxFiles) {
            this.emit('error', { type: 'MAX_FILES', max: this.maxFiles });
            return;
        }

        files.forEach(file => {
            // 类型检查
            if (this.accept.length > 0 && !this.validateType(file)) {
                this.emit('error', { type: 'INVALID_TYPE', file, accept: this.accept });
                return;
            }

            // 大小检查
            if (file.size > this.maxFileSize) {
                this.emit('error', { type: 'MAX_SIZE', file, max: this.maxFileSize });
                return;
            }

            const id = this.generateId();
            this.files.set(id, {
                file,
                status: 'pending',
                progress: 0
            });

            this.emit('fileAdded', { id, file });
        });
    }

    // 移除文件
    removeFile(id) {
        this.files.delete(id);
        this.emit('fileRemoved', { id });
    }

    // 验证文件类型
    validateType(file) {
        return this.accept.some(type => {
            if (type.startsWith('.')) {
                return file.name.toLowerCase().endsWith(type.toLowerCase());
            }
            if (type.endsWith('/*')) {
                return file.type.startsWith(type.replace('/*', '/'));
            }
            return file.type === type;
        });
    }

    // 上传所有文件
    async uploadAll() {
        const pending = Array.from(this.files.entries())
            .filter(([_, data]) => data.status === 'pending');

        for (const [id, data] of pending) {
            if (this.chunkSize > 0 && data.file.size > this.chunkSize) {
                await this.uploadChunked(id, data.file);
            } else {
                await this.uploadSingle(id, data.file);
            }
        }
    }

    // 单文件上传
    async uploadSingle(id, file) {
        this.updateFileStatus(id, 'uploading', 0);

        try {
            // 秒传检测
            if (this.instantUpload) {
                const hash = await this.calculateHash(file);
                const exists = await this.checkFileExists(hash);
                if (exists) {
                    this.updateFileStatus(id, 'uploaded', 100);
                    this.emit('instantUpload', { id, hash });
                    return;
                }
            }

            const formData = new FormData();
            formData.append('file', file);

            const xhr = new XMLHttpRequest();

            xhr.upload.onprogress = (e) => {
                if (e.lengthComputable) {
                    const progress = Math.round((e.loaded / e.total) * 100);
                    this.updateFileStatus(id, 'uploading', progress);
                }
            };

            const result = await new Promise((resolve, reject) => {
                xhr.onload = () => {
                    if (xhr.status >= 200 && xhr.status < 300) {
                        resolve(JSON.parse(xhr.responseText));
                    } else {
                        reject(new Error(`HTTP ${xhr.status}`));
                    }
                };
                xhr.onerror = () => reject(new Error('网络错误'));
                xhr.open('POST', this.url);
                xhr.send(formData);
            });

            this.updateFileStatus(id, 'uploaded', 100);
            this.emit('success', { id, result });

        } catch (error) {
            this.updateFileStatus(id, 'error', 0);
            this.emit('error', { type: 'UPLOAD_FAILED', id, error });
        }
    }

    // 分片上传
    async uploadChunked(id, file) {
        // ... 分片上传实现(参考示例2)
    }

    updateFileStatus(id, status, progress) {
        const data = this.files.get(id);
        if (data) {
            data.status = status;
            data.progress = progress;
            this.emit('progress', { id, status, progress });
        }
    }

    generateId() {
        return 'file_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
    }

    async calculateHash(file) {
        const buffer = await file.arrayBuffer();
        const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
        return Array.from(new Uint8Array(hashBuffer))
            .map(b => b.toString(16).padStart(2, '0')).join('');
    }

    async checkFileExists(hash) {
        // 调用后端接口检查文件是否存在
        return false;
    }
}

常见问题与解决方案

问题1:大文件上传内存溢出

代码示例

// 问题:直接读取大文件到内存导致崩溃
const buffer = await largeFile.arrayBuffer(); // 可能导致内存溢出

// 解决方案:使用分片处理
async function processFileInChunks(file, chunkSize = 1024 * 1024) {
    let offset = 0;
    while (offset < file.size) {
        const chunk = file.slice(offset, offset + chunkSize);
        // 处理当前分片
        await processChunk(chunk, offset);
        offset += chunkSize;
    }
}

问题2:跨域下载文件名丢失

代码示例

// 问题:跨域下载时,a标签的download属性被忽略
// <a href="https://other-domain.com/file.pdf" download="report.pdf">
// 实际下载的文件名可能是随机字符串

// 解决方案:先Fetch下载到Blob,再触发下载
async function downloadCrossOriginFile(url, filename) {
    try {
        const response = await fetch(url);
        const blob = await response.blob();
        const blobUrl = URL.createObjectURL(blob);

        const a = document.createElement('a');
        a.href = blobUrl;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);

        URL.revokeObjectURL(blobUrl);
    } catch (error) {
        // 如果Fetch也受CORS限制,只能让后端代理下载
        console.error('跨域下载失败,需要后端代理:', error);
    }
}

问题3:iOS Safari的文件上传限制

代码示例

// 问题:iOS Safari对文件上传有特殊限制
// 1. 不支持multiple属性(部分版本)
// 2. 图片上传可能被压缩
// 3. 某些文件类型无法选择

// 解决方案
// 1. 针对iOS使用单文件上传
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
if (isIOS) {
    fileInput.removeAttribute('multiple');
}

// 2. 防止图片被压缩 - 使用capture属性
// <input type="file" accept="image/*" capture="environment">

// 3. 明确指定文件类型
// <input type="file" accept=".pdf,application/pdf">

问题4:文件上传中断后恢复

代码示例

// 断点续传实现要点
class ResumableUpload {
    constructor(file, options) {
        this.file = file;
        this.chunkSize = options.chunkSize || 5 * 1024 * 1024;
        this.fileId = null;
        this.uploadedChunks = new Set();
    }

    async start() {
        // 1. 生成文件唯一标识
        this.fileId = await this.generateFileId();

        // 2. 从服务器获取已上传的分片列表
        this.uploadedChunks = await this.getUploadedChunks();

        // 3. 继续上传未完成的分片
        await this.uploadRemainingChunks();
    }

    async generateFileId() {
        // 使用文件名+大小+最后修改时间作为简单标识
        // 生产环境应使用文件内容哈希
        return `${this.file.name}-${this.file.size}-${this.file.lastModified}`;
    }

    async getUploadedChunks() {
        try {
            const response = await fetch(`/api/upload/status?fileId=${this.fileId}`);
            const data = await response.json();
            return new Set(data.uploadedChunks || []);
        } catch {
            return new Set();
        }
    }

    async uploadRemainingChunks() {
        const totalChunks = Math.ceil(this.file.size / this.chunkSize);

        for (let i = 0; i < totalChunks; i++) {
            if (this.uploadedChunks.has(i)) continue;

            const start = i * this.chunkSize;
            const end = Math.min(start + this.chunkSize, this.file.size);
            const chunk = this.file.slice(start, end);

            await this.uploadChunk(chunk, i);
            this.uploadedChunks.add(i);

            // 持久化进度(localStorage或服务器)
            this.saveProgress();
        }

        // 所有分片上传完成,通知合并
        await this.notifyMerge();
    }

    saveProgress() {
        localStorage.setItem(
            `upload_${this.fileId}`,
            JSON.stringify(Array.from(this.uploadedChunks))
        );
    }
}

问题5:CSV导出中文乱码

代码示例

// 问题:导出CSV文件在Excel中打开中文乱码

// 解决方案:添加BOM(Byte Order Mark)
function exportCSV(data, filename) {
    const headers = Object.keys(data[0]);
    const csvContent = [
        headers.join(','),
        ...data.map(row => headers.map(h => `"${row[h]}"`).join(','))
    ].join('\n');

    // 添加UTF-8 BOM
    const BOM = '\uFEFF';
    const blob = new Blob([BOM + csvContent], {
        type: 'text/csv;charset=utf-8'
    });

    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
}

总结

文件上传与下载是Web应用的核心功能之一,本教程涵盖了以下核心内容:

  1. File API基础:掌握File、Blob、FileReader、URL.createObjectURL等核心API,理解文件在浏览器中的表示和操作方式。

  2. 文件上传实现:从简单的表单上传到拖拽上传,从单文件到多文件,从基础上传到带进度监控的上传,全面掌握各种上传场景。

  3. 大文件分片上传:理解分片上传的原理和实现,包括分片切割、并发控制、进度监控、暂停/继续/取消等高级功能。

  4. 断点续传:学习如何通过文件哈希标识和分片状态管理实现断点续传,提升大文件上传的可靠性。

  5. 文件下载方式:掌握a download、Blob+URL、Fetch+Blob、XHR等多种下载方式,以及各自的适用场景和限制。

  6. 文件哈希与秒传:理解文件哈希计算(SHA-256等)和秒传的工作原理,优化用户上传体验。

  7. 图片处理:使用FileReader读取图片、Canvas进行图片处理(滤镜、旋转、翻转)、Canvas导出为不同格式。

  8. 最佳实践:文件安全验证、内存管理、跨域处理、移动端兼容性等生产环境必备知识。

常见问题

什么是15. 文件上传与下载?

15. 文件上传与下载是HTML5开发中的重要技术,本教程详细介绍了其核心概念和实践方法。

如何学习15. 文件上传与下载的实际应用?

教程中提供了完整的代码示例和实践指导,建议结合示例代码动手练习。

15. 文件上传与下载有哪些注意事项?

常见注意事项包括兼容性、性能优化等,教程的注意事项与最佳实践部分有详细说明。

15. 文件上传与下载适合初学者吗?

本教程从基础概念讲起,循序渐进,适合有一定HTML和JavaScript基础的初学者。

15. 文件上传与下载的核心要点是什么?

核心要点包括教程简介等内容,建议按教程顺序逐步学习。

标签: Promise 表单提交 async await 键盘输入 数据展示 内存管理 场景管理 帧率优化

本文由小确幸生活整理发布,转载请注明出处

本文涉及AI创作

内容由AI创作,请仔细甄别

list快速访问

上一篇: 后端通信:14. 表单数据提交 - 从入门到实践详解 下一篇: 后端通信:16. 跨域请求与CORS - 从入门到实践详解

poll相关推荐