五分钟搞定文件上传的所有难点

668 阅读24分钟

在日常工作中,文件上传是一个非常常见的功能。在各种的组件库里面都有相对比较完善的封装,但是所有的封装都是为了适应大多数场景所需。一般情况下,在业务开发中,我们通常都会使用一些成熟的上传组件来实现对应的功能。

而且之前的文章我已经从《二次封装 Axios 下载文件和上传文件》《# 五分钟带你封装 Upload 组件》 多个角度说过文件上传的相关知识了,但是我觉得还是意犹未尽,今天就从知识点的角度,总结下面试官常常考我们的问题:大文件的上传。

本文包含的代码直接复制以后就可以在VScode上跑了,项目环境:npx create-vite,选择 react 和 typescript即可。后端用express,复制代码启动服务,可以全方位的解决前端对文件上传下载的困惑。

如果你想要看大文件分块下载,# 前端开发者无法理解的: HTTP Range 实现文件分片并发下载

文件上传的对象汇总

  1. files对象:当input标签的type属性设置为file的时候,我们可以通过 e.target.files 来读取files对象。它是一个由一个或多个文件对象组成的数组。它是 Blob 对象的子类,继承了 Blob 的方法。

  2. blob对象:表示二进制类型的大对象。在数据库管理系统中,将二进制数据存储为一个单一个体的集合。Blob 通常是影像、声音或多媒体文件。在 JavaScriptBlob 类型的对象表示不可变的类似文件对象的原始数据。构建 Blob 对象 :let blob = new Blob(["Hello"], {type: "text/plain"})

  3. formData对象FormData 就是 XMLHttpRequest Level 2 新增的一个对象,利用它来提交表单、模拟表单提交,最大的优势就是可以上传二进制文件。

  4. fileReader对象:构造函数方式实例化一个 fileReader 对象,readAs() 方法将文件对象读取成 base64 格式或者文本格式。

  5. base64:是一种基于64个可打印字符来表示二进制数据的表示方法,Base64 编码普遍应用于需要通过被设计为处理文本数据的媒介上储存和传输二进制数据而需要编码该二进制数据的场景。这样是为了保证数据的完整并且不用在传输过程中修改这些数据。

关系图如下:

image.png

操作文件的方法汇总

  1. Blob.slice([start[, end[, contentType]]]) 文件切片,主要用在大文件上传的时候。
  2. var url = URL.createObjectURL(blob); 获取文件内存里面的 URL 地址。
  3. URL.revokeObjectURL(url); 删除window URL.createObjectURL() 绑定url的对象,释放内存空间。

区别下FileBlob

  1. File 是 Blob 的子类,File 表示用户选择的文件的二进制数据对象。它包含了文件的元信息,如文件名、大小、修改时间等。

2.Blob表示所有文件的二进制数据的对象。

3. Blob 通常用于一般的二进制数据存储,而 File 更适合表示文件对象。 4. 用 window.Url 拿到文件内存地址以后,你会发现他们都是 Blob 类型的地址 image.png

image.png

仔细看看发现

      console.log(URL.createObjectURL(file), 88)
      console.log(URL.createObjectURL(new Blob([file])), 99)

他俩得出来得都是 Blob 类型得地址,而且都能拿到图片。

  1. Blob 创建内存文件
// 创建HTML文件的Blob URL
var data = "<div style='color:red;'>This is a blob</div>";
var blob = new Blob([data], { type: 'text/html' });
var blobURL = URL.createObjectURL(blob);
 
// 创建JSON文件的Blob URL
var data = { "name": "abc" };
var blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
var blobURL = URL.createObjectURL(blob);

创建文件最主要的就是MIME类型,主要有:

image.png

