前言
- 上传一般都用别人写好的。自己实现遍比较有意思,同时理解更透彻。
流程
一、制作基本样式
- 首先,需要做出个上传的基本样式。
- 由于原生的上传文件样式太丑。所以需要自己额外写个div,放个图标上去来替代原生样式:
function SpliceUpload(){
const fileRef = useRef<HTMLInputElement>(null)
const handleChange=(e:React.ChangeEvent<HTMLInputElement>)=>{
console.log(e.target.files)
}
const handleUpload=()=>{
if(fileRef.current){
fileRef.current.click()
}
}
return(
<div className='spliceupload'>
<input type="file" onChange={handleChange} ref={fileRef}
style={{display:'none'}}
/>
<div className='uploadicon' onClick={handleUpload}>
<Icon type='upload'></Icon>
</div>
</div>)
}
- 我这里使用antd给的图标,点击uploadicon那个div,会触发弹窗,选择文件。在handleChange函数里即可拿到文件。
二、制作图片回显
- 这个文件如果不是图片可以不用回显,但是这个回显是个比较重要的知识点,所以特别总结下。
- 回显一般借助的是浏览器的api,有2个方法。这种生成的图片url地址都是blob地址。就是图片实际存在浏览器内存里,生成个预览的url回显地址。我想起我以前写爬虫跳出个验证码是blob图片,然后怎么找都找不到请求,以及图片不知道哪来的悲惨遭遇。。。
function validateFile(file:File){
const isValideType=['image/jpeg','image/png','image/gif'].includes(file.type)
if(!isValideType){
message.error('不支持的文件类型')
}
const isValideDate=file.size<1024*1024*1024
if(!isValideDate){
message.error('上传文件大小过大')
}
return isValideType&&isValideDate
}
function SpliceUpload(){
const [currentFile,setCurrentFile]=useState<File>()
const [objectURL,setObjectURL]=useState<string>()
const fileRef = useRef<HTMLInputElement>(null)
const handleChange=(e:React.ChangeEvent<HTMLInputElement>)=>{
if(e.target.files){
const file = e.target.files[0]
setCurrentFile(file)
}
}
const handleUpload=()=>{
if(fileRef.current){
fileRef.current.click()
}
}
const handleSubmit=()=>{
if(!currentFile){
return message.error('未选择文件')
}
if(!validateFile(currentFile))return
}
useEffect(()=>{
if(currentFile){
const objecturl = window.URL.createObjectURL(currentFile)
setObjectURL(objecturl)
return ()=>window.URL.revokeObjectURL(objecturl)
}
},[currentFile])
return(
<div className='spliceupload'>
<input type="file" onChange={handleChange} ref={fileRef}
style={{display:'none'}}
/>
<div className='uploadicon' onClick={handleUpload}>
{
!objectURL&&<Icon type='upload'></Icon>
}
{
objectURL&&<img src={objectURL} style={{width:'100%',height:'100%'}}></img>
}
</div>
<button onClick={handleSubmit}>提交</button>
</div>)
}
export default SpliceUpload
- 除了最后发请求给后端,基本上前端这里差不多完工了。
- 这里就是通过window.URL.createObjectURL生成回显url,然后赋给img标签的src。卸载时别忘了把这个对象从内存中释放出来,调用window.URL.revokeObjectURL方法。
- 当然还可以使用另一种操作:
useEffect(()=>{
if(currentFile){
const reader = new FileReader()
reader.addEventListener('load',()=>setObjectURL(reader.result as string))
reader.readAsDataURL(currentFile)
}
},[currentFile])
- 这个使用的是FileReader,读完之后会走load的回调函数。antd的上传组件里也使用的是这种方式,不过它包了个promise,reader还可以监听error然后reject给promise。
三、上传服务器
- 上传服务器一般都是ajax请求过去,需要借助个FormData的api。就像在表单中操作一样添加键值对,传给ajax就可以了。
const formData = new FormData()
formData.append('chunk',currentFile)
formData.append('filename',currentFile.name)
let result =await axios.post('/user/splitupload',formData,{headers:{'Content-Type':'multipart/form-data'}})
console.log(result)
- 服务端以express为例,在控制器中借助插件multiparty解析multipart表单:
export const splitupload = async (_req: Request, _res: Response, _next: NextFunction) => {
const form = new multiparty.Form()
form.parse(_req, async (err, fields, file) => {
if (err) {
return _next(err)
}
console.log(fields)
console.log(file)
})
_res.json({
success: true
})
}
- 可以看见file里面有个path,那个path是上传的临时目录,所以我们要把这个图片移动到public静态目录里。
export const splitupload = async (_req: Request, _res: Response, _next: NextFunction) => {
const form = new multiparty.Form()
form.parse(_req, async (err, fields, files) => {
if (err) {
return _next(err)
}
const filename = fields.filename[0]
const chunk = files.chunk[0]
await fs.move(chunk.path, path.resolve(PUBLIC_UPLOAD_DIR, filename), { overwrite: true })
_res.json({
success: true
})
})
}
- 这样,前端上传图片后,后端public文件夹里能出现图片名,就算完成了。
- 这只是跑通基本上传流程,下面要改造它,实现分片上传。
四、分片上传功能
- 前面基本前后端流程跑通后,需要改造成分片上传。
- 另外,前面那个传的文件名也不太好。所以我们还需要提取哈希,有了哈希后,相同文件上传就能实现秒传功能。
- 所以首先,我们要对其分片,然后计算哈希,为什么用分片的计算哈希?因为如果文件比较大,一个整个文件计算哈希就特别慢,所以使用分片计算哈希。
- 计算哈希由于费时,所以可以使用web worker进行计算,需要调用spark-md5。spark-md5文档。
- 可以看见官网上有一段分片案例:
document.getElementById('file').addEventListener('change', function () {
var blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
file = this.files[0],
chunkSize = 2097152,
chunks = Math.ceil(file.size / chunkSize),
currentChunk = 0,
spark = new SparkMD5.ArrayBuffer(),
fileReader = new FileReader();
fileReader.onload = function (e) {
console.log('read chunk nr', currentChunk + 1, 'of', chunks);
spark.append(e.target.result);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
} else {
console.log('finished loading');
console.info('computed hash', spark.end());
}
};
fileReader.onerror = function () {
console.warn('oops, something went wrong.');
};
function loadNext() {
var start = currentChunk * chunkSize,
end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
}
loadNext();
});
- 可以看见是这样的,先进行切片,切出来的类型是Blob类型,然后读成ArrayBuffer类型,读完后走onload进行判断,调用它的arrayBuffer类里的append方法,最后得到完整文件的md5。
- 所以这个sparkmd5支持arrayBuffer的传入形式。文件类型是继承自Blob类型,Blob类型需要转成arrayBuffer类型,blob和arrayBuffer其实都不是一个纯的二进制文件片段,都相当于一个容器里面放着二进制文件片段。由于单单放个二进制文件没啥用,也没法操控,所以肯定用容器放着方便,还增加了很多操作手段。据说这是当年做WebGL时搞得,为了让二进制数据更快的交给显卡处理。
- 新建一个静态文件hashworker.js,里面用来存放给web worker的代码:
self.importScripts('https://cdn.bootcss.com/spark-md5/3.0.0/spark-md5.js')
self.onmessage = async (event) => {
let { partList } = event.data
const spark = new self.SparkMD5.ArrayBuffer()
let percent = 0
let perSize = 100 / partList.length
let res = await Promise.all(partList.map(({ chunk, size }) => new Promise((resolve) => {
const reader = new FileReader()
reader.readAsArrayBuffer(chunk)
reader.onload = function (event) {
percent += perSize
self.postMessage({ percent: Number(percent.toFixed(2)) })
resolve(event.target.result)
}
})))
res.forEach((item) => { spark.append(item) })
self.postMessage({ percent: 100, hash: spark.end() })
self.close()
}
- 其中就是调用fileReader,然后把传来的切片blob转换成arrayBuffer,最后调用spark的静态方法得到文件的哈希。
- 这个跟上面官方案例其实差不多逻辑。主要就是worker导入脚本就是importScripts,监听事件就是onmessage,发送数据就是postMessage。最后还有个关闭。
- 这里有个坑把我坑到了,就是这个append必须拿出来添加,不然每次算的hash值不一样。因为reader读取的时间不定,导致放入spark里的顺序不对。
- 函数组件里这么处理:
const handleSubmit=async()=>{
if(!currentFile){
return message.error('未选择文件')
}
if(!validateFile(currentFile))return
const partList = createChunks(currentFile)
const flieHash = await createHash(partList)
const extname =currentFile.name.slice(currentFile.name.lastIndexOf('.'))
const fname = `${flieHash}${extname}`
console.log(fname)
}
- 注释掉上传服务器的代码,先使用createChunks对文件进行分片,得到blob数组。然后交给createHash函数,计算hash,最后获得扩展名,把哈希和扩展名拼一起,这样相同文件都是一个名字了。
- createHash与createChunks这么处理:
const DefaultSize = 1024*1024
interface ChunkPart{
chunk:Blob
size:number,
chunk_index:number
}
function createChunks(file:File){
let current = 0;
let partList:ChunkPart[]=[]
while(current <file.size){
const chunk = file.slice(current,DefaultSize+current)
partList.push({chunk,size:chunk.size,chunk_index:current})
current+=DefaultSize
}
return partList
}
function createHash(partList:ChunkPart[]){
return new Promise((res,rej)=>{
const worker=new Worker('public/hashworker.js')
worker.postMessage({partList})
worker.onmessage=function(event){
let {percent ,hash}=event.data
console.log(percent,hash)
if(hash){
res(hash)
}
}
})
}
- createChunks没啥说的,这里我使用1mb一个分片进行分片。就调用blob的slice切就可以了。
- createHash返回个promise,这里开了个worker去计算,worker每算完一个还可以返回百分比。当hash有值时,就是最终结果。
- 另外有些新手可能不太明白静态资源到底配哪。一般如果用脚手架的话,配置都是做好的,有个固定的目录,webpack会不进行编译,直接复制到build目录。devServer在启动时,也是根据build的目录来形成的目录结构。如果自己配的话,可以使用copy-webpack-plugin插件,来配置静态资源。
- 这样就完成了前端分片工作,可以点按钮试一下,出现下面效果:
9.09 undefined
18.18 undefined
27.27 undefined
36.36 undefined
45.45 undefined
54.55 undefined
63.64 undefined
72.73 undefined
81.82 undefined
90.91 undefined
100 undefined
100 "69c0a31482948d7a81a0178dccd4a412"
69c0a31482948d7a81a0178dccd4a412.jpg
- 好玩吧。下面改造后端接口:
- 后端需要拿到这个分片数据,然后合并,先看拿到分片数据。就是建立个临时目录,里面以它哈希命名,存放它的文件片段。
- 这里为了方便,使用动态路由来获取参数:
app.post('/user/splitupload/:filename/:chunk_name', splitupload)
export const splitupload = async (_req: Request, _res: Response, _next: NextFunction) => {
const { filename, chunk_name } = _req.params
const file_dir = path.resolve(TEMP_DIR, filename)
const exist = await fs.pathExists(file_dir)
if (!exist) {
await fs.mkdirs(file_dir)
}
const chunkPath = path.resolve(file_dir, chunk_name)
let ws = fs.createWriteStream(chunkPath)
ws.on('close', () => {
_res.json({ success: true })
})
_req.pipe(ws)
}
- 代码很简单,就是拿到参数,然后看临时目录里有没有这个文件夹,没有这个文件夹就创建,然后再创建可写流把req传来的给灌进去。灌完发个json完事。
- 下面继续回到前端,编写分片上传的请求:
- 这次上传请求和前面有点不太一样。前面只传一次文件,这次我们可以分成好几份传。所以要搞个并发上传。另外需要改一下请求头的格式。
async function uploadRequest(partList:ChunkPart[],filename:string){
let requestList = partList.map((item:ChunkPart)=>
axios.post(`/user/splitupload/${filename}/${filename}-${item.chunk_index}`,
item.chunk,{headers:{'Content-Type':'application/octet-stream'}}))
let res =await Promise.all(requestList)
console.log(res)
}
- 这样前后端就可以通信了。点击上传,可以发现临时目录里出现了文件的分片。
- 这时还差一步就完成了,服务器需要对这些分片文件进行合并,然后放到原本目录下,并删除原来目录。
- 前端先去请求个合并路由,为啥前端请求?因为后端收到多个分片数据不知道什么时候整个分片结束。前端可以利用promise判断分片全部完成的时机,然后再去请求就可以合并完整的文件了。
await Promise.all(requestList)
await axios.get(`/user/merge/${filename}`)
- 后端就是创建可读流和可写流,可读流读取分片文件,可写流的start控制写入文件的起始位。
export const uploadMerge = async (_req: Request, _res: Response, _next: NextFunction) => {
let { filename } = _req.params;
await mergeChunks(filename)
_res.json({
success: true
})
}
const pipeStream = (filePath: string, ws: WriteStream) => (new Promise((resolve, reject) => {
let rs = fs.createReadStream(filePath)
rs.on('end', async () => {
await fs.unlink(filePath)
resolve()
})
rs.on('error', (e) => {
reject(e)
})
rs.pipe(ws)
}))
const mergeChunks = async (filename: string, size: number = 1024 * 1024) => {
const filePath = path.resolve(PUBLIC_UPLOAD_DIR, filename)
const chunkDir = path.resolve(TEMP_DIR, filename)
let chunkFiles = await fs.readdir(chunkDir)
chunkFiles.sort((a, b) => Number(a.split('-')[1]) - Number(b.split('-')[1]))
await Promise.all(chunkFiles.map((item: string, index: number) => {
return (
pipeStream(path.resolve(chunkDir, item), fs.createWriteStream(filePath, { start: index * size }))
)
}))
await fs.rmdir(chunkDir)
}
五、秒传功能
- 这样前后端对接完成!可以试一下,上传没有问题了,现在还缺秒传功能,秒传功能就简单了,传数据前先发个请求看一下地址里有没有对应hash值的文件,有的话就直接返回上传成功!
- 也可以再原本接口里判断,不过如果做断点续传的话,还需要有一些其它逻辑,所以最好是发一个请求,来进行判断。
- 于是前端加个判断,通过一个接口验证文件:
async function uploadRequest(partList:ChunkPart[],filename:string){
const res:VerifyData = await axios.get(`/verify/${filename}`)
if(res.needUpload){
let requestList = partList.map((item:ChunkPart)=>
axios.post(`/user/splitupload/${filename}/${filename}-${item.chunk_index}`,
item.chunk,
{headers:{'Content-Type':'application/octet-stream'}}
))
await Promise.all(requestList)
await axios.get(`/user/merge/${filename}`)
message.success('上传成功')
}else{
message.success('秒传完成')
}
}
export const verifyUpload = async (_req: Request, _res: Response, _next: NextFunction) => {
const { filename } = _req.params
const filePath = path.resolve(PUBLIC_UPLOAD_DIR, filename)
const existFile = await fs.pathExists(filePath)
if (existFile) {
_res.json(
{
success: true,
needUpload: false
}
)
} else {
_res.json(
{
success: true,
needUpload: true
}
)
}
}
六、断点续传功能
- 本来准备下次写这个,都写到这了,验证的逻辑稍微改一下就能实现断点续传!
- 在验证时,我们还要去验证临时目录,临时目录里如果存在这个hash目录,然后就看每个分片里传了多少,在返回给前端一个数组,记录了各个分片的进度。
- 然后前端传分片时,附带分片进度信息,文件还是原来的分片文件。后端收到文件之后,创建可写流的起始位就变为分片进度的起始位。
- 停停停,上面的操作看起来很美好,实际是不可行的,踩坑如下:
- 如果是意外退出比如刷新时,此时临时文件夹里的分片大小确实是不不完整的,但不排除有非常大的可能是损坏的。还记得在学nodejs时流的操作有个highwatermark选项么?也就是说如果异常中断,还在highwatermark的之间中断,那么这个分片的数据就完全不能用,除非你能精确到在哪个highwatermark处传,但是文件的信息会显示一个更大的大小,而不是一个精确的数值。
- 所以,分片异常退出时必须不能采用此分片,而不是接着没传完的分片后面传。
- 这里先改写验证接口,使其能返回分片信息,返回的信息把大小拿到,虽然说我已经验证没用,要么传要么不传,但以后说不定好做扩展。
export const verifyUpload = async (_req: Request, _res: Response, _next: NextFunction) => {
const { filename } = _req.params
const filePath = path.resolve(PUBLIC_UPLOAD_DIR, filename)
const existFile = await fs.pathExists(filePath)
if (existFile) {
_res.json(
{
success: true,
needUpload: false
}
)
} else {
let uploadList: Array<any> = []
const hashDir = path.resolve(TEMP_DIR, filename)
const existDir = await fs.pathExists(hashDir)
if (existDir) {
uploadList = await fs.readdir(hashDir)
uploadList = await Promise.all(uploadList.map(async (chunkName: string) => {
let stat = await fs.stat(path.resolve(hashDir, chunkName))
return {
chunkName,
size: stat.size
}
}))
}
_res.json({
success: true,
needUpload: true,
uploadList
})
}
}
app.post('/user/splitupload/:filename/:chunk_name/:chunk_start', splitupload)
export const splitupload = async (_req: Request, _res: Response, _next: NextFunction) => {
const { filename, chunk_name, chunk_start } = _req.params
const start: number = Number(chunk_start)
const file_dir = path.resolve(TEMP_DIR, filename)
const exist = await fs.pathExists(file_dir)
if (!exist) {
await fs.mkdirs(file_dir)
}
const chunkPath = path.resolve(file_dir, chunk_name)
let ws = fs.createWriteStream(chunkPath, { flags: 'a', start })
_req.on('end', () => {
ws.close()
_res.json({ success: true })
})
_req.on('error', () => {
ws.close()
_res.end()
})
_req.on('close', async () => {
ws.close()
if (!_req.complete) {
await fs.unlink(chunkPath)
}
_res.end()
})
_req.pipe(ws)
}
async function uploadRequest(partList:ChunkPart[],filename:string){
const res:VerifyData = await axios.get(`/verify/${filename}`)
if(res.needUpload){
if(res.uploadList.length!==0){
let solveList = partList.filter((item)=>{
let uploadedItem = res.uploadList.find((it)=>it.chunkName===`${filename}-${item.chunk_index}`)
if(!uploadedItem){
item.start =0
return true
}
if(uploadedItem.size<item.size){
item.start = uploadedItem.size
return true
}
return false
})
let requestList = solveList.map(((item:ChunkPart)=>
axios.post(`/user/splitupload/${filename}/${filename}-${item.chunk_index}/${item.start}`,
item.chunk,
{headers:{'Content-Type':'application/octet-stream'}}
)))
await Promise.all(requestList)
await axios.get(`/user/merge/${filename}`)
message.success('续传成功')
}else{
let requestList = partList.map((item:ChunkPart)=>
axios.post(`/user/splitupload/${filename}/${filename}-${item.chunk_index}/${0}`,
item.chunk,
{headers:{'Content-Type':'application/octet-stream'}}
))
await Promise.all(requestList)
await axios.get(`/user/merge/${filename}`)
message.success('上传成功')
}
}else{
message.success('秒传完成')
}
}
最终效果
- 我没写多少css,做的这个gif分别对应了初次上传、二次上传、请求中断断网、以及异常中断的刷新这4种情况。
文件异常验证tips
- 第一、验证大小是否和上传文件一模一样,如果大小不一样,分片或者合并处有问题。
- 第二、如果大小和上传文件一模一样,但是打不开,检查文件是否被占用,如果被占用,说明文件流没有关闭,比如请求异常退出时,或者中断时,流还在监听状态。
- 第三、如果上述两项都没问题,仍打不开,改为txt检查文件内容与原版比差了多少。有可能是分片损坏,或者文件合并时顺序不对。