如何使用 Ant Design 的 Upload 组件自定义请求上传与下载 OSS ?

1,121 阅读10分钟

1. 前言

最近有客户跟我们说,下单时要上传的文档资料有点大,超出了限制,得麻烦我们支持一下。之前我们都是前端找后端接口,然后后端再帮忙上传到 oss 对象存储服务的桶里。但现在要是放开文档大小,后端服务可能会受不了,还会浪费很多服务端流量。所以,我们得改一下,让前端直接上传到 oss,这样就更方便了。

AD7E3A7B-5248-4AE4-82B1-B452299D8641.png

2. 问题

考虑 Ant Design 的 Upload 组件默认是通过 formData 的方式来上传文件的,所以如果你想自定义上传方式的话,就得用 customRequest 这个功能,这样会更灵活更方便。

/**  
 * 大文件上传,通过 oss 桶直接上传  
 * 1. 传入 filename 先通过后端获取上传的 oss url 和 token  
 * 2. 获取 oss url,然后进行上传  
 */  
import React, { useState } from 'react';  
import { Upload, message } from 'antd';  
import { getUploadOssUrl } from './service';  
  
function BigFileUpload(props) {  
  const { children, onChange, ...rest } = props;  
  const [loading, setLoading] = useState(false);  
  const handleSourceUpload = async ({ file }) => {  
    const { name } = file;  
    try {  
      setLoading(true);  
      const { success, obj, errorMessage } = await getUploadOssUrl({  
        fileName: name,  
      });  
      if (success) {  
        const { fileUrl, fileKey, token } = obj;  
        // 本地验证  
        let url = '';  
        if (window._global.env === 'dev') {  
          url = new URL(fileUrl).pathname;  
        } else {  
          url =  
            window.location.protocol !== 'https'  
              ? fileUrl.replace(':8080''').replace('http:''')  
              : fileUrl.replace('http:''');  
        }  
        await fetch(url, {  
          method'PUT',  
          body: file,  
          headers: {  
            'content-type': file.type,  
            'X-Auth-Token': token,  
          },  
        });  
        onChange({ file, ossId: fileKey });  
      } else {  
        message.error(errorMessage);  
      }  
    } catch (e) {  
      message.error('上传失败');  
    } finally {  
      setLoading(false);  
    }  
  };  
  return (  
      {React.cloneElement(children, { loading })}  
  );  
}  
  
export default BigFileUpload;

上面的代码里,文件一上传,onChange 就会告诉我们成功了。然后,我试着把项目里所有用到 Upload 组件的地方都换成了 BigFileUpload 组件。可是呢,我发现这样搞有点麻烦。自定义上传 oss 和下载都不方便了,而且它跟以前的 Upload 组件返回的东西还不太一样,比如 fileListfile 对象结构。要是以后还得加断点续传什么的,那改动就更大了,真是有点头疼啊。

3. 解决方案

咱们的目标是这样的哈:得支持大家用自己喜欢的方式上传文件,比如oss上传、断点续传这些都容易支持。当然啦,上传过程中要是想取消、暂停或者删除文件也没问题,还能自定义渲染维护已经上传的文件列表。

咱们来聊聊上面的目标哈,咱们可以有哪些策略呢?

  • 首先,咱们现在的 Upload 组件够用不?
  • 还有啊,咱们分析一下 antd-upload 的源码,看看能不能二次封装一下,让它更好用、更容易扩展?
  • 另外,咱们也可以考虑完全自定义一个 Upload 组件,看看有没有现成的开源方案可以直接拿来改改?或者咱们内部有没有现成的方案可以直接拿来用呢?

考虑到跟项目已有的功能兼容性以及改造成本,我仔细分析了Upload组件的源码,最后呢,我决定用居然Upload组件的二次封装来处理。

  • 这样,原来的Upload组件上传列表就不用动了,除了上传默认用咱们自定义的上传方式,其他的功能都跟Upload组件一模一样。
  • 开发者们想用默认的自定义上传器就用,想自己重新定义上传也行,只要按照Upload组件的请求规范来就行。以后咱们还可以继续加强上传功能,比如选个断点续传(也是基于自定义上传的)。

3.1 Upload 组件源码分析

Ant Design 的 Upload 组件其实就是基于 react-component/upload 进行了一些封装和扩展,后面我们就简称它为 antd-upload 和 rc-upload 啦,这样叫起来更简洁。

rc-upload 这个工具能帮你搞定基础的文件和目录上传,还支持批量并发上传哦!在上传前,它还会帮你校验一下文件,确保没问题。而且,它还能实时告诉你文件上传的进度、是不是成功了,或者失败了。最棒的是,你甚至可以随时取消上传!