区别下 BlobFileReader

  1. FileReader 能够异步读取一个 File 或 Blob。
  2. FileReader 是一个异步 API,它可以将 Blob 读取为不同的格式。
  3. 用 FileReader 的构造函数创建读取对象: var reader = new FileReader();
  4. 它提供了一些文件加载的方法:
  • readAsArrayBuffer():读取指定 Blob 中的内容,完成之后,result 属性中保存的将是被读取文件的 ArrayBuffer 数据对象;
  • FileReader.readAsBinaryString():读取指定 Blob 中的内容,完成之后,result 属性中将包含所读取文件的原始二进制数据;
  • FileReader.readAsDataURL():读取指定 Blob 中的内容,完成之后,result 属性中将包含一个data: URL 格式的 Base64 字符串以表示所读取文件的内容。
  • FileReader.readAsText():读取指定 Blob 中的内容,完成之后,result 属性中将包含一个字符串以表示所读取的文件内容。
  1. 它提供了一些事件:
  • abort:该事件在读取操作被中断时触发;
  • error:该事件在读取操作发生错误时触发;
  • load:该事件在读取操作完成时触发;
  • progress:该事件在读取 Blob 时触发。
  • 这些方法可以加上前置 on 后在HTML元素上使用,比如onloadonerroronabortonprogress。除此之外,由于FileReader对象继承自EventTarget,因此还可以使用 addEventListener() 监听上述事件。
const reader = new FileReader();  
  
fileInput.onchange = (e) => {  
    reader.readAsText(e.target.files[0]);  
}  
  
reader.onload = (e) => {  
    console.log(e.target.result);  
}

区别 FileReader 和 Base64

1. Base64 可以用 btoa() 编码,处理后的字符串或者二进制变成一个Base64的字符串, 可以用 atob()解码,将原来的 base64 的字符串退回去。 2. 使用toDataURL() 方法把 canvas 画布内容生成 base64 编码格式的图片。

const canvas = document.getElementById('canvas');   
const ctx = canvas.getContext("2d");  
const dataUrl = canvas.toDataURL();

3.还可以使用FileReader.readAsDataURL() 法把上传的文件转为base64格式的data URI,比如上传头像预览。


import { useState } from 'react';

export default function UploadTest(){
    const [imgSrc, setImgSrc] = useState('')

    const fileChange = ( e: any ) => {
      let files = e.target.files;

      if(!files){
        return;
      }

      const file = files[0]
      const reader = new FileReader();
      reader.readAsDataURL(file);

      
      reader.onload = () => {
        console.log(reader.result, 9999)
        setImgSrc(reader.result as any)
      };
    }

    return <div>
      <input type="file" onChange={fileChange} multiple/>
      <div>原图预览</div>
      {imgSrc && <img  id="ig" src={imgSrc} width="100px" height="100px"/> }
  </div>
}

image.png

点击上传

上传最主要的就是用 FormData 对象包裹文件数据,然后再发送请求的时候,在请求头里面加入: 'Content-Type': 'multipart/form-data'即可。

主要代码如下

import { useState,ChangeEvent } from 'react';
import axios from 'axios';

export default function UploadTest(){
    const [imgSrc, setImgSrc] = useState('')

    const handleFileChange = ( e: ChangeEvent<HTMLInputElement>) => {
        let files = e.target.files as any;
  
        if(!files){
          return;
        }
  
        const file = files[0]

        //原图
        setImgSrc(URL.createObjectURL(new Blob([file])))

        const formData = new FormData()
        formData.append('file', file);
  
        axios.post('http://localhost:3002/upload', formData, {
            headers: {
                'Content-Type': 'multipart/form-data'
            }
        }).then(()=>{
            console.log('上传成功!')
        }).catch(()=>{
          console.log('上传失败!')
            
        })
      }
  
    return <div>
        <input
            type="file"
            onChange={handleFileChange}
        />
        <div>原图预览</div>
        {imgSrc && <img  id="ig" src={imgSrc} width="100px" height="100px"/> }
    </div>
}

一般情况下:如果 input 标签给了 multiple=true 属性,就支持多文件上传,在 e.target.files 里面就能拿到所有上传的文件,但是如果 multiple=false,那 e.target.files里面就只有一个文件,然后我们通常用 let files = e.target.files[0] 来获取。

拖拽上传

另一种获取 File 对象的方式就是拖放 API,这个 API 很简单,就是将浏览器之外的文件拖到浏览器窗口中,并将它放在一个成为拖放区域的特殊区域中。拖放区域用于响应放置操作并从放置的项目中提取信息。这些是通过 ondrop 和 ondragover 两个 API 实现的。

  1. 定义一个 div 标签,
  2. 利用div的三个事件 onDragOver,onDragLeave,onDrop
  3. onDrop 在放的时候上传文件
  4. 你只需要记住 drag 拖,drop 是放,就好了。
import { useRef, useState,DragEvent } from 'react';
import axios from 'axios';

