前言
一开始在知乎发文章,后来感觉知乎平台不太合适我,于是就来掘金啦。
文件上传基本流程
前端通过input
获取到用户选择的文件,放入FormData
中,设置 content-type
为 multipart/form-data
发送给服务端。服务端通过cookie/token/...
等等信息,再对文件/数据库进行处理操作。
好像没啥讲的,直接上代码吧。
<input type="file" onChange={handleFileChange} ref={inputRef} multiple />
const files = inputRef.current?.files;
// 获取到file数组,对数组处理,然后得到file
const formData = new FormData();
formData.append('file', file);
formData.append('xx', xx);
// 可以带上一些信息
const { code } = await uploadFiles(formData);
服务端我用的是node express,然后使用multer去处理multipart/form-data类型的表单数据。multer传送门
import multer from 'multer';
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, './dist')
},
filename: (req, file, cb) => {
cb(null, `${(+ new Date())}${file.originalname}`)
}
});
const upload = multer({ storage });
app.use(upload.single('file'));
// 我这边是单文件处理,对应这req.file;如果要多文件,那就是array,对应req.files
router.post('/upload', async (req, res) => {
const { file } = req;
// to do sth.
// 目录转移的话,可以用fs.renameSync/fs.rename
});
拖拽上传
拖拽上传就是利用onDrop
和onDragOver
,阻止浏览器默认事件,然后就得到对应文件辽。
<div onDrop={onDrop} onDragOver={onDragOver} ></div>
const onDragOver = (e: DragEvent | React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
};
const onDrop = (e: DragEvent | React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
handleFileChange(e.dataTransfer?.files);
};
大文件上传
大文件上传,可能会出现,上传时间过长,接口限制了文件大小。所以,大文件直接上传,也很不友好,一般采用分片上传的方式去上传。而blob
提供了slice
方法, file
继承了blob
自然也能使用slice
去进行分片处理。
处理流程:
- 前端对大文件进行分片,分片名采用文件
hash后续会讲
加下标
- 为了防止完全占用
tcp
资源,我这里是采用4个4个上传 - 在最后,再发送一个合并请求
- 服务端根据请求,对前面的分片内容进行合并成一个整体文件,然后删除分片
const handleUpload = () => {
chunks = [];
const file: File = files.current[dataIndex];
// 获取对应文件file
let start = 0, end = BIG_FILE_SIZE;
// const BIG_FILE_SIZE = 1024 * 1024 * 2;
while(true) {
const part: Blob = file.slice(start, end);
if (part.size) {
chunks.push(part);
} else {
break;
}
start += BIG_FILE_SIZE;
end += BIG_FILE_SIZE;
};
// worker!.postMessage({ chunkList: chunks });
// 利用webworker获取hash,后面会讲
return;
};
const partUpload = async (start: number) => {
const uploadArr = [];
let restReq = MAX_FILE_TCP;
// MAX_FILE_TCP = 4;同时发起4个连接
let index = start;
while (restReq) {
if (index >= chunkCount) {
// chunkCount是chunk的length,即多少个片段
break;
};
// const blobPart = `${hash}-${index}`;
// if (hashData[hash] && hashData[hash][blobPart])
// {
// index++;
// continue;
// };
// 注释部分是,断点续传部分代码,可跳过
const formData = new FormData();
formData.append('file', chunks[index]);
formData.append('xx', xx);
const uploadFunc = () => {
return new Promise(async (res) => {
const { code } = await uploadPart(formData);
res(code);
});
};
uploadArr.push(uploadFunc);
index++;
restReq--;
};
const result = await Promise.all(uploadArr.map(v => v()));
result.map((v) => {
if (v === 0) {
console.log('上传片段成功');
} else {
throw new Error('上传失败');
}
return null;
});
if (index < chunkCount) {
partUpload(index);
} else {
const params = {
// sth.
};
const {code} = await partMerge(params);
// 发送合并请求
// todo code sth.
}
};
服务端的话,我这边是把文件根据对应的hash
和下标进行命名,即static/hash/hash-i
。利用fs.rename
去修改文件&路径, 通过pipe
合并文件。
router.post('/upload_part', (req, res) => {
try {
const { hash, index } = req.body;
const { file } = req;
const sourcePath = path.join(__dirname, file.path);
const destPath = path.join(__dirname, `xxxx/${hash}/${hash}-${index}`);
fs.renameSync(sourcePath, destPath);
return res.json({code: 0});
} catch(e) {
return res.json({code: 1, msg: e});
}
});
router.post('/merge_part', (req, res) => {
try {
const destPath = 'xxx/yyy/zzz/a.png';
const writePath = fs.createWriteStream(destPath);
// 最终合并结果存储在哪
const fileMerge = (i: number) => {
const blobPath = `xxx/part/${hash}/${hash}-${i}`;
const blobFile = fs.createReadStream(blobPath);
blobFile.on("end", async (err) => {
if (err) {
return res.json({code: 1, msg: err});
};
fs.unlink(blobPath);
// 删除片段
if (i + 1 < chunkCount) {
fileMerge(i + 1);
} else {
fs.rmdirSync(`xxx/part/${hash}`);
// 删除文件夹
// 数据库操作 todo
return res.json({ code: 0 });
}
});
blobFile.pipe(writeStream, { end: false });
};
fileMerge(0);
} catch(e) {
return res.json({code: 1, msg: e});
}
});
// 省略了不必要内容
断点续传
大文件分片上传,如果客户端发生异常中断了上传过程,那么下次重新上传的时候,比较友好的做法应该是跳过那些已经上传的片段。
那么问题也就是,怎么跳过那些文件?刚才前面的代码,也显示了,其实就是通过${hash}-${i}
设置文件名,然后保存已经上传成功的片段数据,在文件分片上传的时候,跳过已经上传过的片段,就是断点续传辽。
对于这类数据存储,一般都是两个方法:
- 前端存储
- 服务端存储
前端存储的话,一般就是用localStorage
,不太推荐使用。因为用户如果清了缓存,或者换了设备登陆,就无法生效。
服务端的话,就返回对应的已成功上传的片段名。因为对应userId
下的文件hash-i
,是唯一的。node
这里就采用readdirSync/readdir
去读取文件名辽。
前端这里就是前面提到过的部分。
const blobPart = `${hash}-${index}`;
if (hashData[hash] && hashData[hash][blobPart])
{
// hashData是服务端传回来的数据,判断片段是否存在,存在就跳过
// 具体可以看看前面
index++;
continue;
};
WebWoker获取hash
那么就剩下一个问题辽,去获取文件的hash
。推荐使用spark-md5
去生成文件的hash
。因为生成hash
过程是比较耗时间的,我这边采用了webworker
去计算hash
。
webworker
我这边是直接是将webworker
的代码写到html
上面,然后利用Blob & URL.createObjectURL
去本地创建webwoker
。
<script id="worker" type="app/worker">
self.importScripts("https://cdn.bootcss.com/spark-md5/3.0.0/spark-md5.min.js");
self.onmessage = function(e) {
var spark = new self.SparkMD5.ArrayBuffer();
var chunkList = e.data.chunkList;
var count = 0;
var next = function(index) {
var fileReader = new FileReader();
fileReader.readAsArrayBuffer(chunkList[index]);
fileReader.onload = function(e) {
count ++;
spark.append(e.target.result);
if (count === chunkList.length) {
self.postMessage({
hash: spark.end()
});
} else {
loadNext(count);
}
};
};
next(0);
};
</script>
然后在hook
函数里调用createObjectURL
去生成DOMString
,作为Worker
的参数。
const newWebWorker = () => {
const textContent = document.getElementById('worker')!.textContent;
const blob = new Blob([textContent as BlobPart]);
createURL = window.URL.createObjectURL(blob);
worker = new Worker(createURL);
worker.onmessage = (e) => {
hash = e.data.hash;
// 这里获取到hash
// todo sth.
partUpload(0);
// 进行文件片段上传操作
};
};
这里利用createObjectURL
生成URL
后,使用完,需要使用revokeObjectURL
去释放
文件下载
文件下载方法还是挺多的,form
表单呀,open
呀,直接a
标签呀,blob
方式呀等等。我这边采用的是利用blob
去下载文件,主要考虑到,可以进行鉴权操作&可以下载各种类型文件等等。
过程就是
- 服务端返回
Blob
- 前端这里通过
createObjectURL
生成DOMString
- 设置
a
标签的href
然后执行它的点击事件
{
url
? <a ref={aRef} style={{ display: 'none' }} href={url} download={data.file_name} />
: null
}
// download就是你文件下载下来的文件名
const onDownload = async () => {
const blob = await getFileBlob({ id: data.id, type: data.type });
const url = window.URL.createObjectURL(blob);
setUrl(url);
};
useEffect(() => {
if (url) {
aRef.current?.click();
window.URL.revokeObjectURL(url);
setUrl('');
}
}, [url]);
export const getFileBlob = async (params: GetFileBlob) => {
const { data } = await api.get<Data>('/get_file_blob', {
params,
responseType: 'blob'
// responseType需要设置blob
});
return data;
};
node这里就用createReadStream
读取文件,需要设置Content-Type和Content-Disposition
。
router.get('/get_file_blob', async (req, res) => {
// todo sth.
const destPath = 'xxx/xxx';
const fileStream = fs.createReadStream(destPath);
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment;filename=whatareudoing`);
// 前面a标签已经设置了download,这里的filename不会有影响
fileStream.on("data", (chunk) => res.write(chunk, "binary"));
fileStream.on("end", () => res.end());
});
进度条
进度条就是ProgressEvent
,如果是断点续传,就先对片段进行遍历判断是否有已经上传过的,然后就得到一个初始进度。
const onUploadProgress = (event: ProgressEvent) => {
const progress = Math.floor((event.loaded / event.total) * 100);
// todo sth.
};
export const uploadFiles = async ({ formData, onUploadProgress }: UploadFiles) => {
const { data } = await api.post<Data>('/upload', formData, { onUploadProgress });
//...
};
获取视频文件缩略图
如果是视频类文件,那么很多情况下都需要获取视频文件缩略图。有两个办法,一个是在前端获取,用canvas
可获取;另一个则是服务端自己获取。
我这边用的是node
利用ffmpeg
去获取视频的第一帧作为缩略图保存。
const getVideoSceenshots = (videoPath: string, outPutPath: string, outPutName: string) => {
const process = new ffmpeg(videoPath);
process.then((video) => {
video.fnExtractFrameToJPG(outPutPath, {
frame_rate: 1,
number: 1,
file_name: outPutName
}, error => {
if (error) {
console.log('error: ' + error)
}
})
}, err => {
console.log('Error: ' + err)
})
};
// videoPath视频文件路径,outPutPath缩略图输出路径,outPutName输出名