1. 前言
最近有客户跟我们说,下单时要上传的文档资料有点大,超出了限制,得麻烦我们支持一下。之前我们都是前端找后端接口,然后后端再帮忙上传到 oss 对象存储服务的桶里。但现在要是放开文档大小,后端服务可能会受不了,还会浪费很多服务端流量。所以,我们得改一下,让前端直接上传到 oss,这样就更方便了。
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 组件返回的东西还不太一样,比如 fileList
和 file
对象结构。要是以后还得加断点续传什么的,那改动就更大了,真是有点头疼啊。
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 的基础上,又加了一些超酷的功能。它能展示你上传的文件列表,让你一目了然。而且,它还能把文件上传的进度、成功或失败都展示得清清楚楚。更厉害的是,它还支持拖拽上传、图片预览,甚至还能自定义视频预览等高级功能呢!简直不要太方便!
仔细阅读 rc-upload 组件源码,可以看到关键代码
rc-upload 的核心请求文件 request 是通过 xhr
来发送请求的。当请求成功、失败或进行中时,它会通过 onSuccess
、onError
和 onProgress
这些回调函数,把文件和 xhr
的响应信息传递给 antd-upload 组件。这样,我们就可以更轻松地处理上传过程中的各种情况啦!
import type { UploadRequestOption, UploadRequestError, UploadProgressEvent } 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.action, true);
// 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 组件 onSuccsss
、onError
、onProgress
都会调用 onInternalChange
事件
onInternalChange
事件会调用 onChange
事件,暴露给使用 antd-upload 组件的开发者。
3.2 自定义请求
其实,直接从WEB前端上传文件到OSS,不经过后端,只是说上传的过程不走后端啦。但鉴权流程还是得靠后端哦,毕竟密钥不能随便放在前端嘛。所以,我们得通过后端接口来获取上传凭证(Token)。这样,我们还能顺便对用户进行其他校验,比如看看用户一天能上传多少次,或者当前上传人数太多时,就进行限流。这样大家都能更顺畅地上传文件啦!
最后咱们决定,直接把 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.name, ossId: 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.action, true);
// 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 服务器请求就完事了。
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,
success: true,
});
});
} else {
resolve({
errorMessage: `${res.status + res.statusText}加载失败`,
success: false,
});
}
});
});
}
}
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: {
level: 9, // 压缩等级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 { Image, Button, 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 {...buttonProps} onClick={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 文件来进行定制化处理,超级方便的!
参考资料
-
深入掌握 OSS:最完美的 OSS 上传方案! 建议阅读,特别是对 oss 不熟悉的同学,很详细的 oss 上传方案。
-
一个企业级的文件上传组件应该是什么样的 从头开始开发一个上传组件