React + Koa 实现文件断点续传

断点续传

什么是断点续传

断点续传,指的是在上传/下载的过程中,由于网络或其他原因导致上传/下载终断。可以从已经上传或下载的部分开始继续上传下载未完成的部分,而没有必要从头开始上传/下载。用户可以节省时间,提高速度。

整体思路流程

1. 选择文件-解析文件

先来一个选择文件的input和一个上传按钮

<div>
	<input type="file" ref={InputRef}/>
    <Button type="primary" onClick={uploadFile}>
        上传
    </Button>
</div>

选择文件后点击上传

async function uploadFile() {
    const file = InputRef.current.files[0];   // 获取选择的文件
    const buffer = await fileParse(file);
}

// 把文件解析为buffer数据
function fileParse(file: any) {
    return new Promise((resolve) => {
        const fileRead = new FileReader();
        fileRead.readAsArrayBuffer(file);
        fileRead.onload = (ev: any) => {
            resolve(ev.target.result);
        };
    });
}

2. 生成MD5值

根据文件内容,生成文件的MD5值,用来唯一标识文件,也是后面实现断点续传的基础。

使用 spark-md5 包生成MD5

import SparkMD5 from 'spark-md5';

function makeMd5(buffer) {
    const spark = new SparkMD5.ArrayBuffer();
    spark.append(buffer);
    const hash = spark.end();
}
3. 文件分片

根据设定的每片数据的大小,计算出当前文件可以分成多少片。

将分片后的数据保存在一个数据中。

async function createChunkFile(file: any) {
    if (!file) return;
    const suffix = /\.([0-9a-zA-Z]+)$/i.exec(file.name)[1];  // 获取文件名后缀
    const list = [];
    const count = Math.ceil(file.size / SIZE);  // SIZE指的是规定每一片数据的大小
    const partSize = file.size / count;  // 时间上每片数据的大小

    let cur = 0;
    for (let i = 0; i < count; i++) {
        let item = {
            chunk: file.slice(cur, cur + partSize),
            filename: `${hash}_${i}.${suffix}`,   // 每片文件的名称以MD5_索引值的方式
        };
        cur += partSize;
        list.push(item);
    }
}
4. 确定上传索引值

到这里我们已经得到了一个文件分片后的数据列表,但现在还不能开始上传。

有可能此文件在之前已经上传了一些内容,那么只需要上传剩下的内容即可。

因此需要用 MD5 值查询一下应该从第几片开始上传。

async function getLoadingFiles() {
    axios.get(`/file/uploaded/count?hash=${hash}`)
        .then((res: any) => {
        if (res.code === 1) {
            const count = res.data.count;
            uploadFn(count);  // 上传文件
        }
    })
}
5. 上传文件

现在可以正式开始上传文件了

async function uploadFn(startIndex: number = 0) {
    if (list.length === 0) return;
    const requestList: any[] = [];
    list.forEach((item: any, index: number) => {
        const fn = () => {
            let formData = new FormData();
            formData.append('chunk', item.chunk);
            formData.append('filename', item.filename);
            return axios
                .post('/file/upload', formData, {
                headers: { 'Content-Type': 'multipart/form-data' },
            })
                .then((res: any) => {
                const data = res.data;
                if (res.code === 1) {
                    setUploadedIndex(index);
                    setUploadProgress((data.index + 1) * 100 / partList.current.length);
                }
            })
                .catch(function () {
                setAbort(true);
                message.error('上传失败')
            });
        };
        requestList.push(fn);
    });
    uploadSend(startIndex, requestList);
}

// 上传单个切片
async function uploadSend(index: number, requestList: any) {
    if (abortRef.current) return;
    if (index >= requestList.length) {
        uploadComplete();  // 上传完成
        return;
    }
    await requestList[index]();
    uploadSend( ++index, requestList);
}
6. 上传完成

当列表内的所有数据都上传完成后还没有结局,需要通知后端可以合并文件了。

