前端从0到1实现大文件上传

1.前言

在日常开发中,文件上传是常见的操作之一。文件上传技术使得用户可以方便地将本地文件上传到Web服务器上,这在许多场景下都是必需的,比如网盘上传、头像上传等。

但是当我们需要上传比较大的文件的时候,容易碰到以下问题:

  • 上传时间比较久
  • 中间一旦出错就需要重新上传
  • 一般服务端会对文件的大小进行限制

这两个问题会导致上传时候的用户体验是很不好的,针对存在的这些问题,我们可以通过分片上传来解决,

2.原理介绍

分片上传的原理就像是把一个大蛋糕切成小块一样。

首先,我们将要上传的大文件分成许多小块,每个小块大小相同,比如每块大小为2MB。然后,我们逐个上传这些小块到服务器。上传的时候,可以同时上传多个小块,也可以一个一个地上传。上传每个小块后,服务器会保存这些小块,并记录它们的顺序和位置信息。

所有小块上传完成后,服务器会把这些小块按照正确的顺序拼接起来,还原成完整的大文件。最后,我们就成功地上传了整个大文件。
在这里插入图片描述

分片上传的好处在于它可以减少上传失败的风险。如果在上传过程中出现了问题,只需要重新上传出错的那个小块,而不需要重新上传整个大文件。

此外,分片上传还可以加快上传速度。因为我们可以同时上传多个小块,充分利用网络的带宽。这样就能够更快地完成文件的上传过程。

3.功能实现

3.1 项目搭建

要实现大文件上传,还需要后端的支持,所以我们就用nodejs来写后端代码。

  • 前端:vue3 + vite
  • 后端:express 框架,用到的工具包:multipartyfs-extracorsbody-parsernodemon

前端目录结构如下(自己搭建即可):

在这里插入图片描述
后端目录结构如下(自己搭建即可):
在这里插入图片描述

3.2 读取文件

先搭建一个有上传文件的表单的界面:

<template>
  <h1>大文件上传</h1>
  <input @change="handleUpload" type="file">
</template>
<script setup lang="ts">
</script>
<style scoped>
</style>

在这里插入图片描述
通过监听 inputchange 事件,当选取了本地文件后,可以在回调函数中拿到对应的文件:

const handleUpload = (e: Event) => {
    
    
  const files = (e.target as HTMLInputElement).files
  if (!files) {
    
    
    return
  }
  // 读取选择的文件
  console.log(files[0]);
}

file对象是一个类数组,它大致的样子如下:
在这里插入图片描述

3.3 文件分片

文件分片的核心是用Blob对象的slice方法,我们在上一步获取到选择的文件是一个File对象,它是继承于Blob,所以我们就可以用slice方法对文件进行分片,用法如下:

let blob = instanceOfBlob.slice([start [, end [, contentType]]]};

start 和 end 代表 Blob 里的下标,表示被拷贝进新的 Blob 的字节的起始位置和结束位置。contentType 会给新的 Blob 赋予一个新的文档类型,在这里我们用不到。接下来就来使用slice方法来实现下对文件的分片。

// 1MB = 1024KB = 1024 * 1024B
const CHUNK_SIZE = 1024 * 1024;
const createFileChunks = (file: File) => {
    
    
  const fileChunkList = []
  let cur = 0
  while (cur < file.size) {
    
    
    fileChunkList.push({
    
    
      file: file.slice(cur, cur + CHUNK_SIZE),
    })
    cur += CHUNK_SIZE // CHUNK_SIZE为分片的大小
  }
  return fileChunkList
}

可以打印一下分片数组:

在这里插入图片描述

3.4 抽样计算hash(用于区分不同的文件)

先来思考一个问题,在向服务器上传文件时,怎么去区分不同的文件呢?如果根据文件名去区分的话可以吗?
答案是不可以,因为文件名我们可以是随便修改的,所以不能根据文件名去区分。但是每一份文件的文件内容都不一样,我们可以根据文件的内容去区分,具体怎么做呢?

可以根据文件内容生产一个唯一的 hash 值,大家应该都见过用 webpack 打包出来的文件的文件名都有一串不一样的字符串,这个字符串就是根据文件的内容生成的 hash 值,文件内容变化,hash 值就会跟着发生变化。我们在这里,也可以用这个办法来区分不同的文件。而且通过这个办法,我们还可以实现秒传的功能,怎么做呢?

就是服务器在处理上传文件的请求的时候,要先判断下对应文件的 hash 值有没有记录,如果A和B先后上传一份内容相同的文件,所以这两份文件的 hash 值是一样的。当A上传的时候会根据文件内容生成一个对应的 hash 值,然后在服务器上就会有一个对应的文件,B再上传的时候,服务器就会发现这个文件的 hash 值之前已经有记录了,说明之前已经上传过相同内容的文件了,所以就不用处理B的这个上传请求了,给用户的感觉就像是实现了秒传。

那么怎么计算文件的hash值呢?可以通过一个工具:spark-md5,所以我们得先安装它。

在上一步获取到了文件的所有切片,我们就可以用这些切片来算该文件的 hash 值,但是如果一个文件特别大,每个切片的所有内容都参与计算的话会很耗时间(一个G左右的文件,计算hash值可能需要十几秒),所有我们可以采取以下策略:

  • 第一个和最后一个切片的内容全部参与计算
  • 中间剩余的切片我们分别在前面、后面和中间取2个字节参与计算

这是一种牺牲小部分识别率来换取时间的策略。这样就既能保证所有的切片参与了计算,也能保证不耗费很长的时间。

/**
 * 计算文件的hash值,计算的时候并不是根据所用的切片的内容去计算的,那样会很耗时间,我们采取下面的策略去计算:
 * 1. 第一个和最后一个切片的内容全部参与计算
 * 2. 中间剩余的切片我们分别在前面、后面和中间取2个字节参与计算
 * 这样做会节省计算hash的时间
 */
