前端处理的大文件切片上传和下载

954 阅读9分钟

整体流程图

image.png

接下来开始具体实现

一、 格式校验

对于上传的文件,一般来说,我们要校验其格式,仅需要获取文件的后缀(扩展名),即可判断其是否符合我们的上传限制:

  //文件路径
  var filePath = "file://upload/test.png";
  //获取最后一个.的位置
  var index= filePath.lastIndexOf(".");
  //获取后缀
  var ext = filePath.substr(index+1);
  //输出结果
  console.log(ext);
  // 输出: png

但是,这种方式有个弊端,那就是我们可以随便篡改文件的后缀名,比如:test.mp4 ,我们可以通过修改其后缀名:test.mp4 -> test.png ,这样即可绕过限制进行上传。那有没有更严格的限制方式呢?当然是有的。

那就是通过查看文件的二进制数据来识别其真实的文件类型,因为计算机识别文件类型时,并不是真的通过文件的后缀名来识别的,而是通过 “魔数”(Magic Number)来区分,对于某一些类型的文件,起始的几个字节内容都是固定的,根据这几个字节的内容就可以判断文件的类型。借助十六进制编辑器,可以查看一下图片的二进制数据,我们还是以test.png为例:

由上图可知,PNG 类型的图片前 8 个字节是 0x89 50 4E 47 0D 0A 1A 0A。基于这个结果,我们可以据此来做文件的格式校验,以vue项目为例:

  <template>
  <div>
    <input
      type="file"
      id="inputFile"
      @change="handleChange"
    />
  </div>
</template>

<script>
export default {
  name"HelloWorld",
  methods: {
    check(headers) {
      return (buffers, options = { offset: 0 }) =>

      headers.every(

      (header, index) => header === buffers[options.offset + index]

      );
    },
    async handleChange(event) {
      const file = event.target.files[0];

      // 以PNG为例,只需要获取前8个字节,即可识别其类型
      const buffers = await this.readBuffer(file, 08);

      const uint8Array = new Uint8Array(buffers);

      const isPNG = this.check([0x890x500x4e0x470x0d0x0a0x1a0x0a]);

      // 上传test.png后,打印结果为true
      console.log(isPNG(uint8Array))

    },
    readBuffer(file, start = 0, end = 2) {
      // 获取文件的二进制数据,因为我们只需要校验前几个字节即可,所以并不需要获取整个文件的数据
        return new Promise((resolve, reject) => {
          const reader = new FileReader();

          reader.onload = () => {
            resolve(reader.result);
          };

          reader.onerror = reject;

          reader.readAsArrayBuffer(file.slice(start, end));
        });
    }
  }
};
</script>

以上为校验文件类型的方法,对于其他类型的文件,比如mp4,xsl等,大家感兴趣的话,也可以通过工具查看其二进制数据,以此来做格式校验。

