1. 网页文件夹上传功能概述
在现代Web应用中,文件上传是一个常见需求。传统的文件上传只能选择单个文件,而文件夹上传功能允许用户直接选择整个文件夹及其子目录结构,极大提升了批量文件处理的效率。本文将详细介绍如何使用JavaScript实现这一功能。
文件夹上传的核心在于HTML5的File API和Directory Upload API。通过<input type="file" webkitdirectory>属性,浏览器可以访问用户选择的文件夹内容。这项技术特别适用于网盘、图片库管理等需要批量上传的场景。
2. 基础实现方案
2.1 HTML设置
首先创建一个带特殊属性的文件输入框:
html复制<input type="file" id="folderUpload" webkitdirectory directory multiple>
关键属性说明:
webkitdirectory:Chrome/Edge等基于WebKit的浏览器支持directory:Firefox的等效属性multiple:允许选择多个项目(虽然选择的是文件夹)
2.2 JavaScript事件处理
监听change事件获取文件列表:
javascript复制document.getElementById('folderUpload').addEventListener('change', function(event) {
const files = event.target.files;
processFiles(files);
});
function processFiles(files) {
for (let i = 0; i < files.length; i++) {
const file = files[i];
console.log('文件路径:', file.webkitRelativePath);
console.log('文件名:', file.name);
console.log('文件大小:', file.size);
}
}
每个File对象都包含webkitRelativePath属性,记录了文件在文件夹中的相对路径,如"documents/work/project1/spec.docx"。
3. 完整实现方案
3.1 前端界面构建
创建一个完整的上传界面:
html复制<div class="upload-container">
<label for="folderUpload" class="upload-button">
<svg>...</svg>
<span>选择文件夹</span>
</label>
<input type="file" id="folderUpload" webkitdirectory directory multiple>
<div class="file-list" id="fileList"></div>
<button id="uploadBtn" disabled>开始上传</button>
<div class="progress-container">
<div class="progress-bar" id="progressBar"></div>
</div>
</div>
3.2 文件列表展示
改进processFiles函数,显示文件树:
javascript复制function processFiles(files) {
const fileList = document.getElementById('fileList');
fileList.innerHTML = '';
const fileTree = {};
// 构建文件树结构
files.forEach(file => {
const pathParts = file.webkitRelativePath.split('/');
let currentLevel = fileTree;
pathParts.forEach((part, index) => {
if (!currentLevel[part]) {
currentLevel[part] = index === pathParts.length - 1
? file
: {};
}
currentLevel = currentLevel[part];
});
});
// 渲染文件树
renderFileTree(fileTree, fileList);
document.getElementById('uploadBtn').disabled = false;
}
function renderFileTree(tree, container, indent = 0) {
for (const key in tree) {
const item = tree[key];
const itemElement = document.createElement('div');
itemElement.style.paddingLeft = `${indent * 15}px`;
if (item instanceof File) {
itemElement.innerHTML = `
<span class="file">
<i class="file-icon"></i>
${key} (${formatFileSize(item.size)})
</span>
`;
} else {
itemElement.innerHTML = `
<span class="folder">
<i class="folder-icon"></i>
${key}/
</span>
`;
renderFileTree(item, itemElement, indent + 1);
}
container.appendChild(itemElement);
}
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
4. 文件上传实现
4.1 分块上传策略
对于大文件或大量文件,建议使用分块上传:
javascript复制async function uploadFiles(files) {
const progressBar = document.getElementById('progressBar');
let uploadedSize = 0;
let totalSize = Array.from(files).reduce((sum, file) => sum + file.size, 0);
for (const file of files) {
const chunkSize = 5 * 1024 * 1024; // 5MB分块
const chunks = Math.ceil(file.size / chunkSize);
const fileName = encodeURIComponent(file.webkitRelativePath);
for (let i = 0; i < chunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk);
formData.append('name', fileName);
formData.append('chunkIndex', i);
formData.append('totalChunks', chunks);
try {
await fetch('/upload', {
method: 'POST',
body: formData
});
uploadedSize += chunk.size;
const progress = Math.round((uploadedSize / totalSize) * 100);
progressBar.style.width = `${progress}%`;
progressBar.setAttribute('aria-valuenow', progress);
} catch (error) {
console.error(`上传失败: ${fileName} 分块 ${i}`, error);
return;
}
}
}
// 所有分块上传完成后通知服务器合并
await fetch('/complete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
files: Array.from(files).map(file => ({
name: file.webkitRelativePath,
size: file.size,
type: file.type
}))
})
});
alert('所有文件上传完成!');
}
4.2 服务器端处理
Node.js示例(使用Express):
javascript复制const express = require('express');
const fs = require('fs');
const path = require('path');
const multer = require('multer');
const app = express();
const upload = multer({ dest: 'uploads/' });
// 处理分块上传
app.post('/upload', upload.single('file'), (req, res) => {
const { name, chunkIndex, totalChunks } = req.body;
const tempDir = path.join(__dirname, 'temp', name);
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const tempFilePath = path.join(tempDir, chunkIndex);
fs.renameSync(req.file.path, tempFilePath);
res.status(200).send('Chunk uploaded');
});
// 合并分块
app.post('/complete', express.json(), (req, res) => {
const { files } = req.body;
files.forEach(fileInfo => {
const tempDir = path.join(__dirname, 'temp', fileInfo.name);
const outputPath = path.join(__dirname, 'uploads', fileInfo.name);
const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const writeStream = fs.createWriteStream(outputPath);
const chunks = fs.readdirSync(tempDir).sort((a, b) => a - b);
chunks.forEach(chunk => {
const chunkPath = path.join(tempDir, chunk);
const chunkData = fs.readFileSync(chunkPath);
writeStream.write(chunkData);
fs.unlinkSync(chunkPath);
});
writeStream.end();
fs.rmdirSync(tempDir);
});
res.status(200).send('Files merged');
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
5. 高级功能实现
5.1 拖放文件夹上传
增强用户体验,支持拖放操作:
javascript复制const dropArea = document.getElementById('dropArea');
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropArea.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
dropArea.addEventListener(eventName, highlight, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropArea.addEventListener(eventName, unhighlight, false);
});
function highlight() {
dropArea.classList.add('highlight');
}
function unhighlight() {
dropArea.classList.remove('highlight');
}
dropArea.addEventListener('drop', handleDrop, false);
function handleDrop(e) {
const dt = e.dataTransfer;
const items = dt.items;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file' && item.webkitGetAsEntry) {
const entry = item.webkitGetAsEntry();
if (entry.isDirectory) {
readDirectory(entry);
}
}
}
}
function readDirectory(directory) {
const reader = directory.createReader();
const files = [];
const readEntries = () => {
reader.readEntries(entries => {
if (entries.length === 0) {
processFiles(files);
} else {
entries.forEach(entry => {
if (entry.isFile) {
entry.file(file => {
file.webkitRelativePath = entry.fullPath;
files.push(file);
});
} else if (entry.isDirectory) {
readDirectory(entry);
}
});
readEntries();
}
});
};
readEntries();
}
5.2 文件过滤与验证
添加文件类型和大小限制:
javascript复制function validateFiles(files) {
const MAX_SIZE = 100 * 1024 * 1024; // 100MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'application/pdf'];
const invalidFiles = [];
for (const file of files) {
if (file.size > MAX_SIZE) {
invalidFiles.push({
file,
reason: '大小超过限制'
});
}
if (!ALLOWED_TYPES.includes(file.type)) {
invalidFiles.push({
file,
reason: '类型不支持'
});
}
}
if (invalidFiles.length > 0) {
showValidationErrors(invalidFiles);
return false;
}
return true;
}
function showValidationErrors(invalidFiles) {
const errorList = invalidFiles.map(item =>
`${item.file.webkitRelativePath}: ${item.reason}`
).join('\n');
alert(`以下文件存在问题:\n\n${errorList}`);
}
6. 性能优化与安全
6.1 上传优化技巧
- 并发控制:限制同时上传的文件数量
javascript复制async function uploadWithConcurrency(files, maxConcurrent = 3) {
const batches = [];
for (let i = 0; i < files.length; i += maxConcurrent) {
batches.push(files.slice(i, i + maxConcurrent));
}
for (const batch of batches) {
await Promise.all(batch.map(uploadFile));
}
}
- 断点续传:记录已上传的分块
javascript复制function getUploadedChunks(fileName) {
const tempDir = path.join(__dirname, 'temp', fileName);
if (fs.existsSync(tempDir)) {
return fs.readdirSync(tempDir).map(Number).sort((a, b) => a - b);
}
return [];
}
6.2 安全注意事项
- 文件类型验证:不要依赖客户端验证
javascript复制// 服务器端验证
function isFileTypeAllowed(file) {
const ALLOWED_EXTENSIONS = ['.jpg', '.png', '.pdf'];
const ext = path.extname(file.originalname).toLowerCase();
return ALLOWED_EXTENSIONS.includes(ext);
}
- 文件路径安全:防止目录遍历攻击
javascript复制function sanitizePath(relativePath) {
return path.normalize(relativePath).replace(/^(\.\.(\/|\\|$))+/, '');
}
- 设置上传目录权限:确保上传目录不可执行
7. 浏览器兼容性与降级方案
7.1 兼容性检测
javascript复制function isFolderUploadSupported() {
const input = document.createElement('input');
input.type = 'file';
return 'webkitdirectory' in input || 'directory' in input;
}
if (!isFolderUploadSupported()) {
showFallbackUI();
}
function showFallbackUI() {
const uploadContainer = document.getElementById('uploadContainer');
uploadContainer.innerHTML = `
<div class="alert">
<p>您的浏览器不支持文件夹上传功能</p>
<button id="multiFileUpload">选择多个文件</button>
</div>
`;
document.getElementById('multiFileUpload').addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.click();
input.onchange = () => {
processFiles(input.files);
};
});
}
7.2 使用第三方库
对于需要更广泛兼容性的项目,可以考虑使用以下库:
这些库提供了统一的API,处理了各种浏览器的差异。
8. 实际应用中的问题与解决方案
8.1 常见问题排查
-
webkitRelativePath为空
- 确保设置了webkitdirectory/directory属性
- 检查浏览器是否支持该功能
- 在拖放操作中,确保正确处理DataTransferItem
-
大文件夹处理缓慢
- 实现虚拟滚动,不立即渲染所有文件
- 使用Web Worker处理文件列表
- 分批次处理文件
-
内存不足
- 避免同时读取多个大文件到内存
- 使用FileReader的readAsArrayBuffer分块读取
8.2 调试技巧
javascript复制// 在控制台检查文件信息
document.getElementById('folderUpload').addEventListener('change', function(e) {
console.log('Files:', e.target.files);
Array.from(e.target.files).forEach(file => {
console.log('File:', file.name, file.webkitRelativePath);
});
});
// 性能分析
console.time('Process files');
processFiles(files);
console.timeEnd('Process files');
9. 完整示例代码
以下是一个完整的文件夹上传组件实现:
html复制<!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>
.upload-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
font-family: Arial, sans-serif;
}
.upload-button {
display: inline-block;
padding: 12px 24px;
background: #4285f4;
color: white;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: background 0.3s;
}
.upload-button:hover {
background: #3367d6;
}
#folderUpload {
display: none;
}
.file-list {
margin: 20px 0;
border: 1px solid #ddd;
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
padding: 10px;
}
.file, .folder {
display: block;
padding: 5px 0;
}
.folder {
font-weight: bold;
margin-top: 10px;
}
#uploadBtn {
padding: 10px 20px;
background: #34a853;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
#uploadBtn:disabled {
background: #ccc;
cursor: not-allowed;
}
.progress-container {
margin-top: 20px;
height: 10px;
background: #f1f1f1;
border-radius: 5px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: #4285f4;
width: 0%;
transition: width 0.3s;
}
#dropArea {
border: 2px dashed #ccc;
padding: 40px;
text-align: center;
margin-bottom: 20px;
border-radius: 4px;
}
#dropArea.highlight {
border-color: #4285f4;
background: #f8f9fa;
}
</style>
</head>
<body>
<div class="upload-container">
<div id="dropArea">
<p>拖放文件夹到此处或</p>
<label for="folderUpload" class="upload-button">
选择文件夹
</label>
</div>
<input type="file" id="folderUpload" webkitdirectory directory multiple>
<div class="file-list" id="fileList"></div>
<button id="uploadBtn" disabled>开始上传</button>
<div class="progress-container">
<div class="progress-bar" id="progressBar"></div>
</div>
</div>
<script>
// DOM元素
const folderUpload = document.getElementById('folderUpload');
const fileList = document.getElementById('fileList');
const uploadBtn = document.getElementById('uploadBtn');
const progressBar = document.getElementById('progressBar');
const dropArea = document.getElementById('dropArea');
// 文件处理
let currentFiles = [];
// 监听文件选择
folderUpload.addEventListener('change', handleFileSelect);
// 监听上传按钮
uploadBtn.addEventListener('click', () => {
if (currentFiles.length > 0) {
uploadFiles(currentFiles);
}
});
// 处理文件选择
function handleFileSelect(event) {
const files = event.target.files;
currentFiles = Array.from(files);
renderFileList(currentFiles);
uploadBtn.disabled = false;
}
// 渲染文件列表
function renderFileList(files) {
fileList.innerHTML = '';
const fileTree = {};
// 构建文件树
files.forEach(file => {
const pathParts = file.webkitRelativePath.split('/');
let currentLevel = fileTree;
pathParts.forEach((part, index) => {
if (!currentLevel[part]) {
currentLevel[part] = index === pathParts.length - 1
? file
: {};
}
currentLevel = currentLevel[part];
});
});
// 渲染文件树
renderFileTree(fileTree, fileList);
}
// 递归渲染文件树
function renderFileTree(tree, container, indent = 0) {
for (const key in tree) {
const item = tree[key];
const itemElement = document.createElement('div');
itemElement.style.paddingLeft = `${indent * 15}px`;
if (item instanceof File) {
itemElement.innerHTML = `
<span class="file">
<i class="file-icon">📄</i>
${key} (${formatFileSize(item.size)})
</span>
`;
} else {
itemElement.innerHTML = `
<span class="folder">
<i class="folder-icon">📁</i>
${key}/
</span>
`;
renderFileTree(item, itemElement, indent + 1);
}
container.appendChild(itemElement);
}
}
// 格式化文件大小
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// 上传文件
async function uploadFiles(files) {
uploadBtn.disabled = true;
progressBar.style.width = '0%';
let uploadedSize = 0;
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
try {
for (const file of files) {
const chunkSize = 5 * 1024 * 1024; // 5MB分块
const chunks = Math.ceil(file.size / chunkSize);
const fileName = encodeURIComponent(file.webkitRelativePath);
for (let i = 0; i < chunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk);
formData.append('name', fileName);
formData.append('chunkIndex', i);
formData.append('totalChunks', chunks);
// 模拟上传延迟
await new Promise(resolve => setTimeout(resolve, 300));
uploadedSize += chunk.size;
const progress = Math.round((uploadedSize / totalSize) * 100);
progressBar.style.width = `${progress}%`;
}
}
alert('所有文件上传完成!');
} catch (error) {
console.error('上传出错:', error);
alert('上传过程中出错');
} finally {
uploadBtn.disabled = false;
}
}
// 拖放功能
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropArea.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
dropArea.addEventListener(eventName, highlight, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropArea.addEventListener(eventName, unhighlight, false);
});
function highlight() {
dropArea.classList.add('highlight');
}
function unhighlight() {
dropArea.classList.remove('highlight');
}
dropArea.addEventListener('drop', handleDrop, false);
function handleDrop(e) {
const dt = e.dataTransfer;
const items = dt.items;
const files = [];
function processEntry(entry, path = '') {
return new Promise(resolve => {
if (entry.isFile) {
entry.file(file => {
file.webkitRelativePath = path + entry.name;
files.push(file);
resolve();
});
} else if (entry.isDirectory) {
const dirReader = entry.createReader();
dirReader.readEntries(entries => {
const promises = entries.map(childEntry =>
processEntry(childEntry, path + entry.name + '/')
);
Promise.all(promises).then(resolve);
});
}
});
}
const promises = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const entry = item.webkitGetAsEntry();
if (entry) {
promises.push(processEntry(entry));
}
}
}
Promise.all(promises).then(() => {
if (files.length > 0) {
currentFiles = files;
renderFileList(currentFiles);
uploadBtn.disabled = false;
}
});
}
</script>
</body>
</html>
10. 未来发展与替代方案
随着Web技术的发展,文件夹上传的实现方式也在不断演进:
- File System Access API:更强大的本地文件系统访问能力
javascript复制async function getFolder() {
const dirHandle = await window.showDirectoryPicker();
const files = [];
async function processDirectory(handle, path = '') {
for await (const entry of handle.values()) {
if (entry.kind === 'file') {
const file = await entry.getFile();
file.webkitRelativePath = path + entry.name;
files.push(file);
} else if (entry.kind === 'directory') {
await processDirectory(entry, path + entry.name + '/');
}
}
}
await processDirectory(dirHandle);
return files;
}
- Web Workers:将文件处理放到后台线程
javascript复制// 主线程
const worker = new Worker('file-worker.js');
worker.postMessage({ files: fileList }, fileList.map(f => f.slice()));
// worker.js
self.onmessage = function(e) {
const files = e.data.files;
// 处理文件...
self.postMessage(result);
};
- WASM加速:使用Rust等语言编写高性能文件处理逻辑
在实际项目中,建议根据目标用户群体选择合适的技术方案。对于企业内部应用,可以使用较新的API;对于公众网站,则需要考虑更广泛的兼容性方案。