export default function UploadTest(){
    const fileRef = useRef<HTMLInputElement>(null);
    const [imgSrc, setImgSrc] = useState('')


    const handleChange = () => {
        fileRef.current && fileRef.current.click()
    }

    const handleDrag = (e: DragEvent<HTMLElement>, over: boolean) => {
        e.preventDefault()
        console.log('拖', e.dataTransfer.files)
      }

    const handleDrop = (e: DragEvent<HTMLElement>) => {
      e.preventDefault()
      console.log('放', e.dataTransfer.files)
      const file = e.dataTransfer.files[0]
      setImgSrc(URL.createObjectURL(file))
      //上传请求数据全部写这里
      const formData = new FormData()
      formData.append('file', file);

      axios.post('http://localhost:3002/upload', formData, {
          headers: {
              'Content-Type': 'multipart/form-data'
          }
      }).then(()=>{
          console.log('上传成功!')
      }).catch(()=>{
        console.log('上传失败!')
          
      })
    }
    
    return <div>
      <div onClick={handleChange}>
        <div 
          id="drop-zone" 
          style={{width: '200px', height: '200px', border: '1px solid red', textAlign: "center", lineHeight: "200px"}}
          onDragOver={e => { handleDrag(e, true)}}
          onDragLeave={e => { handleDrag(e, false)}}
          onDrop={handleDrop}
        >点击拖拽文件</div>
        <input
            style={{display: 'none'}}
            type="file"
            ref={fileRef}
        />
      </div>
      <div>原图预览</div>
      {imgSrc && <img  id="ig" src={imgSrc} width="100px" height="100px"/> }
  </div>
}


测试:

image.png

预览文件的三种方式

File 预览

File本来就是Blob的子集,所以它可以直接用 window.URL.createObjectURL转化成内存地址

import {ChangeEvent, useState } from 'react';

export default function UploadTest(){
    const [imgSrc, setImgSrc] = useState('')

    const fileChange = ( e: ChangeEvent<HTMLInputElement>) => {
      let files = e.target.files as any;

      if(!files){
        return;
      }

      const file = files[0];
      setImgSrc(URL.createObjectURL(file))
      // 下面发送请求
    }

    return <div>
      <input type="file" onChange={fileChange} multiple/>
      <div>原图预览</div>
      {imgSrc && <img  id="ig" src={imgSrc} width="100px" height="100px"/> }
  </div>
}

Blob 预览

files对象与blob对象的相互转换,而且我们可以用blob对象的切割方法,在项目中实现缩略图和文本预览。

import { ChangeEvent, useState } from 'react';

export default function UploadTest(){
    const [imgSrc, setImgSrc] = useState('')

    const fileChange = ( e: ChangeEvent<HTMLInputElement>) => {
      let files = e.target.files as any;

      if(!files){
        return;
      }

      const file = files[0]
      const blobFile = new Blob([file])
      setImgSrc(URL.createObjectURL(blobFile))
    }

    return <div>
      <input type="file" onChange={fileChange} multiple/>
      <div>原图预览</div>
      {imgSrc && <img  id="ig" src={imgSrc} width="100px" height="100px"/> }
  </div>
}

FileReader 预览

FileReader 可以把 blob 转化成各种格式,进行读取。

import { useState } from 'react';

export default function UploadTest(){
    const [imgSrc, setImgSrc] = useState('')

    const fileChange = ( e: any ) => {
      let files = e.target.files;

      if(!files){
        return;
      }

      const file = files[0]
      const reader = new FileReader();
      reader.readAsDataURL(file);

      
      reader.onload = () => {
        setImgSrc(reader.result as any)
      };
    }

    return <div>
      <input type="file" onChange={fileChange} multiple/>
      <div>原图预览</div>
      {imgSrc && <img  id="ig" src={imgSrc} width="100px" height="100px"/> }
  </div>
}

Blob的切片

image.png

测试

image.png

单文件上传

单文件上传最主要两点:

  1. 往 formData 里面加入文件,然后发送http请求。
  2. 发送请求的请求头里面添加文件格式是form类型的,'Content-Type': 'multipart/form-data'

例如:

image.png

后端文件:

image.png

多文件上传

