vc-upload源码分析 – ant-design-vue系列
1 整体结构
上传组件的使用分两种:点击上传和拖拽上传。
点击的是组件或者是卡片,这个是用户通过插槽传递的。除上传外的其他功能,比如预览、自定义文件渲染等功能,也不是上传的核心功能。
上传是通过vc-upload
组件来实现的。整体结构如下:
2 源码分析
在vc-upload
中,Upload.tsx
的逻辑比较少,包括:设置componentTag: 'span'
,给<AjaxUpload>
对应的节点挂上abort
方法等等。主要逻辑在AjaxUpload.tsx
组件中。
2.1 渲染函数(重点)
先看一下最后的渲染函数。
🎯 浏览器调用文件选择,常用的只有 <input type="file" />
这种方法。
- 为了自定义样式,所以
input
组件是不可见的,当我们点击Tag
区域时,需要触发input
的点击事件,这个可以通过input
的引用:fileInput.value.click()
来实现。
这里的Tag
,不是组件,而是Upload.tsx
组件中设置的默认值span
。
<Tag {...events} class={cls} role="button" style={attrs.style}>
<input
{...pickAttrs(otherProps, { aria: true, data: true })}
id={id}
type="file"
ref={fileInput}
onClick={e => e.stopPropagation()} // https://github.com/ant-design/ant-design/issues/19948
key={uid.value}
style={{ display: 'none' }}
accept={accept}
{...dirProps}
multiple={multiple}
onChange={onChange}
{...(capture != null ? { capture } : {})}
/>
{slots.default?.()}
</Tag>
- 看一下
Tag
的events
事件,重点:onClick
事件、onDrop
事件。
const events = {
onClick: openFileDialogOnClick ? onClick : () => {},
onKeydown: openFileDialogOnClick ? onKeyDown : () => {},
onMouseenter,
onMouseleave,
onDrop: onFileDrop,
onDragover: onFileDrop,
tabindex: '0',
};
-
onClick
事件:调用fileInput.value.click,打开文件选择框const onClick = (e: MouseEvent | KeyboardEvent) => { const el = fileInput.value; if (!el) { return; } const { onClick } = props; el.click(); if (onClick) { onClick(e); } };
-
onDrop / onDragover
事件:onDragover
指的是鼠标在目标区域内移动,这时候只阻止默认事;onDrop
指的是在目标区域松开鼠标点击,这时候会处理上传。const onFileDrop = (e: DragEvent) => { const { multiple } = props; e.preventDefault(); if (e.type === 'dragover') { return; } /** * 如果是文件夹,先处理文件树,然后调用第二个参数传递的uploadFiles,上传文件 */ if (props.directory) { traverseFileTree( Array.prototype.slice.call(e.dataTransfer.items), uploadFiles, (_file: RcFile) => attrAccept(_file, props.accept), ); } else { /** * 如果选的是文件,上传选择成功的 *(windows电脑可以选任意类型,但是上传的时候可能会被类型校验卡掉一些文件;mac只能选择类型匹配的文件) */ const files: [RcFile[], RcFile[]] = partition( Array.prototype.slice.call(e.dataTransfer.files), (file: RcFile) => attrAccept(file, props.accept), ); let successFiles = files[0]; const errorFiles = files[1]; if (multiple === false) { successFiles = successFiles.slice(0, 1); } uploadFiles(successFiles); if (errorFiles.length && props.onReject) props.onReject(errorFiles); } };
2.2 文件选择成功后的流程
文件选择成功后,会触发input
的change
方法。主流程如下:
<Tag {...events} class={cls} role="button" style={attrs.style}>
<input
// ......
onChange={onChange}
/>
{slots.default?.()}
</Tag>
2.2.1 onChange方法
const onChange = (e: ChangeEvent) => {
const { accept, directory } = props;
const { files } = e.target as any;
// 非文件夹且校验通过的文件
const acceptedFiles = [...files].filter(
(file: RcFile) => !directory || attrAccept(file, accept),
);
uploadFiles(acceptedFiles);
reset();
};
🚀 attrAccept
方法,见 3.1
2.2.2 uploadFiles方法
const uploadFiles = (files: File[]) => {
const originFiles = [...files] as RcFile[];
/**
* 为每个文件生成一个id,调用processFile进行处理
*/
const postFiles = originFiles.map((file: RcFile & { uid?: string }) => {
file.uid = getUid();
return processFile(file, originFiles);
});
/**
* 所有文件处理完成后,回调onBatchStart方法,然后依次上传文件。
*/
Promise.all(postFiles).then(fileList => {
const { onBatchStart } = props;
onBatchStart?.(fileList.map(({ origin, parsedFile }) => ({ file: origin, parsedFile })));
fileList
.filter(file => file.parsedFile !== null)
.forEach(file => {
post(file);
});
});
};
2.2.3 processFile 方法
const processFile = async (file: RcFile, fileList: RcFile[]): Promise<ParsedFileInfo> => {
const { beforeUpload } = props;
/**
* 1 调用用户传递的beforeUpload方法,如果这个方法返回false,则停止上传
*/
let transformedFile: BeforeUploadFileType | void = file;
if (beforeUpload) {
try {
transformedFile = await beforeUpload(file, fileList);
} catch (e) {
// Rejection will also trade as false
transformedFile = false;
}
if (transformedFile === false) {
return {
origin: file,
parsedFile: null,
action: null,
data: null,
};
}
}
/**
* 2 action一般是上传地址,也可以是返回地址的函数
*/
const { action } = props;
let mergedAction: string;
if (typeof action === 'function') {
mergedAction = await action(file);
} else {
mergedAction = action;
}
/**
* 3 上传所需参数或返回上传参数的方法
*/
const { data } = props;
let mergedData: Record<string, unknown>;
if (typeof data === 'function') {
mergedData = await data(file);
} else {
mergedData = data;
}
/**
* 可以忽略,当作简单赋值语句即可
*/
const parsedData =
// string type is from legacy `transformFile`.
// Not sure if this will work since no related test case works with it
(typeof transformedFile === 'object' || typeof transformedFile === 'string') &&
transformedFile
? transformedFile
: file;
/**
* 4 最后的文件如果不是file类型,把它转换成file类型
*/
let parsedFile: File;
if (parsedData instanceof File) {
parsedFile = parsedData;
} else {
parsedFile = new File([parsedData], file.name, { type: file.type });
}
const mergedParsedFile: RcFile = parsedFile as RcFile;
mergedParsedFile.uid = file.uid;
/**
* 5 最后的file,叫parsedFile
*/
return {
origin: file,
data: mergedData,
parsedFile: mergedParsedFile,
action: mergedAction,
};
};
2.2.4 post方法
request
方法见3.2。
const post = ({ data, origin, action, parsedFile }: ParsedFileInfo) => {
if (!isMounted) {
return;
}
const { onStart, customRequest, name, headers, withCredentials, method } = props;
const { uid } = origin;
/**
* 可以使用自定义的上传函数,默认使用request.ts提供的上传方法
*/
const request = customRequest || defaultRequest;
const requestOption = {
action,
filename: name,
data,
file: parsedFile,
headers,
withCredentials,
method: method || 'post',
onProgress: (e: UploadProgressEvent) => {
const { onProgress } = props;
onProgress?.(e, parsedFile);
},
onSuccess: (ret: any, xhr: XMLHttpRequest) => {
const { onSuccess } = props;
onSuccess?.(ret, parsedFile, xhr);
delete reqs[uid];
},
onError: (err: UploadRequestError, ret: any) => {
const { onError } = props;
onError?.(err, ret, parsedFile);
delete reqs[uid];
},
};
onStart(origin);
/**
* reqs 是一个全局的对象,方法调用abort方法。
*/
reqs[uid] = request(requestOption);
};
3 辅助函数
3.1 检查文件类型
源码地址:https://github.com/vueComponent/ant-design-vue/blob/main/components/vc-upload/attr-accept.ts
使用 some
函数来判断,只要文件file
匹配了accept
数组的某一项规则,则验证通过。
export default (file: RcFile, acceptedFiles: string | string[]) => {
if (file && acceptedFiles) {
const acceptedFilesArray = Array.isArray(acceptedFiles)
? acceptedFiles
: acceptedFiles.split(',');
const fileName = file.name || '';
const mimeType = file.type || '';
const baseMimeType = mimeType.replace(/\/.*$/, ''); // 把“.“以及之后的所有字符清空
return acceptedFilesArray.some(type => {
const validType = type.trim();
// 如果validType是*/*或者*,那么所有文件都通过
if (/^\*(\/\*)?$/.test(type)) { // 以 * 开头,后面可以接0个或者1个 /*
return true;
}
// 如果validType是 .jpg .png之类的,检查文件名后缀
if (validType.charAt(0) === '.') {
const lowerFileName = fileName.toLowerCase();
const lowerType = validType.toLowerCase();
let affixList = [lowerType];
if (lowerType === '.jpg' || lowerType === '.jpeg') {
affixList = ['.jpg', '.jpeg'];
}
return affixList.some(affix => lowerFileName.endsWith(affix));
}
// 如果validType是image/*之类的,那么比较 baseMimeType 和 斜杠之前的部分
if (/\/\*$/.test(validType)) {
return baseMimeType === validType.replace(/\/.*$/, ''); // 把“.“以及之后的所有字符清空
}
// 类型完全匹配,通过
if (mimeType === validType) {
return true;
}
// 验证规则无效,也通过
if (/^\w+$/.test(validType)) { // \w表示数字和字符,+表示1个及以上
warning(false, `Upload takes an invalidate 'accept' type '${validType}'.Skip for check.`);
return true;
}
return false;
});
}
return true;
};
3.2 xhr上传文件
源码地址:https://github.com/vueComponent/ant-design-vue/blob/main/components/vc-upload/request.ts
针对单个文件,调用upload
方法,把option
对象传进去,对象如下示例:
整体过程:
export default function upload(option: UploadRequestOption) {
const xhr = new XMLHttpRequest();
if (option.onProgress && xhr.upload) {
/**
* 上传过程会实时计算进度,通过onProgress回调返回给用户
*/
xhr.upload.onprogress = function progress(e: UploadProgressEvent) {
if (e.total > 0) {
e.percent = (e.loaded / e.total) * 100;
}
option.onProgress(e);
};
}
/**
* FormData这种格式会自动修改'content-type'
*/
const formData = new FormData();
/**
* data是用户自定义的属性,或者自定义的方法的返回值
*/
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);
});
}
/**
* 用户上传的文件
*/
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() {
// 只有2xx认为是成功的
if (xhr.status < 200 || xhr.status >= 300) {
return option.onError(getError(option, xhr), getBody(xhr));
}
return option.onSuccess(getBody(xhr), xhr);
};
/**
* 设置请求的方法、url、是否异步,这里的true代表异步
*/
xhr.open(option.method, option.action, true);
/**
* 可以通过设置 withCredentials 属性为 true 来启用 cookies 和 HTTP 认证信息的发送
*/
// 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');
}
/**
* 设置所有的header
*/
Object.keys(headers).forEach(h => {
if (headers[h] !== null) {
xhr.setRequestHeader(h, headers[h]);
}
});
/**
* 发送请求
*/
xhr.send(formData);
return {
/**
* 返回取消的方法,所以上传过程是可以中断的
*/
abort() {
xhr.abort();
},
};
}
4 总结
上传文件的每个步骤都已经在上文中体现,除了处理文件树的部分。剩下Upload
和Dragger
对vc-upload
的封装,下篇文章再进行分析。