refactor: vc-upload

refactor-upload
tangjinzhou 2022-02-20 21:48:14 +08:00
parent 3cf5d4fa43
commit b0c3677d50
8 changed files with 685 additions and 0 deletions

View File

@ -0,0 +1,313 @@
import defaultRequest from './request';
import getUid from './uid';
import attrAccept from './attr-accept';
import traverseFileTree from './traverseFileTree';
import type {
RcFile,
UploadProgressEvent,
UploadRequestError,
BeforeUploadFileType,
} from './interface';
import { uploadProps } from './interface';
import { defineComponent, onBeforeUnmount, onMounted, ref } from 'vue';
import type { ChangeEvent } from '../_util/EventInterface';
import pickAttrs from '../_util/pickAttrs';
interface ParsedFileInfo {
origin: RcFile;
action: string;
data: Record<string, unknown>;
parsedFile: RcFile;
}
export default defineComponent({
name: 'AjaxUploader',
inheritAttrs: false,
props: uploadProps(),
setup(props, { slots, attrs, expose }) {
const uid = ref(getUid());
const reqs: any = {};
const fileInput = ref<HTMLInputElement>();
let isMounted = false;
/**
* Process file before upload. When all the file is ready, we start upload.
*/
const processFile = async (file: RcFile, fileList: RcFile[]): Promise<ParsedFileInfo> => {
const { beforeUpload } = props;
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,
};
}
}
// Get latest action
const { action } = props;
let mergedAction: string;
if (typeof action === 'function') {
mergedAction = await action(file);
} else {
mergedAction = action;
}
// Get latest data
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;
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;
return {
origin: file,
data: mergedData,
parsedFile: mergedParsedFile,
action: mergedAction,
};
};
const post = ({ data, origin, action, parsedFile }: ParsedFileInfo) => {
if (!isMounted) {
return;
}
const { onStart, customRequest, name, headers, withCredentials, method } = props;
const { uid } = origin;
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[uid] = request(requestOption);
};
const reset = () => {
uid.value = getUid();
};
const abort = (file?: any) => {
if (file) {
const uid = file.uid ? file.uid : file;
if (reqs[uid] && reqs[uid].abort) {
reqs[uid].abort();
}
delete reqs[uid];
} else {
Object.keys(reqs).forEach(uid => {
if (reqs[uid] && reqs[uid].abort) {
reqs[uid].abort();
}
delete reqs[uid];
});
}
};
onMounted(() => {
isMounted = true;
});
onBeforeUnmount(() => {
isMounted = false;
abort();
});
const uploadFiles = (files: File[]) => {
const originFiles = [...files] as RcFile[];
const postFiles = originFiles.map((file: RcFile & { uid?: string }) => {
// eslint-disable-next-line no-param-reassign
file.uid = getUid();
return processFile(file, originFiles);
});
// Batch upload files
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);
});
});
};
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();
};
const onClick = (e: MouseEvent | KeyboardEvent) => {
const el = fileInput.value;
if (!el) {
return;
}
const { onClick } = props;
// TODO
// if (children && (children as any).type === 'button') {
// const parent = el.parentNode as HTMLInputElement;
// parent.focus();
// parent.querySelector('button').blur();
// }
el.click();
if (onClick) {
onClick(e);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
onClick(e);
}
};
const onFileDrop = (e: DragEvent) => {
const { multiple } = props;
e.preventDefault();
if (e.type === 'dragover') {
return;
}
if (props.directory) {
traverseFileTree(
Array.prototype.slice.call(e.dataTransfer.items),
uploadFiles,
(_file: RcFile) => attrAccept(_file, props.accept),
);
} else {
let files = [...e.dataTransfer.files].filter((file: RcFile) =>
attrAccept(file, props.accept),
);
if (multiple === false) {
files = files.slice(0, 1);
}
uploadFiles(files);
}
};
expose({
abort,
});
return () => {
const {
componentTag: Tag,
prefixCls,
disabled,
id,
multiple,
accept,
capture,
directory,
openFileDialogOnClick,
onMouseenter,
onMouseleave,
...otherProps
} = props;
const cls = {
[prefixCls]: true,
[`${prefixCls}-disabled`]: disabled,
[attrs.class as string]: !!attrs.class,
};
// because input don't have directory/webkitdirectory type declaration
const dirProps: any = directory
? { directory: 'directory', webkitdirectory: 'webkitdirectory' }
: {};
const events = disabled
? {}
: {
onClick: openFileDialogOnClick ? onClick : () => {},
onKeydown: openFileDialogOnClick ? onKeyDown : () => {},
onMouseenter,
onMouseleave,
onDrop: onFileDrop,
onDragover: onFileDrop,
tabindex: '0',
};
return (
<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>
);
};
},
});