上面说了,当input标签的 multiple 属性是true的时候,他就能一次性选择多个文件了。选择后e.target.files就是个数组。多文件上传的最主要的三点:

  1. 把files类数组转化成数组 Array.from(files)
  2. 用循环将他们加入FormData 里面
  3. 发送请求。

image.png

后端接口变了

image.png

后端需要改改,原来的upload.single('file')改成upload.array('file', 3), 3代表,只能最多上传3个文件。后端接受的内容如下:

image.png

其实不管单文件上传还是多文件上传,formDatafieldname保持一致,他就能知道是那个字段的值。

image.png

大文件切片上传

前端大文件上传核心是利用 Blob.prototype.slice 方法,和数组的 slice 方法相似,文件的 slice 方法可以返回原文件的某个切片预先定义好单个切片大小,将文件切分为一个个切片,然后借助 http 的可并发性,同时上传多个切片。这样从原本传一个大文件,变成了并发传多个小的文件切片,可以大大减少上传时间

另外由于是并发,传输到服务端的顺序可能会发生变化,因此我们还需要给每个切片记录顺序。实现思路如下:

  1. spark-md5 计算文件的内容hash,以此来确定文件的唯一性
  2. 将文件hash发送到服务端进行查询,以此来确定该文件在服务端的存储情况,这里可以分为三种: 未上传、已上传、上传部分。(前提:分块大小固定)
  3. 根据服务端返回的状态执行不同的上传策略:
  • 已上传: 执行秒传策略,即快速上传(实际上没有对该文件进行上传,因为服务端已经有这份文件了),用户体验下来就是上传得飞快,嗖嗖嗖。。。
  • 未上传、上传部分: 执行计算待上传分块的策略
  1. 并发上传还未上传的文件分块。
  2. 当传完最后一个文件分块时,向服务端发送合并的指令,即完成整个大文件的分块合并,实现在服务端的存储。

image.png

总结一下:将大文件通过切分成N个小文件,通过并发多个HTTP请求,实现快速上传;在每次上传前计算文件hash,带着这个文件hash去服务端查询该文件在服务端的存储状态,通过状态来判断需要上传的分块,实现断点续传、秒传。

步骤1:利用 Blob 对象的 slice()法,用法如下:

let blob = instanceOfBlob.slice([start [, end [, contentType]]]};
start 和 end 代表 Blob 里的下标,表示被拷贝进新的 Blob 的字节的起始位置和结束位置。
contentType 会给新的 Blob 赋予一个新的文档类型,在这里我们用不到。

封装切割方法

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
}

步骤2:hash 计算

值的注意的是:如果是同一个文件,不管上传多少次,他的hash值是一样的。

把一个大文件分割成很多个小文件以后,我们如何区分他们,并且方便后端组合成一个完整的文件?

我们可以想一下,在webpack 打包的时候,有没有见过hash名?每个文件都有一个hash名,而且他的热更新也是根据用匹配hash名来做的,我们也可以根据hash来做断点续传,同一份文件,上传到一半,中止了,下次用户进来,还要上传就可以根据已经上传的hash 找到上传的位置,现在再继续传。同时,如果上传过相同的文件,用hash值就能检索出来,这样我们就不用再重复上传,给人的感觉就是秒传。

安装spark-md5。它可以为我们的切片文件提供hash值。如果是ts记得安装 @types/spark-md5

npm i spark-md5 @types/spark-md5 -D

但是如果一个文件特别大,每个切片的所有内容都参与计算的话会很耗时间,所有我们可以采取以下策略:

  1. 第一个和最后一个切片的内容全部参与计算;
  2. 中间剩余的切片我们分别在前面、后面和中间取 2 个字节参与计算;
  3. 既能保证所有的切片参与了计算,也能保证不耗费很长的时间
import {ChangeEvent, useState } from 'react';
import SparkMD5 from 'spark-md5'

const CHUNK_SIZE = 50000;