而 antd-upload 就是在 rc-upload 的基础上,又加了一些超酷的功能。它能展示你上传的文件列表,让你一目了然。而且,它还能把文件上传的进度、成功或失败都展示得清清楚楚。更厉害的是,它还支持拖拽上传、图片预览,甚至还能自定义视频预览等高级功能呢!简直不要太方便!

F4D8E880-6E96-4718-8A17-0508BE4075A3.png

仔细阅读 rc-upload 组件源码,可以看到关键代码

4F7A366A-960D-4FA7-ADE0-1C38B139C7BB.png

rc-upload 的核心请求文件 request 是通过 xhr 来发送请求的。当请求成功、失败或进行中时,它会通过 onSuccessonErroronProgress 这些回调函数,把文件和 xhr 的响应信息传递给 antd-upload 组件。这样,我们就可以更轻松地处理上传过程中的各种情况啦!

import type { UploadRequestOptionUploadRequestErrorUploadProgressEvent } from './interface';  
  
function getError(option: UploadRequestOption, xhr: XMLHttpRequest) {  
  const msg = `cannot ${option.method} ${option.action} ${xhr.status}'`;  
  const err = new Error(msg) as UploadRequestError;  
  err.status = xhr.status;  
  err.method = option.method;  
  err.url = option.action;  
  return err;  
}  
  
function getBody(xhr: XMLHttpRequest) {  
  const text = xhr.responseText || xhr.response;  
  if (!text) {  
    return text;  
  }  
  
  try {  
    return JSON.parse(text);  
  } catch (e) {  
    return text;  
  }  
}  
  
export default function upload(option: UploadRequestOption) {  
  // eslint-disable-next-line no-undef  
  const xhr = new XMLHttpRequest();  
  
  if (option.onProgress && xhr.upload) {  
    xhr.upload.onprogress = function progress(e: UploadProgressEvent) {  
      if (e.total > 0) {  
        e.percent = (e.loaded / e.total) * 100;  
      }  
      option.onProgress(e);  
    };  
  }  
  
  // eslint-disable-next-line no-undef  
  const formData = new FormData();  
  
  if (option.data) {  
    Object.keys(option.data).forEach(key => {  
      const value = option.data[key];  
      // support key-value array data  
      if (Array.isArray(value)) {  
        value.forEach(item => {  
          // { list: [ 11, 22 ] }  
          // formData.append('list[]', 11);  
          formData.append(`${key}[]`, item);  
        });  
        return;  
      }  
  
      formData.append(key, value as string | Blob);  
    });  
  }  
  
  // eslint-disable-next-line no-undef  
  if (option.file instanceof Blob) {  
    formData.append(option.filename, option.file, (option.file as any).name);  
  } else {  
    formData.append(option.filename, option.file);  
  }  
  
  xhr.onerror = function error(e) {  
    option.onError(e);  
  };  
  
  xhr.onload = function onload() {  
    // allow success when 2xx status  
    // see https://github.com/react-component/upload/issues/34  
    if (xhr.status < 200 || xhr.status >= 300) {  
      return option.onError(getError(option, xhr), getBody(xhr));  
    }  
  
    return option.onSuccess(getBody(xhr), xhr);  
  };  
  
  xhr.open(option.method, option.actiontrue);  
  
  // Has to be after `.open()`. See https://github.com/enyo/dropzone/issues/179  
  if (option.withCredentials && 'withCredentials' in xhr) {  
    xhr.withCredentials = true;  
  }  
  
  const headers = option.headers || {};  
  
  // when set headers['X-Requested-With'] = null , can close default XHR header  
  // see https://github.com/react-component/upload/issues/33  
  if (headers['X-Requested-With'] !== null) {  
    xhr.setRequestHeader('X-Requested-With''XMLHttpRequest');  
  }  
  
  Object.keys(headers).forEach(h => {  
    if (headers[h] !== null) {  
      xhr.setRequestHeader(h, headers[h]);  
    }  
  });  
  
  xhr.send(formData);  
  
  return {  
    abort() {  
      xhr.abort();  
    },  
  };  
}

而 antd-upload 组件 onSuccsssonErroronProgress 都会调用 onInternalChange 事件

833A7EFB-895A-4578-970B-34959680F83C.png

onInternalChange 事件会调用 onChange 事件,暴露给使用 antd-upload 组件的开发者。

0A713DC6-27E5-4C1C-AEE0-584E46C8C726.png

3.2 自定义请求