// 上传完成
async function uploadComplete() {
    let result: any = await axios.get(`/file/upload/finish?hash=${hash.current}`);
    if (result.code === 1) {
        message.success('上传成功');
    }
}

到这了前端的内容就全部完成了,贴一下完整代码

import { useState, useRef, useEffect } from 'react';
import { Button, message, Progress } from 'antd';
import SparkMD5 from 'spark-md5';
import { formatFileSizeUnit } from '@/common';
import axios from '@/axios';
import { PlayCircleFilled, PauseCircleFilled } from '@ant-design/icons';
import './uploadingPanel.less';

const SIZE = 1024 * 1024 * 2;

const UploadingPanel = (props: any) => {

    const {} = props;

    const [abort, setAbort] = useState<boolean>(false);
    const [uploadProgress, setUploadProgress] = useState<number>(0);  // 上传进度
    const [uploadedIndex, setUploadedIndex] = useState(0);  // 上传进度
    const [infoMsg, setInfoMsg] = useState<string>('');   // 
    // const [fileInfo, setFileInfo] = useState<any>({});
    const InputRef = useRef<any>(null);
    const hash = useRef<any>(null);  // 文件hash值
    const partList = useRef<any>([]);   // 分片后的文件
    const abortRef = useRef<any>(false);

    // 上传文件
    async function uploadFile() {
        setUploadProgress(0);
        setInfoMsg('文件分解中');
        const file = InputRef.current.files[0];
        await createChunkFile(file);
    }

    //转换文件类型(解析为BUFFER数据)
    function fileParse(file: any) {
        return new Promise((resolve) => {
            const fileRead = new FileReader();
            fileRead.readAsArrayBuffer(file);
            fileRead.onload = (ev: any) => {
                resolve(ev.target.result);
            };
        });
    }

    async function createChunkFile(file: any) {
        if (!file) return;
        const buffer = await fileParse(file);
        const spark = new SparkMD5.ArrayBuffer();
        spark.append(buffer);
        hash.current = spark.end();
        const suffix = /\.([0-9a-zA-Z]+)$/i.exec(file.name)[1];
        const list = [];
        const count = Math.ceil(file.size / SIZE);
        const partSize = file.size / count;

        let cur = 0;
        for (let i = 0; i < count; i++) {
            let item = {
                chunk: file.slice(cur, cur + partSize),
                filename: `${hash.current}_${i}.${suffix}`,
            };
            cur += partSize;
            list.push(item);
        }
        partList.current = list;
        getLoadingFiles();
    }

    async function getLoadingFiles() {
        axios.get(`/file/uploaded/count?hash=${hash.current}`)
            .then((res: any) => {
                if (res.code === 1) {
                    const count = res.data.count;
                    setInfoMsg('文件上传中');
                    setUploadProgress((count * 100 / partList.current.length).toFixed(2));
                    uploadFn(count);
                }
            })
    }

    async function uploadFn(startIndex: number = 0) {
        if (partList.current.length === 0) return;
        abortRef.current = false;
        const requestList: any[] = [];
        partList.current.forEach((item: any, index: number) => {
            const fn = () => {
                let formData = new FormData();
                formData.append('chunk', item.chunk);
                formData.append('filename', item.filename);
                return axios
                    .post('/file/upload', formData, {
                        headers: { 'Content-Type': 'multipart/form-data' },
                    })
                    .then((res: any) => {
                        const data = res.data;
                        if (res.code === 1) {
                            setUploadedIndex(index);
                            setUploadProgress((data.index + 1) * 100 / partList.current.length);
                        }
                    })
                    .catch(function () {
                        setAbort(true);
                        message.error('上传失败')
                    });
            };
            requestList.push(fn);
        });
        uploadSend(startIndex, requestList);
    }

    // 上传单个切片
    async function uploadSend(index: number, requestList: any) {
        if (abortRef.current) return;
        if (index >= requestList.length) {
            uploadComplete();
            return;
        }
        requestList[index] ? await requestList[index]() : setInfoMsg('');
        uploadSend( ++index, requestList);
    }

    // 上传完成
    async function uploadComplete() {
        let result: any = await axios.get(`/file/upload/finish?hash=${hash.current}`);
        if (result.code === 1) {
            message.success('上传成功');
            setInfoMsg('上传完成');
        }
    }

    // 选择文件
    function changeFile() {
        const file = InputRef.current.files[0];
        setFileInfo(file || {});
    }

    return (
        <div className="uploading-panel">
            <input type="file" onChange={changeFile} ref={InputRef}/>
            {infoMsg && (
                <span>【{infoMsg}】</span>
            )}
            {fileInfo.size && (
                <span>{formatFileSizeUnit(fileInfo.size)}</span>
            )}
            <Button type="primary" onClick={uploadFile}>
                上传
            </Button>
            <Button
                type="primary"
                shape="circle"
                icon={abort ? <PlayCircleFilled /> : <PauseCircleFilled />}
                onClick={() => {
                    abortRef.current = !abort;
                    abort && uploadFn(uploadedIndex + 1)
                    setAbort(!abort)
                }}
            />
            <div>
                <Progress percent={uploadProgress} status="active"/>
            </div>
        </div>
    );
};