View File

@ -0,0 +1,41 @@
import { defineComponent, ref } from 'vue';
import { initDefaultProps } from '../_util/props-util';
import AjaxUpload from './AjaxUploader';
import type { RcFile } from './interface';
import { uploadProps } from './interface';
function empty() {}
export default defineComponent({
name: 'Upload',
inheritAttrs: false,
props: initDefaultProps(uploadProps(), {
componentTag: 'span',
prefixCls: 'rc-upload',
data: {},
headers: {},
name: 'file',
multipart: false,
onStart: empty,
onError: empty,
onSuccess: empty,
multiple: false,
beforeUpload: null,
customRequest: null,
withCredentials: false,
openFileDialogOnClick: true,
}),
setup(props, { slots, attrs, expose }) {
const uploader = ref();
const abort = (file: RcFile) => {
uploader.value?.abort(file);
};
expose({
abort,
});
return () => {
return <AjaxUpload {...props} {...attrs} v-slots={slots} ref={uploader} />;
};
},
});

View File

@ -0,0 +1,53 @@
import { warning } from '../vc-util/warning';
import type { RcFile } from './interface';
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();
// This is something like */*,* allow all files
if (/^\*(\/\*)?$/.test(type)) {
return true;
}
// like .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));
}
// This is something like a image/* mime type
if (/\/\*$/.test(validType)) {
return baseMimeType === validType.replace(/\/.*$/, '');
}
// Full match
if (mimeType === validType) {
return true;
}
// Invalidate type should skip
if (/^\w+$/.test(validType)) {
warning(false, `Upload takes an invalidate 'accept' type '${validType}'.Skip for check.`);
return true;
}
return false;
});
}
return true;
};

View File

@ -0,0 +1,7 @@
// rc-upload 4.3.3
import Upload from './Upload';
import { UploadProps } from './interface';
export { UploadProps };
export default Upload;

View File