其实,直接从WEB前端上传文件到OSS,不经过后端,只是说上传的过程不走后端啦。但鉴权流程还是得靠后端哦,毕竟密钥不能随便放在前端嘛。所以,我们得通过后端接口来获取上传凭证(Token)。这样,我们还能顺便对用户进行其他校验,比如看看用户一天能上传多少次,或者当前上传人数太多时,就进行限流。这样大家都能更顺畅地上传文件啦!

C71A4AEE-3159-4C77-BE8C-9D2935B7A6DF.png

最后咱们决定,直接把 Upload 组件依赖的 rc-upload 里的自定义 request 拷贝过来,这样就能轻松兼容现有的 Upload 状态、api 啥的了,简直不要太方便!

/**  
 * 大文件上传,通过 oss 桶直接上传  
 * 1. 传入 filename 先通过后端获取上传的 oss url 和 token  
 * 2. 获取 oss url,然后进行上传  
 */  
import React from 'react';  
import { Upload, message } from 'antd';  
import request from './request';  
import { getUploadOssUrl } from './service';  
const docMaxSize = 300// 单位M  
function BigFileUpload(props) {  
  const { children, maxSize = docMaxSize, ...rest } = props;  
  const handleSourceUpload = async ({  
    file,  
    onProgress,  
    onSuccess,  
    onError,  
  }) => {  
    const { name } = file;  
    try {  
      const { success, obj, errorMessage } = await getUploadOssUrl({  
        fileName: name,  
      });  
      if (success) {  
        const { fileUrl, fileKey, token } = obj;  
        // 本地验证  
        let url = '';  
        if (window._global.env === 'dev') {  
          url = new URL(fileUrl).pathname;  
        } else {  
          url =  
            window.location.protocol !== 'https'  
              ? fileUrl.replace(':8080''').replace('http:''')  
              : fileUrl.replace('http:''');  
        }  
        request({  
          action: url,  
          method'PUT',  
          // 因为 OSS 这里的 PUT 没有任何响应参数,为了兼容 Upload 组件,这里需要注入一个 response  
          // 这里的 response 会在 onSuccess 回调中返回  
          response: { obj: { name: file.nameossId: fileKey } },  
          headers: {  
            'content-type': file.type,  
            'X-Auth-Token': token,  
          },  
          body: file,  
          onProgress,  
          onSuccess,  
          onError,  
        });  
      } else {  
        message.error(errorMessage);  
      }  
    } catch (e) {  
      message.error('上传失败');  
    }  
  };  
  function handleBeforeUpload(file) {  
    const { beforeUpload } = props;  
    if (file.size / 1024 / 1024 > maxSize) {  
      message.error(`文件大于${maxSize}MB,请压缩后再进行上传`);  
      return Upload.LIST_IGNORE;  
    }  
    return typeof beforeUpload === 'function' ? beforeUpload(file) : true;  
  }  
  return (  
          {...rest}  
      customRequest={handleSourceUpload}  
      beforeUpload={handleBeforeUpload}  
    >  
      {children}  
      
  );  
}  
  
export default BigFileUpload;

自定义请求

/**  
 * 基于 https://github.com/react-component/upload request 源码修改  
 * 原因:  
 * 1. 因为 antd Upload 组件默认使用 formData 进行上传,而顺丰 OSS 需要直接上传文件  
 * 2. 自定义上传兼容 antd Upload 文件上传组件结构,兼容 onSuccess、onError、onProgress 等方法,保证 Upload 上传组件一致  
 */  
function getError(option, xhr) {  
  const msg = `cannot ${option.method} ${option.action} ${xhr.status}'`;  
  const err = new Error(msg);  
  err.status = xhr.status;  
  err.method = option.method;  
  err.url = option.action;  
  return err;  
}  
  
function getBody(xhr) {  
  const text = xhr.responseText || xhr.response;  
  if (!text) {  
    return text;  
  }  
  
  try {  
    return JSON.parse(text);  
  } catch (e) {  
    return text;  
  }  
}  
  