export default function UploadTest(){

    const [imgSrc, setImgSrc] = useState('')

    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
    }

    /**
 * 计算文件的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 = () => {
            spark.append(reader.result as ArrayBuffer)
            resolve(spark.end())
          }
          console.log(reader, 999)
        })
      }
      

    const handleFileChange = ( e: ChangeEvent<HTMLInputElement>) => {
      let files = e.target.files as any;

      if(!files){
        return;
      }

      const file = files[0];
      const fileChunkList = createFileChunks(file);
      console.log(fileChunkList, 999)
      calculateHash(fileChunkList)
    }

    return <div>
      <input type="file" onChange={handleFileChange} multiple/>
      <div>原图预览</div>
      {imgSrc && <img  id="ig" src={imgSrc} width="100px" height="100px"/> }
  </div>
}

测试如下:

image.png

步骤3: 请求接口,上传文件

uploadChunks方法

import {ChangeEvent, useState, useRef } from 'react';
import SparkMD5 from 'spark-md5'

const CHUNK_SIZE = 50000;

export default function UploadTest(){

    const [imgSrc, setImgSrc] = useState('')
    const fileContent = useRef({name: ''})
    const fileHash = useRef('')
    const percentage = useRef('');

    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
    }

    /**
 * 计算文件的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 = () => {
            spark.append(reader.result as ArrayBuffer)
            resolve(spark.end())
          }
        })
      }

      const uploadChunks = async (fileChunks: Array<{ file: Blob }>) => {
        const fileHashVal = fileHash.current;
        const fileName = fileContent.current;

        const data = fileChunks.map(({ file }, index) => ({
          fileHash: fileHashVal,
          index,
          chunkHash: `${fileHashVal}-${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?.name)
          // 大文件hash
          formData.append('fileHash', fileHashVal)
          return formData
        })
      
        let index = 0
        const max = 6 // 并发请求数量
        const taskPool: any = [] // 请求队列
      
        while (index < formDatas.length) {
          const task = fetch('http://127.0.0.1:3002/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++
          percentage.current = ((index / formDatas.length) * 100).toFixed(0)
        }
      
        await Promise.all(taskPool)
      }
       

    const handleFileChange = async( e: ChangeEvent<HTMLInputElement>) => {
      let files = e.target.files as any;

      if(!files){
        return;
      }

      const file = files[0];
      fileContent.current = file
      const fileChunkList = createFileChunks(file);
      console.log(fileChunkList, 999)
      fileHash.current = await calculateHash(fileChunkList) as string;
      uploadChunks(fileChunkList)
    }

    return <div>
      <input type="file" onChange={handleFileChange} multiple/>
      <div>原图预览</div>
      {imgSrc && <img  id="ig" src={imgSrc} width="100px" height="100px"/> }
  </div>
}

最主要的点,就是将分好的文件片段放到数据组里面去,然后用promise.all并列请求。

image.png

如果没有后端代码永远体会不到上传到底是咋回事,所以动动咱们的小手,复制代码,用node启动就好。 后端代码用的express,记得安装对应的依赖文件: npm i multiparty fs-extra cors -D

import express from 'express';
import multer from 'multer';
import cors from 'cors';
import iconv from 'iconv-lite';
import multiparty from 'multiparty';
import fse from 'fs-extra'
import path from 'path'

const __dirname = path.resolve();
const app = express()
app.use(cors());

const UPLOAD_DIR = path.resolve(__dirname, 'uploads')
app.post('/uploadSliceFile', function (req, res, next) {
// 所有上传的文件存放到该目录下
  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',
    })
  })
})

app.listen(3002, ()=>{
    console.log('接口是:3002')
});

启动服务,进行测试,前端18个切片,后端18个文件

image.png

image.png

步骤4: 合并文件

你有没有发现一个问题,uploads文件夹里面存储的全部都是文件切片,如何把文件切片整合成一个文件变成了问题的关键。当切片全部上传成功以后,我们要发一个merge请求,要后端把接收到的文件切片整合成一个文件存放起来。

image.png

后端解释:

首先express并没有获取post请求bodyapi,所有我们要想拿到前端传过来的值,需要用中间件:body-parser,直接引入:npm i body-parser -D然后在merge接口里面就能够拿到具体的参数了。

image.png

先看看uploads里面有没有对应hash的文件,有的话,就说明以前上传过,直接返回个成功的响应给前端就好了,如果没有的话就要看看有没有对应hash的文件夹。如果有应的文件夹,说明这个文件是刚才上传的,还没有来得及做合并处理呢,所以直接做合并处理。

image.png

具体代码如下:

image.png

image.png

import express from 'express';
import multer from 'multer';
import cors from 'cors';
import iconv from 'iconv-lite';
import multiparty from 'multiparty';
import fse from 'fs-extra'
import path from 'path'
import concat from 'concat-stream'
import bodyParser from 'body-parser'

const __dirname = path.resolve();
const app = express()
app.use(cors());
// app用了cors(),就不用每个接口带 res.setHeader('Access-Control-Allow-Origin', '*'); 了
//const upload = multer({ dest: 'uploads/'})

const upload = multer({
  storage: multer.diskStorage({
    destination: function (req, file, cb) {
      cb(null, 'uploads/')
    },
    filename: function (req, file, cb) {
      // 假设上传的文件名是GBK编码
      let filename = iconv.decode(file.originalname, 'UTF-8');
      console.log(filename)
      cb(null, filename);
    }
  })
});

app.post('/upload', upload.single('file'), function (req, res, next) {
    res.send(JSON.stringify({
      code: 200,
      message: 'success'
    }));
})

app.post('/uploadMore', upload.array('file', 3), function (req, res, next) {
  console.log(req)
  res.send(JSON.stringify({
    code: 200,
    message: 'success'
  }));
})

//接受切片

const UPLOAD_DIR = path.resolve(__dirname, 'uploads')
app.post('/uploadSliceFile', function (req, res, next) {
  console.log(11)
// 所有上传的文件存放到该目录下
  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',
    })
  })
})


//合并
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({extended: false}))
// 文件合并路由
app.post('/merge', async function(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: '合并成功',
  })

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

/**
 * 合并文件夹中的切片,生成一个完整的文件
 */
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)
}

