概述
可耐滴居居↑↑
- 大文件的分片上传原理:
- 文件数据的本质是字节数组
- 既然是数组,就可以分割为一个个的子数组
- 要上传的文件太大时,一次性上传既阻塞网络,给服客两端的网络IO同时带来巨大压力的同时,还容易造成上传失败,来回来去地重传听起来十分不靠谱!
- 解决方案就是:把大文件给分割为片段,编好序号,一片一片地进行上传
- 服务端接收完毕以后,再按序号重新组装为大文件
本文以React为例,带诸位来封装一个简单地体现核心原理的大文件上传组件,LETS GO!
居居分割示意图↑↑
设置布局
- 一个input + 一个上传按钮即可
- 样子暂时比较丑,但是很温柔!
import React, { useState } from 'react';
import axios from 'axios';
const StoUploader = () => {
...
/* 组件布局JSX */
return (
<div className='sto-uploader-root'>
{/* 选择文件 选择完毕后触发handleFileChange */}
<input type="file" onChange={handleFileChange} id='file-input'/>
{/* 点击开始上传 */}
<button onClick={handleUpload} disabled={!selectedFile}>上传文件</button>
</div>
);
};
export default StoUploader;
选择文件
- 用一个状态存储用户选择的文件
const [selectedFile, setSelectedFile] = useState(null);
- 用户选中的文件存储在状态中
{/* 选择文件 选择完毕后触发handleFileChange */}
<input type="file" onChange={handleFileChange} id='file-input'/>
/* 用户一旦选择了文件... */
const handleFileChange = (e: any) => {
// 将用户选择的文件交给selectedFile这个state保存
setSelectedFile(e.target.files[0]);
};
开始上传
- 用户点击上传
{/* 点击开始上传 */}
<button onClick={handleUpload} disabled={!selectedFile}>上传文件</button>
- 执行上传:将大文件分割为一堆切片,然后并发地对每个切片进行上传
/* 用户点击了上传... */
const handleUpload = () => {
// 分片大小
const chunkSize = 1024 * 10; // 每片10K(生产环境1M左右为宜)
// 读取用户选择文件的大小(字节)
const fileSize = selectedFile!.size;
// 文件的原始数据是一个【字节数组】
// 我们根据chunkSize的大小 从中截取分片
// [字节,字节,字节,【字节,...分片数据...,字节】,字节,字节,字节,字节,字节,字节...]
// 第一个分片的起始位置 [0,10240*1)
// 第二个分片的起始位置 [10240*1,10240*2) 依次类推
let start = 0; //开始字节
let end = chunkSize;//结束字节
let index = 0; //分片序号
/* 并发地上传分片 */
function uploadChunksAsync() {
...
}
uploadChunksAsync()
};
分片上传
- 核心代码来也!
- 分片:无非你要理解一个文件的本质就是一个字节数组,既然是数组,就可以
slice切片它,分割为一堆子数组,也就是分片了 - 并发上传:将每个分片按顺序编号,上传到服务端就是了,服务端需要根据编号将分片数据重新组合为文件;
- 由于是多个Promise任务并行,我们需要清楚地知道每个任务的执行结果
- 其实并没有什么luan东西...
/* 并发地上传分片 */
function uploadChunksAsync() {
// 预备存储分片
const chunks = []
/* 截取分片 */
while (start < fileSize) {
// 截取一个分片的字节数据
const chunk = selectedFile!.slice(start, start + chunkSize);
// 丢入分片数组
chunks.push(chunk)
// 起始游标移动到下一个分片开始处
start += chunkSize
}
console.log("chunks=", chunks);
/* 分片数组 => 上传任务数组 */
const tasks = chunks.map(
(chunk, index) => {
/* 构造一个表单数据对象 */
const formData = new FormData();
// 携带关键数据
formData.append('chunk', chunk);//分片的字节数据
formData.append('index', index);//分片序号
formData.append('total', Math.ceil(fileSize / chunkSize));//文件大小
formData.append('fileName', selectedFile!.name);//文件名称
// 分片名称:文件名-分片序号
const chunkName = selectedFile!.name + "-" + index
console.log("chunkName", chunkName);
formData.append('chunkName', chunkName);//文件大小
/*
返回一个AJAX任务 其类型是一个Promise对象
注意:此时多个上传任务已经并发执行起来了
serverApi = 服务端接收接口
formData = 分片的表单数据
*/
return axios.post(serverApi, formData)
.then(response => {
console.log(response.data);
})
.catch(error => {
console.error(error);
});
}
)
/* 使用Promise.allSettled “详查”每个任务的结果 */
Promise.allSettled(tasks)
.then((results) => {
results.forEach((result) => {
console.log(result.status)
// 如果任务状态为成功/fulfilled,进度+1
// 如果任务状态为失败/rejected,我们应该对对应的分片进行重传
// 已经不是最核心的代码了,相信各位看官都能自行推导出了!
// (难道我会承认在赶时间...)
})
})
}
服务端代码
- 首先...
- 让我们直接上代码吧!
const express = require('express');
const fs = require('fs');
// 接收表单的中间件
const multer = require('multer');
const app = express();
/* 配置multer */
const storage = multer.diskStorage({
// 文件保存在 uploads/ 目录中
destination: (req, file, cb) => {
cb(null, 'uploads/');
},
// 胡乱一配 这里其实并没有用到
filename: (req, file, cb) => {
cb(null, file.originalname + Date.now() + parseInt(Math.random() * 1000000000));
},
});
const upload = multer({ storage: storage });
/* 同意跨域简单粗暴版 */
// 处理跨域请求的中间件
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*'); // 允许所有来源访问
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); // 允许特定的请求头字段
res.header('Access-Control-Allow-Methods', 'POST'); // 只允许 POST 方法
next();
});
/* 静态资源支持 */
app.use(express.static('public'));
/* 处理上传接口 */
app.post('/upload', upload.single('chunk'), (req, res) => {
/* 从请求中读取切片信息 */
const chunk = req.file;
const index = req.body.index;
const total = req.body.total;
const fileName = req.body.fileName;
const chunkName = req.body.chunkName;
// 构建切片存储位置
const filePath = `uploads/${chunkName}`;
// 重命名分片文件
fs.renameSync(chunk.path, filePath);
if (index == total - 1) {
// 凑齐分片后 重新组装为文件
const combinedFilePath = combineChunks(fileName, total); // 组合分片
console.log("combinedFilePath", combinedFilePath);
if (combinedFilePath) {
console.log('文件上传完成');
res.send('文件上传完成');
} else {
console.log('分片上传失败');
res.send('分片上传失败');
}
} else {
console.log('分片上传成功');
res.send('分片上传成功');
}
});
/*
组装分片为文件
无非是按顺序读入分片文件字节数据 再一股脑怼到目标文件里
大家需要熟悉一下文件读写操作 都是基本功
*/
function combineChunks(filename, total) {
const combinedFilePath = `uploads/${filename}`;
// 创建写出流
const writeStream = fs.createWriteStream(combinedFilePath, { flags: 'a' });
/* 按顺序读取分片 怼入写出流 */
for (let i = 0; i < total; i++) {
// 读取分片数据
const chunkPath = `uploads/${filename}-${i}`;
let chunkData = fs.readFileSync(chunkPath);
// 怼入写出流
writeStream.write(chunkData);
fs.unlinkSync(chunkPath);
}
// 搞定
writeStream.end();
// 返回组装好的文件路径
return combinedFilePath;
}
/* 监听在指定端口 */
app.listen(8000, () => {
console.log('服务器已启动,监听端口 8000');
});
组件完整代码
import React, { useState } from 'react';
import axios from 'axios';
// import "./StoUploader.scss"
type StoUploaderProps = {
serverApi: string
}
const StoUploader: React.FC<StoUploaderProps> = ({ serverApi }) => {
// 用户选中的文件
const [selectedFile, setSelectedFile] = useState<File | null>(null);
/* 用户一旦选择了文件... */
const handleFileChange = (e: any) => {
// 将用户选择的文件交给selectedFile这个state保存
setSelectedFile(e.target.files[0]);
};
/* 用户点击了上传... */
const handleUpload = () => {
// 分片大小
const chunkSize = 1024 * 10; // 每片10K(生产环境1M左右为宜)
// 读取用户选择文件的大小(字节)
const fileSize = selectedFile!.size;
// 文件的原始数据是一个【字节数组】
// 我们根据chunkSize的大小 从中截取分片
// [字节,字节,字节,【字节,...分片数据...,字节】,字节,字节,字节,字节,字节,字节...]
// 第一个分片的起始位置 [0,10240*1)
// 第二个分片的起始位置 [10240*1,10240*2) 依次类推
let start = 0; //开始字节
let end = chunkSize;//结束字节
let index = 0; //分片序号
function uploadChunkSync() {
if (start < fileSize) {
// 一个分片的字节数据
const chunk = selectedFile.slice(start, end);
const formData = new FormData();
formData.append('chunk', chunk);//chunk的字节数据
formData.append('index', index);//chunk的序号
formData.append('total', Math.ceil(fileSize / chunkSize));//文件大小
axios.post('http://localhost:8000/upload', formData)
.then(response => {
console.log(response.data);
start = end;
end = start + chunkSize;
index++;
uploadChunkSync();
})
.catch(error => {
console.error(error);
});
} else {
console.log('分片上传完成');
}
}
// uploadChunkSync();
/* 并发地上传分片 */
function uploadChunksAsync() {
// 预备存储分片
const chunks = []
/* 截取分片 */
while (start < fileSize) {
// 截取一个分片的字节数据
const chunk = selectedFile!.slice(start, start + chunkSize);
// 丢入分片数组
chunks.push(chunk)
// 起始游标移动到下一个分片开始处
start += chunkSize
}
console.log("chunks=", chunks);
/* 分片数组 => 上传任务数组 */
const tasks = chunks.map(
(chunk, index) => {
/* 构造一个表单数据对象 */
const formData = new FormData();
// 携带关键数据
formData.append('chunk', chunk);//分片的字节数据
formData.append('index', index);//分片序号
formData.append('total', Math.ceil(fileSize / chunkSize));//文件大小
formData.append('fileName', selectedFile!.name);//文件名称
// 分片名称:文件名-分片序号
const chunkName = selectedFile!.name + "-" + index
console.log("chunkName", chunkName);
formData.append('chunkName', chunkName);//文件大小
/*
返回一个AJAX任务 其类型是一个Promise对象
注意:此时多个上传任务已经并发执行起来了
serverApi = 服务端接收接口
formData = 分片的表单数据
*/
return axios.post(serverApi, formData)
.then(response => {
console.log(response.data);
})
.catch(error => {
console.error(error);
});
}
)
/* 使用Promise.allSettled “详查”每个任务的结果 */
Promise.allSettled(tasks)
.then((results) => {
results.forEach((result) => {
console.log(result.status)
// 如果任务状态为成功/fulfilled,进度+1
// 如果任务状态为失败/rejected,我们应该对对应的分片进行重传
// 已经不是最核心的代码了,相信各位看官都能自行推导出了!
// (难道我会承认在赶时间...)
})
})
}
uploadChunksAsync()
};
/* 组件布局JSX */
return (
<div className='sto-uploader-root'>
{/* 选择文件 选择完毕后触发handleFileChange */}
<input type="file" onChange={handleFileChange} id='file-input' />
{/* 点击开始上传 */}
<button onClick={handleUpload} disabled={!selectedFile}>上传文件</button>
</div>
);
};
export default StoUploader;
组件的部署
import StoUploader from '@/components/StoUploader/StoUploader'
export default function UploaderDemo() {
return (
<div>
<h3>UploaderDemo</h3>
<StoUploader serverApi={'http://localhost:8000/upload'}></StoUploader>
</div>
)
}
据说罗伯·史塔克看了兰尼斯特家的文章没点赞收藏转发...