export default function upload(option) {  
  // eslint-disable-next-line no-undef  
  const xhr = new XMLHttpRequest();  
  
  if (option.onProgress && xhr.upload) {  
    xhr.upload.onprogress = function progress(e) {  
      if (e.total > 0) {  
        e.percent = (e.loaded / e.total) * 100;  
      }  
      option.onProgress(e);  
    };  
  }  
  
  xhr.onerror = function error(e) {  
    option.onError(e);  
  };  
  
  xhr.onload = function onload() {  
    // allow success when 2xx status  
    // see https://github.com/react-component/upload/issues/34  
    if (xhr.status < 200 || xhr.status >= 300) {  
      return option.onError(getError(option, xhr), getBody(xhr));  
    }  
  
    return option.onSuccess(  
      option.response ? option.response : getBody(xhr),  
      xhr,  
    );  
  };  
  
  xhr.open(option.method, option.actiontrue);  
  
  // Has to be after `.open()`. See https://github.com/enyo/dropzone/issues/179  
  if (option.withCredentials && 'withCredentials' in xhr) {  
    xhr.withCredentials = true;  
  }  
  
  const headers = option.headers || {};  
  
  // when set headers['X-Requested-With'] = null , can close default XHR header  
  // see https://github.com/react-component/upload/issues/33  
  if (headers['X-Requested-With'] !== null) {  
    xhr.setRequestHeader('X-Requested-With''XMLHttpRequest');  
  }  
  
  Object.keys(headers).forEach(h => {  
    if (headers[h] !== null) {  
      xhr.setRequestHeader(h, headers[h]);  
    }  
  });  
  xhr.send(option.body);  
  
  return {  
    abort() {  
      xhr.abort();  
    },  
  };  
}

3.3 文件下载

上传文件不是直接通过后端服务,所以下载文件也不应该让应用服务器插手。首先,咱们得用 ossId 从后端服务那里拿到 token 和拼接好的 url 地址,然后直接找 oss 服务器请求就完事了。

0333D560-BE68-4EBF-B314-F4383574C1D3.png

3.3.1 单个文件下载

export async function ossFileDownload({ ossId }) {  
  const { success: getOssSuccess, obj } = await getOssDownLoadUrl({  
    ossId,  
  });  
  if (getOssSuccess && !isEmpty(obj)) {  
    const { fileUrl, token } = obj || {};  
    let url = '';  
    // @ts-ignore  
    if (window._global.env === 'dev') {  
      url = new URL(fileUrl).pathname;  
    } else {  
      url =  
        window.location.protocol !== 'https'  
          ? fileUrl.replace(':8080''').replace('http:''')  
          : fileUrl.replace('http:''');  
    }  
    // 编写 fetch 请求,返回 blob 类型  
    return new Promise(resolve => {  
      fetch(url, {  
        method'GET',  
        headers: {  
          'X-Auth-Token': token,  
        },  
      }).then(res => {  
        if (res.ok) {  
          res.blob().then(blob => {  
            resolve({  
              obj: blob,  
              successtrue,  
            });  
          });  
        } else {  
          resolve({  
            errorMessage`${res.status + res.statusText}加载失败`,  
            successfalse,  
          });  
        }  
      });  
    });  
  }  
}

3.3.2 批量下载

/**  
 * 由于目前 oss 不支持批量下载接口,因此需要前端下载文件  
 * 接收一堆 blob 和 filename,通过 zip 压缩,并且下载  
 */  
export async function batchDownloadOssFileZip({  
  files: list,  
  zipName = '文档',  
}) {  
  try {  
    const blobList = await batchDownloadOssFile(list);  
    // 针对相同文件名,需要添加一个索引前缀,比如有3个 a.txt ,那么 a.txt、a(1).txt、a(2).txt,避免覆盖  
    // 过滤出一批相同的文件,[a.txt, a.txt, b.txt, b.txt] => [[a.txt, a.txt], [b.txt, b.txt]] => [a.txt, a(1).txt, b.txt, b(1).txt]  
    const fileList = blobList.map(item => ({  
      filename: item.obj.filename,  
      blob: item.obj.blob,  
    }));  
    const formatBlobList = addIndexToFilenames(fileList, 'filename');  
    const zip = new JSZip();  
    formatBlobList.forEach(obj => {  
      const { filename, blob } = obj;  
      zip.file(filename, blob);  
    });  
    zip  
      .generateAsync({  
        type'blob',  
        compression'DEFLATE'// STORE:默认不压缩 DEFLATE:需要压缩  
        compressionOptions: {  
          level9// 压缩等级1~9    1压缩速度最快,9最优压缩方式  
        },  
      })  
      .then(content => {  
        saveAs(content, `${zipName}.zip`);  
      });  
  } catch (err) {  
    console.log(err);  
    message.error('下载失败');  
  }  
}  
  
/**  
 * 批量下载 oss 文件,返回一批 blob 和 filename  
 */  
