各位老铁们,晚上好!今儿咱们唠唠浏览器文件上传那点事儿,特别是大文件上传,这可是个技术活,搞不好就GG了。咱争取用最接地气的语言,把这事儿掰开了揉碎了,让大家听完就能上手。
一、文件上传的那些事儿
简单来说,文件上传就是把本地文件传到服务器上,让服务器保存起来。这听起来简单,但里面门道可不少。
-
HTML 表单是基础
想要上传文件,首先得有个地方让用户选文件吧?HTML 的
<input type="file">
元素就是干这个的。<input type="file" id="fileInput" name="file"> <button onclick="uploadFile()">上传</button>
这段代码创建了一个文件选择框和一个上传按钮。
name="file"
很重要,服务器端会根据这个名字来接收文件。 -
FormData 对象是搬运工
选好文件后,怎么把文件数据送到服务器呢?
FormData
对象就是个好帮手。它可以把表单数据打包成一种特殊的格式,方便通过XMLHttpRequest
或fetch
发送。function uploadFile() { const fileInput = document.getElementById('fileInput'); const file = fileInput.files[0]; // 获取选中的文件 if (!file) { alert('请选择文件!'); return; } const formData = new FormData(); formData.append('file', file); // 把文件添加到 FormData 中 // 使用 XMLHttpRequest 或 fetch 发送 FormData // 下面以 fetch 为例 fetch('/upload', { method: 'POST', body: formData }) .then(response => response.json()) .then(data => { console.log('上传成功:', data); }) .catch(error => { console.error('上传失败:', error); }); }
这段代码先把选中的文件放进
FormData
对象,然后用fetch
发送给服务器的/upload
接口。 -
XMLHttpRequest vs. Fetch:选哪个?
- XMLHttpRequest (XHR): 老牌选手,兼容性好,但 API 比较繁琐。
- Fetch API: 新秀,API 更简洁,基于 Promise,用起来更舒服。
现在主流都推荐用 Fetch API,因为它更现代、更易用。上面的例子就是用 Fetch API 实现的。
-
服务器端接收文件
服务器端需要编写代码来接收上传的文件。具体用什么语言和框架,就看你的选择了。比如,用 Node.js + Express 可以这样:
const express = require('express'); const multer = require('multer'); const app = express(); const upload = multer({ dest: 'uploads/' }); // 指定文件存储目录 app.post('/upload', upload.single('file'), (req, res) => { // req.file 包含上传文件的信息 // req.body 包含其他表单数据 console.log('上传的文件信息:', req.file); res.json({ message: '文件上传成功!' }); }); app.listen(3000, () => { console.log('服务器启动,监听 3000 端口'); });
这段代码用了
multer
中间件来处理文件上传。upload.single('file')
表示只接收一个名为 "file" 的文件。
二、大文件分块上传
上传小文件没啥问题,但遇到几个 G 的大文件,直接传肯定不行。这时候就需要把文件分成小块,一块一块地传,这就是分块上传。
-
为什么需要分块上传?
- 网络不稳定: 大文件容易传到一半断掉,重新传很浪费时间。
- 浏览器限制: 有些浏览器对上传文件大小有限制。
- 服务器压力: 一次性上传大文件,服务器压力山大。
-
分块上传的流程
- 客户端:
- 把文件分成若干个小块(chunk)。
- 为每个 chunk 生成一个唯一的标识(chunkIndex)。
- 依次上传每个 chunk。
- 所有 chunk 上传完成后,通知服务器合并文件。
- 服务器端:
- 接收并临时存储每个 chunk。
- 记录每个 chunk 的上传状态。
- 收到合并文件的通知后,按顺序合并所有 chunk。
- 客户端:
-
客户端代码实现
async function uploadLargeFile(file) { const chunkSize = 1024 * 1024 * 2; // 2MB 一个 chunk const totalSize = file.size; const chunkCount = Math.ceil(totalSize / chunkSize); for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex++) { const start = chunkIndex * chunkSize; const end = Math.min(totalSize, start + chunkSize); const chunk = file.slice(start, end); // 切割文件 const formData = new FormData(); formData.append('chunk', chunk); formData.append('chunkIndex', chunkIndex); formData.append('totalChunks', chunkCount); formData.append('filename', file.name); // 方便服务器端合并 try { const response = await fetch('/upload-chunk', { method: 'POST', body: formData }); if (!response.ok) { throw new Error(`上传 chunk ${chunkIndex} 失败: ${response.status}`); } console.log(`chunk ${chunkIndex} 上传成功`); } catch (error) { console.error(`chunk ${chunkIndex} 上传出错:`, error); // 处理上传错误,可以重试或者提示用户 } } // 所有 chunk 上传完成后,通知服务器合并文件 try { const response = await fetch('/merge-file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: file.name }) }); if (!response.ok) { throw new Error(`合并文件失败: ${response.status}`); } const data = await response.json(); console.log('文件合并成功:', data); } catch (error) { console.error('文件合并出错:', error); } }
这段代码把文件分成若干个 2MB 的 chunk,然后依次上传每个 chunk。上传完成后,会通知服务器合并文件。
-
服务器端代码实现
const express = require('express'); const multer = require('multer'); const fs = require('fs'); const path = require('path'); const app = express(); app.use(express.json()); // 解析 JSON 格式的请求体 const uploadDir = 'uploads/'; // 临时存储 chunk 的目录 const finalDir = 'final/'; // 最终文件存储目录 // 创建目录(如果不存在) if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir); } if (!fs.existsSync(finalDir)) { fs.mkdirSync(finalDir); } const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, uploadDir); // 临时存储目录 }, filename: (req, file, cb) => { const filename = req.body.filename; const chunkIndex = req.body.chunkIndex; cb(null, `${filename}-${chunkIndex}`); // 给每个 chunk 命名 } }); const upload = multer({ storage: storage }); app.post('/upload-chunk', upload.single('chunk'), (req, res) => { // 接收并临时存储 chunk console.log(`接收到 chunk: ${req.body.chunkIndex}`); res.json({ message: 'chunk 上传成功' }); }); app.post('/merge-file', (req, res) => { const filename = req.body.filename; const totalChunks = parseInt(req.body.totalChunks); // 获取总 chunk 数量(如果客户端发送了) const filePath = path.join(finalDir, filename); const chunkFiles = fs.readdirSync(uploadDir) .filter(file => file.startsWith(filename + '-')) .sort((a, b) => { // 确保 chunk 按照顺序合并 const indexA = parseInt(a.split('-')[1]); const indexB = parseInt(b.split('-')[1]); return indexA - indexB; }); // 检查是否所有 chunk 都已上传 (可选,更健壮的做法) // if (chunkFiles.length !== totalChunks) { // return res.status(400).json({ message: '还有 chunk 未上传' }); // } const writeStream = fs.createWriteStream(filePath); chunkFiles.forEach(chunkFile => { const chunkPath = path.join(uploadDir, chunkFile); const readStream = fs.createReadStream(chunkPath); readStream.pipe(writeStream, { end: false }); // 不要关闭 writeStream readStream.on('end', () => { fs.unlinkSync(chunkPath); // 删除已合并的 chunk }); }); writeStream.on('finish', () => { console.log(`文件 ${filename} 合并完成`); res.json({ message: '文件合并成功', filePath: filePath }); }); writeStream.on('error', (err) => { console.error('合并文件出错:', err); res.status(500).json({ message: '合并文件出错', error: err.message }); }); }); app.listen(3000, () => { console.log('服务器启动,监听 3000 端口'); });
这段代码接收并临时存储每个 chunk,然后按顺序合并所有 chunk。合并完成后,会删除临时 chunk 文件。
三、断点续传
分块上传已经很好了,但如果传到一半断了,下次还得从头开始传,这也很烦。断点续传就是解决这个问题的。
-
断点续传的原理
- 记录已上传的 chunk: 客户端和服务器都要记录哪些 chunk 已经上传成功。
- 下次上传时跳过已上传的 chunk: 客户端在上传前先检查哪些 chunk 已经上传了,然后只上传未上传的 chunk。
-
客户端代码实现
async function uploadLargeFileWithResume(file) { const chunkSize = 1024 * 1024 * 2; // 2MB 一个 chunk const totalSize = file.size; const chunkCount = Math.ceil(totalSize / chunkSize); const filename = file.name; // 获取已上传的 chunk 列表 const uploadedChunks = await getUploadedChunks(filename); for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex++) { // 如果 chunk 已经上传,就跳过 if (uploadedChunks.includes(chunkIndex)) { console.log(`chunk ${chunkIndex} 已上传,跳过`); continue; } const start = chunkIndex * chunkSize; const end = Math.min(totalSize, start + chunkSize); const chunk = file.slice(start, end); const formData = new FormData(); formData.append('chunk', chunk); formData.append('chunkIndex', chunkIndex); formData.append('totalChunks', chunkCount); formData.append('filename', filename); try { const response = await fetch('/upload-chunk', { method: 'POST', body: formData }); if (!response.ok) { throw new Error(`上传 chunk ${chunkIndex} 失败: ${response.status}`); } console.log(`chunk ${chunkIndex} 上传成功`); } catch (error) { console.error(`chunk ${chunkIndex} 上传出错:`, error); // 处理上传错误,可以重试或者提示用户 } } // 所有 chunk 上传完成后,通知服务器合并文件 try { const response = await fetch('/merge-file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: filename }) }); if (!response.ok) { throw new Error(`合并文件失败: ${response.status}`); } const data = await response.json(); console.log('文件合并成功:', data); } catch (error) { console.error('文件合并出错:', error); } } // 获取已上传的 chunk 列表 async function getUploadedChunks(filename) { try { const response = await fetch(`/get-uploaded-chunks?filename=${filename}`); if (!response.ok) { throw new Error(`获取已上传 chunk 列表失败: ${response.status}`); } const data = await response.json(); return data.uploadedChunks; } catch (error) { console.error('获取已上传 chunk 列表出错:', error); return []; // 默认返回空数组 } }
这段代码在上传前先调用
getUploadedChunks
函数,获取已上传的 chunk 列表。然后跳过已上传的 chunk,只上传未上传的 chunk。 -
服务器端代码实现
const express = require('express'); const multer = require('multer'); const fs = require('fs'); const path = require('path'); const app = express(); app.use(express.json()); const uploadDir = 'uploads/'; const finalDir = 'final/'; // 创建目录(如果不存在) if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir); } if (!fs.existsSync(finalDir)) { fs.mkdirSync(finalDir); } const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, uploadDir); }, filename: (req, file, cb) => { const filename = req.body.filename; const chunkIndex = req.body.chunkIndex; cb(null, `${filename}-${chunkIndex}`); } }); const upload = multer({ storage: storage }); app.post('/upload-chunk', upload.single('chunk'), (req, res) => { // 接收并临时存储 chunk console.log(`接收到 chunk: ${req.body.chunkIndex}`); res.json({ message: 'chunk 上传成功' }); }); app.post('/merge-file', (req, res) => { const filename = req.body.filename; const filePath = path.join(finalDir, filename); const chunkFiles = fs.readdirSync(uploadDir) .filter(file => file.startsWith(filename + '-')) .sort((a, b) => { const indexA = parseInt(a.split('-')[1]); const indexB = parseInt(b.split('-')[1]); return indexA - indexB; }); const writeStream = fs.createWriteStream(filePath); chunkFiles.forEach(chunkFile => { const chunkPath = path.join(uploadDir, chunkFile); const readStream = fs.createReadStream(chunkPath); readStream.pipe(writeStream, { end: false }); readStream.on('end', () => { fs.unlinkSync(chunkPath); }); }); writeStream.on('finish', () => { console.log(`文件 ${filename} 合并完成`); res.json({ message: '文件合并成功', filePath: filePath }); }); writeStream.on('error', (err) => { console.error('合并文件出错:', err); res.status(500).json({ message: '合并文件出错', error: err.message }); }); }); // 获取已上传的 chunk 列表 app.get('/get-uploaded-chunks', (req, res) => { const filename = req.query.filename; const uploadedChunks = fs.readdirSync(uploadDir) .filter(file => file.startsWith(filename + '-')) .map(file => parseInt(file.split('-')[1])); // 提取 chunkIndex res.json({ uploadedChunks: uploadedChunks }); }); app.listen(3000, () => { console.log('服务器启动,监听 3000 端口'); });
这段代码添加了一个
/get-uploaded-chunks
接口,用于返回已上传的 chunk 列表。
四、优化与注意事项
-
前端优化
- 使用 Web Workers: 把文件切割和上传逻辑放在 Web Workers 中,避免阻塞主线程,提高用户体验。
- 进度条显示: 实时显示上传进度,让用户心里有数。
- 错误处理: 完善的错误处理机制,及时提示用户。
- 取消上传: 允许用户取消上传。
-
后端优化
- CDN 加速: 使用 CDN 加速文件上传,提高上传速度。
- 存储优化: 选择合适的存储方案,比如对象存储服务 (OSS)。
- 安全: 检查文件类型和大小,防止恶意上传。
- 并发控制: 限制并发上传数量,防止服务器过载。
-
通用优化
- 唯一标识: 为每个上传任务生成一个唯一的标识符 (UUID),方便跟踪和管理。
五、总结
文件上传是个看似简单,实则水很深的技术。掌握了分块上传和断点续传,就能轻松应对大文件上传的挑战。当然,实际项目中还需要考虑更多细节,比如安全性、性能优化等等。
技术点 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
普通上传 | 简单易用 | 无法上传大文件、网络不稳定容易失败 | 小文件上传,网络环境良好 |
分块上传 | 可以上传大文件、提高上传成功率 | 客户端和服务器端都需要复杂逻辑 | 大文件上传,网络环境不稳定 |
断点续传 | 在分块上传的基础上,支持断点续传,节省时间 | 需要记录已上传的 chunk,实现更复杂 | 超大文件上传,网络环境非常不稳定 |
Web Workers | 避免阻塞主线程,提高用户体验 | 增加了代码复杂度 | 需要在后台处理文件切割和上传逻辑的场景 |
CDN 加速 | 提高上传速度 | 增加成本 | 对上传速度有较高要求的场景 |
对象存储 | 可靠性高、扩展性好 | 增加成本、需要学习新的 API | 需要长期存储大量文件的场景 |
希望今天的分享对大家有所帮助! 记住,实践是检验真理的唯一标准,多动手写代码,才能真正掌握这些技术。 下课!