线上源码
详细源码可见上链接中的 components/QiniuUpImg.tsx 文件
要点
获取七牛上传文件的 token
跟后端同事沟通接口,看怎样获取七牛上传文件的 token
可以每次加载组件都请求一次 token,或者将 token 放在缓存中,过期再重新请求
beforeUpload
上传文件之前的钩子,如果开启了 multiple 多选文件的话,选了几个文件,这个函数就执行几次
在这个钩子中比较关键的是给每个 file 对象添加 fileKey 属性(这个属性叫什么不重要,大家可以随便修改)。fileKey 是上传七牛需要用到的 key (详情可以查看七牛云直传文件API文档),而这个 key 本质上就是文件在七牛云上存储的文件名(资源名),唯一的要求就是不能重复。
目前选择了这样的格式定义 key :
crm/20211107/95269c8d-0d3a-4ba1-acb7-4f8389a009e3.png
crm可以根据自己需要修改; (重点:crm前不要加/不然七牛那边会有问题)20211107是对应上传当前客户端的日期,方便在七牛上做好文件夹分类95269c8d-0d3a-4ba1-acb7-4f8389a009e3是uuid生成的一串随机码.png是源文件的后缀
onChange
核心需求点:
封装的 QiniuUpImg 组件,我希望对外暴露的只是上传完七牛之后得到的线上 URL 地址,而不是复杂的 file 对象
基于这点需求,在 onChange 中做了以下控制:
- 将
Upload变成受控组件- 做成受控组件后,对外暴露什么都可以我们自己定义,符合需求
- PS:一定要在
onChange中同步修改受控的fileList,不然在上传文件时,onChange函数只会执行一次
- 过滤掉
status === 'error'的情况,这种情况判断token是否已经过期 - 过滤掉
status === 'uploading'的情况,如果上传队列中有文件处于这种状态,会一直执行onChange函数 - 在
(status === 'done' || status === 'removed') && !isFileListHadUploading,(文件已经上传完成或者被删除) 并且 (上传队列中没有status === 'uploading'状态) 时,循环文件队列,找到线上 URL,并对外暴露出去
useEffect
怎样初始化 fileList ? 何时重置 fileList ?
/**
* 将 props 中的 value (父组件)传来的值格式化成 Upload 组件可以读懂的数据格式
* 具体可以参考: https://ant.design/components/upload-cn/#UploadFile
* @param value
* @returns
*/
const formatValue = (value: string | [] | void): any[] => {
if (!value) {
return [];
}
const fileList = lodash.isArray(value) ? lodash.cloneDeep(value) : [value];
return fileList.map((item) => {
const uid = uuidv4();
const exp = /(\.(\w+)$)|(\.(\w+)(?=\?))/g; // 匹配 url 中的后缀,前一个分组匹配的是没有?参数的情况,后一个分组匹配有?参数的情况
const ext = item?.match?.(exp)?.[0] || '';
return {
uid,
name: `imgs-${uid}${ext}`,
status: 'done',
url: item,
thumbUrl: item,
};
});
}
useEffect(() => {
// 只在 value 跟prevValue 不相等,并且跟 curFileList 也不相等的时候才会重置 curFileList
if (!lodash.isEqual(prevValue, value) && !isValueAndFileListEqual(value, curFileList)) {
console.log('upload-prevValue', prevValue);
console.log('upload-value', value);
console.log('upload-curFileList', curFileList);
setCurFileList(formatValue(value));
}
}, [value]);
源码中这两段比较关键,可以结合源码查看注释
源码
为了避免线上源码网络访问不佳,补充一份(跟线上源码一样)
/*
* @Author: your name
* @Date: 2021-11-04 18:48:02
* @LastEditTime: 2021-11-05 15:41:15
* @LastEditors: Please set LastEditors
* @Description: 将图片直传到七牛云图床
*/
import React, { useState, useEffect, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid';
import lodash from 'lodash';
import { PlusOutlined, UploadOutlined } from '@ant-design/icons';
import { Upload, Modal, message, Button } from 'antd';
// import request from '@/utils/request'; 可以换成其它 fetch 库
/**
* 保存前一个状态的值
* @param value 任意 state 的值
* @returns 前一个状态的值
*/
const usePrevious = (value: any) => {
const ref = useRef();
useEffect(() => {
if (!lodash.isEqual(ref.current, value)) {
ref.current = lodash.cloneDeep(value);
}
});
return ref.current;
};
/**
* 获得一个根据 uuid 生成的唯一文件名,避免上传到七牛后文件名字有冲突
* 格式: crm/20211107/95269c8d-0d3a-4ba1-acb7-4f8389a009e3.png
* ps:
* - crm 可以根据自己需要修改; (重点:crm 前不要加 / 不然七牛那边会有问题)
* - 20211107 是对应上传当前客户端的日期,方便在七牛上做好文件夹分类
* @param file 文件对象
* @returns
*/
const getUuidFileName = (file = {}) => {
const nowDate = new Date();
const year = nowDate.getFullYear();
let month = nowDate.getMonth() + 1;
let date = nowDate.getDate();
let curr = '';
const suffix = uuidv4();
const fileName = file.name || '';
const exp = /\.(\w+)$/g;
const ext = fileName?.match?.(exp)[0] || '';
month = month < 10 ? `0${month}` : month;
date = date < 10 ? `0${date}` : date;
curr = `${year}${month}${date}`;
return encodeURI(`crm/${curr}/${suffix}${ext}`);
};
/**
* 将 props 中的 value (父组件)传来的值格式化成 Upload 组件可以读懂的数据格式
* 具体可以参考: https://ant.design/components/upload-cn/#UploadFile
* @param value
* @returns
*/
const formatValue = (value: string | [] | void): any[] => {
if (!value) {
return [];
}
const fileList = lodash.isArray(value) ? lodash.cloneDeep(value) : [value];
return fileList.map((item) => {
const uid = uuidv4();
const exp = /(\.(\w+)$)|(\.(\w+)(?=\?))/g; // 匹配 url 中的后缀,前一个分组匹配的是没有?参数的情况,后一个分组匹配有?参数的情况
const ext = item?.match?.(exp)?.[0] || '';
return {
uid,
name: `imgs-${uid}${ext}`,
status: 'done',
url: item,
thumbUrl: item,
};
});
};
/**
* 判读当前传进来的 value 是否跟组件的 curFileList 相等
* value 是 url 字符串,直接跟 curFileList 中各项的 url 对比,看是否相等
* @param value 父组件传进来的 url 字符串(或 url 字符串数组)
* @param fileList 组件当前的 curFileList
* @returns boolean
*/
const isValueAndFileListEqual = (value, fileList) => {
let result = true;
// fileList 至少也是一个数组,如果 value 没有值,两者肯定不相等
if (!value) {
return false;
}
const valueList = lodash.isArray(value) ? value : [value];
// 判断 fileList 中每一项的 url 或 fileKey 是否都能在 valueList 中找到,让若有一个找不到的,两者都不相等
for (const item of fileList) {
const { fileKey, url } = item;
const fileKeyToUlr = `${QINIU_URL}/${fileKey}`;
const targetUlr = fileKey ? fileKeyToUlr : url;
// 一般不会有这种情况
if (!fileKey && !url) {
result = false;
break;
}
if (!valueList.includes(targetUlr)) {
result = false;
break;
}
}
return result;
};
const verifyFileType = (file, accept) => {
const { type } = file;
const ext = type.split('/')?.[1] || '';
if (accept === 'image/*') {
return true;
}
if (!accept.includes(ext.toLocaleLowerCase())) {
return false;
}
return true;
};
const verifyFileSize = (file, maxsize) => {
const isOverload = file.size / 1024 / 1024 > maxsize; // true: 超重,不通过验证
return !isOverload;
};
export interface Props {
value?: string | [];
[propName: string]: any;
}
const QiniuUpImg: React.FC<Props> = (props) => {
const {
action = QINIU_UPLOAD_API,
accept = '.jpg, .jpeg, .png .gif',
limit = 1,
multiple = true,
listType = 'picture-card',
errorMsg = '图片上传失败',
maxsize = 2, // 默认图片最大为2M
value,
onChange,
} = props;
const filterProps = lodash.omit(props, [
'action',
'accept',
'limit',
'multiple',
'listType',
'errorMsg',
'maxsize',
'value',
'onChange',
]);
const prevValue = usePrevious(value);
const [token, setToken] = useState('');
const [curFileList, setCurFileList] = useState(formatValue(value));
const [previewImage, setPreviewImage] = useState('');
const [isPreviewVisible, setIsPreviewVisible] = useState(false);
const getQiNiuToken = () => {
// 获取七牛上传文件的 token,具体可以跟后端同学协商接口
// request('/getQiNiuToken').then((res) => {
// setToken(res.data || '');
// });
};
const debGetQiNiuToken = lodash.debounce(getQiNiuToken, 2000, {
leading: true,
trailing: false,
});
const getExtraData = (file) => {
return {
key: file.fileKey,
token,
};
};
const errorHandler = (error) => {
const { status } = error || {};
// 上传七牛的 token 过期,重新获取
if (status === 401) {
debGetQiNiuToken();
return;
}
};
/**
* 上传文件之前的钩子,如果开启了 multiple 多选文件的话,选了几个文件,这个函数就是执行几次。另外,不要直接 copy file 对象,也不要修改 file 对象的只读属性,可以额外增加自己需要的属性
* 具体可以参考:https://ant.design/components/upload-cn/#API
* @param file
* @param fileList
* @returns
*/
const handleBeforeUpload = (file, fileList) => {
if (!verifyFileType(file, accept)) {
message.error(`图片只支持 ${accept} 格式`);
return false;
}
if (!verifyFileSize(file, maxsize)) {
message.error(`图片最大为${maxsize}M`);
return false;
}
const fileKey = getUuidFileName(file);
file.fileKey = fileKey; // file 对象添加上传七牛需要用到的 key
return file;
};
/**
* 这个函数很关键,按以下逻辑步骤执行:
* 1. setCurFileList(fileList); 整个 Upload 的封装采用了受控的方式,通过设置当前的 curFileList,让处于 uploading 状态的文件可以多次触发 onChange 函数
* 2. 错误检查,目前主要是核对 token 有没有过期,如果有需要,可以增加更多
* 3. 将上传完成的图片 url 返回给父组件
* - 这一步也很关键,设计 QiniuUpImg 的初衷之一是为了避免把多余的内容返回给父组件,毕竟都已经上传到七牛,直接返回一个 url 地址就够用了
* - 筛选 fileList ,只将处于 done 状态的 file,读取 url 并返回(ps:注意处理处于 removed 和 uploading 状态的 file)
* @param param0
* @returns
*/
const handleChange = ({ file, fileList }) => {
const { status, error } = file;
const doneFileUrl: string[] = [];
const isFileListHadUploading = lodash.some(fileList, {
status: 'uploading',
});
if (!status) {
return;
}
setCurFileList(fileList);
if (status === 'error') {
message.error(errorMsg, 2);
errorHandler(error);
return;
}
// 只将上传完成的图片 url 返回给父组件; 如果列表中有 status: 'uploading' 的项,还会继续执行 onChange 函数,所以要等所有文件都上传完
if (
(status === 'done' || status === 'removed') &&
!isFileListHadUploading
) {
fileList.forEach((item) => {
const { url, response, status: itemStatus } = item;
const { key } = response || {};
const tempUrl = response ? `${QINIU_URL}/${key}` : url; // QINIU_URL 换成你对应七牛存储空间的访问域名即可
if (itemStatus === 'done' && tempUrl) {
doneFileUrl.push(tempUrl);
}
});
onChange?.(doneFileUrl, fileList);
}
};
const handlePreview = (file) => {
const { url, thumbUrl } = file;
if (!url && !thumbUrl) {
return;
}
setPreviewImage(url || thumbUrl);
setIsPreviewVisible(true);
};
const handleCancel = () => {
setIsPreviewVisible(false);
};
useEffect(() => {
// 只在 value 跟prevValue 不相等,并且跟 curFileList 也不相等的时候才会重置 curFileList
if (
!lodash.isEqual(prevValue, value) &&
!isValueAndFileListEqual(value, curFileList)
) {
console.log('upload-prevValue', prevValue);
console.log('upload-value', value);
console.log('upload-curFileList', curFileList);
setCurFileList(formatValue(value));
}
}, [value]);
useEffect(() => {
getQiNiuToken();
}, []);
const renderUploadButton = () => {
return (
<>
{listType === 'picture-card' ? (
<div>
<PlusOutlined />
<div style={{ marginTop: 8 }}>上传</div>
</div>
) : (
<Button icon={<UploadOutlined />}>上传</Button>
)}
</>
);
};
return (
<>
<Upload
action={action}
accept={accept}
multiple={multiple}
listType={listType}
fileList={curFileList}
data={getExtraData}
beforeUpload={handleBeforeUpload}
onChange={handleChange}
onPreview={handlePreview}
{...filterProps}
>
{curFileList.length >= limit ? null : renderUploadButton()}
</Upload>
<Modal
visible={isPreviewVisible}
title="预览"
footer={null}
onCancel={handleCancel}
>
<img alt="example" style={{ width: '100%' }} src={previewImage} />
</Modal>
</>
);
};
export default QiniuUpImg;