阿里云OSS分片上传(前端实现)

1,138 阅读3分钟

u=2537966709,2852517020&fm=253&fmt=auto&app=138&f=JPEG.webp

背景

公司用的阿里云的oss对象存储,没有单独对大文件视频上传的单独处理,作为一个眼睛里容不进沙子的前端工程师,本着对公司认真负责的职业态度(哈哈,这里楼主就不自卖自夸了,技术优化而已),因为oss提供了大文件视频分片上传的sdk,这里我们前端实现。

需求

基本上实现视频的普通上传,支持分片上传以及断点续传功能就可以了,这几个方法阿里云也都提供给我们了,所以基本上我们不需要太多改动,老老实实实现就可以了;另外就是ui让我们加了一个进度条,这里我们后面也会放。

实现

既然是通用的,所以我们就要尽可能的去封装实现;这里废话就不多说了,我们直接上干货 这里的videoCallback是我们传进来一个回调,前期实现我是放了一个promise进去,通过我在外面的手动resolve或者reject来控制我们的视频上传过程,后来和领导讨论后否决,不影响视频上传动作。

DEFAULT_IMG_LIMT: 视频最大上传限制与否,根据业务需求调整即可。

videoCallback: 业务代码传进来的回调函数。

cancel: 这个方法也是阿里云给到我们的官方取消上传方法,但是这个方法oss sdk里面并没有抛出,实际上阿里云源码上的proto上也确实挂载了这个方法,具体可以参考github源码(如下图),官方readme文档也介绍了这个方法,这里就不在赘述了,使用即可。

proto.png 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()调用成功后);附上官方回答。

image.png

温馨提示

此功能.jpeg