前端技术实现文件上传的断点续传

本文要实现断点续传,点续传,续传,传。。。。。
断点续传是啥!!!戳这里—>百科断点续传
大白话:就是将一个大文件分成好几个小文件,再通过http请求或者webSocket等方式上传到服务器或者下载到本地。
本文主要介绍上传的续传,egg做完服务端,react做完前端

效果图

在这里插入图片描述

服务端代码解析

后端代码是在使用egg生成器生成的基础上,进行编写的:

路由

/app/routes.ts

import { Application } from 'egg';

export default (app: Application) => {
  const { controller, router } = app;

  router.post('/', controller.httpFile.index);

  router.post('/chunks_upload', controller.httpFile.chunksUpload);

  router.post('/chunks_merge', controller.httpFile.chunksMerge);

  router.post('/hash_check', controller.httpFile.hashCheck);

};

控制层

/app/controller/http-file.ts
主要实现上方法

  1. 上传前检测
    存在已上传,上传没有完成,没有上传三种情况
  2. 保存每次传来的切片
  3. 合并切片
import { Controller } from 'egg';
import { mkdirsSync, del } from '../public/common';
import { streamMerge } from 'split-chunk-merge';

import path = require('path');
import fs = require('fs');

const uploadPath = path.join(__dirname, '../../uploads');

export default class HomeController extends Controller {

  public async index() {
    const { ctx } = this;
    ctx.body = await ctx.service.test.sayHi('egg');
  }

  // 上传前检测
  public async hashCheck() {
    const { ctx } = this;
    const { total, chunkSize, hash, name } = ctx.request.body;
    // 上传的文件哈希文件夹加名
    const chunksPath = path.join(uploadPath, hash + '-' + chunkSize, '/');
    const filePath = path.join(uploadPath, name);
    if (fs.existsSync(filePath)) {
      // 文件已存在
      ctx.status = 200;
      ctx.body = {
        success: true,
        msg: '检查成功,文件在服务器上已存在,不需要重复上传',
        data: {
          type: 0, // type=0 为文件已上传过
        },
      };
    } else {
      if (fs.existsSync(chunksPath)) {
        // 存在文件切片文件夹,上传没有上传完
        // 上次没有上传完成,找到以及上传的切片
        const index: any = [];
        const chunks = fs.readdirSync(chunksPath);

        if (chunks.length === Number(total)) {
          // 切片上传完了,没有合并
          ctx.status = 200;
          ctx.body = {
            success: true,
            msg: '切片上传完毕,没有合并',
            data: {
              type: 1, // type=1 切片上传完毕,没有合并
            },
          };
        } else {
          // 切片没有上传完
          chunks.forEach(item => {
            const chunksNameArr = item.split('-');
            index.push(chunksNameArr[chunksNameArr.length - 1]);
          });
          ctx.status = 200;
          ctx.body = {
            success: true,
            msg: '检查成功,需要断点续传',
            data: {
              type: 2, // type= 2 需要断点续传
              index,
            },
          };
        }
      } else {
        // 没有这个文件的切片和文件
        ctx.status = 200;
        ctx.body = {
          success: true,
          msg: '检查成功,为从未上传',
          data: {
            type: 3, // type=3 为从未上传
          },
        };
      }
    }
  }

  // 保存切片
  public async chunksUpload() {
    const { ctx } = this;
    const { /* name, total, */ index, /* size, */ chunkSize, hash } = ctx.request.body;
    const file = ctx.request.files[0];

    const chunksPath = path.join(uploadPath, hash + '-' + chunkSize, '/');

    if (!fs.existsSync(chunksPath)) mkdirsSync(chunksPath);
    // 创建读入流
    const readStream = fs.createReadStream(file.filepath);
    // 创建写入流
    const writeStream = fs.createWriteStream(chunksPath + hash + '-' + index);
    // 管道输送
    readStream.pipe(writeStream);
    readStream.on('end', () => {
      // 删除临时文件
      fs.unlinkSync(file.filepath);
    });
    ctx.status = 200;
    ctx.body = {
      success: true,
      msg: '上传成功',
      data: 200,
    };

  }