const calculateHash = async (fileChunks: Array<{
     
     file: Blob}>) => {
    
    
  return new Promise(resolve => {
    
    
    const spark = new sparkMD5.ArrayBuffer()
    const chunks: Blob[] = []

    fileChunks.forEach((chunk, index) => {
    
    
      if (index === 0 || index === fileChunks.length - 1) {
    
    
        // 1. 第一个和最后一个切片的内容全部参与计算
        chunks.push(chunk.file)
      } else {
    
    
        // 2. 中间剩余的切片我们分别在前面、后面和中间取2个字节参与计算
        // 前面的2字节
        chunks.push(chunk.file.slice(0, 2))
        // 中间的2字节
        chunks.push(chunk.file.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2))
        // 后面的2字节
        chunks.push(chunk.file.slice(CHUNK_SIZE - 2, CHUNK_SIZE))
      }
    })

    const reader = new FileReader()
    reader.readAsArrayBuffer(new Blob(chunks))
    reader.onload = (e: Event) => {
    
    
      spark.append(e?.target?.result as ArrayBuffer)
      resolve(spark.end())
    }
  })
}

打印一下生成的hash:

在这里插入图片描述

3.5 文件上传

3.5.1 前端实现

前面已经完成了上传的前置操作,接下来就来看下如何去上传这些切片。

我们以1G的文件来分析,假如每个分片的大小为1M,那么总的分片数将会是1024个,如果我们同时发送这1024个分片,浏览器肯定处理不了,原因是切片文件过多,浏览器一次性创建了太多的请求。这是没有必要的,拿 chrome 浏览器来说,默认的并发数量只有 6,过多的请求并不会提升上传速度,反而是给浏览器带来了巨大的负担。因此,我们有必要限制前端请求个数。

怎么做呢,我们要创建最大并发数的请求,比如6个,那么同一时刻我们就允许浏览器只发送6个请求,其中一个请求有了返回的结果后我们再发起一个新的请求,依此类推,直至所有的请求发送完毕。

上传文件时一般还要用到 FormData 对象,需要将我们要传递的文件还有额外信息放到这个 FormData 对象里面。

const uploadChunks = async (fileChunks: Array<{
     
     file: Blob}>) => {
    
    
  const data = fileChunks.map(({
     
      file }, index) => ({
    
    
    fileHash: fileHash.value,
    index,
    chunkHash: `${
      
      fileHash.value}-${
      
      index}`,//用于区分切片
    chunk: file,//当前分片
    size: file.size,//当前分片所属的文件的总大小
  }))
 
  const formDatas = data
    .map(({
     
      chunk, chunkHash }) => {
    
          
      const formData = new FormData()
      // 切片文件
      formData.append('chunk', chunk)
      // 切片文件hash
      formData.append('chunkHash', chunkHash)
      // 大文件的文件名
      formData.append('fileName', fileName.value)
      // 大文件hash
      formData.append('fileHash', fileHash.value)
      return formData
    })

  let index = 0;
  const max = 6; // 并发请求数量
  const taskPool: any = [] // 请求队列 
  
  while(index < formDatas.length) {
    
    
    const task = fetch('http://127.0.0.1:3000/upload', {
    
    
      method: 'POST',
      body: formDatas[index],
    })

    task.then(() => {
    
    
      //当某个请求完成了  需要将它从请求队列删除
      taskPool.splice(taskPool.findIndex((item: any) => item === task))
    })
    taskPool.push(task);
    if (taskPool.length === max) {
    
    
      // 当请求队列中的请求数达到最大并行请求数的时候,得等之前的请求完成再循环下一个
      //Promise.race表示有一个完成就可以
      //有一个完成就往下执行
      await Promise.race(taskPool)
    }
    index ++
    percentage.value = (index / formDatas.length * 100).toFixed(0)
  }  

  await Promise.all(taskPool)
}

3.5.2 后端实现

后端我们处理文件时需要用到 multiparty 这个工具,所以也是得先安装,然后再引入它。

我们在处理每个上传的分片的时候,应该先将它们临时存放到服务器的一个地方,方便我们合并的时候再去读取。为了区分不同文件的分片,我们就用文件对应的那个hash为文件夹的名称,将这个文件的所有分片放到这个文件夹中。

// 所有上传的文件存放到该目录下
const path = require('path');
const multiparty = require('multiparty');
const fse = require('fs-extra');
const UPLOAD_DIR = path.resolve(__dirname, 'uploads');

// 处理上传的分片
app.post('/upload', async (req, res) => {
    
    
  const form = new multiparty.Form();
  
  form.parse(req, async function (err, fields, files) {
    
    
    if (err) {
    
    
      res.status(401).json({
    
     
        ok: false,
        msg: '上传失败'
      });
    }
    const chunkHash = fields['chunkHash'][0]
    const fileName = fields['fileName'][0]
    const fileHash = fields['fileHash'][0]

    // 存储切片的临时文件夹
    const chunkDir = path.resolve(UPLOAD_DIR, fileHash)

    // 切片目录不存在,则创建切片目录
    if (!fse.existsSync(chunkDir)) {
    
    
      await fse.mkdirs(chunkDir)
    }

    const oldPath = files.chunk[0].path;
    // 把文件切片移动到我们的切片文件夹中
    await fse.move(oldPath, path.resolve(chunkDir, chunkHash))

    res.status(200).json({
    
     
      ok: true,
      msg: 'received file chunk'
    });
  });
});

写完前后端代码后就可以来试下看看文件能不能实现切片的上传,如果没有错误的话,我们的 uploads 文件夹下应该就会多一个文件夹,这个文件夹里面就是存储的所有文件的分片了。

在这里插入图片描述

3.6 文件合并

上一步我们已经实现了将所有切片上传到服务器了,上传完成之后,我们就可以将所有的切片合并成一个完整的文件了,下面就一块来实现下。

3.6.1 前端实现

前端只需要向服务器发送一个合并的请求,并且为了区分要合并的文件,需要将文件的hash值给传过去。

