diff --git a/README.md b/README.md index 7490676..8875ad8 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ React.render(, container); |style | object | {}| root component inline style | |className | string | - | root component className | |disabled | boolean | false | whether disabled | -|component | "div"|"span" | "span"| wrap component name | +|component | "div"|"span" | "span"| |action| string | function(file): string | Promise<string> | | form action url | |method | string | post | request method | |directory| boolean | false | support upload whole directory | @@ -69,6 +69,7 @@ React.render(, container); |accept | string | | input accept attribute | |capture | string | | input capture attribute | |multiple | boolean | false | only support ie10+| +|concurrencyLimit | number | | asynchronously posts files with the concurrency limit | |onStart | function| | start upload file | |onError| function| | error callback | |onSuccess | function | | success callback | diff --git a/src/AjaxUploader.tsx b/src/AjaxUploader.tsx index e88c291..e35d5cb 100644 --- a/src/AjaxUploader.tsx +++ b/src/AjaxUploader.tsx @@ -6,6 +6,7 @@ import attrAccept from './attr-accept'; import type { BeforeUploadFileType, RcFile, + RequestTask, UploadProgressEvent, UploadProps, UploadRequestError, @@ -13,6 +14,7 @@ import type { import defaultRequest from './request'; import traverseFileTree from './traverseFileTree'; import getUid from './uid'; +import asyncPool from './asyncPool'; interface ParsedFileInfo { origin: RcFile; @@ -21,8 +23,13 @@ interface ParsedFileInfo { parsedFile: RcFile; } -class AjaxUploader extends Component { - state = { uid: getUid() }; +interface UploadState { + uid: string; + requestTasks: RequestTask[]; +} + +class AjaxUploader extends Component { + state = { uid: getUid(), requestTasks: [] as RequestTask[] }; reqs: any = {}; @@ -30,6 +37,10 @@ class AjaxUploader extends Component { private _isMounted: boolean; + appendRequstTask = (task: RequestTask) => { + this.setState(pre => ({ ...pre, requestTasks: [...pre.requestTasks, task] })); + }; + onChange = (e: React.ChangeEvent) => { const { accept, directory } = this.props; const { files } = e.target; @@ -111,17 +122,32 @@ class AjaxUploader extends Component { return this.processFile(file, originFiles); }); + const { onBatchStart, concurrencyLimit } = this.props; // Batch upload files Promise.all(postFiles).then(fileList => { - const { onBatchStart } = this.props; - onBatchStart?.(fileList.map(({ origin, parsedFile }) => ({ file: origin, parsedFile }))); - fileList - .filter(file => file.parsedFile !== null) - .forEach(file => { + const parsedFiles = fileList.filter(file => file.parsedFile !== null); + if (concurrencyLimit) { + // Asynchronously posts files with the concurrency limit. + asyncPool( + concurrencyLimit, + this.state.requestTasks, + item => + new Promise(resolve => { + const xhr = item.xhr; + + item.done = resolve; + + xhr.send(item.data); + }), + ); + } else { + parsedFiles.forEach(file => { this.post(file); }); + this.state.requestTasks.forEach(({ xhr, data }) => xhr.send(data)); + } }); }; @@ -162,7 +188,7 @@ class AjaxUploader extends Component { const { data } = this.props; let mergedData: Record; if (typeof data === 'function') { - mergedData = await data(file); + mergedData = data(file); } else { mergedData = data; } @@ -230,12 +256,13 @@ class AjaxUploader extends Component { }; onStart(origin); - this.reqs[uid] = request(requestOption); + this.reqs[uid] = request(requestOption, this.appendRequstTask); } reset() { this.setState({ uid: getUid(), + requestTasks: [], }); } diff --git a/src/asyncPool.ts b/src/asyncPool.ts new file mode 100644 index 0000000..d5ed7eb --- /dev/null +++ b/src/asyncPool.ts @@ -0,0 +1,38 @@ +/** + * Asynchronously processes an array of items with a concurrency limit. + * + * @template T - Type of the input items. + * @template U - Type of the result of the asynchronous task. + * + * @param {number} concurrencyLimit - The maximum number of asynchronous tasks to execute concurrently. + * @param {T[]} items - The array of items to process asynchronously. + * @param {(item: T) => Promise} asyncTask - The asynchronous task to be performed on each item. + * + * @returns {Promise} - A promise that resolves to an array of results from the asynchronous tasks. + */ +export default async function asyncPool( + concurrencyLimit: number, + items: T[], + asyncTask: (item: T) => Promise, +): Promise { + const tasks: Promise[] = []; + const pendings: Promise[] = []; + + for (const item of items) { + const task = asyncTask(item); + tasks.push(task); + + if (concurrencyLimit <= items.length) { + task.then(() => { + pendings.splice(pendings.indexOf(task), 1); + }); + pendings.push(task); + + if (pendings.length >= concurrencyLimit) { + await Promise.race(pendings); + } + } + } + + return Promise.all(tasks); +} diff --git a/src/interface.tsx b/src/interface.tsx index 9d02f27..0b8d390 100644 --- a/src/interface.tsx +++ b/src/interface.tsx @@ -29,7 +29,7 @@ export interface UploadProps file: RcFile, FileList: RcFile[], ) => BeforeUploadFileType | Promise | void; - customRequest?: (option: UploadRequestOption) => void; + customRequest?: (option: UploadRequestOption) => void | { abort: () => void }; withCredentials?: boolean; openFileDialogOnClick?: boolean; prefixCls?: string; @@ -44,6 +44,7 @@ export interface UploadProps input?: React.CSSProperties; }; hasControlInside?: boolean; + concurrencyLimit?: number; } export interface UploadProgressEvent extends Partial { @@ -76,3 +77,9 @@ export interface UploadRequestOption { export interface RcFile extends File { uid: string; } + +export interface RequestTask { + xhr: XMLHttpRequest; + data: File | FormData; + done?: () => void; +} diff --git a/src/request.ts b/src/request.ts index 898847d..712af61 100644 --- a/src/request.ts +++ b/src/request.ts @@ -1,4 +1,9 @@ -import type { UploadRequestOption, UploadRequestError, UploadProgressEvent } from './interface'; +import type { + UploadRequestOption, + UploadRequestError, + UploadProgressEvent, + RequestTask, +} from './interface'; function getError(option: UploadRequestOption, xhr: XMLHttpRequest) { const msg = `cannot ${option.method} ${option.action} ${xhr.status}'`; @@ -22,7 +27,10 @@ function getBody(xhr: XMLHttpRequest) { } } -export default function upload(option: UploadRequestOption) { +export default function upload( + option: UploadRequestOption, + appendTask: (task: RequestTask) => void, +) { // eslint-disable-next-line no-undef const xhr = new XMLHttpRequest(); @@ -62,11 +70,16 @@ export default function upload(option: UploadRequestOption) { formData.append(option.filename, option.file); } + const task: RequestTask = { xhr, data: formData }; + xhr.onerror = function error(e) { option.onError(e); + task.done?.(); }; xhr.onload = function onload() { + task.done?.(); + // allow success when 2xx status // see https://github.com/react-component/upload/issues/34 if (xhr.status < 200 || xhr.status >= 300) { @@ -97,7 +110,7 @@ export default function upload(option: UploadRequestOption) { } }); - xhr.send(formData); + appendTask(task); return { abort() {