  // 合并切片
  public async chunksMerge() {
    const { ctx } = this;
    const { chunkSize, name, total, hash } = ctx.request.body;
    // 根据hash值,获取分片文件。
    const chunksPath = path.join(uploadPath, hash + '-' + chunkSize, '/');
    const filePath = path.join(uploadPath, name);
    // 读取所有的chunks 文件名存放在数组中, 并进行排序
    const chunks = fs.readdirSync(chunksPath).sort((a: any, b: any) => (
      a.split('-')[1] - b.split('-')[1]
    ));
    const chunksPathList: any = [];
    if (chunks.length !== total || chunks.length === 0) {
      ctx.status = 200;
      ctx.body = {
        success: false,
        msg: '切片文件数量与请求不符合,无法合并',
        data: '',
      };
    }
    chunks.forEach((item: string) => {
      chunksPathList.push(path.join(chunksPath, item));
    });

    try {
      await streamMerge(chunksPathList, filePath, chunkSize);
      // 递归删除文件
      del(chunksPath);
      ctx.status = 200;
      ctx.body = {
        success: true,
        msg: '合并成功',
        data: '',
      };
    } catch {
      ctx.status = 200;
      ctx.body = {
        success: false,
        msg: '合并失败,请重试',
        data: '',
      };
    }
  }
}
客户端端代码解析

create-react-app创建的ts项目,引入antd

逻辑思路:
  1. 选择好文件,拿到文件的信息,点击上传后先对文件进行hash编码,保证上传的文件的名称唯一性
  2. 设置好传输切片的大小,通过FIle的slice方法将文件分割成若个片,
  3. 上传切片前先调用检测接口,根据返回回来的值进行上传
    • 上传过的不要上传
    • 上传没有完成的,根据服务端返回最后一个切片的序列来接着之后的切片续传
    • 没有上传过的就可以直接上传
  4. 每次上传完最后一个切片后,使用切片合并接口,将服务端中上传的文件的切片文件夹里的切片按照序列进行合并。

主要代码

import React, { useState } from "react";
import { Upload, Button, Progress  } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import SparkMD5 from 'spark-md5';
import axios from 'axios';
import {getState, mutate, useStore} from 'stook';
import "./index.css";

interface file{
  file: File,           // 上传的文件
  chunkSize: number;    // 文件的切片大小
}

interface chunks_upload{
  blockCount: number,   // 文件的切片数量
  chunkSize: number,    // 文件的切片大小
  hash: string,         // 文件的哈希值
  file: File,           // 上传的文件
  num: number,          // 上传的第几个切片
}

interface chunks_merge{
  blockCount: number,   // 文件的切片数量
  chunkSize: number,    // 文件的切片大小
  hash: string,         // 文件的哈希值
  name: string,         // 文件名字
}

const win: any = window;
// 文件数据的分割方法
const blobSlice = win.File.prototype.slice || win.File.prototype.mozSlice || win.File.prototype.webkitSlice;
let totalNum:number=0;
// 停止操作
let stop: boolean = false;

// 获取文件哈希值
function hashFile({file, chunkSize}: file){
  return new Promise((resolve, reject) => { 
    let currentChunk = 0;
    const chunks = Math.ceil(file.size / chunkSize);
    const spark = new SparkMD5.ArrayBuffer();
    const fileReader = new FileReader();
    function loadNext() {
      const start = currentChunk * chunkSize;
      const end = start + chunkSize >= file.size ? file.size : start + chunkSize;
      fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
    }
    fileReader.onload = (e: any) => {
      spark.append(e.target.result); // Append array buffer
      currentChunk += 1;
      if (currentChunk < chunks) {
        loadNext();
      } else {
        console.log('finished loading');
        const result = spark.end();
        // 如果单纯的使用result 作为hash值的时候, 如果文件内容相同,而名称不同的时候
        // 想保留两个文件无法保留。所以把文件名称加上。
        const sparkMd5 = new SparkMD5();
        sparkMd5.append(result);
        sparkMd5.append(file.name);
        const hexHash: string = sparkMd5.end();
        resolve(hexHash);
      }
    };
    fileReader.onerror = () => {
      console.warn('文件读取失败!');
    };
    loadNext();
  }).catch(err => {
      console.log(err);
  });
}

