From d5818b1c58c43c37167754154019eda47906c8e8 Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Sat, 13 Jan 2024 19:00:34 +0800 Subject: [PATCH 1/3] feat: support concurrency limit When enabling multi-file upload, you can control the concurrency by providing the `concurrencyLimit` property. The `concurrencyLimit` property creates a `ConcurrencyRequester` instance, storing all requests in the instance's queue. The concurrent upload task quantity is restricted by the `concurrencyLimit` when using the `send` method of the instance at the end of the `uploadFiles` process. --- README.md | 72 +++++++++--------- src/AjaxUploader.tsx | 22 +++++- src/concurrencyRequest.ts | 154 ++++++++++++++++++++++++++++++++++++++ src/interface.tsx | 7 ++ src/request.ts | 79 +++++++++++-------- 5 files changed, 261 insertions(+), 73 deletions(-) create mode 100644 src/concurrencyRequest.ts diff --git a/README.md b/README.md index 74906767..ac410e2c 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ online example: https://upload.react-component.vercel.app/ ## Feature -* support IE11+, Chrome, Firefox, Safari +- support IE11+, Chrome, Firefox, Safari ## install @@ -54,29 +54,30 @@ React.render(, container); ### props -|name|type|default| description| -|-----|---|--------|----| -|name | string | file| file param post to server | -|style | object | {}| root component inline style | -|className | string | - | root component className | -|disabled | boolean | false | whether disabled | -|component | "div"|"span" | "span"| wrap component name | -|action| string | function(file): string | Promise<string> | | form action url | -|method | string | post | request method | -|directory| boolean | false | support upload whole directory | -|data| object/function(file) | | other data object to post or a function which returns a data object(a promise object which resolve a data object) | -|headers| object | {} | http headers to post, available in modern browsers | -|accept | string | | input accept attribute | -|capture | string | | input capture attribute | -|multiple | boolean | false | only support ie10+| -|onStart | function| | start upload file | -|onError| function| | error callback | -|onSuccess | function | | success callback | -|onProgress | function || progress callback, only for modern browsers| -|beforeUpload| function |null| before upload check, return false or a rejected Promise will stop upload, only for modern browsers| -|customRequest | function | null | provide an override for the default xhr behavior for additional customization| -|withCredentials | boolean | false | ajax upload with cookie send | -|openFileDialogOnClick | boolean | true | useful for drag only upload as it does not trigger on enter key or click event | +| name | type | default | description | +| --- | --- | --- | --- | +| name | string | file | file param post to server | +| style | object | {} | root component inline style | +| className | string | - | root component className | +| disabled | boolean | false | whether disabled | +| component | "div" | "span" | "span" | wrap component name | +| action | string | function(file): string | Promise<string> | | form action url | +| method | string | post | request method | +| directory | boolean | false | support upload whole directory | +| data | object/function(file) | | other data object to post or a function which returns a data object(a promise object which resolve a data object) | +| headers | object | {} | http headers to post, available in modern browsers | +| accept | string | | input accept attribute | +| capture | string | | input capture attribute | +| multiple | boolean | false | only support ie10+ | +| concurrencyLimit | number | undefined | undefined | asynchronously posts files with the concurrency limit | +| onStart | function | | start upload file | +| onError | function | | error callback | +| onSuccess | function | | success callback | +| onProgress | function | | progress callback, only for modern browsers | +| beforeUpload | function | null | before upload check, return false or a rejected Promise will stop upload, only for modern browsers | +| customRequest | function | null | provide an override for the default xhr behavior for additional customization | +| withCredentials | boolean | false | ajax upload with cookie send | +| openFileDialogOnClick | boolean | true | useful for drag only upload as it does not trigger on enter key or click event | #### onError arguments @@ -88,9 +89,7 @@ React.render(, container); 1. `result`: response body 2. `file`: upload file -3. `xhr`: xhr header, only for modern browsers which support AJAX upload. since - 2.4.0 - +3. `xhr`: xhr header, only for modern browsers which support AJAX upload. since 2.4.0 ### customRequest @@ -98,16 +97,15 @@ Allows for advanced customization by overriding default behavior in AjaxUploader customRequest callback is passed an object with: -* `onProgress: (event: { percent: number }): void` -* `onError: (event: Error, body?: Object): void` -* `onSuccess: (body: Object): void` -* `data: Object` -* `filename: String` -* `file: File` -* `withCredentials: Boolean` -* `action: String` -* `headers: Object` - +- `onProgress: (event: { percent: number }): void` +- `onError: (event: Error, body?: Object): void` +- `onSuccess: (body: Object): void` +- `data: Object` +- `filename: String` +- `file: File` +- `withCredentials: Boolean` +- `action: String` +- `headers: Object` ### methods diff --git a/src/AjaxUploader.tsx b/src/AjaxUploader.tsx index e88c291d..1cc0ef07 100644 --- a/src/AjaxUploader.tsx +++ b/src/AjaxUploader.tsx @@ -13,6 +13,7 @@ import type { import defaultRequest from './request'; import traverseFileTree from './traverseFileTree'; import getUid from './uid'; +import ConcurrencyRequester from './concurrencyRequest'; interface ParsedFileInfo { origin: RcFile; @@ -26,6 +27,8 @@ class AjaxUploader extends Component { reqs: any = {}; + private concurrencyRequester?: ConcurrencyRequester; + private fileInput: HTMLInputElement; private _isMounted: boolean; @@ -111,10 +114,14 @@ class AjaxUploader extends Component { return this.processFile(file, originFiles); }); + const { onBatchStart, concurrencyLimit } = this.props; + + if (concurrencyLimit) { + this.concurrencyRequester = new ConcurrencyRequester(concurrencyLimit); + } + // Batch upload files Promise.all(postFiles).then(fileList => { - const { onBatchStart } = this.props; - onBatchStart?.(fileList.map(({ origin, parsedFile }) => ({ file: origin, parsedFile }))); fileList @@ -122,6 +129,11 @@ class AjaxUploader extends Component { .forEach(file => { this.post(file); }); + + // Asynchronously posts files with the concurrency limit. + if (this.concurrencyRequester) { + this.concurrencyRequester.send(); + } }); }; @@ -230,7 +242,11 @@ class AjaxUploader extends Component { }; onStart(origin); - this.reqs[uid] = request(requestOption); + if (this.concurrencyRequester) { + this.reqs[uid] = this.concurrencyRequester.append(requestOption); + } else { + this.reqs[uid] = request(requestOption); + } } reset() { diff --git a/src/concurrencyRequest.ts b/src/concurrencyRequest.ts new file mode 100644 index 00000000..dcef9da8 --- /dev/null +++ b/src/concurrencyRequest.ts @@ -0,0 +1,154 @@ +import { ConcurrencyRequestTask, UploadRequestOption } from './interface'; +import { prepareData, prepareXHR } from './request'; + +/** + * 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. + */ +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); +} + +type DataType = 'form' | 'blob' | 'string'; + +/** + * Represents a class for handling concurrent requests with a specified concurrency limit. + * + * @template T - The type of data to be uploaded. + */ +export default class ConcurrencyRequester { + /** + * The concurrency limit for handling requests simultaneously. + */ + private concurrencyLimit: number; + + /** + * An array to store the tasks for concurrent requests. + */ + private tasks: ConcurrencyRequestTask[] = []; + + /** + * The type of data to be sent in the request ('form', 'blob', or 'string'). + */ + private dataType: DataType; + + /** + * Creates an instance of ConcurrencyRequester. + * + * @param {number} concurrencyLimit - The concurrency limit for handling requests simultaneously. + * @param {DataType} [dataType='form'] - The type of data to be sent in the request ('form', 'blob', or 'string'). + */ + constructor(concurrencyLimit: number, dataType: DataType = 'form') { + this.concurrencyLimit = concurrencyLimit; + this.dataType = dataType; + } + + /** + * Prepares data based on the specified data type. + * + * @param {UploadRequestOption} option - The upload request option. + * @returns {string | Blob | FormData} - The prepared data based on the specified data type. + * @private + */ + private prepareData = (option: UploadRequestOption): string | Blob | FormData => { + if (this.dataType === 'form') { + return prepareData(option); + } + + return option.file; + }; + + /** + * Prepares a task for a concurrent request. + * + * @param {UploadRequestOption} option - The upload request option. + * @returns {ConcurrencyRequestTask} - The prepared task for the concurrent request. + * @private + */ + private prepare = (option: UploadRequestOption): ConcurrencyRequestTask => { + const xhr = prepareXHR(option); + + const data = this.prepareData(option); + + const task: ConcurrencyRequestTask = { xhr, data }; + + xhr.onerror = function error(e) { + task.done?.(); + xhr.onerror(e); + }; + + xhr.onload = function onload(e) { + task.done?.(); + xhr.onload(e); + }; + + return task; + }; + + /** + * Appends a new upload request to the tasks array. + * + * @param {UploadRequestOption} option - The upload request option. + * @returns {{ abort: () => void }} - An object with an `abort` function to cancel the request. + */ + append = (option: UploadRequestOption): { abort: () => void } => { + const task = this.prepare(option); + + this.tasks.push(task); + + return { + abort() { + task.xhr.abort(); + }, + }; + }; + + /** + * Sends all the appended requests concurrently. + */ + send = (): void => { + asyncPool( + this.concurrencyLimit, + this.tasks, + item => + new Promise(resolve => { + const xhr = item.xhr; + + item.done = resolve; + + xhr.send(item.data); + }), + ); + }; +} diff --git a/src/interface.tsx b/src/interface.tsx index 9d02f276..b994693f 100644 --- a/src/interface.tsx +++ b/src/interface.tsx @@ -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 ConcurrencyRequestTask { + xhr: XMLHttpRequest; + data: File | FormData | string | Blob; + done?: () => void; +} diff --git a/src/request.ts b/src/request.ts index 898847d0..904a24a2 100644 --- a/src/request.ts +++ b/src/request.ts @@ -22,7 +22,7 @@ function getBody(xhr: XMLHttpRequest) { } } -export default function upload(option: UploadRequestOption) { +export function prepareXHR(option: UploadRequestOption): XMLHttpRequest { // eslint-disable-next-line no-undef const xhr = new XMLHttpRequest(); @@ -35,6 +35,45 @@ export default function upload(option: UploadRequestOption) { }; } + 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.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); + }; + + return xhr; +} + +export function prepareData(option: UploadRequestOption) { // eslint-disable-next-line no-undef const formData = new FormData(); @@ -62,40 +101,14 @@ export default function upload(option: UploadRequestOption) { 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 || {}; + return formData; +} - // 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'); - } +export default function upload(option: UploadRequestOption) { + const xhr = prepareXHR(option); - Object.keys(headers).forEach(h => { - if (headers[h] !== null) { - xhr.setRequestHeader(h, headers[h]); - } - }); + // eslint-disable-next-line no-undef + const formData = prepareData(option); xhr.send(formData); From bd547efb0b3e23368124f595e5c68315600b5bf9 Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Sun, 14 Jan 2024 10:54:23 +0800 Subject: [PATCH 2/3] fix: a lint error --- src/request.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/request.ts b/src/request.ts index 904a24a2..9acd4ef2 100644 --- a/src/request.ts +++ b/src/request.ts @@ -60,6 +60,7 @@ export function prepareXHR(option: UploadRequestOption): XMLHttpRequest { option.onError(e); }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars xhr.onload = function onload(_) { // allow success when 2xx status // see https://github.com/react-component/upload/issues/34 From 89f3d6bf2ebf4c2861e109cc9abdcd5f87a6318b Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Sun, 14 Jan 2024 12:12:13 +0800 Subject: [PATCH 3/3] refactor: improve XHR handling and type imports - Use type imports in concurrencyRequest.ts for better clarity. - Extract XHR load event handling into a separate function, onXHRLoad, in request.ts. - Update references to onXHRLoad in both concurrencyRequest.ts and request.ts. --- src/concurrencyRequest.ts | 11 ++++++----- src/request.ts | 20 ++++++++++++-------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/concurrencyRequest.ts b/src/concurrencyRequest.ts index dcef9da8..97bce5ba 100644 --- a/src/concurrencyRequest.ts +++ b/src/concurrencyRequest.ts @@ -1,5 +1,5 @@ -import { ConcurrencyRequestTask, UploadRequestOption } from './interface'; -import { prepareData, prepareXHR } from './request'; +import type { ConcurrencyRequestTask, UploadRequestOption } from './interface'; +import { onXHRLoad, prepareData, prepareXHR } from './request'; /** * Asynchronously processes an array of items with a concurrency limit. @@ -105,12 +105,13 @@ export default class ConcurrencyRequester { xhr.onerror = function error(e) { task.done?.(); - xhr.onerror(e); + option.onError(e); }; - xhr.onload = function onload(e) { + xhr.onload = function onload() { task.done?.(); - xhr.onload(e); + + onXHRLoad(this, option); }; return task; diff --git a/src/request.ts b/src/request.ts index 9acd4ef2..56fe726c 100644 --- a/src/request.ts +++ b/src/request.ts @@ -1,4 +1,4 @@ -import type { UploadRequestOption, UploadRequestError, UploadProgressEvent } from './interface'; +import type { UploadProgressEvent, UploadRequestError, UploadRequestOption } from './interface'; function getError(option: UploadRequestOption, xhr: XMLHttpRequest) { const msg = `cannot ${option.method} ${option.action} ${xhr.status}'`; @@ -22,6 +22,16 @@ function getBody(xhr: XMLHttpRequest) { } } +export function onXHRLoad(xhr: XMLHttpRequest, option: UploadRequestOption) { + // 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); +} + export function prepareXHR(option: UploadRequestOption): XMLHttpRequest { // eslint-disable-next-line no-undef const xhr = new XMLHttpRequest(); @@ -62,13 +72,7 @@ export function prepareXHR(option: UploadRequestOption): XMLHttpRequest { // eslint-disable-next-line @typescript-eslint/no-unused-vars 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); + onXHRLoad(this, option); }; return xhr;