/**
 * 发请求通知服务器,合并切片
 */
const mergeRequest = () => {
    
      
  // 发送合并请求
  fetch('http://127.0.0.1:3000/merge', {
    
    
    method: 'POST',
    headers: {
    
    
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
    
    
      size: CHUNK_SIZE,
      fileHash: fileHash.value,
      fileName: fileName.value,
    }),
  })
    .then((response) => response.json())
    .then(() => {
    
    
      alert('上传成功')
    })
}

3.6.2 后端实现

在之前已经可以将所有的切片上传到服务器并存储到对应的目录里面去了,合并的时候需要从对应的文件夹中获取所有的切片,然后利用文件的读写操作,就可以实现文件的合并了。合并完成之后,我们将生成的文件以hash值命名存放到对应的位置就可以了。

// 提取文件后缀名
const extractExt = filename => {
    
    
	return filename.slice(filename.lastIndexOf('.'), filename.length)
}

/**
 * 读的内容写到writeStream中
 */
const pipeStream = (path, writeStream) => {
    
    
	return new Promise((resolve, reject) => {
    
    
		// 创建可读流
		const readStream = fse.createReadStream(path)
		readStream.on('end', async () => {
    
    
			fse.unlinkSync(path)
			resolve()
		})
		readStream.pipe(writeStream)
	})
}

/**
 * 合并文件夹中的切片,生成一个完整的文件
 */
async function mergeFileChunk(filePath, fileHash, size) {
    
    
  const chunkDir = path.resolve(UPLOAD_DIR, fileHash)
  const chunkPaths = await fse.readdir(chunkDir)
  // 根据切片下标进行排序
  // 否则直接读取目录的获得的顺序可能会错乱
  chunkPaths.sort((a, b) => {
    
    
    return a.split('-')[1] - b.split('-')[1]
  })

  const list = chunkPaths.map((chunkPath, index) => {
    
    
    return pipeStream(
      path.resolve(chunkDir, chunkPath),
      fse.createWriteStream(filePath, {
    
    
        start: index * size,
        end: (index + 1) * size
      })
    )
  })

  await Promise.all(list)
	// 文件合并后删除保存切片的目录
	fse.rmdirSync(chunkDir)
}


// 合并文件
app.post('/merge', async (req, res) => {
    
    
  const {
    
     fileHash, fileName, size } = req.body
  const filePath = path.resolve(UPLOAD_DIR, `${
      
      fileHash}${
      
      extractExt(fileName)}`)
  // 如果大文件已经存在,则直接返回
  if (fse.existsSync(filePath)) {
    
    
    res.status(200).json({
    
     
      ok: true,
      msg: '合并成功'
    });
    return
  }
  const chunkDir = path.resolve(UPLOAD_DIR, fileHash)
  // 切片目录不存在,则无法合并切片,报异常
  if (!fse.existsSync(chunkDir)) {
    
    
    res.status(200).json({
    
     
      ok: false,
      msg: '合并失败,请重新上传'
    });
    return
  }
  await mergeFileChunk(filePath, fileHash, size)
  res.status(200).json({
    
     
    ok: true,
    msg: '合并成功'
  });
});

测试一下:
在这里插入图片描述

到这里,我们就已经实现了大文件的分片上传的基本功能了,但是我们没有考虑到如果上传相同的文件的情况,而且如果中间网络断了,我们就得重新上传所有的分片,这些情况在大文件上传中也都需要考虑到,下面,我们就来解决下这两个问题。

3.7 秒传和断点续传

我们在上面有提到,如果内容相同的文件进行hash计算时,对应的hash值应该是一样的,而且我们在服务器上给上传的文件命名的时候就是用对应的hash值命名的,所以在上传之前是不是可以加一个判断,如果有对应的这个文件,就不用再重复上传了,直接告诉用户上传成功,给用户的感觉就像是实现了秒传。接下来,就来看下如何实现的。

3.7.1 秒传

3.7.1.1 前端实现

前端在上传之前,需要将对应文件的hash值告诉服务器,看看服务器上有没有对应的这个文件,如果有,就直接返回,不执行上传分片的操作了。

/**
 * 验证该文件是否需要上传,文件通过hash生成唯一,改名后也是不需要再上传的,也就相当于秒传
 */
const verifyUpload = async () => {
    
    
  return fetch('http://127.0.0.1:3000/verify', {
    
    
    method: 'POST',
    headers: {
    
    
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
    
    
      fileName: fileName.value,
      fileHash: fileHash.value
    })
  })
  .then((response) => response.json())
  .then((data) => {
    
    
    return data; // data中包含对应的表示服务器上有没有该文件的查询结果
  });
}


// 点击上传事件
const handleUpload = async (e: Event) => {
    
      
  // ...

  // uploadedList已上传的切片的切片文件名称
  const res = await verifyUpload()

  const {
    
     shouldUpload } = res.data

  if (!shouldUpload) {
    
    
    // 服务器上已经有该文件,不需要上传
    alert('秒传:上传成功')
    return;
  }

  // 服务器上不存在该文件,继续上传
  uploadChunks(fileChunks)
}

3.7.1.2 后端实现

因为我们在合并文件时,文件名时根据该文件的hash值命名的,所以只需要看看服务器上有没有对应的这个hash值的那个文件就可以判断了。

// 根据文件hash验证文件有没有上传过
app.post('/verify', async (req, res) => {
    
    
  const {
    
     fileHash, fileName } = req.body
  const filePath = path.resolve(UPLOAD_DIR, `${
      
      fileHash}${
      
      extractExt(fileName)}`)

  if (fse.existsSync(filePath)) {
    
    
    // 文件存在服务器中,不需要再上传了
    res.status(200).json({
    
     
      ok: true,
      data: {
    
    
        shouldUpload: false,
      }
    });
  } else {
    
    
    // 文件不在服务器中,就需要上传
    res.status(200).json({
    
     
      ok: true,
      data: {
    
    
        shouldUpload: true,
      }
    });
  }
});