// 文件切片请求
async function chunks_upload({blockCount, chunkSize, hash, file, num}: chunks_upload){
  
  let bool:boolean=true;
  for (let i = num; i < blockCount; i++) {
    if(stop){
      return;
    }
    const start = i * chunkSize;
    const end = Math.min(file.size, start + chunkSize);
    // 构建表单
    const form = new FormData();
    form.append('file', blobSlice.call(file, start, end));
    form.append('name', file.name);
    form.append('total', blockCount.toString());
    form.append('index', i.toString());
    form.append('chunkSize', file.size.toString());
    form.append('hash', hash);
    // ajax提交 分片,此时 content-type 为 multipart/form-data
    const axiosOptions = {
      // 文件上传成功的处理
      onUploadProgress: (e: any) => {
        // 处理上传的进度
        console.log(blockCount, i);
        mutate('num', getState('num') + 1 );
      },
    };
    let res = await axios.post('http://192.168.15.210:7002/chunks_upload', form, axiosOptions);
    if(res.data.data !== 200){
      bool = false;
    }
  }
  // 请求切片合并数据
  const data = {
    chunkSize: file.size,
    name: file.name,
    blockCount,
    hash
  };
  if(bool){
    chunks_merge(data);
  }
}

// 切片合并
function chunks_merge({ chunkSize, name, blockCount, hash }: chunks_merge){
  axios.post('http://192.168.15.210:7002/chunks_merge', { chunkSize, name, total: blockCount, hash }).then(res => {
    console.log('上传成功');
  }).catch(err => {
    console.log(err);
  });
}

function FileUpdate(){
    const [fileList, setFileList]: any = useState([]);
    const [num, setNum] = useStore('num', 0);
    // 切片大小
    const [chunkSize, ]= useState(2 * 1024 * 1024);

    const props = {
        onRemove: (file: any) => {
            const index = fileList.indexOf(file);
            const newFileList = fileList.slice();
            newFileList.splice(index, 1);
            setFileList(newFileList);
        },
        beforeUpload: (file: any) => {
            setFileList([...fileList, file]);
          return false;
        },
        fileList,
    };


    async function handleUpload(){
        setNum(0);
        const file: File = fileList[0];
        stop = false
        if (!file) {
          alert('没有获取文件');
          return;
        }
        // 文件切片数量
        const blockCount = Math.ceil(file.size / chunkSize);
        totalNum = blockCount;
        // 文件哈希值
        const hash: any = await hashFile({file, chunkSize});
        // 先检查是否上传过
        const check_form = new FormData();
        check_form.append('total', blockCount.toString());
        check_form.append('hash', hash);
        check_form.append('chunkSize', file.size.toString());
        check_form.append('name', file.name);
        const res = await axios.post('http://192.168.15.210:7002/hash_check', check_form);
        const type = res.data.data.type;
        if(type === 0){
          // 存在了
          console.log("存在了");
          mutate('num', blockCount );
          return;
        } else if(type === 1) {
          // 切片上传完毕,没有合并
           const data = {
            chunkSize: file.size,
            name: file.name,
            blockCount: blockCount,
            hash
          };
          chunks_merge(data);
          mutate('num', blockCount );
          console.log("切片上传完毕,没有合并");
          return;
        } else if (type === 2){
          // 检查成功,需要断点续传
          const sum: number = res.data.data.index.length;
          console.log("sum", sum);
          
          mutate('num', sum );
          console.log("上次上传没有完成");
          chunks_upload({blockCount, chunkSize, file, hash, num: sum});
    
        } else if (type === 3){
          console.log("没有上传过");
          chunks_upload({blockCount, chunkSize, file, hash, num: 0});
        }
    }

    return <div className="file">
        <Upload {...props}>
          <Button icon={<UploadOutlined />}>Select File</Button>
        </Upload>
        <Progress percent={parseInt(`${(num / totalNum) * 100}`)} />
        <Button
          type="primary"
          onClick={handleUpload}
          disabled={fileList.length === 0}
          style={{ marginTop: 16 }}
        >
          Start Upload
        </Button>
        <Button
          type="primary"
          onClick={()=>{stop=true;}}
          disabled={fileList.length === 0}
          style={{ marginTop: 16 }}
        >
          stop
        </Button>
    </div>
}

export default FileUpdate;

再附上效果图

  1. 没有上传的效果
    检测到没有上传记录
  2. 续传效果
    续传效果
  3. 已经上传过文件
    文件已存在
学习捷径

两端的代码已在码云上,想继续研发的同学可以戳这里:file-slice文件断点续传demo

  • 5
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值