React + Node 实现大文件分片上传、断点续传、秒传思路

「这是我参与2022首次更文挑战的第4天,活动详情查看:2022首次更文挑战」。

1、整体思路

  1. 将文件切成多个小的文件;
  2. 将切片并行上传;
  3. 所有切片上传完成后,服务器端进行切片合成;
  4. 当分片上传失败,可以在重新上传时进行判断,只上传上次失败的部分;
  5. 当切片合成为完整的文件,通知客户端上传成功;
  6. 已经传到服务器的完整文件,则不需要重新上传到服务器,实现,秒传功能;

2、实现步骤

2.1 文件切片加密

利用MD5 , MD5 是文件的唯一标识,可以利用文件的 MD5 查询文件的上传状态; ​

读取进度条进度,生成MD5: image.png

实现结果: image.png

实现代码如下:

const md5File = (file) => {
    return new Promise((resolve, reject) => {
      // 文件截取
      let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
        chunkSize = file?.size / 100,
        chunks = 100,
        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 += 1;

        if (currentChunk < chunks) {
          loadNext();
        } else {
          let result = spark.end()
          resolve(result)
        }
      };

      fileReader.onerror = function () {
        message.error('文件读取错误')
      };

      const loadNext = () => {
        const start = currentChunk * chunkSize,
          end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;

        // 文件切片
        fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
        // 检查进度条
        dispatch({ type: 'check', checkPercent: currentChunk + 1 })
      }

      loadNext();
    })
  }
复制代码

2.2 查询上传文件状态

利用当前md5去查询服务器创建的md5文件夹是否存在,如果存在则返回该目录下的所有分片; image.png

前端只需要拿MD5和文件名去请求后端,这里就不在列出来; ​

node端代码逻辑:

app.get('/check/file', (req, resp) => {
  let query = req.query
  let fileName = query.fileName
  let fileMd5Value = query.fileMd5Value
  // 获取文件Chunk列表
  getChunkList(
      path.join(uploadDir, fileName),
      path.join(uploadDir, fileMd5Value),
      data => {
          resp.send(data)
      }
  )
})

// 获取文件Chunk列表
async function getChunkList(filePath, folderPath, callback) {
  let isFileExit = await isExist(filePath)
  let result = {}
  // 如果文件已在存在, 不用再继续上传, 真接秒传
  if (isFileExit) {
      result = {
          stat: 1,
          file: {
              isExist: true,
              name: filePath
          },
          desc: 'file is exist'
      }
  } else {
      let isFolderExist = await isExist(folderPath)
      // 如果文件夹(md5值后的文件)存在, 就获取已经上传的块
      let fileList = []
      if (isFolderExist) {
          fileList = await listDir(folderPath)
      }
      result = {
          stat: 1,
          chunkList: fileList,
          desc: 'folder list'
      }
  }
  callback(result)
}
复制代码

2.3 秒传

如果上传的当前文件已经存在服务器目录,则秒传; ​

服务器端代码已给出,前端根据返回的接口做判断;

if (data?.file) {
  message.success('文件已秒传')
  return
}
复制代码

实现效果: image.png

2.4 上传分片、断点续传

检查本地切片和服务器对应的切片,如果没有当前切片则上传,实现断点续传;

同步并发上传所有的切片,维护上传进度条状态; ​

前端代码:

