大文件上传技术:切片上传
随着互联网技术的发展,文件上传已成为我们日常开发中不可或缺的功能之一。特别是当上传的文件较大时,上传过程容易受到网络不稳定、超时等问题的影响。为了提升上传效率,保证上传过程的稳定性,我们通常采用大文件上传技术,特别是切片上传和断点续传技术。本文将结合前端和后端的实现方式,详细介绍大文件上传的实现过程。
一、切片上传的原理
切片上传是将大文件拆分成若干个小文件块,分批次上传,每一块文件称为一个切片。这样做有以下几个优点:
- 减少上传失败的风险:如果某一个切片上传失败,只需要重新上传该切片,而不需要从头开始上传整个文件。
- 提高上传效率:切片文件相对较小,可以更快地上传并避免超时。
- 支持断点续传:切片上传能根据需要恢复上传,尤其是在网络中断的情况下。
在切片上传过程中,前端将文件分成若干个小块,并逐一上传到后端。后端收到切片后,进行保存,直到所有切片都上传完毕,再将这些切片合并成一个完整的文件。
二、前端实现大文件切片上传
前端实现大文件上传的过程可以分为以下几个步骤:
-
读取本地文件并做切片处理
我们可以使用
Blob.slice()
方法将文件分成多个小块。以下是前端实现切片功能的代码:javascriptfunction createChunk(file, size = 5 * 1024 * 1024) { // 默认每个切片5MB const chunkList = []; let cur = 0; while (cur < file.size) { // 使用 slice 方法对文件进行切片 const chunk = file.slice(cur, cur + size); chunkList.push(chunk); cur += size; } return chunkList; }
-
将切片转换为
FormData
对象,并附带标识信息在上传每个切片时,需要附带文件名、切片名等信息,以便后端能准确接收并存储这些切片。
javascriptfunction handleChunk(chunkList) { const handleChunkList = chunkList.map((chunk, index) => { return { file: chunk, size: chunk.size, chunkName: `${fileObj.file.name}-${index}`, fileName: fileObj.file.name, index } }); // 发起请求上传切片 uploadChunks(handleChunkList); } function uploadChunks(handleChunkList) { const formDataChunkList = handleChunkList.map(({ file, chunkName, fileName, index }) => { const formData = new FormData(); formData.append('file', file); formData.append('fileName', fileName); formData.append('chunkName', chunkName); return { formData, index }; }); // 循环发送每个切片 const requestList = formDataChunkList.map(({ formData, index }) => { return axios.post('http://localhost:3000/upload', formData); }); Promise.all(requestList).then(res => { // 当所有切片上传完成后,通知后端进行合并 axios.post('http://localhost:3000/merge', { size: 5 * 1024 * 1024, // 切片大小 fileName: fileObj.file.name }); }); }
三、后端实现切片存储与合并
后端负责接收前端发送的切片文件,并将它们保存在本地。待所有切片上传完毕后,后端会将这些切片合并成一个完整的文件。
-
接收并保存切片
后端通过
multiparty
插件解析FormData
数据,提取切片并保存。javascriptif (req.url === '/upload') { const form = new multiparty.Form(); form.parse(req, async (err, fields, files) => { if (err) { console.log(err); return; } const [file] = files.file; const [fileName] = fields.fileName; const [chunkName] = fields.chunkName; const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`); // 判断文件夹是否存在,不存在则创建 if (!fs.existsSync(chunkDir)) { await fs.mkdirs(chunkDir); } // 将切片文件移动到指定目录 fs.move(file.path, `${chunkDir}/${chunkName}`); res.end('切片上传成功'); }); }
-
合并切片
一旦所有切片上传完成,后端将会合并这些切片生成完整的文件。合并的过程通过创建可写流来逐个写入文件。
javascriptconst mergeFileChunks = async (filePath, fileName, size) => { const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`); let chunkPaths = fs.readdirSync(chunkDir); chunkPaths.sort((a, b) => a.split('-').pop() - b.split('-').pop()); const arr = chunkPaths.map((chunkPath, index) => { return pipeStream( path.resolve(chunkDir, chunkPath), fs.createWriteStream(filePath, { start: index * size, end: (index + 1) * size }) ); }); await Promise.all(arr); };
通过
pipeStream
函数将切片读入并写入到目标文件中。javascriptconst pipeStream = (path, writeStream) => { return new Promise((resolve) => { const readStream = fs.createReadStream(path); readStream.on('end', () => { fs.unlinkSync(path); // 删除已合并的切片 resolve(); }); readStream.pipe(writeStream); }); };
四、完整代码
1.前端代码
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body>
<input type="file" id="input">
<button id="btn">上传</button>
<script>
const input = document.getElementById('input');
const btn = document.getElementById('btn');
const fileObj= {
file: null
}
input.addEventListener('change', function(e) {
const [ file ] = e.target.files;
if (!file) return;
fileObj.file = file;
})
btn.addEventListener('click', function() {
if (!fileObj.file) return;
// 先将文件切片
const chunkList = createChunk(fileObj.file);
// console.log(fileObj.file);
// console.log(chunkList);
// 处理切片
handleChunk(chunkList);
})
function createChunk(file, size = 5 * 1024 * 1024) { // 切片
const chunkList = [];
let cur = 0;
while (cur < file.size) {
// slice 方法的参数是 开始位置和结束位置,左闭右开 //文件对象也拥有slice方法
const chunk = file.slice(cur, cur + size);
chunkList.push(chunk);
cur += size;
}
return chunkList;
}
function handleChunk(chunkList) { // 处理切片
const handleChunkList = chunkList.map((chunk, index) => {
return {
file: chunk,
size: chunk.size,
chunkName: `${fileObj.file.name}-${index}`,
fileName: fileObj.file.name,
index
}
})
console.log(handleChunkList);
// 发请求
uploadChunks(handleChunkList)
}
function uploadChunks(handleChunkList) { // 上传切片
const formDataChunkList = handleChunkList.map(({ file, chunkName, fileName, index }) => {
const formData = new FormData(); //十六进制对象
formData.append('file', file);
formData.append('fileName', fileName);
formData.append('chunkName', chunkName);
return {formData, index}
})
// 将formDataChunkList 中的formData 一份一份发送给服务器
const requestList = formDataChunkList.map(({ formData, index }) => {
return axios.post('http://localhost:3000/upload', formData)
})
// console.log(requestList);
Promise.all(requestList).then(res => {
axios.post('http://localhost:3000/merge', {
size: 5 * 1024 * 1024,
fileName: fileObj.file.name
})
})
}
</script>
</body>
</html>
2. 后端代码
js
const http = require('http');
const multiparty = require('multiparty');
const path = require('path');
const fs = require('fs-extra');
const UPLOAD_DIR = path.resolve(__dirname, 'qiepian')
// 解析post请求
const resolvePost = (req) => {
return new Promise((resolve) => {
let chunk = ''
req.on('data', (data) => {
chunk += data
})
req.on('end', () => {
resolve(JSON.parse(chunk))
})
})
}
// 合并切片
const mergeFileChunks = async(filePath, fileName, size) => {
const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`)
let chunkPaths = fs.readdirSync(chunkDir)
chunkPaths.sort((a, b) => a.split('-').pop() - b.split('-').pop())
const arr = chunkPaths.map((chunkPath, index) => {
return pipeStream(
path.resolve(chunkDir, chunkPath),
fs.createWriteStream(filePath, {
start: index * size,
end: (index + 1) * size
})
)
})
await Promise.all(arr)
}
// pipeStream
const pipeStream = (path, writeStream) => {
return new Promise((resolve) => {
const readStream = fs.createReadStream(path) // 读取流
readStream.on('end', () => {
fs.unlinkSync(path) // 删除文件
resolve()
})
readStream.pipe(writeStream)
})
}
const server = http.createServer(async(req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*'); // 处理跨域问题
res.setHeader('Access-Control-Allow-Headers', '*');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
if (req.url === '/upload') {
const form = new multiparty.Form();
form.parse(req, async(err, fields, files) => { //parse读取到响应体
// console.log(fields, files);
if (err) {
console.log(err)
return
}
const [ file ] = files.file
const [ fileName ] = fields.fileName
const [ chunkName ] = fields.chunkName
// 保存片段
const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`)
// console.log(chunkDir);
// 判断文件夹是否存在
if (!fs.existsSync(chunkDir)) {
await fs.mkdirs(chunkDir)
}
fs.move(file.path, `${chunkDir}/${chunkName}`)
res.end('切片上传成功')
})
}
if (req.url === '/merge') { // 该合并某一个文件切片了
const data = await resolvePost(req)
const { fileName, size } = data
const filePath = path.resolve(UPLOAD_DIR, fileName)
// 将 path 路径对应的文件夹下的所有文件合并
await mergeFileChunks(filePath, fileName, size)
res.end('文件合并成功')
}
})
server.listen(3000, () => {
console.log('Server is running on port 3000');
});
3. 运行结果
此时我们将前后端都运行起来。
然后在前端上传文件。
此时在后端的qiepian文件夹里就能成功收到完整文件啦。
五、断点续传
这里我们再提一种优化手段:断点续传。
这是大文件上传过程中非常重要的技术,它允许在网络中断后,或者用户选择暂停后,文件上传能够从中断的地方继续上传,而不是重新开始上传。
-
获取已经上传的切片信息
在前端点击"继续上传"按钮时,前端会先发起一个请求,查询哪些切片已经成功上传,哪些尚未上传。
-
上传未完成的切片
前端会过滤掉已上传的切片,只上传那些未上传的部分。通过这种方式,用户可以实现从断点处恢复上传。
五、总结
切片上传通过将大文件分成多个小块,降低了上传失败的风险,提高了上传效率,同时也为断点续传提供了支持。前端和后端需要紧密配合,前端负责将文件切片并上传,后端则负责接收切片、保存文件并在所有切片上传完成后合并成完整文件。
通过这一技术,用户能够在上传大文件时获得更好的体验,避免因网络中断等问题导致的上传失败。