export default UploadingPanel;

接口实现

现在来看看后端接口的实现,后端我使用的 koa

1. 查询某个文件已经上传多少片了

前端在分片时,对数据是按照索引顺序进行上传的,并且只有上一片上传成功后才会开始下一片的上传。

那么我们只需要计算当前已经上传分片的数量,返回给前端,前端只需要从相应的索引出上传即可。

router.get('/uploaded/count', async (ctx, next) => {
  const {
    hash
  } = ctx.query;
  const filePath = `${uploadDir}${hash}`;
  const fileList = (fs.existsSync(filePath) && fs.readdirSync(filePath)) || [];
  ctx.body = {
    code: 1,
    data: {
      count: fileList.length
    }
  }
})
2. 接收上传文件
router.post('/upload', async (ctx, next) => {
  const file = ctx.request.files.chunk // 获取上传文件
  const {
    filename,
  } = ctx.request.body;
  const reader = fs.createReadStream(file.path);
  const [hash, suffix] = filename.split('_');
  const folder = uploadDir + hash;
  !fs.existsSync(folder) && fs.mkdirSync(folder);
  const filePath =  `${folder}/${filename}`;
  const upStream = fs.createWriteStream(filePath);
  reader.pipe(upStream);
  ctx.body = await new Promise((resolve, reject) => {
    reader.on('error', () => {
      reject({
        code: 0,
        massage: '上传失败',
      })
    })
    reader.on('close', () => {    
      resolve({
        code: 1,
        massage: '上传成功',
        data: {
          hash,
          index: Number(suffix.split('.')[0])
        }
      })
    })
  })
  
})
3. 合并文件
router.get('/upload/finish', async (ctx, next) => {
  const {
    hash
  } = ctx.query;
  const filePath = `${uploadDir}${hash}`;
  const fileList = fs.readdirSync(filePath);
  let suffix = '';
  fileList.sort((a, b) => {
      let reg = /_(\d+)/;
      return reg.exec(a)[1] - reg.exec(b)[1];
    }).forEach((item) => {
      suffix = /\.([0-9a-zA-Z]+)$/.exec(item)[1] || null;
      fs.appendFileSync(`${path.join(__dirname, '../public/file/')}${hash}.${suffix}`, fs.readFileSync(`${filePath}/${item}`));
      fs.existsSync(`${filePath}/${item}`) && fs.unlinkSync(`${filePath}/${item}`);
    });
    fs.existsSync(filePath) && fs.unlinkSync(filePath);
  ctx.body = {
    code: 1,
    msg: '上传成功'
  }
})

到现在为止就已经完全实现了断点续传的功能,在上传过程中可以手动暂停/继续。

即使是刷新页面后,当我们重新选择未上传完成的文件时,也可以从上次断开的地方继续上传。

欢迎访问我的个人博客网站:www.dengzhanyong.com 【新博客优先发布】

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

前端筱园

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值