完成上面的步骤后,当我们再上传相同的文件,即使改了文件名,也会提示我们秒传成功了,因为服务器上已经有对应的那个文件了。

3.7.2 断点续传

上面我们解决了重复上传的文件,但是对于网络中断需要重新上传的问题没有解决,那该如何解决呢?

如果我们之前已经上传了一部分分片了,我们只需要在上传之前拿到这部分分片,然后再过滤掉是不是就可以避免去重复上传这些分片了,也就是只需要上传那些上传失败的分片,所以,在上传之前还得加一个判断。

3.7.2.1 前端实现

我们还是在那个 verify 的接口中去获取已经上传成功的分片,然后在上传分片前进行一个过滤。

const uploadChunks = async (fileChunks: Array<{
     
     file: Blob}>, uploadedList: Array<string>) => {
    
    
  const formDatas = fileChunks
  	.filter((chunk, index) => {
    
    
      // 过滤服务器上已经有的切片
      return !uploadedList.includes(`${
      
      fileHash.value}-${
      
      index}`)
    })
  	.map(({
     
      file }, index) => {
    
    
      const formData = new FormData()
      // 切片文件
      formData.append('file', file)
      // 切片文件hash
      formData.append('chunkHash', `${
      
      fileHash.value}-${
      
      index}`)
      // 大文件的文件名
      formData.append('fileName', fileName.value)
      // 大文件hash
      formData.append('fileHash', fileHash.value)
      return formData
    })

  // ...
}
3.7.2.2 后端实现

只需要在 /verify 这个接口中加上已经上传成功的所有切片的名称就可以,因为所有的切片都存放在以文件的hash值命名的那个文件夹,所以需要读取这个文件夹中所有的切片的名称就可以。

/**
 * 返回已经上传切片名
 * @param {*} fileHash 
 * @returns 
 */
const createUploadedList = async fileHash => {
    
    
	return fse.existsSync(path.resolve(UPLOAD_DIR, fileHash))
		? await fse.readdir(path.resolve(UPLOAD_DIR, fileHash)) // 读取该文件夹下所有的文件的名称
		: []
}

// 根据文件hash验证文件有没有上传过
app.post('/verify', async (req, res) => {
    
    
  const {
    
     fileHash, fileName } = req.body
  const filePath = path.resolve(UPLOAD_DIR, `${
      
      fileHash}${
      
      extractExt(fileName)}`)

  if (fse.existsSync(filePath)) {
    
    
    // 文件存在服务器中,不需要再上传了
    res.status(200).json({
    
     
      ok: true,
      data: {
    
    
        shouldUpload: false,
      }
    });
  } else {
    
    
    // 文件不在服务器中,就需要上传,并且返回服务器上已经存在的切片
    res.status(200).json({
    
     
      ok: true,
      data: {
    
    
        shouldUpload: true,
        uploadedList: await createUploadedList(fileHash)
      }
    });
  }
});

4.完整代码

4.1 前端代码

<template>
  <h1>大文件上传</h1>
  <input @change="handleUpload" type="file" />
</template>

<script setup lang="ts">
import {
    
     ref } from "vue";
import SparkMD5 from "spark-md5";

// 1MB = 1024KB = 1024 * 1024B
const CHUNK_SIZE = 1024 * 1024;

const fileName = ref<string>("");
const fileSize = ref<number>(0);
const fileHash = ref<string>("");

// 文件分片
const createFileChunks = (file: File) => {
    
    
  let cur = 0;
  const chunks = [];
  while (cur < file.size) {
    
    
    chunks.push(file.slice(cur, cur + CHUNK_SIZE));
    cur += CHUNK_SIZE;
  }

  return chunks;
};

// 计算hash值
const calcuteHash = (chunks: Array<Blob>) => {
    
    
  return new Promise((resolve) => {
    
    
    const targets: Blob[] = [];
    const spark = new SparkMD5.ArrayBuffer();
    // 1. 第一个和最后一个切片全部参与计算
    // 2. 中间的切片只有前两个字节、中间两个字节、后面两个字节参与计算
    chunks.forEach((chunk, index) => {
    
    
      if (index === 0 || index === chunks.length - 1) {
    
    
        targets.push(chunk);
      } else {
    
    
        targets.push(chunk.slice(0, 2)); // 前两个字节
        targets.push(chunk.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2)); // 中间两个字节
        targets.push(chunk.slice(CHUNK_SIZE - 2, CHUNK_SIZE)); // 后面两个字节
      }
    });

    const fileReader = new FileReader();
    fileReader.readAsArrayBuffer(new Blob(targets));

    fileReader.onload = (e) => {
    
    
      spark.append((e.target as FileReader).result);
      resolve(spark.end());
    };
  });
};

// 合并请求
const mergeRequest = () => {
    
    
  fetch("http://localhost:3000/merge", {
    
    
    method: "POST",
    headers: {
    
    
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
    
    
      fileHash: fileHash.value,
      fileName: fileName.value,
      size: CHUNK_SIZE,
    }),
  }).then(() => {
    
    
    alert("合并成功!");
  });
};

// 分片上传
const uploadChunks = async (chunks: Array<Blob>, existsChunks: string[]) => {
    
    
  const data = chunks.map((chunk, index) => {
    
    
    return {
    
    
      fileName: fileName.value,
      fileHash: fileHash.value,
      chunkHash: fileHash.value + "-" + index,
      chunk: chunk,
    };
  });

  const formDatas = data
    .filter((item) => !existsChunks.includes(item.chunkHash))
    .map((item) => {
    
    
      const formData = new FormData();
      formData.append("fileName", item.fileName);
      formData.append("fileHash", item.fileHash);
      formData.append("chunkHash", item.chunkHash);
      formData.append("chunk", item.chunk);

      return formData;
    });

  // [1,2,3,4,6,7]
  const max = 6; // 最大并行请求数
  const taskPool: any = []; // 请求队列
  let index = 0;

  while (index < formDatas.length) {
    
    
    const task = fetch("http://localhost:3000/upload", {
    
    
      method: "POST",
      body: formDatas[index],
    });

    task.then(() => {
    
    
      // 执行完后把当前任务从任务队列中删除
      taskPool.splice(taskPool.findIndex((item: any) => item === task));
    });
    taskPool.push(task);
    if (taskPool.length === max) {
    
    
      await Promise.race(taskPool);
    }
    index++;
  }

  await Promise.all(taskPool);
  // 所有分片上传完成后,通知服务器可以合并了
  mergeRequest();
};

