pin_drop当前位置:知识文库 ❯ 图文
后端通信:15. 文件上传与下载 - 从入门到实践详解
教程简介
文件上传与下载是Web应用中常见的功能需求,从用户头像上传到大型文件传输,从前端预览到后端存储,涉及的技术细节远比想象中复杂。本教程将全面讲解浏览器端的文件上传与下载技术,包括File API、拖拽上传、分片上传、断点续传、文件下载方式、进度监控等核心内容,帮助你构建高性能、用户友好的文件传输体验。
无论是简单的表单文件上传,还是复杂的大文件分片传输,理解底层原理和最佳实践都是构建可靠文件传输功能的关键。
核心概念
文件相关API层次结构
代码示例
Blob (二进制大对象)
File (带文件名的Blob)
FileList (文件列表)
FileReader (读取文件)
readAsArrayBuffer()
readAsDataURL()
readAsText()
readAsBinaryString()
URL (创建临时URL)
createObjectURL(blob)
revokeObjectURL(url)文件上传方式对比
文件下载方式对比
语法与用法
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); // 读取为二进制字符串拖拽上传核心事件
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>浏览器兼容性
注意事项与最佳实践
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应用的核心功能之一,本教程涵盖了以下核心内容:
File API基础:掌握File、Blob、FileReader、URL.createObjectURL等核心API,理解文件在浏览器中的表示和操作方式。
文件上传实现:从简单的表单上传到拖拽上传,从单文件到多文件,从基础上传到带进度监控的上传,全面掌握各种上传场景。
大文件分片上传:理解分片上传的原理和实现,包括分片切割、并发控制、进度监控、暂停/继续/取消等高级功能。
断点续传:学习如何通过文件哈希标识和分片状态管理实现断点续传,提升大文件上传的可靠性。
文件下载方式:掌握a download、Blob+URL、Fetch+Blob、XHR等多种下载方式,以及各自的适用场景和限制。
文件哈希与秒传:理解文件哈希计算(SHA-256等)和秒传的工作原理,优化用户上传体验。
图片处理:使用FileReader读取图片、Canvas进行图片处理(滤镜、旋转、翻转)、Canvas导出为不同格式。
最佳实践:文件安全验证、内存管理、跨域处理、移动端兼容性等生产环境必备知识。
常见问题
什么是15. 文件上传与下载?
15. 文件上传与下载是HTML5开发中的重要技术,本教程详细介绍了其核心概念和实践方法。
如何学习15. 文件上传与下载的实际应用?
教程中提供了完整的代码示例和实践指导,建议结合示例代码动手练习。
15. 文件上传与下载有哪些注意事项?
常见注意事项包括兼容性、性能优化等,教程的注意事项与最佳实践部分有详细说明。
15. 文件上传与下载适合初学者吗?
本教程从基础概念讲起,循序渐进,适合有一定HTML和JavaScript基础的初学者。
15. 文件上传与下载的核心要点是什么?
核心要点包括教程简介等内容,建议按教程顺序逐步学习。
本文由小确幸生活整理发布,转载请注明出处
本文涉及AI创作
内容由AI创作,请仔细甄别