实现文件上传
大约 8 分钟
文件上传是Web应用中的常见功能,实现起来需要考虑多个方面。让我详细讲解一下文件上传的实现思路和最佳实践。
1. 基本文件上传实现
前端实现
HTML 表单上传
<!-- 简单的文件上传表单 -->
<form action="/upload" method="POST" enctype="multipart/form-data">
<input type="file" name="file" accept="image/*" required />
<input type="text" name="description" placeholder="文件描述" />
<button type="submit">上传文件</button>
</form>使用 JavaScript 实现
// 使用原生 JavaScript 实现文件上传
class FileUploader {
constructor(options = {}) {
this.url = options.url || '/upload';
this.maxFileSize = options.maxFileSize || 10 * 1024 * 1024; // 10MB
this.allowedTypes = options.allowedTypes || ['image/jpeg', 'image/png', 'image/gif'];
this.onProgress = options.onProgress || (() => {});
this.onSuccess = options.onSuccess || (() => {});
this.onError = options.onError || (() => {});
}
async upload(file, additionalData = {}) {
// 验证文件
if (!this.validateFile(file)) {
return;
}
const formData = new FormData();
formData.append('file', file);
// 添加额外数据
Object.keys(additionalData).forEach(key => {
formData.append(key, additionalData[key]);
});
try {
const xhr = new XMLHttpRequest();
// 监听上传进度
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
this.onProgress(percentComplete);
}
});
// 监听完成事件
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
this.onSuccess(response);
} else {
this.onError(new Error(`Upload failed with status ${xhr.status}`));
}
});
// 监听错误事件
xhr.addEventListener('error', () => {
this.onError(new Error('Upload failed'));
});
xhr.open('POST', this.url);
xhr.send(formData);
} catch (error) {
this.onError(error);
}
}
validateFile(file) {
// 检查文件大小
if (file.size > this.maxFileSize) {
this.onError(new Error(`文件大小不能超过 ${this.maxFileSize / 1024 / 1024}MB`));
return false;
}
// 检查文件类型
if (!this.allowedTypes.includes(file.type)) {
this.onError(new Error('不支持的文件类型'));
return false;
}
return true;
}
}
// 使用示例
const uploader = new FileUploader({
url: '/api/upload',
maxFileSize: 5 * 1024 * 1024, // 5MB
allowedTypes: ['image/jpeg', 'image/png'],
onProgress: (percent) => {
console.log(`上传进度: ${percent.toFixed(2)}%`);
},
onSuccess: (response) => {
console.log('上传成功:', response);
},
onError: (error) => {
console.error('上传失败:', error.message);
}
});
// 监听文件选择
document.getElementById('fileInput').addEventListener('change', (event) => {
const file = event.target.files[0];
if (file) {
uploader.upload(file, { description: '用户上传的图片' });
}
});React 组件实现
import React, { useState, useRef } from 'react';
const FileUpload = ({
onUploadSuccess,
onUploadError,
maxFileSize = 10 * 1024 * 1024,
allowedTypes = ['image/jpeg', 'image/png', 'image/gif']
}) => {
const [file, setFile] = useState(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [preview, setPreview] = useState(null);
const fileInputRef = useRef(null);
const validateFile = (selectedFile) => {
// 检查文件大小
if (selectedFile.size > maxFileSize) {
throw new Error(`文件大小不能超过 ${maxFileSize / 1024 / 1024}MB`);
}
// 检查文件类型
if (!allowedTypes.includes(selectedFile.type)) {
throw new Error('不支持的文件类型');
}
return true;
};
const handleFileChange = (event) => {
const selectedFile = event.target.files[0];
if (!selectedFile) return;
try {
validateFile(selectedFile);
setFile(selectedFile);
// 生成预览
if (selectedFile.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
setPreview(e.target.result);
};
reader.readAsDataURL(selectedFile);
}
} catch (error) {
onUploadError && onUploadError(error.message);
}
};
const handleUpload = async () => {
if (!file) {
onUploadError && onUploadError('请选择文件');
return;
}
setUploading(true);
setProgress(0);
const formData = new FormData();
formData.append('file', file);
formData.append('fileName', file.name);
formData.append('fileSize', file.size);
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
// 监听上传进度
signal: AbortSignal.timeout(30000) // 30秒超时
});
if (!response.ok) {
throw new Error(`上传失败: ${response.statusText}`);
}
const result = await response.json();
setUploading(false);
onUploadSuccess && onUploadSuccess(result);
} catch (error) {
setUploading(false);
onUploadError && onUploadError(error.message);
}
};
const handleDragOver = (event) => {
event.preventDefault();
};
const handleDrop = (event) => {
event.preventDefault();
const droppedFile = event.dataTransfer.files[0];
if (droppedFile) {
fileInputRef.current.files = event.dataTransfer.files;
handleFileChange({ target: { files: event.dataTransfer.files } });
}
};
return (
<div
className="file-upload"
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div className="upload-area">
<input
ref={fileInputRef}
type="file"
onChange={handleFileChange}
style={{ display: 'none' }}
accept={allowedTypes.join(',')}
/>
{preview ? (
<div className="preview">
<img src={preview} alt="预览" />
<button onClick={() => {
setPreview(null);
setFile(null);
fileInputRef.current.value = '';
}}>
重新选择
</button>
</div>
) : (
<div className="drop-zone">
<p>拖拽文件到此处或点击选择文件</p>
<button onClick={() => fileInputRef.current.click()}>
选择文件
</button>
</div>
)}
</div>
{file && (
<div className="file-info">
<p>文件名: {file.name}</p>
<p>文件大小: {(file.size / 1024 / 1024).toFixed(2)} MB</p>
<p>文件类型: {file.type}</p>
</div>
)}
{uploading && (
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${progress}%` }}
></div>
<span>{progress.toFixed(0)}%</span>
</div>
)}
<button
onClick={handleUpload}
disabled={!file || uploading}
className="upload-button"
>
{uploading ? '上传中...' : '上传文件'}
</button>
</div>
);
};
export default FileUpload;2. 后端实现
使用 Express 和 Multer
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const app = express();
// 配置文件存储
const storage = multer.diskStorage({
destination: (req, file, cb) => {
// 确保上传目录存在
const uploadDir = 'uploads/';
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: (req, file, cb) => {
// 生成唯一文件名
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const extension = path.extname(file.originalname);
cb(null, file.fieldname + '-' + uniqueSuffix + extension);
}
});
// 文件过滤器
const fileFilter = (req, file, cb) => {
// 允许的文件类型
const allowedTypes = /jpeg|jpg|png|gif|pdf|doc|docx/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (mimetype && extname) {
return cb(null, true);
} else {
cb(new Error('不支持的文件类型'));
}
};
// 创建 multer 实例
const upload = multer({
storage: storage,
limits: {
fileSize: 10 * 1024 * 1024, // 10MB
files: 5 // 最多5个文件
},
fileFilter: fileFilter
});
// 单文件上传
app.post('/upload', upload.single('file'), (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: '没有文件被上传' });
}
// 保存文件信息到数据库(示例)
const fileInfo = {
originalName: req.file.originalname,
fileName: req.file.filename,
path: req.file.path,
size: req.file.size,
mimeType: req.file.mimetype,
uploadedAt: new Date()
};
// 这里应该保存到数据库
console.log('文件上传成功:', fileInfo);
res.json({
success: true,
message: '文件上传成功',
file: fileInfo
});
} catch (error) {
console.error('上传错误:', error);
res.status(500).json({ error: '文件上传失败' });
}
});
// 多文件上传
app.post('/upload-multiple', upload.array('files', 5), (req, res) => {
try {
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: '没有文件被上传' });
}
const fileInfos = req.files.map(file => ({
originalName: file.originalname,
fileName: file.filename,
path: file.path,
size: file.size,
mimeType: file.mimetype,
uploadedAt: new Date()
}));
res.json({
success: true,
message: `${req.files.length} 个文件上传成功`,
files: fileInfos
});
} catch (error) {
console.error('上传错误:', error);
res.status(500).json({ error: '文件上传失败' });
}
});
// 带字段的文件上传
app.post('/upload-with-fields', upload.single('file'), (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: '没有文件被上传' });
}
const fileInfo = {
originalName: req.file.originalname,
fileName: req.file.filename,
path: req.file.path,
size: req.file.size,
mimeType: req.file.mimetype,
description: req.body.description || '',
category: req.body.category || '',
uploadedAt: new Date()
};
res.json({
success: true,
message: '文件上传成功',
file: fileInfo
});
} catch (error) {
console.error('上传错误:', error);
res.status(500).json({ error: '文件上传失败' });
}
});3. 高级功能实现
断点续传
// 前端实现断点续传
class ResumableUpload {
constructor(options) {
this.file = options.file;
this.url = options.url;
this.chunkSize = options.chunkSize || 1024 * 1024; // 1MB
this.onProgress = options.onProgress || (() => {});
this.onSuccess = options.onSuccess || (() => {});
this.onError = options.onError || (() => {});
}
async upload() {
const totalChunks = Math.ceil(this.file.size / this.chunkSize);
let uploadedChunks = 0;
// 检查已上传的块
const uploadedInfo = await this.getUploadedChunks();
for (let i = 0; i < totalChunks; i++) {
// 跳过已上传的块
if (uploadedInfo.chunks.includes(i)) {
uploadedChunks++;
continue;
}
const start = i * this.chunkSize;
const end = Math.min(start + this.chunkSize, this.file.size);
const chunk = this.file.slice(start, end);
try {
await this.uploadChunk(chunk, i, totalChunks);
uploadedChunks++;
this.onProgress((uploadedChunks / totalChunks) * 100);
} catch (error) {
this.onError(error);
return;
}
}
// 合并文件
await this.mergeChunks();
this.onSuccess();
}
async getUploadedChunks() {
const response = await fetch(`${this.url}/chunks/${this.file.name}`);
return await response.json();
}
async uploadChunk(chunk, chunkIndex, totalChunks) {
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('chunkIndex', chunkIndex);
formData.append('fileName', this.file.name);
formData.append('totalChunks', totalChunks);
const response = await fetch(`${this.url}/chunk`, {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`Chunk ${chunkIndex} upload failed`);
}
}
async mergeChunks() {
const response = await fetch(`${this.url}/merge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
fileName: this.file.name
})
});
if (!response.ok) {
throw new Error('Merge chunks failed');
}
}
}后端断点续传支持
// 断点续传后端实现
const express = require('express');
const fs = require('fs').promises;
const path = require('path');
const app = express();
const CHUNKS_DIR = 'chunks/';
const UPLOADS_DIR = 'uploads/';
// 获取已上传的块信息
app.get('/upload/chunks/:fileName', async (req, res) => {
try {
const fileName = req.params.fileName;
const chunksDir = path.join(CHUNKS_DIR, fileName);
let chunks = [];
try {
const files = await fs.readdir(chunksDir);
chunks = files.map(file => parseInt(file.split('-')[1]));
} catch (error) {
// 目录不存在,返回空数组
}
res.json({ chunks });
} catch (error) {
res.status(500).json({ error: '获取块信息失败' });
}
});
// 上传块
app.post('/upload/chunk', upload.single('chunk'), async (req, res) => {
try {
const { chunkIndex, fileName, totalChunks } = req.body;
const chunkFile = req.file;
const chunksDir = path.join(CHUNKS_DIR, fileName);
await fs.mkdir(chunksDir, { recursive: true });
const chunkPath = path.join(chunksDir, `chunk-${chunkIndex}`);
await fs.rename(chunkFile.path, chunkPath);
res.json({ success: true });
} catch (error) {
console.error('块上传失败:', error);
res.status(500).json({ error: '块上传失败' });
}
});
// 合并块
app.post('/upload/merge', async (req, res) => {
try {
const { fileName } = req.body;
const chunksDir = path.join(CHUNKS_DIR, fileName);
const targetPath = path.join(UPLOADS_DIR, fileName);
// 读取所有块
const chunkFiles = await fs.readdir(chunksDir);
chunkFiles.sort((a, b) => {
const indexA = parseInt(a.split('-')[1]);
const indexB = parseInt(b.split('-')[1]);
return indexA - indexB;
});
// 合并文件
const writeStream = fs.createWriteStream(targetPath);
for (const chunkFile of chunkFiles) {
const chunkPath = path.join(chunksDir, chunkFile);
const chunkBuffer = await fs.readFile(chunkPath);
writeStream.write(chunkBuffer);
}
writeStream.end();
// 删除临时块文件
await fs.rm(chunksDir, { recursive: true });
res.json({ success: true, path: targetPath });
} catch (error) {
console.error('合并失败:', error);
res.status(500).json({ error: '文件合并失败' });
}
});4. 安全和优化
文件安全检查
// 文件安全验证中间件
const fileSecurity = {
// 检查文件内容类型
async validateFileType(fileBuffer, expectedTypes) {
const fileType = await import('file-type');
const type = await fileType.fileTypeFromBuffer(fileBuffer);
if (!expectedTypes.includes(type.mime)) {
throw new Error('文件类型不匹配');
}
return type;
},
// 病毒扫描(示例)
async scanForVirus(filePath) {
// 这里应该集成实际的病毒扫描服务
console.log(`扫描文件: ${filePath}`);
return { isInfected: false };
},
// 生成安全的文件名
generateSafeFileName(originalName) {
const extension = path.extname(originalName);
const safeName = originalName
.replace(/[^a-zA-Z0-9.-]/g, '_')
.substring(0, 100); // 限制长度
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}${extension}`;
}
};
// 使用安全检查
app.post('/secure-upload', upload.single('file'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: '没有文件被上传' });
}
// 读取文件内容进行验证
const fileBuffer = await fs.readFile(req.file.path);
// 验证文件类型
await fileSecurity.validateFileType(
fileBuffer,
['image/jpeg', 'image/png', 'image/gif']
);
// 病毒扫描
const scanResult = await fileSecurity.scanForVirus(req.file.path);
if (scanResult.isInfected) {
await fs.unlink(req.file.path); // 删除感染文件
return res.status(400).json({ error: '文件包含恶意内容' });
}
res.json({
success: true,
message: '文件上传成功',
file: {
originalName: req.file.originalname,
fileName: req.file.filename,
path: req.file.path
}
});
} catch (error) {
console.error('安全检查失败:', error);
// 清理上传的文件
if (req.file && req.file.path) {
try {
await fs.unlink(req.file.path);
} catch (unlinkError) {
console.error('删除文件失败:', unlinkError);
}
}
res.status(500).json({ error: error.message || '文件上传失败' });
}
});云存储集成
// 集成 AWS S3
const aws = require('aws-sdk');
const multerS3 = require('multer-s3');
const s3 = new aws.S3({
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
region: process.env.AWS_REGION
});
const s3Storage = multerS3({
s3: s3,
bucket: 'my-bucket',
metadata: function (req, file, cb) {
cb(null, { fieldName: file.fieldname });
},
key: function (req, file, cb) {
cb(null, Date.now().toString() + '-' + file.originalname);
}
});
const s3Upload = multer({ storage: s3Storage });
app.post('/s3-upload', s3Upload.single('file'), (req, res) => {
res.json({
success: true,
file: {
location: req.file.location,
key: req.file.key,
bucket: req.file.bucket
}
});
});总结
文件上传的完整实现思路包括:
前端实现:
- 文件选择和验证
- 上传进度显示
- 拖拽上传支持
- 预览功能
后端实现:
- 使用 multer 处理文件上传
- 文件存储和管理
- 错误处理和验证
高级功能:
- 断点续传
- 多文件上传
- 并发控制
安全和优化:
- 文件类型验证
- 大小限制
- 病毒扫描
- 云存储集成
用户体验:
- 进度条显示
- 错误提示
- 取消上传功能
通过这样的设计,可以创建一个功能完整、安全可靠的文件上传系统。