/**
 * 验证该文件是否需要上传,文件通过hash生成唯一,改名后也是不需要再上传的,也就相当于秒传
 */
const verifyUpload = async () => {
    
    
  return fetch("http://127.0.0.1:3000/verify", {
    
    
    method: "POST",
    headers: {
    
    
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
    
    
      fileName: fileName.value,
      fileHash: fileHash.value,
    }),
  })
    .then((response) => response.json())
    .then((data) => {
    
    
      return data; // data中包含对应的表示服务器上有没有该文件的查询结果
    });
};

const handleUpload = async (e: Event) => {
    
    
  // console.log((e.target as HTMLInputElement).files); // 伪数组
  // 读取文件
  const files = (e.target as HTMLInputElement).files;
  if (!files) return;

  fileName.value = files[0].name;
  fileSize.value = files[0].size;

  // 文件分片
  const chunks = createFileChunks(files[0]);

  // 计算hash值
  const hash = await calcuteHash(chunks);
  fileHash.value = hash as string;

  // 校验是否需要上传
  const {
    
     data } = await verifyUpload();

  if (!data.shouldUpload) {
    
    
    alert("秒传成功");
    return;
  }
  // 分片上传
  uploadChunks(chunks, data.existsChunks);
};
</script>
<style scoped></style>

4.2 后端代码

const express = require('express');
const path = require('path');
const multiparty = require('multiparty');
const fse = require('fs-extra');
const cors = require("cors");
const bodyParser = require('body-parser');

const UPLOAD_DIR = path.resolve(__dirname, 'uploads')

const app = express();

// 提取文件后缀名
const extractExt = filename => {
    
    
	return filename.slice(filename.lastIndexOf('.'), filename.length)
}

app.use(bodyParser.json());
app.use(cors());

app.post('/upload', function (req, res) {
    
    
  const form = new multiparty.Form()

  form.parse(req, async (err, fields, files) => {
    
    
    const fileHash = fields['fileHash'][0]
    const chunkHash = fields['chunkHash'][0]

    // 临时存放切片的文件夹 
    const chunkDir = path.resolve(UPLOAD_DIR, fileHash)

    // 如果目录不存在,则创建一个新的
    if (!fse.existsSync(chunkDir)) {
    
    
      await fse.mkdirs(chunkDir)
    }

    // 如果存在,将所有的切片放到对应的目录里面
    const oldPath = files['chunk'][0]['path']
    await fse.move(oldPath, path.resolve(chunkDir, chunkHash))

    res.status(200).json({
    
    
      ok: true,
      msg: '上传成功'
    })
  })
})

app.post('/merge', async function(req, res) {
    
    
  const {
    
     fileHash, fileName, size } = req.body
  // console.log(fileHash);
  // console.log(fileName);

  const filePath = path.resolve(UPLOAD_DIR, fileHash + extractExt(fileName))
  const chunkDir = path.resolve(UPLOAD_DIR, fileHash)

  if(fse.existsSync(filePath)) {
    
    
    res.status(200).json({
    
    
      ok: true,
      msg: '合并成功'
    })
    return;
  }

  if (!fse.existsSync(chunkDir)) {
    
    
    res.status(410).json({
    
    
      ok: true,
      msg: '合并失败,请重新上传'
    })
    return;
  }

  const allChunks = await fse.readdir(chunkDir)

  allChunks.sort((a, b) => {
    
    
    return a.split('-')[1] - b.split('-')[1]
  })

  // console.log(filePath);

  const list = allChunks.map((chunkPath, index) => {
    
    
    return new Promise(resolve => {
    
    
      const readSream = fse.createReadStream(path.resolve(chunkDir, chunkPath))
      const writeSream = fse.createWriteStream(filePath, {
    
    
        start: index * size,
        end: (index + 1) * size
      })
  
      readSream.on('end', async () => {
    
    
        await fse.unlink(path.resolve(chunkDir, chunkPath))
        resolve()
      })
  
      readSream.pipe(writeSream)
    })
  })

  await Promise.all(list)
  fse.rmdirSync(chunkDir)


  res.status(200).json({
    
    
    ok: true,
    msg: '合并成功'
  })
})

app.post('/verify', async function (req, res) {
    
    
  const {
    
     fileHash, fileName } = req.body

  const filePath = path.resolve(UPLOAD_DIR, fileHash + extractExt(fileName))
  const chunkDir = path.resolve(UPLOAD_DIR, fileHash)

  let allChunks = []
  // 拿到之前已经上传过的分片
  if (fse.existsSync(chunkDir)) {
    
    
    allChunks = await fse.readdir(chunkDir)
  }

  if (fse.existsSync(filePath)) {
    
    
    res.status(200).json({
    
    
      ok: true,
      data: {
    
    
        shouldUpload: false
      }
    })
  } else {
    
    
    res.status(200).json({
    
    
      ok: true,
      data: {
    
    
        shouldUpload: true,
        existsChunks: allChunks
      }
    })
  }
})


app.listen(3000, () => {
    
    
  console.log('Server is running on port 3000');
});

5.总结与扩展

5.1 如何计算文件的hash,怎么做计算hash是最快的

在计算文件hash的方式,主要有以下几种: 分片全量计算hash抽样计算hash。在这两种方式上,分别又可以使用web-work和浏览器空闲(requestIdleCallback)来实现.