/**
 * 读的内容写到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)
  })
}

app.listen(3002, ()=>{
    console.log('接口是:3002')
});

文件秒传

一听这个名字是不是就想继续看下去?文件确实有点长,但是包含了所有文件相关的知识点,而且是用react写的,还是很有参考价值的,是不是?学到了,就不要忘记给我个小心心呀,谢谢!

言归正传,在说hash的时候就明说了一点,一个文件对应的hash值是固定的,不管你有没有换浏览器,有没有切换页面,hash都是一定的。所以当同一个文件上传的时候,会根据hash值看看文件夹里面有没有这个文件,有的话就直接给返回上传成功!这样给用户的感觉就是秒传。

注意:同一个文件,名字不一样,hash也一样

前端

image.png

后端

image.png

测试

image.png

断点续传

如果我们之前已经上传了一部分分块了,我们只需要拿到已经上传的部分分块,然后将他们都过滤掉,然后再把没有上传的分块,上传上去,这样就达到了断点续传的效果。

使用场景:用户在上传的过程中,断点了,导致一部分上传成功,一部分失败了。 咱们只需要在 verify 的接口中去获取已经上传成功的分片,然后在上传分片前进行一个过滤就好了。

image.png

后端

image.png

案例完整代码

所有的代码都可以复制下来自己跑,从中找到诀窍。创作不易,请给个小心心!

前端

import {ChangeEvent, useState, useRef } from 'react';
import SparkMD5 from 'spark-md5';

const CHUNK_SIZE = 50000;

export default function UploadTest(){

    const [imgSrc, setImgSrc] = useState('')
    //const [uploadedList, setUploadList] = useState<string[]>([]) //有个闭包陷阱,用hook解决
    const uploadedList = useRef<string[]>([])
    const fileContent = useRef({name: ''})
    const fileHash = useRef('')
    const percentage = useRef('');

    console.log(uploadedList.current, 999)

    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
    }

    /**
 * 计算文件的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 = () => {
            spark.append(reader.result as ArrayBuffer)
            resolve(spark.end())
          }
        })
      }


      const uploadChunks = async (fileChunks: Array<{ file: Blob }>) => {
        const fileHashVal = fileHash.current;
        const fileName = fileContent.current;
        const uploadedArr = uploadedList.current;

        const data = fileChunks.map(({ file }, index) => ({
          fileHash: fileHashVal,
          index,
          chunkHash: `${fileHashVal}-${index}`,
          chunk: file,
          size: file.size,
        })).filter((chunk, index) => {
          // 过滤服务器上已经有的切片
          return !uploadedArr.includes(`${fileHashVal}-${index}`)
        })
      
        const formDatas = data.map(({ chunk, chunkHash }) => {
          const formData = new FormData()
          // 切片文件
          formData.append('chunk', chunk)
          // 切片文件hash
          formData.append('chunkHash', chunkHash)
          // 大文件的文件名
          formData.append('fileName', fileName?.name)
          // 大文件hash
          formData.append('fileHash', fileHashVal)
          return formData
        })
      
        let index = 0
        const max = 6 // 并发请求数量
        const taskPool: any = [] // 请求队列
      
        //控制数量的并发异步请求
        while (index < formDatas.length) {
          const task = fetch('http://127.0.0.1:3002/uploadSliceFile', {
            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++
          percentage.current = ((index / formDatas.length) * 100).toFixed(0)
        }
      
        await Promise.all(taskPool).then(()=>{
          //当执行到then说明所有的切片都已经传递完成了
          mergeRequest();
        })
      }

    const mergeRequest = () => {
      const fileHashVal = fileHash.current;
      const fileName = fileContent.current;
      fetch('http://127.0.0.1:3002/merge', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            size: CHUNK_SIZE,
            fileHash: fileHashVal,
            fileName: fileName?.name,
          }),
        })
          .then((response) => response.json())
          .then(() => {
            alert('上传成功')
          })
    }

    /**
    * 验证该文件是否需要上传,文件通过hash生成唯一,改名后也是不需要再上传的,也就相当于秒传
    */
    const verifyUpload = async () => {
      const fileHashVal = fileHash.current;
      const fileName = fileContent.current;

      return fetch('http://127.0.0.1:3002/verify', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          fileName: fileName?.name,
          fileHash: fileHashVal,
        }),
      })
        .then((response) => response.json())
        .then(({data}) => {
          return data // data中包含对应的表示服务器上有没有该文件的查询结果
        })
    }
       
    const handleFileChange = async( e: ChangeEvent<HTMLInputElement>) => {
      let files = e.target.files as any;

      if(!files){
        return;
      }

      const file = files[0];
      fileContent.current = file
      const fileChunkList = createFileChunks(file);
      fileHash.current = await calculateHash(fileChunkList) as string;
      const res = await verifyUpload();
    
      if (!res?.shouldUpload) {
        // 服务器上已经有该文件,不需要上传
        alert('秒传:上传成功')
        return
      }

      uploadedList.current = res?.uploadedList
      uploadChunks(fileChunkList)
    }

    return <div>
      <input type="file" onChange={handleFileChange} multiple/>
      <div>原图预览</div>
      {imgSrc && <img  id="ig" src={imgSrc} width="100px" height="100px"/> }
  </div>
}

