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相关推荐