接下来我们来计算文件的hash,计算文件的hash需要使用spark-md5这个库。

5.1.1 全量计算文件hash

export async function calcHashSync(file: File) {
    
    
 // 对文件进行分片,每一块文件都是分为2MB,这里可以自己来控制
  const size = 2 * 1024 * 1024;
  let chunks: any[] = [];
  let cur = 0;
  while (cur < file.size) {
    
    
    chunks.push({
    
     file: file.slice(cur, cur + size) });
    cur += size;
  }
  // 可以拿到当前计算到第几块文件的进度
  let hashProgress = 0
  return new Promise(resolve => {
    
    
    const spark = new SparkMD5.ArrayBuffer();
    let count = 0;
    const loadNext = (index: number) => {
    
    
      const reader = new FileReader();
      reader.readAsArrayBuffer(chunks[index].file);
      reader.onload = e => {
    
    
        // 累加器 不能依赖index,
        count++;
        // 增量计算md5
        spark.append(e.target?.result as ArrayBuffer);
        if (count === chunks.length) {
    
    
          // 通知主线程,计算结束
          hashProgress = 100;
          resolve({
    
     hashValue: spark.end(), progress: hashProgress });
        } else {
    
    
          // 每个区块计算结束,通知进度即可
          hashProgress += 100 / chunks.length
          // 计算下一个
          loadNext(count);
        }
      };
    };
    // 启动
    loadNext(0);
  });
}

全量计算文件hash,在文件小的时候计算是很快的,但是在文件大的情况下,计算文件的hash就会非常慢,并且影响主进程。

5.1.2 抽样计算文件hash

抽样就是取文件的一部分来继续,原理如下:
在这里插入图片描述

抽样计算hash我在上面已经写过代码了。

在这两个的基础上,我们还可以分别使用web-workerrequestIdleCallback来实现。代码示例如下:

import SparkMD5 from 'spark-md5'

/**
 * 抽样计算hash值 大概是1G文件花费1S的时间
 * 
 * 采用抽样hash的方式来计算hash
 * 我们在计算hash的时候,将超大文件以2M进行分割获得到另一个chunks数组,
 * 第一个元素(chunks[0])和最后一个元素(chunks[-1])我们全要了
 * 其他的元素(chunks[1,2,3,4....])我们再次进行一个分割,这个时候的分割是一个超小的大小比如2kb,我们取* 每一个元素的头部,尾部,中间的2kb。
 *  最终将它们组成一个新的文件,我们全量计算这个新的文件的hash值。
 * @param file {File}
 * @returns 
 */
export async function calcHashSample(file: File) {
    
    
  return new Promise(resolve => {
    
    
    const spark = new SparkMD5.ArrayBuffer();
    const reader = new FileReader();
    // 文件大小
    const size = file.size;
    let offset = 2 * 1024 * 1024;
    let chunks = [file.slice(0, offset)];
    // 前面2mb的数据
    let cur = offset;
    /**
     * 
     */
    while (cur < size) {
    
    
      // 最后一块全部加进来
      if (cur + offset >= size) {
    
    
        chunks.push(file.slice(cur, cur + offset));
      } else {
    
    
        // 中间的 前中后去两个字节
        const mid = cur + offset / 2;
        const end = cur + offset;
        chunks.push(file.slice(cur, cur + 2));
        chunks.push(file.slice(mid, mid + 2));
        chunks.push(file.slice(end - 2, end));
      }
      // 前取两个字节
      cur += offset;
    }
    // 拼接
    reader.readAsArrayBuffer(new Blob(chunks));
    // 最后100K
    reader.onload = e => {
    
    
      spark.append(e.target?.result as ArrayBuffer);
      resolve({
    
     hashValue: spark.end(), progress: 100 });
    };
  });
}

/**
 * 使用分片和web-work 全量计算hash值
 * @param file {File}
 * @returns 
 */
export async function calcHashByWebWorker(file: File) {
    
    
  // 每一个片段都是2M
  const size = 2 * 1024 * 1024;
  let chunks: any[] = [];
  let cur = 0;
  while (cur < file.size) {
    
    
    chunks.push({
    
     file: file.slice(cur, cur + size) });
    cur += size;
  }

  return new Promise(resolve => {
    
    
    // web-worker 防止卡顿主线程
    const workder = new Worker(new URL('./../worker/hash.worker.ts', import.meta.url));
    workder.postMessage({
    
     chunks });
    workder.onmessage = e => {
    
    
      const {
    
     progress, hash } = e.data;
      if (hash) {
    
    
        resolve({
    
     hashValue: hash, progress });
      }
    };
  });
}

/**
 * 分片 web-worker 抽样计算hash值
 * @param file 
 * @returns 
 */
export async function calcHashSampleByWebWorker(file: File) {
    
    
  // 文件大小
  const size = file.size as number;
  let offset = 2 * 1024 * 1024;
  let chunks = [file.slice(0, offset)];
  // 前面100K
  let cur = offset;
  /**
   * 采用抽样hash的方式来计算hash
   * 我们在计算hash的时候,将超大文件以2M进行分割获得到另一个chunks数组,
   * 第一个元素(chunks[0])和最后一个元素(chunks[-1])我们全要了
   * 其他的元素(chunks[1,2,3,4....])我们再次进行一个分割,这个时候的分割是一个超小的大小比如2kb,我们取* 每一个元素的头部,尾部,中间的2kb。
   *  最终将它们组成一个新的文件,我们全量计算这个新的文件的hash值。
   */
  while (cur < size) {
    
    
    // 最后一块全部加进来
    if (cur + offset >= size) {
    
    
      chunks.push(file.slice(cur, cur + offset));
    } else {
    
    
      // 中间的 前中后去两个字节
      const mid = cur + offset / 2;
      const end = cur + offset;
      chunks.push(file.slice(cur, cur + 2));
      chunks.push(file.slice(mid, mid + 2));
      chunks.push(file.slice(end - 2, end));
    }
    cur += offset;
  }

  return new Promise(resolve => {
    
    
    // web-worker 防止卡顿主线程
    const workder = new Worker(new URL('./../worker/simple-hash.work.ts', import.meta.url));
    workder.postMessage({
    
     chunks: chunks });
    workder.onmessage = e => {
    
    
      const {
    
     progress, hash } = e.data;
      if (hash) {
    
    
        resolve({
    
     hashValue: hash, progress });
      }
    };
  });
}


