前言
日常项目开发中,想必大伙都接触过一下需要上传图片视频或者文件类的场景。那么,我们一般都是如何实现的呢。本篇文章,作者描述一些文件上传过程用所涉及的前端技术和原理,并列举相关代码。一来是为了加深记忆,二来可以在需要时拿来就用。本文将从基础的文件上传入手,逐步降到大文件上传,断点续传的实现过程和原理。
叠个甲,作者技术水平一般,欢迎大佬们发表见解。
基本的文件上传
文件的上传一般来说会采用两种方案:一种是,后端编写一个上传接口,将需要上传的文件,上传的服务器上的某一文件夹中,并返回该文件夹的服务器地址,供前端使用。另一种方案是,上传到诸如阿里云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…
来源:稀土掘金