export async function batchDownloadOssFile(list) {  
  return Promise.all(  
    list.map(async ({ ossId, filename }) => {  
      const { obj, success: downloadSuccess } = await ossFileDownload({  
        ossId,  
      });  
      if (!downloadSuccess) {  
        return Promise.reject();  
      }  
      return {  
        obj: {  
          blob: obj,  
          filename,  
        },  
        downloadSuccess,  
      };  
    }),  
  );  
}  
  
/**  
 * 针对相同文件名,需要添加一个索引前缀,比如有3个 a.txt ,那么 a.txt、a(1).txt、a(2).txt,避免覆盖  
 * 过滤出一批相同的文件,[a.txt, a.txt, b.txt, b.txt] => [[a.txt, a.txt], [b.txt, b.txt]] => [a.txt, a(1).txt, b.txt, b(1).txt]  
 * 统计文件名的出现次数:  
 * 使用一个对象 fileCount 来统计每个文件名在列表中出现的次数。  
  
 * 生成索引前缀:  
 * 如果一个文件名出现超过一次,就需要为其添加索引。使用另一个对象 currentIndex 来跟踪每个文件名的当前索引。  
  
 * 重命名文件:  
 * 遍历原始文件名列表,如果某个文件名出现次数超过一次,则为其添加索引。否则,直接保留原名。  
 * 这种算法的时间复杂度为 O(n),其中 n 是文件名列表的长度。它确保所有重复的文件名都能正确地添加索引前缀,避免覆盖。  
 */  
function addIndexToFilenames(filenames, filenameKey) {  
  // 统计文件名的出现次数  
  const fileCount = {};  
  filenames.forEach(item => {  
    const filename = item[filenameKey];  
    fileCount[filename] = (fileCount[filename] || 0) + 1;  
  });  
  
  // 用于存放重命名后的文件名  
  const renamedFiles = [];  
  // 用于记录每个文件名的当前索引  
  const currentIndex = {};  
  
  filenames.forEach(item => {  
    const filename = item[filenameKey];  
    if (fileCount[filename] > 1) {  
      // 增加当前文件的索引  
      currentIndex[filename] = currentIndex[filename] || 0;  
      const index = currentIndex[filename]++;  
      if (index === 0) {  
        renamedFiles.push(item);  
      } else {  
        // 添加索引前缀  
        const dotIndex = filename.lastIndexOf('.');  
        const name = filename.substring(0, dotIndex);  
        const ext = filename.substring(dotIndex);  
        const newFilename = `${name}(${index})${ext}`;  
        item.filename = newFilename;  
        renamedFiles.push(item);  
      }  
    } else {  
      renamedFiles.push(item);  
    }  
  });  
  
  return renamedFiles;  
}

3.3.3 图片预览

import React, { memo, useState } from 'react';  
import { ImageButton, message } from 'antd';  
import { getFileUrlByBlob, ossFileDownload } from '@/utils/utils';  
import useRefCallback from '@/utils/hooks/useRefCallback';  
  
// 点击按钮预览图片  
function ImagePreview(props) {  
  const [imagePreviewVisible, setImagePreviewVisible] = useState(false);  
  const [imgUrl, setImgUrl] = useState('');  
  const { ossId, buttonProps } = props;  
  const [loading, setLoading] = useState(false);  
  const getImgUrl = () => {  
    setLoading(true);  
    ossFileDownload({ ossId })  
      .then(res => {  
        const { success, obj, errorMessage } = res;  
        if (success) {  
          const url = getFileUrlByBlob(obj);  
          setImgUrl(url);  
          setImagePreviewVisible(!imagePreviewVisible);  
        } else {  
          message.error(errorMessage);  
        }  
      })  
      .finally(() => {  
        setLoading(false);  
      });  
  };  
  
  const handleReview = useRefCallback(() => {  
    getImgUrl();  
  });  
  return (  
    <div>
      <Button {...buttonPropsonClick={handleReview} loading={loading}>查看</Button>
      <Image
        width={0}  
        style={{ display: imagePreviewVisible ? 'block: 'none' }}  
        preview={{  
          visible: imagePreviewVisible,  
          onVisibleChange: setImagePreviewVisible,  
        }}  
        src={imgUrl}  
      />  
    </div>
  );  
}  
export default memo(ImagePreview);

4. 总结

这篇文章会给大家展示一下怎么上传文件到 oss 服务器。如果你想用 Ant Design Upload 来自定义上传请求,但又不想破坏 Upload 组件的兼容性,那你得确保你上传的文件得符合 Upload 的规范哦。其实,antd-upload 是基于 rc-upload 做的扩展,所以你可以直接拿 rc-upload 的 request 文件来进行定制化处理,超级方便的!

参考资料