项目亮点之《文件上传功能》解析

41 阅读10分钟

前言

日常项目开发中,想必大伙都接触过一下需要上传图片视频或者文件类的场景。那么,我们一般都是如何实现的呢。本篇文章,作者描述一些文件上传过程用所涉及的前端技术和原理,并列举相关代码。一来是为了加深记忆,二来可以在需要时拿来就用。本文将从基础的文件上传入手,逐步降到大文件上传,断点续传的实现过程和原理。

叠个甲,作者技术水平一般,欢迎大佬们发表见解。

基本的文件上传

文件的上传一般来说会采用两种方案:一种是,后端编写一个上传接口,将需要上传的文件,上传的服务器上的某一文件夹中,并返回该文件夹的服务器地址,供前端使用。另一种方案是,上传到诸如阿里云oss,腾讯云cos,或者七牛云等等第三方的对象存储桶中,获取到第三方存储地址进行操作。

先来说第一种方式,以pc端来说,我们一般通过ElementUi的上传文件组件

获取到文件对象file。在通过FormData进行文件上传到服务器。示例代码如下

    // 定义上传    
const uploadFile = (file) => {
      let formData = new FormData();
      formData.append('file', file);
      // 这里为自己的上传接口调用方法
      uploadApi(formData).then(res => {
        if (res.data.code === '1') {
          ElMessage({
            type: 'success',
            message: '上传成功',
            duration: 1800
          })
          console.log(res.data.data)
        } else {
          ElMessage({
            type: 'error',
            message: '上传失败',
            duration: 1800
          })
        }
      })
    }

这种方案比较通用,图片、视频、文件上传都可调用,缺点是会大量占用服务器资源。而服务器存储资源又是相对而言比较昂贵的,因此一般大型项目,文件上传比较频繁的项目,不考虑这种方式。

第二种方案为上传至云服务器。以阿里oss为例,一般我们需要向后端请求获取一些调用上传oss需要的参数,在通过ali-oss插件调用oss存储上传文件,上传成功后,会得到一个存储桶文件的相对地址。我们只需将这个地址拼接好后,保存起来即可。示例代码如下

// html 上传控件
<el-upload
      element-loading-background="rgba(0, 0, 0, 0.5)"
      element-loading-text="上传中..."
      :show-file-list="false"

      :action="action"
      :name="name"
      :before-upload="beforeUpload"
      :on-progress="onProgress"
      :on-success="onSuccess"
      :http-request="imagesRequest"
      :accept="accept"
      drag
      class="images-upload"
    >
      <div class="image-slot" :style="`width:${width}px;height:${height}px;`">
        <i class="el-icon-plus avatar-uploader-icon" />
      </div>
      <div v-if="uploadData.progress.percent" class="progress" :style="`width:${width}px;height:${height}px;`">        <el-image :src="uploadData.progress.preview" :style="`width:${width}px;height:${height}px;`" fit="fill" />        <el-progress type="circle" :width="Math.min(width, height) * 0.8" :percentage="uploadData.progress.percent" />      </div>    </el-upload>

// script部分
import Client from '@/utils/stsOssUpload'
import { ref, defineEmits, defineProps, withDefaults } from 'vue'
import { getOssToken } from '@/api/modules/upload'// 接受父组件参数,配置默认值
const props = withDefaults(defineProps<imagesUploadProps>(), {
  action: '',
  modelValue: '',
  name: 'file',
  max: 1,
  size: 10,
  width: 118,
  height: 118,
  ext: () => ['jpg', 'jpeg', 'png', 'gif', 'bmp'],
  accept: '.jpg,.jpeg,.png,.gif,.bmp'
})
const uploadData = ref({
  dialogImageIndex: 0,
  imageViewerVisible: false,
  progress: {
    preview: '',
    percent: 0
  }})

// 上传前校验文件大小和类型,如果有需要可以加入
const beforeUpload: any = (file: any) => {
  console.log(file, 12345)
  const fileName = file.name.split('.')
  const fileExt = fileName[fileName.length - 1]
  const isTypeOk = props.ext.includes(fileExt as string)
  const isSizeOk = file.size / 1024 / 1024 < props.size
  if (!isTypeOk) {
    ElMessage.error(`上传图片只支持 ${props.ext.join(' / ')} 格式!`)
    return false
  }  if (!isSizeOk) {
    ElMessage.error(`上传图片大小不能超过 ${props.size}MB!`)
    return false
  }  if (isTypeOk && isSizeOk) {
    uploadData.value.progress.preview = URL.createObjectURL(file)
  } 
 return isTypeOk && isSizeOk}
// 上传进度
const onProgress: any = (file: { percent: number }) => {
  uploadData.value.progress.percent = ~~file.percent}// 获取Token
const handelGetAliToken = () => {
  return new Promise((resolve, reject) => {
    getOssToken({})
      .then((res: any) => {
        res = res.data
        if (res.code === '1') {
          resolve(res.data)
        } else {
          reject(false)
        } 
     })
      .catch((err: any) => {
        console.log('err', err)
        reject(false)
      })
  })}