后端文件

import express from 'express';
import multer from 'multer';
import cors from 'cors';
import iconv from 'iconv-lite';
import multiparty from 'multiparty';
import fse from 'fs-extra'
import path from 'path'
import concat from 'concat-stream'
import bodyParser from 'body-parser'

const __dirname = path.resolve();
const app = express()
app.use(cors());
// app用了cors(),就不用每个接口带 res.setHeader('Access-Control-Allow-Origin', '*'); 了
//const upload = multer({ dest: 'uploads/'})

const upload = multer({
  storage: multer.diskStorage({
    destination: function (req, file, cb) {
      cb(null, 'uploads/')
    },
    filename: function (req, file, cb) {
      // 假设上传的文件名是GBK编码
      let filename = iconv.decode(file.originalname, 'UTF-8');
      console.log(filename)
      cb(null, filename);
    }
  })
});

app.post('/upload', upload.single('file'), function (req, res, next) {
    res.send(JSON.stringify({
      code: 200,
      message: 'success'
    }));
})

app.post('/uploadMore', upload.array('file', 3), function (req, res, next) {
  console.log(req)
  res.send(JSON.stringify({
    code: 200,
    message: 'success'
  }));
})

//接受切片

const UPLOAD_DIR = path.resolve(__dirname, 'uploads')
app.post('/uploadSliceFile', function (req, res, next) {
// 所有上传的文件存放到该目录下
  const form = new multiparty.Form()
  console.log(form, 999)

  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)
    }

    console.log(files, 5555)

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

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


//合并
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({extended: false}))
// 文件合并路由
app.post('/merge', async function(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: '合并成功',
  })

})

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

/**
 * 合并文件夹中的切片,生成一个完整的文件
 */
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)
}

/**
 * 读的内容写到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)
  })
}

// 秒传 根据文件hash验证文件有没有上传过
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),
      },
    })
  }
})


app.listen(3002, ()=>{
    console.log('接口是:3002')
});