背景
某一天早上,主管说今天能不能搞下大文件上传,紧急支持一下
我心想,哇趣,根本没做过呀,虽然文章很多是了,但没法子拒绝,那天就只有我1个前端在公司上班,于是只能硬着头皮上了,后来发现,这个大文件上传好像很简单
需求
- 支持单个的大文件上传,如100MB
- 有进度条展示
- 可取消上传
- 支持保留上传进度,比方说页面不小心关掉了
技术栈
- vue3
- node: v16.x.x
- elment-plus
- qiniu、qiniu-js的v3版本
开发前的准备工作
考虑到,相关文章非常多,时间又非常紧迫,这种情况,只能继续使用gpt了,于是询问他关于vue3+element-plus+七牛实现一个大文件上传功能,然后gpt就甩给我一个hooks,我一看基本都满足我的需求了,而且看起来蛮简单,就开心的复制了。
不过只靠gpt也不行,还需要看七牛文档,对照下参数是否有问题,七牛文档中有个2个关键点: 服务端生成token、前端调用文件上传服务,我一看大文件上传服务核心点都被七牛实现了,我就负责传递参数即可,心想这真的是帮大忙了。
特别注意:代码只涉及到了单个的大文件上传的场景
具体开发过程
服务端生成token
对接服务端的同学,先给了我ak、sk、bucket用,考虑到时间紧迫,等不起他的开发,我就先用js脚本模拟生成了我需要的token,反正不复杂,具体如下
// config.js
// 定一个七牛配置
export const qiniuConfig = {
ak: 'xxxx',
sk: 'xxx',
bucket: 'xxx',
}
// qn.js
// 获取token
import qiniu from 'qiniu
import { qiniuConfig } from './config.js'
const AK = qiniuConfig.ak
const SK = qiniuConfig.sk
const BUCKET = qiniuConfig.bucket
const mac = new qiniu.auth.digest.Mac(AK, SK)
export function getUploadToken(fileName) {
const prefix = 'xxx/xxx/' // 可以和后端沟通需要什么路径,比如file/时间戳/
const key = prefix + fileName // key其实就是文件资源名,加上前缀可以指定存放路径
const putPolicy = new qiniu.rs.PutPolicy({ scope: `${BUCKET}:${key}` })
const uploadToken = putPolicy.uploadToken(mac)
return { token: uploadToken, key }
}
记住这个token只能用node运行得出来,因为qiniu是后端服务,不能被前端调用
前端大文件上传hooks
hooks比较简单,最关键的是发起上传和取消上传时机,具体如下
// js v3 七牛参考文档: https://developer.qiniu.com/kodo/1283/javascript
import * as qiniu from 'qiniu-js'
import commonApi from '@/api/common'
export interface QnConfig {
bucket: string // 七牛存储空间名称
prefix: string // 七牛存储前缀
}
export interface UseQiniuUploaderOptions {
chunkSize?: number // 切片大小(默认4MB)
concurrency?: number // 并发上传数(默认3)
retryCount?: number // 失败重试次数(默认3)
isShowMessage?: boolean // 是否显示消息提示
qnConfig: QnConfig // 七牛配置
onError?: (args?: any) => void // 错误回调
}
export interface ProgressCompose {
size: number // chunk 的尺寸
loaded: number // 已经发送完毕的尺寸
percent: number // 进度比例,范围在 0 - 100
fromCache: boolean // 是否是来自缓存
}
export interface UseQiniuNextInfo {
uploadInfo: {
id: string | number // 上传任务的唯一标识
url: string // 上传地址
}
chunks: Array<ProgressCompose>
total: {
loaded: number // 已上传大小,单位为字节
total: number // 本次上传的总量控制信息,单位为字节,注意这里的 total 跟文件大小并不一致
percent: number // 当前上传进度,范围:0~100
}
}
export function useBigFileUpload(options: UseQiniuUploaderOptions) {
const {
chunkSize = 4,
concurrency = 3,
retryCount = 3,
qnConfig = {
bucket: '',
prefix: '',
},
isShowMessage = true,
onError = (args?: any) => {
/* noop */
},
} = options
const progress = ref(0)
const uploading = ref(false)
const error = ref<Error | null>(null)
const success = ref(false)
// 七牛返回的 subscription,对象仅需调用 unsubscribe() 取消上传
let uploadSubscription: { unsubscribe: () => void } | null = null
async function getUploadToken(fileName = '') {
const res = await commonApi.getQnToken()
if (!res) return null
return {
token: res.data.uploadToken,
key: qnConfig.prefix + fileName,
}
}
// 开始上传
async function startUpload(file: File) {
uploading.value = true
error.value = null
success.value = false
progress.value = 0
try {
const tokenRes = await getUploadToken(file.name)
if (!tokenRes) {
error.value = new Error('获取上传凭证失败')
return
}
const { token, key } = tokenRes
// 上传额外参数,可按需扩展
const putExtra = {
fname: file.name, // 文件名称
mimeType: file.type, // 文件类型
customVars: {} as Record<string, string>, // x:开头,如: {'x:tst': 'qiniu'}
metadata: {} as Record<string, string>, // x-qn-meta-开头,如: {'x-qn-meta-title': 'qiniu'}
}
// 上传配置
const config = {
useCdnDomain: true, // 是否使用 cdn 加速域名
retryCount, // 上传自动重试次数
chunkSize, // 分片上传时每片的大小,必须为正整数,单位为 MB,且最大不能超过 1024,默认值 4
concurrentRequestLimit: concurrency, // 分片上传的并发请求量,默认为3;因为浏览器本身也会限制最大并发量,所以最大并发量与浏览器有关。
forceDirect: file.size < 1 * 1024 * 1024, // 是否上传全部采用直传方式
checkByMD5: true, // 是否开启 MD5 校验, 用于断点上传
// disableStatisticsReport: false, // 是否禁用日志报告
// debugLogLevel: 'ERROR', // 调试日志级别 INFO | WARN | ERROR | OFF
}
// 发起上传
const observable = qiniu.upload(file, key, token, putExtra, config) as any
uploadSubscription = observable.subscribe(
(res: UseQiniuNextInfo) => {
// 上传进度
progress.value = Number(res.total?.percent.toFixed(2)) || 0
},
(err: any) => {
// 上传失败
error.value = err
uploading.value = false
if (isShowMessage) {
ElMessage({
type: 'error',
message: err.message,
})
}
},
(_: any) => {
// 上传成功
progress.value = 100
success.value = true
uploading.value = false
if (isShowMessage) {
ElMessage({
type: 'success',
message: '上传成功',
})
}
}
)
} catch (err: any) {
error.value = err
uploading.value = false
}
}
// 取消上传
function cancelUpload() {
uploadSubscription?.unsubscribe()
uploading.value = false
}
// 错误处理,比方说上传失败要清空文件列表
watch(
() => error.value,
(val) => {
if (val) {
onError(val)
}
}
)
// 组件卸载时自动取消
onBeforeUnmount(() => {
cancelUpload()
})
return {
progress,
uploading,
error,
success,
startUpload,
cancelUpload,
}
}
前端文件上传样式
看这篇文章:七牛的单个大文件上传的组件封装,vue3+elment-plus+hooks
核心点
- 传入qnConfig,灵活控制前缀和bucket
uploadSubscription = observable.subscribe,这段函数就是上传的核心点- 根据
getUploadToken拿到的token,给后端进行查询链接