/**
 * 全量计算hash值, 最慢,占用主进程
 * @param file 
 * @returns 
 */
export async function calcHashSync(file: File) {
    
    
  const size = 2 * 1024 * 1024;
  let chunks: any[] = [];
  let cur = 0;
  while (cur < file.size) {
    
    
    chunks.push({
    
     file: file.slice(cur, cur + size) });
    cur += size;
  }
  let hashProgress = 0
  return new Promise(resolve => {
    
    
    const spark = new SparkMD5.ArrayBuffer();
    let count = 0;
    const loadNext = (index: number) => {
    
    
      const reader = new FileReader();
      reader.readAsArrayBuffer(chunks[index].file);
      reader.onload = e => {
    
    
        // 累加器 不能依赖index,
        count++;
        // 增量计算md5
        spark.append(e.target?.result as ArrayBuffer);
        if (count === chunks.length) {
    
    
          // 通知主线程,计算结束
          hashProgress = 100;
          resolve({
    
     hashValue: spark.end(), progress: hashProgress });
        } else {
    
    
          // 每个区块计算结束,通知进度即可
          hashProgress += 100 / chunks.length
          // 计算下一个
          loadNext(count);
        }
      };
    };
    // 启动
    loadNext(0);
  });
}

/**
 * 通过时间片的方式来计算hash值
 * @param file {File}
 * @returns 
 */
export async function calcHashByIdle(file: File) {
    
    
  const size = 2 * 1024 * 1024;
  let chunks: any[] = [];
  let cur = 0;
  while (cur < file.size) {
    
    
    chunks.push({
    
     file: file.slice(cur, cur + size) });
    cur += size;
  }
  const spark = new SparkMD5.ArrayBuffer();
  let hashProgress = 0

  return new Promise(resolve => {
    
    

    let count = 0;
    const appendToSpark = async (file: File) => {
    
    
      return new Promise(resolve => {
    
    
        const reader = new FileReader();
        reader.readAsArrayBuffer(file);
        reader.onload = e => {
    
    
          spark.append(e.target?.result as ArrayBuffer);
          resolve(1);
        };
      });
    };
    const workLoop = async (deadline: IdleDeadline) => {
    
    
      // 有任务,并且当前帧还没结束
      while (count < chunks.length && deadline.timeRemaining() > 1) {
    
    
        await appendToSpark(chunks[count].file);
        count++;
        // 没有了 计算完毕
        if (count < chunks.length) {
    
    
          // 计算中
          hashProgress = Number(
            ((100 * count) / chunks.length).toFixed(2)
          );
        } else {
    
    
          // 计算完毕
          hashProgress = 100;
          resolve({
    
     hashValue: spark.end(), progress: hashProgress });
        }
      }
      window.requestIdleCallback(workLoop);
    };
    window.requestIdleCallback(workLoop);
  });
}

5.2 文件分片的方式

这里可能大家会说,文件分片方式不就是等分吗,其实还可以根据网速上传的速度来实时调整分片的大小!

const handleUpload1 = async (file:File) => {
    
    
 
  if (!file) return;
  const fileSize = file.size
  let offset = 2 * 1024 * 1024
  let cur = 0
  let count = 0
  // 每一刻的大小需要保存起来,方便后台合并
  const chunksSize = [0, 2 * 1024 * 1024]
  const obj = await calcHashSample(file) as {
    
     hashValue: string };
  fileHash.value = obj.hashValue;
  
  //todo 判断文件是否存在存在则不需要上传,也就是秒传
  
  while (cur < fileSize) {
    
    
    const chunk = file.slice(cur, cur + offset)
    cur += offset
    const chunkName = fileHash.value + "-" + count;
    const form = new FormData();
    form.append("chunk", chunk);
    form.append("hash", chunkName);
    form.append("filename", file.name);
    form.append("fileHash", fileHash.value);
    form.append("size", chunk.size.toString());
    let start = new Date().getTime()
    // todo 上传单个碎片
    const now = new Date().getTime()
    const time = ((now - start) / 1000).toFixed(4)
    let rate = Number(time) / 10
    // 速率有最大和最小 可以考虑更平滑的过滤 比如1/tan 
    if (rate < 0.5) rate = 0.5
    if (rate > 2) rate = 2

    offset = parseInt((offset / rate).toString())
    chunksSize.push(offset)
    count++
  }
  
  //todo 可以发送合并操作了
  
}

如果是这样上传的文件碎片,如果中途断开是无法续传的(每一刻的网速都是不一样的),除非每一次上传都把 chunksSize(分片的数组)保存起来

5.3 控制http请求(控制并发)

控制http的请求其实就是控制异步任务。 TCP拥塞控制的问题 其实就是根据当前网络情况,动态调整切片的大小。

/**
 * 异步控制池 - 异步控制器
 * @param concurrency 最大并发次数
 * @param iterable  异步控制的函数的参数
 * @param iteratorFn 异步控制的函数
 */
export async function* asyncPool<IN, OUT>(concurrency: number, iterable: ReadonlyArray<IN>, iteratorFn: (item: IN, iterable?: ReadonlyArray<IN>) => Promise<OUT>): AsyncIterableIterator<OUT> {
    
    
// 传教set来保存promise
  const executing = new Set<Promise<IN>>();
  // 消费函数
  async function consume() {
    
    
    const [promise, value] = await Promise.race(executing) as unknown as [Promise<IN>, OUT];
    executing.delete(promise);
    return value;
  }
  // 遍历参数变量
  for (const item of iterable) {
    
    
    const promise = (async () => await iteratorFn(item, iterable))().then(
      value => [promise, value]
    ) as Promise<IN>;
    executing.add(promise);
    // 超出最大限制,需要等待
    if (executing.size >= concurrency) {
    
    
      yield await consume();
    }
  }
  // 存在的时候继续消费promise
  while (executing.size) {
    
    
    yield await consume();
  }
}

