背景
公司用的阿里云的oss对象存储,没有单独对大文件视频上传的单独处理,作为一个眼睛里容不进沙子的前端工程师,本着对公司认真负责的职业态度(哈哈,这里楼主就不自卖自夸了,技术优化而已),因为oss提供了大文件视频分片上传的sdk,这里我们前端实现。
需求
基本上实现视频的普通上传,支持分片上传以及断点续传功能就可以了,这几个方法阿里云也都提供给我们了,所以基本上我们不需要太多改动,老老实实实现就可以了;另外就是ui让我们加了一个进度条,这里我们后面也会放。
实现
既然是通用的,所以我们就要尽可能的去封装实现;这里废话就不多说了,我们直接上干货 这里的videoCallback是我们传进来一个回调,前期实现我是放了一个promise进去,通过我在外面的手动resolve或者reject来控制我们的视频上传过程,后来和领导讨论后否决,不影响视频上传动作。
DEFAULT_IMG_LIMT: 视频最大上传限制与否,根据业务需求调整即可。
videoCallback: 业务代码传进来的回调函数。
cancel: 这个方法也是阿里云给到我们的官方取消上传方法,但是这个方法oss sdk里面并没有抛出,实际上阿里云源码上的proto上也确实挂载了这个方法,具体可以参考github源码(如下图),官方readme文档也介绍了这个方法,这里就不在赘述了,使用即可。
checkpoint: 帮我们记录断点位置,方便续传操作。
在处理分片文件上传超时的情况,阿里云需要业务方自行捕捉ConnectionTimeoutError来进行超时逻辑的处理,文档请自行参考 help.aliyun.com/document_de…。
import OSS from 'ali-oss';
import RError from './rError';
interface OSSClass extends OSS {
cancel?: any;
}
const DEFAULT_IMG_LIMT = {
videoMaxSize: 524288000, // 视频最大字节 500M
videoNorSize: 10485760, // 无需切割限制 10M
};
// 默认视频切片配置
const DEFAULTVIDEO = {
parallel: 5, // 并发上传切片量
partSize: 1024 * 1024 * 2, // 分片大小
}
class Upload {
constructor(bucket: string, projectName = '', opts: OSS.Options = DEFAULT, videoCallback = null) {
this.Options = .... // 配置项
//初始化
this.client = new OSS(this.Options);
this.ProjectName = projectName ? projectName + '/' : '';
this.abortCheckpoint = {};
this.videoCallback = videoCallback;
this.process = 0;
}
client: OSSClass;
Options: OSS.Options;
Host: string;
ProjectName: string;
abortCheckpoint: Object;
videoCallback: any;
process: number;
async getFileName(file: File, imgdata?: null) {
// 自行定义上传文件名...
}
/**
* 简单的上传文件
* @param file 文件对象
* @param opts 参数
* @returns 文件结果对象
*/
async upload(file: File, opts: OSS.PutObjectOptions = {}, imglimit: iImgLimit = DEFAULT_IMG_LIMT) {
const fileName = await this.getFileName(file);
opts.mime = file.type;
// 视频
if (file.type.startsWith('video/')) {
if (imglimit.videoMaxSize && file.size > imglimit.videoMaxSize) {
throw new RError(`视频大小不能超过${parseInt(String(imglimit.videoMaxSize / 1024 / 1024), 10)}M`);
}
if (imglimit.videoMaxSize > file.size && file.size > imglimit.videoNorSize) {
try {
// 进行切片上传
const result = await this.client.multipartUpload(fileName, file, {
...opts,
...DEFAULTVIDEO,
progress: async (p, cpt, res) => {
this.process = p;
if (this.videoCallback) {
this.videoCallback({
status: 'uploading',
process: p,
resumeUploadParam: {
fileName,
file,
...opts,
...DEFAULTVIDEO,
checkpoint: this.abortCheckpoint,
},
});
}
// 记录中断点
this.abortCheckpoint = cpt;
}
});
if (this.videoCallback) {
this.videoCallback({
status: 'success',
process: this.process,
resumeUploadParam: null,
});
}
return {
url: this.Host + fileName,
name: result.name,
}
} catch (err: any) {
if (err.code && err.code === 'ConnectionTimeoutError') {
// 这里我们需要单独去判断上传超时的情况
if (this.videoCallback) {
this.videoCallback({
status: 'fail',
process: this.process,
resumeUploadParam: {
fileName,
file,
...opts,
...DEFAULTVIDEO,
checkpoint: this.abortCheckpoint,
},
});
}
throw new RError("上传超时,请重新上传");
}
}
} else {
// 不超过videoNorSize限制的情况 调用普通上传即可
const result = await this.client.put(fileName, file, opts);
return {
url: this.Host + fileName,
name: result.name,
};
}
}
}
/**
* 是否重新上传的函数
* @param resumeUploadParam 重现上传参数
* @returns 续传
*/
async resumeUpload (resumeUploadParam: any): Promise<any> {
const { fileName, file } = resumeUploadParam;
try {
const result = await this.client.multipartUpload(fileName, file, {
...resumeUploadParam,
progress: async (p, cpt, res) => {
this.process = p;
if (this.videoCallback) {
this.videoCallback({
status: 'resumeing',
process: p,
resumeUploadParam: {
...resumeUploadParam,
checkpoint: cpt,
},
})
}
this.abortCheckpoint = cpt;
}
});
if (this.videoCallback) {
this.videoCallback({
status: 'success',
process: this.process,
resumeUploadParam: null,
});
}
return {
url: this.Host + fileName,
name: result.name,
};
} catch (err: any) {
if (err?.code && err?.code === 'ConnectionTimeoutError') {
if (this.videoCallback) {
this.videoCallback({
status: 'fail',
process: this.process,
resumeUploadParam: {
fileName,
file,
...resumeUploadParam,
checkpoint: this.abortCheckpoint,
},
});
}
throw new RError('');
}
}
}
/**
* 停止上传
* @returns 停止上传
*/
async stopUpload(): Promise<any> {
try {
const data = await this.client.cancel();
return data;
} catch (err) {
throw new RError('');
}
}
}
代码到这里,我们的功能代码基本上就完成了,接下来就是业务代码的部分。(React18 + antd4)
在组件销毁的时候记得调destroyFn方法来关闭当前上传请求
import React, { FC, useState, useEffect } from 'react';
import { Upload, message, Progress } from 'antd';
import * as math from 'mathjs';
import IMGCLIENT from '@/utils/imgOss';
import { useSafeState } from 'ahooks';
import { LoadingOutlined, PlusOutlined } from '@ant-design/icons';
import './index.less';
interface UploadProps {
videoMax?: number; // 视频最大上传限制
video: string; // 视频url
handleDelate: any; // 删除事件回调
getRequestData: any; // 上传成功回调
width?: number;
disabled?: boolean; // 是否禁用
disabledMsg?: string; // 禁用操作提示语
customRequestFile?: any; // 自定义上传回调
}
const limit = {
videoMax: 500,
width: 500,
};
const Index: FC<UploadProps> = (props) => {
const { videoMax = limit.videoMax, video, handleDelate, getRequestData, width = limit.width, disabled, disabledMsg, customRequestFile } = props;
const [loading, setLoading] = useState<boolean>(false);
const [percent, setPercent] = useSafeState<number>(0);
const [uploadDisable, setUploadDisable] = useState<boolean>(true);
const [cancelDisable, setCancelDisable] = useState<boolean>(true);
// 续传信息
const [resumeParam, setResumeParam] = useState<any>(null);
useEffect(() => {
if (video) {
setPercent(100);
} else {
setPercent(0);
}
}, [video]);
useEffect(
() => () => {
destroyFn();
},
[],
);
// 销毁停止上传
const destroyFn = async () => {
try {
await IMGCLIENT.stopUpload();
setPercent(0);
} catch (err) {
setPercent(0);
console.log(err);
}
};
// 上传之前的回调
const beforeUpload = (file: File) => {
if (loading) return;
resumeCancel();
setLoading(true);
const isJpgOrPng = file.type === 'video/mp4';
if (!isJpgOrPng) {
message.warning('视频类型支持mp4');
setLoading(false);
return false;
}
const isLt2M = file.size / (1024 * 1024);
if (isLt2M > videoMax) {
message.warning(`上传视频最大为${videoMax}MB,请重新上传`);
setLoading(false);
return false;
}
return true;
};
const uploadButton = (
<div>
{loading ? <LoadingOutlined /> : <PlusOutlined />}
<div style={{ marginTop: 8 }}>{loading ? '上传中' : '上传'}</div>
</div>
);
const videoProcess = ({ status, process, resumeUploadParam }: { status: string; process: number; resumeUploadParam?: any }) => {
try {
if (process) {
const num = Number(math.multiply(math.bignumber(process.toFixed(2)), math.bignumber(100))).valueOf();
setPercent(num);
}
if (resumeUploadParam) {
setResumeParam(resumeUploadParam);
}
if (status === 'fail') {
setUploadDisable(false);
setCancelDisable(false);
}
if (status === 'uploading' || status === 'resumeing') {
setUploadDisable(true);
setCancelDisable(false);
}
if (status === 'success') {
setUploadDisable(true);
setCancelDisable(true);
}
} catch (err) {
setPercent(0);
setResumeParam(null);
}
};
const customRequestFn = async (uploadFile: any) => {
try {
if (customRequestFile) {
customRequestFile(uploadFile);
}
setLoading(true);
setPercent(0);
setCancelDisable(true);
setUploadDisable(true);
const data = await IMGCLIENT.videoUpload(uploadFile.file, videoProcess);
if (data) {
getRequestData(data);
setLoading(false);
} else {
setLoading(false);
}
} catch (err: any) {
console.log(err);
setLoading(false);
setUploadDisable(false);
message.warning(err?.message || '上传失败');
}
};
const deleteVideo = () => {
if (disabled && disabledMsg) {
message.info(disabledMsg);
}
if (disabled) return;
setUploadDisable(true);
setCancelDisable(true);
setResumeParam(null);
setLoading(false);
handleDelate();
};
const resumeClick = async () => {
if (uploadDisable || !resumeParam) return;
setUploadDisable(true);
setLoading(true);
// 重新续传
try {
const data = await IMGCLIENT.resumeUpload(videoProcess, resumeParam);
if (data) {
getRequestData(data);
setLoading(false);
} else {
setLoading(false);
}
} catch (err) {
console.log(err);
setUploadDisable(false);
setLoading(false);
}
};
const resumeCancel = async () => {
if (cancelDisable) return;
try {
await IMGCLIENT.stopUpload();
setUploadDisable(true);
setCancelDisable(true);
setResumeParam(null);
setPercent(0);
} catch (err) {
console.log(err);
setCancelDisable(false);
setUploadDisable(true);
}
};
return (
<div style={{ width: width > limit.width ? width : limit.width }}>
{video ? (
<div className="upload-content">
<img src="" onClick={deleteVideo} alt="" />
<video src={video} controls>
<track kind="captions"></track>
</video>
</div>
) : (
<div className="upload-region">
<span>
<Upload
disabled={loading || disabled}
accept="video/mp4"
listType="picture-card"
className="avatar-uploader"
showUploadList={false}
beforeUpload={beforeUpload}
customRequest={customRequestFn}
>
{uploadButton}
</Upload>
</span>
<span className="upload-tipText">
<span>视频类型上限{videoMax}M</span>
<span>支持mp4</span>
</span>
</div>
)}
<div className="upload-process" style={{ marginTop: 2 }}>
<Progress style={{ width: '70%' }} percent={percent} />
{video ? (
<span className="upload-delete" onClick={deleteVideo}>
删除
</span>
) : (
<>
<span onClick={resumeClick} className={`upload-continue ${uploadDisable && 'upload-disable'}`}>
{uploadDisable ? '' : '继续上传'}
</span>
<span onClick={resumeCancel} className={`upload-cancelUpload ${cancelDisable && 'upload-disable'}`}>
{cancelDisable ? '' : '取消上传'}
</span>
</>
)}
</div>
</div>
);
};
export default Index;
代码到这里,一个简单的视频分片上传就完成了,同时支持断点续传功能,注意的是,在上传实例的时候续传要用旧实例,新的文件上传的时候调用新实例生成(但实测一个实例可以上传多个,前提是需要等待client.cancel()调用成功后);附上官方回答。
温馨提示