@ -0,0 +1,82 @@
import type { ExtractPropTypes, PropType } from 'vue';
export type BeforeUploadFileType = File | Blob | boolean | string;
export type Action = string | ((file: RcFile) => string | PromiseLike<string>);
export const uploadProps = () => {
return {
capture: [Boolean, String] as PropType<boolean | 'user' | 'environment'>,
multipart: Boolean,
name: String,
disabled: Boolean,
componentTag: String as PropType<any>,
action: [String, Function] as PropType<Action>,
method: String as PropType<UploadRequestMethod>,
directory: Boolean,
data: [Object, Function] as PropType<
Record<string, unknown> | ((file: RcFile | string | Blob) => Record<string, unknown>)
>,
headers: Object as PropType<UploadRequestHeader>,
accept: String,
multiple: Boolean,
onBatchStart: Function as PropType<
(fileList: { file: RcFile; parsedFile: Exclude<BeforeUploadFileType, boolean> }[]) => void
>,
onStart: Function as PropType<(file: RcFile) => void>,
onError: Function as PropType<
(error: Error, ret: Record<string, unknown>, file: RcFile) => void
>,
onSuccess: Function as PropType<
(response: Record<string, unknown>, file: RcFile, xhr: XMLHttpRequest) => void
>,
onProgress: Function as PropType<(event: UploadProgressEvent, file: RcFile) => void>,
beforeUpload: Function as PropType<
(
file: RcFile,
FileList: RcFile[],
) => BeforeUploadFileType | Promise<void | BeforeUploadFileType>
>,
customRequest: Function as PropType<(option: UploadRequestOption) => void>,
withCredentials: Boolean,
openFileDialogOnClick: Boolean,
prefixCls: String,
id: String,
onMouseenter: Function as PropType<(e: MouseEvent) => void>,
onMouseleave: Function as PropType<(e: MouseEvent) => void>,
onClick: Function as PropType<(e: MouseEvent | KeyboardEvent) => void>,
};
};
export type UploadProps = Partial<ExtractPropTypes<ReturnType<typeof uploadProps>>>;
export interface UploadProgressEvent extends Partial<ProgressEvent> {
percent?: number;
}
export type UploadRequestMethod = 'POST' | 'PUT' | 'PATCH' | 'post' | 'put' | 'patch';
export type UploadRequestHeader = Record<string, string>;
export interface UploadRequestError extends Error {
status?: number;
method?: UploadRequestMethod;
url?: string;
}
export interface UploadRequestOption<T = any> {
onProgress?: (event: UploadProgressEvent) => void;
onError?: (event: UploadRequestError | ProgressEvent, body?: T) => void;
onSuccess?: (body: T, xhr?: XMLHttpRequest) => void;
data?: Record<string, unknown>;
filename?: string;
file: Exclude<BeforeUploadFileType, File | boolean> | RcFile;
withCredentials?: boolean;
action: string;
headers?: UploadRequestHeader;
method: UploadRequestMethod;
}
export interface RcFile extends File {
uid: string;
}

View File

@ -0,0 +1,107 @@
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();
},
};
}

View File

@ -0,0 +1,75 @@
import type { RcFile } from './interface';
interface InternalDataTransferItem extends DataTransferItem {
isFile: boolean;
file: (cd: (file: RcFile & { webkitRelativePath?: string }) => void) => void;
createReader: () => any;
fullPath: string;
isDirectory: boolean;
name: string;
path: string;
}
function loopFiles(item: InternalDataTransferItem, callback) {
const dirReader = item.createReader();
let fileList = [];
function sequence() {
dirReader.readEntries((entries: InternalDataTransferItem[]) => {
const entryList = Array.prototype.slice.apply(entries);
fileList = fileList.concat(entryList);
// Check if all the file has been viewed
const isFinished = !entryList.length;
if (isFinished) {
callback(fileList);
} else {
sequence();
}
});
}
sequence();
}
const traverseFileTree = (files: InternalDataTransferItem[], callback, isAccepted) => {
// eslint-disable-next-line @typescript-eslint/naming-convention
const _traverseFileTree = (item: InternalDataTransferItem, path?: string) => {
// eslint-disable-next-line no-param-reassign
item.path = path || '';
if (item.isFile) {
item.file(file => {
if (isAccepted(file)) {
// https://github.com/ant-design/ant-design/issues/16426
if (item.fullPath && !file.webkitRelativePath) {
Object.defineProperties(file, {
webkitRelativePath: {
writable: true,
},
});
// eslint-disable-next-line no-param-reassign
(file as any).webkitRelativePath = item.fullPath.replace(/^\//, '');
Object.defineProperties(file, {
webkitRelativePath: {
writable: false,
},
});
}
callback([file]);
}
});
} else if (item.isDirectory) {
loopFiles(item, (entries: InternalDataTransferItem[]) => {
entries.forEach(entryItem => {
_traverseFileTree(entryItem, `${path}${item.name}/`);
});
});
}
};
files.forEach(file => {
_traverseFileTree(file.webkitGetAsEntry() as any);
});
};
export default traverseFileTree;

View File

@ -0,0 +1,7 @@
const now = +new Date();
let index = 0;
export default function uid() {
// eslint-disable-next-line no-plusplus
return `vc-upload-${now}-${++index}`;
}