/**
   * 上传chunk
   * @param {*} fileMd5Value 
   * @param {*} chunkList 
   */
  async function checkAndUploadChunk(file, fileMd5Value, chunkList) {
    let chunks = Math.ceil(file.size / chunkSize)
    const requestList = []
    for (let i = 0; i < chunks; i++) {
      let exit = chunkList.indexOf(i + "") > -1
      // 如果不存在,则上传
      if (!exit) {
        requestList.push(upload({ i, file, fileMd5Value, chunks }))
      }
    }

    // 并发上传
    if (requestList?.length) {
      await Promise.all(requestList)
    }
  }

	// 上传chunk
  function upload({ i, file, fileMd5Value, chunks }) {
    current = 0
    //构造一个表单,FormData是HTML5新增的
    let end = (i + 1) * chunkSize >= file.size ? file.size : (i + 1) * chunkSize
    let form = new FormData()
    form.append("data", file.slice(i * chunkSize, end)) //file对象的slice方法用于切出文件的一部分
    form.append("total", chunks) //总片数
    form.append("index", i) //当前是第几片     
    form.append("fileMd5Value", fileMd5Value)
    return axios({
      method: 'post',
      url: BaseUrl + "/upload",
      data: form
    }).then(({ data }) => {
      if (data.stat) {
        current = current + 1
        const uploadPercent = Math.ceil((current / chunks) * 100)
        dispatch({ type: 'upload', uploadPercent })
      }
    })
  }
复制代码

Node端代码:

app.all('/upload', (req, resp) => {
  const form = new formidable.IncomingForm({
      uploadDir: 'nodeServer/tmp'
  })
  form.parse(req, function(err, fields, file) {
      let index = fields.index
      let fileMd5Value = fields.fileMd5Value
      let folder = path.resolve(__dirname, 'nodeServer/uploads', fileMd5Value)
      folderIsExit(folder).then(val => {
          let destFile = path.resolve(folder, fields.index)
          copyFile(file.data.path, destFile).then(
              successLog => {
                  resp.send({
                      stat: 1,
                      desc: index
                  })
              },
              errorLog => {
                  resp.send({
                      stat: 0,
                      desc: 'Error'
                  })
              }
          )
      })
  })
复制代码

实现效果: image.png 存储形式: image.png

2.5 合成分片还原完整文件

当所有的分片上传完成,前端通知服务器端分片上传完成,准备合成; ​

前端代码:

  /**
   * 所有的分片上传完成,准备合成
   * @param {*} file 
   * @param {*} fileMd5Value 
   */
  function notifyServer(file, fileMd5Value) {
    let url = BaseUrl + '/merge?md5=' + fileMd5Value + "&fileName=" + file.name + "&size=" + file.size
    axios.get(url).then(({ data }) => {
      if (data.stat) {
        message.success('上传成功')
      } else {
        message.error('上传失败')
      }
    })
  }
复制代码

Node端代码:

// 合成
app.all('/merge', (req, resp) => {
  let query = req.query
  let md5 = query.md5
  let fileName = query.fileName
  console.log(md5, fileName)
  mergeFiles(path.join(uploadDir, md5), uploadDir, fileName)
  resp.send({
      stat: 1
  })
})


// 合并文件
async function mergeFiles(srcDir, targetDir, newFileName) {
  let fileArr = await listDir(srcDir)
  fileArr.sort((x,y) => {
      return x-y;
  })
  // 把文件名加上文件夹的前缀
  for (let i = 0; i < fileArr.length; i++) {
      fileArr[i] = srcDir + '/' + fileArr[i]
  }
  concat(fileArr, path.join(targetDir, newFileName), () => {
      console.log('合成成功!')
  })
}
复制代码

请求实现: ​

image.png

合成文件效果: image.png

3、总结

  1. 将文件切片,并发上传切片,切片合成完整文件,实现分片上传;
  2. 使用MD5标识文件夹,得到唯一标识;
  3. 分片上传前通过文件 MD5 查询已上传切片列表,上传时只上传未上传过的切片,实现断点续传;
  4. 检查当前上传文件,如果已存在服务器,则不需要再次上传,实现秒传;

4、后续扩展与思考

  1. 使用时间切片计算hash

当文件过大时需要计算很久的hash,页面不能做其他的操作,所以考虑使用React-Fiber的架构理念,利用浏览器空闲时间去计算hash。考虑使用window.requestIdleCallback()函数;

  1. 请求并发控制

假如一个文件过大,就会切割成许多的碎片,一次性发几百个请求,这显然是不行的;所以要考虑请求并发数控制;

5、源码

地址:github.com/linhexs/fil…

猜你喜欢

转载自juejin.im/post/7055474765611401253