以下为汇总的一些文件的二进制标识:

  1.JPEG/JPG - 文件头标识 (2 bytes): ff, d8 文件结束标识 (2 bytes): ff, d9
  2.TGA - 未压缩的前 5 字节 00 00 02 00 00 - RLE 压缩的前 5 字节 00 00 10 00 00
  3.PNG - 文件头标识 (8 bytes89 50 4E 47 0D 0A 1A 0A
  4.GIF - 文件头标识 (6 bytes47 49 46 38 39(3761
  5.BMP - 文件头标识 (2 bytes42 4D B M
  6.PCX - 文件头标识 (1 bytes) 0A
  7.TIFF - 文件头标识 (2 bytes) 4D 4D 或 49 49
  8.ICO - 文件头标识 (8 bytes00 00 01 00 01 00 20 20
  9.CUR - 文件头标识 (8 bytes00 00 02 00 01 00 20 20
  10.IFF - 文件头标识 (4 bytes46 4F 52 4D
  11.ANI - 文件头标识 (4 bytes52 49 46 46

二、 文件切片

假设我们要把一个1G的视频,分割为每块1MB的切片,可定义 DefualtChunkSize = 1 * 1024 * 1024,通过 spark-md5来计算文件内容的hash值。那如何分割文件呢,使用文件对象File的方法File.prototype.slice即可。

需要注意的是,切割一个较大的文件,比如10G,那分割为1Mb大小的话,将会生成一万个切片,众所周知,js是单线程模型,如果这个计算过程在主线程中的话,那我们的页面必然会直接崩溃,这时,就该我们的 Web Worker 来上场了。

Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。具体的作用,不了解的同学可以自行去学些一下。这里就不展开讲了。

以下为部分关键代码:

  // upload.js

  // 创建一个worker对象
  const worker = new worker('worker.js')
  // 向子线程发送消息,并传入文件对象和切片大小,开始计算分割切片
  worker.postMessage(file, DefaultChunkSize)

  // 子线程计算完成后,会将切片返回主线程
  worker.onmessage = (chunks) => {
    ...
  }

子线程代码:

  // worker.js

  // 接收文件对象及切片大小
  onmessage (file, DefualtChunkSize) => {
    let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
      chunks = Math.ceil(file.size / DefaultChunkSize),
      currentChunk = 0,
      spark = new SparkMD5.ArrayBuffer(),
      fileReader = new FileReader();

    fileReader.onload = function (e) {
      console.log('read chunk nr', currentChunk + 1, 'of');

      const chunk = e.target.result;
      spark.append(chunk);
      currentChunk++;

      if (currentChunk < chunks) {
        loadNext();
      } else {
        let fileHash = spark.end();
        console.info('finished computed hash', fileHash);
        // 此处为重点,计算完成后,仍然通过postMessage通知主线程
        postMessage({ fileHash, fileReader })
      }
    };

    fileReader.onerror = function () {
      console.warn('oops, something went wrong.');
    };

    function loadNext() {
      let start = currentChunk * DefualtChunkSize,
        end = ((start + DefaultChunkSize) >= file.size) ? file.size : start + DefaultChunkSize;
      let chunk = blobSlice.call(file, start, end);
      fileReader.readAsArrayBuffer(chunk);
    }

    loadNext();
  }

以上利用worker线程,我们即可得到计算后的切片,以及md5值。

三、 断点续传 + 秒传 + 上传进度

在拿到切片和md5后,我们首先去服务器查询一下,是否已经存在当前文件。

  1. 如果已存在,并且已经是上传成功的文件,则直接返回前端上传成功,即可实现"秒传"。
  2. 如果已存在,并且有一部分切片上传失败,则返回给前端已经上传成功的切片name,前端拿到后,根据返回的切片,计算出未上传成功的剩余切片,然后把剩余的切片继续上传,即可实现"断点续传"。
  3. 如果不存在,则开始上传,这里需要注意的是,在并发上传切片时,需要控制并发量,避免一次性上传过多切片,导致崩溃。
// 检查是否已存在相同文件
   async function checkAndUploadChunk(chunkList, fileMd5Value) {
    const requestList = []
    // 如果不存在,则上传
    for (let i = 0; i < chunkList; i++) {
      requestList.push(upload({ chunkList[i], fileMd5Value, i }))
    }

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

 // 上传chunk
  function upload({ chunkList, chunk, fileMd5Value, i }) {
    current = 0
    let form = new FormData()
    form.append("data", chunk) //切片流
    form.append("total", chunkList.length//总片数
    form.append("index", i) //当前是第几片     
    form.append("fileMd5Value", fileMd5Value)
    return axios({
      method'post',
      urlBaseUrl + "/upload",
      data: form
    }).then(({ data }) => {
      if (data.stat) {
        current = current + 1
        // 获取到上传的进度
        const uploadPercent = Math.ceil((current / chunkList.length) * 100)
      }
    })
  }

所有切片上传完成后,再向后端发送一个上传完成的请求,即通知后端把所有切片进行合并,最终完成整个上传流程。

并发请求的改进

有个改进的地方,目前这么写会一次性把所有请求都发出来吧,但是能浏览器限制了最多6个请求能上传,其它请求只能等待,很容易就超时了,建议加一个请求池机制,一个请求完了,再发送下一个,只有六个请求在请求池内。

Promise.race()

Promise.race()方法的主要意义在于允许你同时观察多个Promise的状态,并且只要其中一个Promise的状态发生变化,它就会采用该Promise的状态和值。

这种功能在编程中很有用,特别是在处理并发任务时。一些常见的应用包括:

  1. 限时操作: 你可以创建一个Promise数组,其中包含了一系列可能会超时的操作。使用Promise.race()可以使得只要有一个操作在规定的时间内完成,就立即返回结果,而不用等待所有操作完成或超时。
  2. 资源竞争: 当多个异步操作需要访问同一资源时,你可能希望只有一个操作能够成功获取资源。通过将多个操作封装成Promise,并使用Promise.race()来等待第一个成功的操作,你可以实现资源的竞争控制。
  3. 优化并发请求: 在并发请求的场景中,你可能希望控制同时发送的请求数量。Promise.race()可以用于动态地监控当前并发请求的数量,并在某个请求完成后立即添加新的请求,以保持并发请求的数量在一个合理的范围内。
//promise并发限制
class PromisePool {
    constructor(max, fn) {
        this.max = max; //最大并发量
        this.fn = fn; //自定义的请求函数
        this.pool = []; //并发池
        this.urls = []; //剩余的请求地址
    }
    start(urls) {
        this.urls = urls; //先循环把并发池塞满
        while (this.pool.length < this.max) {
            let url = this.urls.shift();
            this.setTask(url);
        }
        //利用Promise.race方法来获得并发池中某任务完成的信号
        let race = Promise.race(this.pool);
        return this.run(race);
    }
    run(race) {
        race
            .then(res => {
                //每当并发池跑完一个任务,就再塞入一个任务
                let url = this.urls.shift();
                this.setTask(url);
                return this.run(Promise.race(this.pool));
            })
    }
    setTask(url) {
        if (!url) return
        let task = this.fn(url);
        this.pool.push(task); //将该任务推入pool并发池中
        console.log(`\x1B[43m ${url} 开始,当前并发数:${this.pool.length}`)
        task.then(res => {
            //请求结束后将该Promise任务从并发池中移除
            this.pool.splice(this.pool.indexOf(task), 1);
            console.log(`\x1B[43m ${url} 结束,当前并发数:${this.pool.length}`);
        })
    }
}
//test
const URLS = [
    'bytedance.com',
    'tencent.com',
    'alibaba.com',
    'microsoft.com',
    'apple.com',
    'hulu.com',
    'amazon.com'
]
//自定义请求函数
var requestFn = url => {
    return new Promise(resolve => {
        setTimeout(() => {
            resolve(`任务${url}完成`)
        }, 1000)
    }).then(res => {
        console.log('外部逻辑', res);
    })
}
const pool = new PromisePool(5, requestFn); //并发数为5
pool.start(URLS)

思路:定义一个 PromisePool 对象,初始化一个 pool 作为并发池,然后先循环把并发池塞满,不断地调用 setTask,当满足最大并发量的限制后,会执行Promise.race 调用第一个执行完的任务的then方法去shift一个任务再push一个任务再去race,保证正在执行的函数始终保持在最大并发量的限制

实现方案:分片slice 、并发(Promise.race)、并发量max
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=s, initial-scale=1.0">
    <title>Document</title>
    <script src="https://cdn.bootcdn.net/ajax/libs/axios/0.24.0/axios.min.js"></script>
</head>
<body>
    <input type="file" id="fileInput">
    <button id="uploadBtn">上传</button>
</body>
<script>
// 请求基准地址
axios.defaults.baseURL = 'http://localhost:3000'
// 选中的文件
var file = null
// 选择文件
document.getElementById('fileInput').onchange = function({target: {files}}){
    file = files[0] 
}
// 开始上传
document.getElementById('uploadBtn').onclick = function(){
    if (!file) return;
    // 创建切片   
    // let size = 1024 * 1024 * 10; //10MB 切片大小
    let size = 1024 * 50; //50KB 切片大小
    let fileChunks = [];
    let index = 0 //切片序号
    for(let cur = 0; cur < file.size; cur += size){
        fileChunks.push({
            hash: index++,
            chunk: file.slice(cur, cur + size)
        })
    }
    // 控制并发和断点续传
    const uploadFileChunks = async function(list){
        if(list.length === 0){
            //所有任务完成,合并切片
            await axios({
                method: 'get',
                url: '/merge',
                params: {
                    filename: file.name
                }
            });
            console.log('上传完成')
            return
        }
        let pool = []//并发池
        let max = 3 //最大并发量
        let finish = 0//完成的数量
        let failList = []//失败的列表
        for(let i=0;i<list.length;i++){
            let item = list[i]
            let formData = new FormData()
            formData.append('filename', file.name)
            formData.append('hash', item.hash)
            formData.append('chunk', item.chunk)
            // 上传切片
            let task = axios({
                method: 'post',
                url: '/upload',
                data: formData
            })
            task.then((data)=>{
                //请求结束后将该Promise任务从并发池中移除
                let index = pool.findIndex(t=> t===task)
                pool.splice(index)
            }).catch(()=>{
                failList.push(item)
            }).finally(()=>{
                finish++
                //所有请求都请求完成
                if(finish===list.length){
                    uploadFileChunks(failList)
                }
            })
            pool.push(task)
            if(pool.length === max){
                //每当并发池跑完一个任务,就再塞入一个任务
                await Promise.race(pool)
            }
        }
    }
    uploadFileChunks(fileChunks)

}
</script>
</html>

ps:虽然会一直调用后端请求,但是在满足 pool.length === max的时候会去await直到有响应返回,才会继续调用,所以同时并发的数量始终是max个,从而达到了控制并发的要求

Vue版本

<template>
  <div>
    <input type="file" @change="handleFileChange" />
    <el-button @click="handleUpload"> 上传 </el-button>
    <div style="width: 300px">
      总进度:
      <el-progress :percentage="totalPercent"></el-progress>
      切片进度:
      <div v-for="item in fileObj.chunkList" :key="item">
        <span>{{ item.chunkName }}:</span>
        <el-progress :percentage="item.percent"></el-progress>
      </div>
    </div>
  </div>
</template>
<script>
import axios from "axios";
export default {
  name: "",
  data() {
    return {
      fileObj: {
        file: null,
        chunkList: [],
      },
    };
  },
  computed: {
    totalPercent() {
      const fileObj = this.fileObj;
      if (fileObj.chunkList.length === 0) return 0;
      const loaded = fileObj.chunkList
        .map(({ size, percent }) => size * percent)
        .reduce((pre, next) => pre + next);
      return parseInt((loaded / fileObj.file.size).toFixed(2));
    },
  },
  methods: {
    axiosRequest({
      url,
      method = "post",
      data,
      headers = {},
      onUploadProgress = (e) => e, // 进度回调
    }) {
      return new Promise((resolve, reject) => {
        axios[method](url, data, {
          headers,
          onUploadProgress, // 传入监听进度回调
        })
          .then((res) => {
            resolve(res);
          })
          .catch((err) => {
            reject(err);
          });
      });
    },
    handleFileChange(e) {
      const [file] = e.target.files;
      if (!file) return;
      this.fileObj.file = file;
    },
    async handleUpload() {
      const fileObj = this.fileObj;
      if (!fileObj.file) return;
      const { shouldUpload } = await this.verifyUpload(fileObj.file.name);
      if (!shouldUpload) {
        alert("秒传:上传成功");
        return;
      }
      const chunkList = this.createChunk(fileObj.file);
      console.log(chunkList); // 看看chunkList长什么样子
      this.fileObj.chunkList = chunkList.map(({ file }, index) => ({
        file,
        size: file.size,
        percent: 0,
        chunkName: `${fileObj.file.name}-${index}`,
        fileName: fileObj.file.name,
        index,
      }));
      console.log(this.fileObj);
      this.uploadChunks(); // 执行上传切片的操作
    },
    createChunk(file, size = 2 * 1024 * 1024) {
      const chunkList = [];
      let cur = 0;
      while (cur < file.size) {
        // 使用slice方法切片
        chunkList.push({ file: file.slice(cur, cur + size) });
        cur += size;
      }
      return chunkList;
    },
    async uploadChunks() {
      const requestList = this.fileObj.chunkList
        .map(({ file, fileName, index, chunkName }) => {
          const formData = new FormData();
          formData.append("file", file);
          formData.append("fileName", fileName);
          formData.append("chunkName", chunkName);
          console.log({ formData, index });
          return { formData, index };
        })
        .map(({ formData, index }) =>
          this.axiosRequest({
            url: "http://localhost:3000/upload",
            data: formData,
            onUploadProgress: this.createProgressHandler(
              this.fileObj.chunkList[index]
            ), // 传入监听上传进度回调
          })
        );
      const result = await Promise.all(requestList); // 使用Promise.all进行请求
      console.log(result);
      this.mergeChunks();
    },
    async verifyUpload(fileName) {
      const { data } = await this.axiosRequest({
        url: "http://localhost:3000/verify",
        headers: {
          "content-type": "application/json",
        },
        data: JSON.stringify({
          fileName,
        }),
      });
      return data;
    },
    createProgressHandler(item) {
      return (e) => {
        // 设置每一个切片的进度百分比
        item.percent = parseInt(String((e.loaded / e.total) * 100));
      };
    },
    mergeChunks(size = 2 * 1024 * 1024) {
      this.axiosRequest({
        url: "http://localhost:3000/merge",
        headers: {
          "content-type": "application/json",
        },
        data: JSON.stringify({
          size,
          fileName: this.fileObj.file.name,
        }),
      });
    },
  },
};
</script>
<style scoped></style>

文件下载

详细参考:juejin.cn/post/721914…

文件分断下载的好处

分段下载和不分段下载的主要区别在于传输数据的方式。

在不分段下载中,服务器会将整个文件一次性发送给客户端进行下载。这种方式对于小文件来说是没有问题的,但是对于大文件来说就存在一些问题,比如下载速度慢、占用带宽多、容易出现网络错误等。

而分段下载则可以将一个大文件分成若干个较小的片段进行下载,每个片段可以独立进行下载,并且可以同时下载多个片段,从而大大提高下载速度,减少出错的概率。

此外,分段下载还可以实现断点续传功能。如果下载过程中因为网络原因或其他问题中断了,可以从已经下载的部分继续下载,而不需要从头开始下载整个文件。这对于下载大文件来说,是非常有用的功能。

参考文章:juejin.cn/post/711012… mp.weixin.qq.com/s/EkZQSLsGm…