5.4 暂停请求

暂停请求,其实也很简单,在原生的XMLHttpRequest 里面有一个方法是 xhr?.abort(),在发送请求的同时,在发送请求的时候,咋们用一个数组给他装起来,然后就可以自己直接调用abort方法了。

在封装request的时候,我们要求传入一个requestList就好:

export function request({
    
    
    url,
    method = "post",
    data,
    onProgress = e => e,
    headers = {
    
    },
    requestList
}: IRequest) {
    
    
    return new Promise((resolve, reject) => {
    
    
        const xhr = new XMLHttpRequest();
        xhr.upload.onprogress = onProgress
        // 发送请求
        xhr.open(method, baseUrl + url);
        // 放入其他的参数
        Object.keys(headers).forEach(key =>
            xhr.setRequestHeader(key, headers[key])
        );
        xhr.send(data);

        xhr.onreadystatechange = e => {
    
    
        // 请求是成功的
            if (xhr.readyState === 4) {
    
    
            
                if (xhr.status === 200) {
    
    
                    if (requestList) {
    
    
                        // 成功后删除列表
                        const i = requestList.findIndex(req => req === xhr)
                        requestList.splice(i, 1)
                    }
                    // 获取服务响应的结构
                    const resp = JSON.parse(xhr.response);
                    // 这个code是后台规定的,200是正确的响应,500是异常
                    if (resp.code === 200) {
    
    
                        // 成功操作
                        resolve({
    
    
                            data: (e.target as any)?.response
                        });
                    } else {
    
    
                        reject('报错了 大哥')
                    }

                } else if (xhr.status === 500) {
    
    
                    reject('报错了 大哥')
                }
            }
        };
        // 存入请求
        requestList?.push(xhr)
    });
}

有了请求数组后,那么咋们想暂时直接遍历请求数组,调用 abort方法

5.5 文件碎片清理

如果很多人传了一半就离开了,这些切片存在就没意义了,可以考虑定期清理,当然 ,我们可以使用node-schedule来管理定时任务 比如我们每天扫一次target,如果文件的修改时间是一个月以前了,就直接删除。

// 为了方便测试,我改成每5秒扫一次, 过期1分钟的删除做演示
const fse = require("fs-extra");
const path = require("path");
const schedule = require("node-schedule");
// 空目录删除
function remove(file, stats) {
    
    
  const now = newDate().getTime();
  const offset = now - stats.ctimeMs;
  if (offset > 1000 * 60) {
    
    
    // 大于60秒的碎片
    console.log(file, "过期了,浪费空间的玩意,删除");
    fse.unlinkSync(file);
  }
}
async function scan(dir, callback) {
    
    
  const files = fse.readdirSync(dir);
  files.forEach((filename) => {
    
    
    const fileDir = path.resolve(dir, filename);
    const stats = fse.statSync(fileDir);
    if (stats.isDirectory()) {
    
    
      return scan(fileDir, remove);
    }
    if (callback) {
    
    
      callback(fileDir, stats);
    }
  });
}
// * * * * * *
// ┬ ┬ ┬ ┬ ┬ ┬
// │ │ │ │ │ │
// │ │ │ │ │ └ day of week (0 - 7) (0 or 7 is Sun)
// │ │ │ │ └───── month (1 - 12)
// │ │ │ └────────── day of month (1 - 31)
// │ │ └─────────────── hour (0 - 23)
// │ └──────────────────── minute (0 - 59)
// └───────────────────────── second (0 - 59, OPTIONAL)
let start = function (UPLOAD_DIR) {
    
    
  // 每5秒
  schedule.scheduleJob("*/5 * * * * *", function () {
    
    
    console.log("开始扫描");
    scan(UPLOAD_DIR);
  });
};
exports.start = start;

5.6 大文件下载

对于文件下载的话,后端其实很简单,就是返回一个流就行,如下:

/**
   * 文件下载
   * @param req  
   * @param res 
   */
  async handleDownload(req: http.IncomingMessage, res: http.ServerResponse) {
    
    
  // 解析get请求参数
    const resp: UrlWithParsedQuery = await resolveGet(req)
    // 获取文件名称
    const filePath = path.resolve(this.UPLOAD_DIR, resp.query.filename as string)
    // 判断文件是否存在
    if (fse.existsSync(filePath)) {
    
    
      // 创建流来读取文件并下载
      const stream = fse.createReadStream(filePath)
      // 写入文件
      stream.pipe(res)
    }
  }

对于前端的话,咋们需要使用一个库,就是 streamsaver,这个库调用了 TransformStream api来实现浏览器中把文件用流的方式保存在本地的。有了这个后,那就非常简单的使用啦。

const downloadFile = async () => {
    
    
  // StreamSaver
  // 下载的路径
  const url = 'http://localhost:4001/download?filename=b0d9a1481fc2b815eb7dbf78f2146855.zip'
  // 创建一个文件写入流
  const fileStream = streamSaver.createWriteStream('b0d9a1481fc2b815eb7dbf78f2146855.zip')
  // 发送请求下载
  fetch(url).then(res => {
    
    
    const readableStream = res.body

    // more optimized
    if (window.WritableStream && readableStream?.pipeTo) {
    
    
      return readableStream.pipeTo(fileStream)
        .then(() => console.log('done writing'))
    }
   
    const writer = fileStream.getWriter()

    const reader = res.body?.getReader()
    const pump: any = () => reader?.read()
      .then(res => res.done
        ? writer.close()
        : writer.write(res.value).then(pump))

    pump()
  })
}