【React】前后端手写分片上传秒传与断点续传

前言

  • 上传一般都用别人写好的。自己实现遍比较有意思,同时理解更透彻。

流程

一、制作基本样式

  • 首先,需要做出个上传的基本样式。
  • 由于原生的上传文件样式太丑。所以需要自己额外写个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 //单位字节 1024字节=1kb 1024k = 1mb ...
    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>
            }
            {//有url显示图片
                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,                             // Read in chunks of 2MB
        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);                   // Append array buffer
        currentChunk++;
 
        if (currentChunk < chunks) {
            loadNext();
        } else {
            console.log('finished loading');
            console.info('computed hash', spark.end());  // Compute hash
        }
    };
 
    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)//读取每个chunk,转成arraybuffer
        reader.onload = function (event) {
            percent += perSize
            self.postMessage({ percent: Number(percent.toFixed(2)) })
            resolve(event.target.result)
        }
    })))
    res.forEach((item) => { spark.append(item) })//必须单独拿出来append,因为每个chunk不一样,会导致上面循环顺序不对,从而使得最后算出的hash不一样
    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 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)
        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  //1mb分片
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) => {//size是和前端约定的分片大小
    const filePath = path.resolve(PUBLIC_UPLOAD_DIR, filename)//目标路径
    const chunkDir = path.resolve(TEMP_DIR, filename)//temp下以文件名为路径的目录
    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检查文件内容与原版比差了多少。有可能是分片损坏,或者文件合并时顺序不对。
发布了166 篇原创文章 · 获赞 10 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/yehuozhili/article/details/105135971