const onSuccess: any = (url: any) => {
  console.log(url, 'url')
  // 进行一些后续操作
}
// 获取到oss上传需要用的的一些参数并组装
const imagesRequest = async(options: any) => {  try {
    handelGetAliToken().then(async(response: any) => {
      ossStsData.value = response
      ossClient.value = Client({
        accessKeyId: ossStsData.value.accessKeyId,
        accessKeySecret: ossStsData.value.accessKeySecret,
        stsToken: ossStsData.value.securityToken,
        regionId: ossStsData.value.region,
        bucketName: ossStsData.value.bucket 
     }) 
     let file = options.file // 拿到 file 
     console.log('file', file)      // 如果文件大学小于分片大小,使用普通上传,否则使用分片上传
      commonUpload(file, options)
    })
  } catch (e) {
    options.onError('上传失败', e)
  }}
// 执行普通上传
const commonUpload = (file: any, options: any) => {
  let fileName = file.name.substr(file.name.indexOf('.')) // 文件后缀
  let date = new Date().getTime()
  let fileNames = `${date}_${fileName}` // 拼接文件名,保证唯一,这里使用时间戳+原文件名  // 上传文件,这里是上传到OSS的 uploads文件夹下
  let folderName = 'sys/image/'
  return ossClient.value
    .put(folderName + fileNames, file, {
      headers: {
        'x-oss-object-acl': 'public-read' // oss资源访问权限
      }
    })
    .then((result: any) => {
      console.log('res----->', result)
      if (result?.res?.statusCode === 200) {
        let ossFileName = 'https://' + ossStsData.value.bucket + '.' + ossStsData.value.region + '.aliyuncs.com/' + result.name
        console.log(options, 'options.onSuccess')
        options.onSuccess(ossFileName)
      } else {
        options.onError('上传失败')
        uploadLoading.value = false
      }
    })
    .catch((err: any) => {
    })}

以上就是上传到oss的关键代码部分,引入的 stsOssUpload 代码如下

// Client.js
import OSS from 'ali-oss'
import { getOssToken } from '@/api/modules/upload'

export default function Client(data: any) {

  // 后端提供数据
  return new OSS({
    bucket: data.bucketName, // 你的 OSS bucket 名称
    region: data.regionId, // bucket 所在地
    accessKeyId: data.accessKeyId,
    accessKeySecret: data.accessKeySecret,
    stsToken: data.stsToken,
    refreshSTSToken: async() => {
      const ossData: any = await getOssToken({})
      return {
        bucket: data.bucketName,
        region: data.regionId,
        accessKeyId: ossData.accessKeyId,
        accessKeySecret: ossData.accessKeySecret,
        stsToken: ossData.securityToken
      }
    },
    refreshSTSTokenInterval: 300000 
 })}

至此我们就完成了两种方式的图片上传功能。

以上的内容都是基础的文件上传实现,下面才是本文主要内容。

大文件上传的处理

如果对上传文件大小有所限制,那么在before-uplaod阶段判断一下文件大小,如文件超过限制,给出提醒,终止上传即可。

但是我们有时也有大文件动辄好几百兆的视频/文件需要上传,要如何实现呢。

上传大文件一般会遇到下面几个问题:

1.上传的时间比较久,中间一旦出问题,如页面刷新,网络卡顿或连接中断,就需要重新上传;

2.服务器对单个文件资源上传大小有所限制,超出限制,会返回服务端错误。

解决方案就是通过文件切片的方式来处理文件。

以下内容参考其他掘友的文章,并摘了一部分内容

文章连接:Vue3 + Express 实现大文件分片上传、断点续传、秒传

原理

首先我们将一个文件,分成许多小块,每个小块大小相同,比如每块大小都是1MB,然后逐个将这些小块上传到服务器,上传到服务器,服务器会保存这些小块,并记录他们的位置和顺序,等所有的小块上传完成,服务器会把这些小块按正确的顺序拼接起来还原成完整的大文件。

分片上传的好处在于它可以减少上传失败的风险。如果在上传过程中出现了问题,只需要重新上传出错的那个小块,而不需要重新上传整个大文件。此外,分片上传还可以加快上传速度。因为我们可以同时上传多个小块,充分利用网络的带宽。这样就能够更快地完成文件的上传过程。

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

const upload = (file)=> {
    const chunks = createChunks(file);
    // 获取到了所有的切片集合
}
const CHUNK_SIZE = 1024 * 1024;  // 1MB
// 创建文件分片
const createChunks = (file: File) => {
  let start = 0;
  const chunks = [];
  while (start < file.size) {
    chunks.push(file.slice(start, start + CHUNK_SIZE));
    start += CHUNK_SIZE;
  }
  return chunks;
};

hash计算

先来思考一个问题,在向服务器上传文件时,怎么去区分不同的文件呢?如果根据文件名去区分的话可以吗?

