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/19948key={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 falsetransformedFile = 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 dataif (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/179if (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/33if (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
的封装,下篇文章再进行分析。