答案是不可以,因为文件名我们可以是随便修改的,所以不能根据文件名去区分。但是每一份文件的文件内容都不一样,我们可以根据文件的内容去区分,具体怎么做呢?

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

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

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

// npm install spark-md5

var SparkMD5 = require('spark-md5');
// 计算文件内容hash值
const fileHash = ref(""); // 文件hash
fileHash.value = await calculateHash(file);// 计算文件内容hash值const calculateHash = (file: File): Promise<string> => {
  return new Promise((resolve) => {
    const fileReader = new FileReader();
    fileReader.readAsArrayBuffer(file);
    fileReader.onload = function (e) {
      const spark = new SparkMD5.ArrayBuffer();
      spark.append((e.target as FileReader).result as ArrayBuffer);
      resolve(spark.end());
    };
  });
};

文件上传

前端实现

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

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

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

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

const upload = (file)=> {
    const chunks = createChunks(file);
    // 获取到了所有的切片集合
    uploadChunks(chunks);
}

// 上传文件分片
const uploadChunks = async (
  chunks: Array<Blob>,
) => {
  const formDatas = chunks
    .map((chunk, index) => ({
      fileHash: fileHash.value,
      chunkHash: fileHash.value + "-" + index,
      chunk,
    }))
    .map((item) => {
      const formData = new FormData();
      formData.append("fileHash", item.fileHash);
      formData.append("chunkHash", item.chunkHash);
      formData.append("chunk", item.chunk);
      return formData;
    });

  const taskPool = formDatas.map(
    (formData) => () =>
      fetch("http://localhost:3000/upload", {
        method: "POST",
        body: formData,
      })
  );

  // 控制请求并发
  await concurRequest(taskPool, 6);
};// 控制请求并发
const concurRequest = (
  taskPool: Array<() => Promise<Response>>,
  max: number
): Promise<Array<Response | unknown>> => {
  return new Promise((resolve) => {
    if (taskPool.length === 0) {
      resolve([]);
      return;
    }

    const results: Array<Response | unknown> = [];
    let index = 0;
    let count = 0;

    const request = async () => {
      if (index === taskPool.length) return;
      const i = index;
      const task = taskPool[index];
      index++;
      try {
        results[i] = await task();
      } catch (err) {
        results[i] = err;
      } finally {
        count++;
        if (count === taskPool.length) {
          resolve(results);
        }
        request();
      }
    };

    const times = Math.min(max, taskPool.length);
    for (let i = 0; i < times; i++) {
      request();
    }
  });
};

后端的实现

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

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

代码实现我就不贴了,为节省篇幅本篇只讨论前端的实现。(ps:后端的事情就让后端的同学去学习吧)

文件合并

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

前端实现

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

const fileName = ref(""); // 文件名称

const upload = (file)=> {
    if (!file) return;
    fileName.value = file.name;
    const chunks = createChunks(file);
    // 获取到了所有的切片集合
    uploadChunks(chunks);
}

// 上传文件分片
const uploadChunks = async (
  chunks: Array<Blob>,
) => {
  ...
    // 合并分片请求
     mergeRequest();
};
// 合并分片请求
const mergeRequest = () => {
  fetch("http://localhost:3000/merge", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      fileHash: fileHash.value,
      fileName: fileName.value,
    }),
  });
};

后端实现

...

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

秒传&断点续传

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

前端实现

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

// 校验文件、文件分片是否存在
const verify = (fileHash: string, fileName: string) => {
  return fetch("http://localhost:3000/verify", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      fileHash,
      fileName,
    }),
  }).then((res) => res.json());
};

// 绑定上传事件
const handleUpload = async (e: Event) => {
  ...
  
  // 计算文件内容hash值
  fileHash.value = await calculateHash(file);

+  // 校验文件、文件分片是否存在
+  const verifyRes = await verify(fileHash.value, fileName.value);
+  const { existFile, existChunks } = verifyRes.data;
+  if (existFile) return;

};

后端实现

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

完成上面的步骤后,当我们再上传相同的文件,即使改了文件名,也会提示我们秒传成功了,因为服务器上已经有对应的那个文件了。上面我们解决了重复上传的文件,但是对于网络中断需要重新上传的问题没有解决,那该如何解决呢?

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

前端实现

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

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

  // 上传文件分片
-  uploadChunks(chunks);
+  uploadChunks(chunks, existChunks);
};

// 上传文件分片
const uploadChunks = async (
  ...
+  existChunks: Array<string>
) => {
  const formDatas = chunks
    .map((chunk, index) => ({
      fileHash: fileHash.value,
      chunkHash: fileHash.value + "-" + index,
      chunk,
    }))
+    .filter((item) => !existChunks.includes(item.chunkHash))
    .map((item) => {
      const formData = new FormData();
      formData.append("fileHash", item.fileHash);
      formData.append("chunkHash", item.chunkHash);
      formData.append("chunk", item.chunk);
      return formData;
    });
    
  ...
};

学习了。。。

以上大文件上传部分内容摘自

作者:宾燕哥哥
链接:juejin.cn/post/729722…
来源:稀土掘金