diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 64627cb..b92bd3a 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -60,7 +60,7 @@ jobs: run: pnpm i - name: Build with VitePress - run: pnpm build:docs + run: pnpm docs:build - name: Upload artifact uses: actions/upload-pages-artifact@v3 diff --git a/.gitignore b/.gitignore index 6874957..3710000 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ refs objects info hooks + +# server +server/uploads diff --git a/.vscode/settings.json b/.vscode/settings.json index 2d442a3..c6f8a28 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,5 +15,11 @@ }, "[vue]": { "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[html]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" } } diff --git a/docs/.vitepress/cache/deps/_metadata.json b/docs/.vitepress/cache/deps/_metadata.json index bf88f90..775d65d 100644 --- a/docs/.vitepress/cache/deps/_metadata.json +++ b/docs/.vitepress/cache/deps/_metadata.json @@ -1,43 +1,43 @@ { - "hash": "b2a223ce", - "configHash": "3c801238", - "lockfileHash": "2eb6c3e6", - "browserHash": "5197b7f6", + "hash": "c068ed14", + "configHash": "ca39393b", + "lockfileHash": "64117e8b", + "browserHash": "8a8cc074", "optimized": { "vue": { "src": "../../../../node_modules/.pnpm/vue@3.4.35_typescript@5.5.4/node_modules/vue/dist/vue.runtime.esm-bundler.js", "file": "vue.js", - "fileHash": "b116bb2d", + "fileHash": "c1ba99dc", "needsInterop": false }, "vitepress > @vue/devtools-api": { "src": "../../../../node_modules/.pnpm/@vue+devtools-api@7.3.7/node_modules/@vue/devtools-api/dist/index.js", "file": "vitepress___@vue_devtools-api.js", - "fileHash": "d624aba5", + "fileHash": "105dd1ac", "needsInterop": false }, "vitepress > @vueuse/core": { "src": "../../../../node_modules/.pnpm/@vueuse+core@10.11.0_vue@3.4.35_typescript@5.5.4_/node_modules/@vueuse/core/index.mjs", "file": "vitepress___@vueuse_core.js", - "fileHash": "7caa917e", + "fileHash": "ff660189", "needsInterop": false }, "vitepress > @vueuse/integrations/useFocusTrap": { "src": "../../../../node_modules/.pnpm/@vueuse+integrations@10.11.0_focus-trap@7.5.4_vue@3.4.35_typescript@5.5.4_/node_modules/@vueuse/integrations/useFocusTrap.mjs", "file": "vitepress___@vueuse_integrations_useFocusTrap.js", - "fileHash": "d94660bf", + "fileHash": "60206fc0", "needsInterop": false }, "vitepress > mark.js/src/vanilla.js": { "src": "../../../../node_modules/.pnpm/mark.js@8.11.1/node_modules/mark.js/src/vanilla.js", "file": "vitepress___mark__js_src_vanilla__js.js", - "fileHash": "d534ef1e", + "fileHash": "7f67ece3", "needsInterop": false }, "vitepress > minisearch": { "src": "../../../../node_modules/.pnpm/minisearch@7.1.0/node_modules/minisearch/dist/es/index.js", "file": "vitepress___minisearch.js", - "fileHash": "f3681b8a", + "fileHash": "5b665f23", "needsInterop": false } }, diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index c7254e5..d7cc444 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -12,7 +12,6 @@ export default { }) }, enhanceApp({ app, router, siteData }) { - // ... - console.log(app) + // console.log(app) } } satisfies Theme diff --git a/docs/sdk/questions.md b/docs/sdk/questions.md index d072a1a..61c4b74 100644 --- a/docs/sdk/questions.md +++ b/docs/sdk/questions.md @@ -7,88 +7,48 @@ outline: deep ## 模拟接口请求 -在仓库 server 目录下有基于`nest.js`模拟的接口 +在仓库 server 目录下有基于`NestJS`模拟的接口 -1. `upload`接口 - -```js - @Post('upload') - // file和前端上传的名称保持一致 - @UseInterceptors( - FileInterceptor('file', { - dest: 'uploads', - }), - ) - uploadFile(@UploadedFile() file: Express.Multer.File, @Body() body) { - const { filename, hash, index } = body; - const chunkDir = `uploads/${hash}_${filename}`; +```bash +# 启动sever +pnpm run server:dev +``` - if (!fs.existsSync(chunkDir)) { - fs.mkdirSync(chunkDir); - } - fs.cpSync(file.path, chunkDir + '/' + index); - fs.rmSync(file.path); +1. `check` 接口 - return { data: true }; - } +```js +axios.get('http://localhost:3000/check', { + params: { + hash: 'xxxx-xxxx-xxxx', + filename: 'xxx.png', + status: 'none' // none, part, waitMerge, success + }, +}) ``` -2. `merge`接口 +2. `upload`接口 ```js - @Post('merge') - merge(@Body() body) { - const { hash, name: filename } = body; - const chunkDir = `uploads/${hash}_${filename}`; - const files = fs.readdirSync(chunkDir); +const data = { + hash: 'xxxx-xxxx-xxxx', + filename: 'xxx.png', + index: 2, + file: (blob) +} +const formData = new FormData() +Object.entries(data).forEach(([key, value]) => formData.append(key, value)) - let startPos = 0; - files.map((file) => { - const filePath = chunkDir + '/' + file; - const stream = fs.createReadStream(filePath); - stream.pipe( - fs.createWriteStream('uploads/' + filename, { - start: startPos, - }), - ); +axios.post('http://localhost:3000/upload', { data: formData }) - startPos += fs.statSync(filePath).size; - }); - - return { - data: `http://localhost:3000/static/${filename}`, - }; - } ``` -3. `checkFile`接口 +2. `merge`接口 ```js -@Post('checkFile') - checkFile(@Body() body) { - const { status } = body; - if (status === 'success') { - return { - status: 'success', - data: 'https://baidu.com', - }; - } - - if (status === 'part') { - return { - status: 'part', - // 成功的索引 - data: [0, 2, 4, 6, 8, 10], - }; - } - - if (status === 'none') { - return { - status: 'none', - data: false, - }; - } - } +axios.get('http://localhost:3000/merge', { + params: { + hash: 'xxxx-xxxx-xxxx', + filename: 'xxx.png', + }, +}) ``` - -clone 代码到本地,启动 server 在`3000`端口 diff --git a/package.json b/package.json index 8123761..01bd283 100644 --- a/package.json +++ b/package.json @@ -4,31 +4,38 @@ "workspaces": [ "packages/*" ], - "description": "", + "description": "大文件分片上传解决方案", "private": true, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "sdk:dev": "pnpm -F @tinyuploader/sdk dev", + "sdk:build": "pnpm -F @tinyuploader/sdk run build", + "sdk:preview": "pnpm -F @tinyuploader/sdk run preview", "build": "pnpm run --parallel --filter @tinyuploader/* build", - "dev:sdk": "pnpm -F @tinyuploader/sdk run dev:play", - "build:sdk": "pnpm -F @tinyuploader/sdk run build", "dev:package-vue": "pnpm -F @tinyuploader/vue dev:play", "build:package-vue": "pnpm -F @tinyuploader/vue run build", "dev:package-vuenext": "pnpm -F @tinyuploader/vuenext run dev:play", "build:package-vuenext": "pnpm -F @tinyuploader/vuenext run build", - "build:docs": "pnpm run -C docs docs:build", + "docs:dev": "pnpm run -C docs docs:dev", + "docs:build": "pnpm run -C docs docs:build", + "docs:preview": "pnpm run -C docs docs:preview", "dev:vue": "pnpm -F @demos/vue run dev", "dev:vuenext": "pnpm -F @demos/vuenext run dev", "play:vue": "npm-run-all -p dev:sdk dev:package-vue dev:vue", "play:vuenext": "npm-run-all -p dev:sdk dev:package-vuenext dev:vuenext", - "dev:server": "pnpm run -C server start:debug", + "server:dev": "pnpm run -C server start:debug", "change": "pnpm changeset", "change-version": "pnpm changeset version", "publish": "pnpm install && pnpm build && pnpm publish -F @tinyuploader/* ", - "clear": "rimraf node_modules & rimraf packages/**/node_modules & rimraf demos/**/node_modules & rimraf docs/node_modules & rimraf server/node_modules" + "clean": "pnpm -r exec rimraf node_modules --verbose && npx rimraf node_modules --verbose" }, "keywords": [ "uploader", "upload", + "file", + "big file", + "retry", + "pause", + "resume", "上传", "分片上传", "大文件", diff --git a/packages/sdk/example/index.html b/packages/sdk/example/index.html deleted file mode 100644 index 19dd7b7..0000000 --- a/packages/sdk/example/index.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - dev sdk - - - - - -
-
- - -
-
-
{{ file.name }}
-
{{ file.status }}
-
{{ (file.progress * 100).toFixed(2) }}%
-
- - - - -
-
-
-
-
- - - diff --git a/packages/sdk/example/mian.js b/packages/sdk/example/mian.js deleted file mode 100644 index f0bf078..0000000 --- a/packages/sdk/example/mian.js +++ /dev/null @@ -1,109 +0,0 @@ -const { createApp, ref, onMounted } = Vue - -// import { create, Status, Events, CheckStatus } from '../dist/sdk.mjs' -import { create, Status, Events, CheckStatus } from '../src' - -createApp({ - setup() { - const defaultFileList = [{ path: 'http://baidu.com', name: 'haha' }] - const uploader = ref(null) - const files = ref([]) - - uploader.value = create({ - action: 'http://localhost:3000/upload', - fileList: defaultFileList, - chunkSize: 1024 * 1024 * 10, // 10M, - // chunkSize: 1024 * 3, // 3k, - maxRetries: 0, - withCredentials: true, - async mergeRequest(file) { - const { hash, name } = file - const { data } = await axios.post('http://localhost:3000/merge', { name, hash }) - file.path = data.data - }, - async checkFileRequest(file) { - const { data } = await axios.post('http://localhost:3000/checkFile', { - // status: CheckStatus.Success - status: CheckStatus.Part - // status: CheckStatus.None - }) - return data - } - }) - files.value = uploader.value.fileList - - onMounted(() => { - uploader.value.assignBrowse(document.querySelector('.uploader-btn')) - }) - - const pause = (file) => { - uploader.value.pause(file) - } - - const resume = (file) => { - uploader.value.resume(file) - } - - const retry = (file) => { - uploader.value.retry(file) - } - - const remove = (file) => { - uploader.value.remove(file) - } - - const clear = () => { - uploader.value.clear() - } - - uploader.value.on(Events.FilesAdded, (fileList) => { - files.value = fileList - }) - - uploader.value.on(Events.FileProgress, (progress, file, fileList) => { - console.log(`${file.name}: 上传进度${(progress * 100).toFixed(2)}`) - files.value = fileList - }) - - uploader.value.on(Events.FileUploadFail, (file, fileList) => { - console.log(`${file.name}: 分片上传失败`) - files.value = fileList - }) - - uploader.value.on(Events.FileFail, (file, fileList) => { - console.log(`${file.name}: merge失败`) - files.value = fileList - }) - - uploader.value.on(Events.FileUploadSuccess, (file, fileList) => { - console.log(`${file.name}: 分片上传成功,准备merge`) - files.value = fileList - }) - - uploader.value.on(Events.FileSuccess, (file, fileList) => { - console.log(`${file.name}: 上传且合并成功`) - files.value = fileList - }) - - uploader.value.on(Events.FileRemove, (file, fileList) => { - console.log(`${file.name}: 被删除了`) - files.value = fileList - }) - - uploader.value.on(Events.AllFileSuccess, (fileList) => { - console.log(`全部上传成功`, fileList) - files.value = fileList - }) - - return { - Status, - uploader, - files, - pause, - resume, - retry, - remove, - clear - } - } -}).mount('#app') diff --git a/packages/sdk/examples/quick-start/index.html b/packages/sdk/examples/quick-start/index.html new file mode 100644 index 0000000..f2af59d --- /dev/null +++ b/packages/sdk/examples/quick-start/index.html @@ -0,0 +1,154 @@ + + + + + + Quick Start + + + + + + + + +
+ Set Option + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Server + + + {{key}} + + + + + +
+
+
+ Uploader Drag +
+
+ 点击上传 + +
+
+
+ + {{ file.name }} + +
+
{{ file.status }}
+
Read {{ file.readProgress.toFixed(2) }}%
+
Upload {{ (file.progress * 100).toFixed(2) }}%
+
{{ file.renderSize }}
+
+ + Pause + + + Resume + + + Retry + + X +
+
+
+ + 上传到服务器 + Uploader clear +
+
+ + + diff --git a/packages/sdk/examples/quick-start/quick-start.js b/packages/sdk/examples/quick-start/quick-start.js new file mode 100644 index 0000000..058bac0 --- /dev/null +++ b/packages/sdk/examples/quick-start/quick-start.js @@ -0,0 +1,190 @@ +const { createApp, ref, reactive, onMounted, watch } = Vue +// const { VideoPause, VideoPlay, RefreshRight, CircleClose } = ElementPlusIconsVue + +// dist +// import { create, FileStatus, ChunkStatus, CheckStatus, Callbacks } from '../dist/sdk.mjs' + +// dev +import { create, FileStatus, Callbacks, CheckStatus } from '../../src/index.js' +import { requestSucceed, customRequest, checkRequest, mergeRequest } from './request.js' + +const app = createApp({ + setup() { + const drawer = ref(false) + const actionList = [ + 'http://localhost:3000/upload', + 'https://jsonplaceholder.typicode.com/posts' + ] + const options = reactive({ + drag: true, + // input相关 + accept: '*', + multiple: true, + // 文件相关 + limit: 10, + autoUpload: true, + addFailToRemove: true, + chunkSize: 2, + fakeProgress: true, + withHash: true, + useWebWoker: true, + // 上传相关 + name: 'file', + action: 'http://localhost:3000/upload', + withCredentials: true, + data: { + bucket: 'test-public', + filePath: 'files/test01/', + status: CheckStatus.None + }, + headers: { + userauth: 'xxxxx-xxxx-xxxxx' + }, + maxConcurrency: 6, + maxRetries: 3, + retryInterval: 1000 + }) + const uploader = ref(null) + const files = ref([]) + + onMounted(() => { + uploader.value.assignBrowse(document.querySelector('.uploader-btn')) + uploader.value.assignBrowse(document.querySelector('.uploader-drag')) + uploader.value.assignDrop(document.querySelector('.uploader-drag')) + }) + + uploader.value = create({ + ...options, + chunkSize: options.chunkSize * 1024 * 1024, + customRequest, + requestSucceed, + // checkRequest, + mergeRequest + }) + + uploader.value.on(Callbacks.Exceed, (file, _fileList) => { + console.log(`Exceed ---- ${file.name} ---- ${file.status}`) + }) + + uploader.value.on(Callbacks.FileChange, (file, fileList) => { + console.log(`FileChange ---- ${file.name} ---- ${file.status}`) + files.value = fileList + }) + + uploader.value.on(Callbacks.FileAdded, (file, _fileList) => { + console.log(`FileAdded ---- ${file.name} ---- ${file.status}`) + }) + + uploader.value.on(Callbacks.FilesAdded, (fileList) => { + console.log(`FilesAdded ----`, fileList) + }) + + uploader.value.on(Callbacks.FileReadStart, (file, _fileList) => { + console.log(`FileReadStart ---- ${file.name} ---- ${file.status}`) + }) + + uploader.value.on(Callbacks.FileReadProgress, (file, _fileList) => { + console.log(`FileReadProgress ---- ${file.name} ---- ${file.status}---- ${file.readProgress}`) + }) + + uploader.value.on(Callbacks.FileReadEnd, (file, _fileList) => { + console.log(`FileReadEnd ---- ${file.name} ---- ${file.status}`) + }) + + uploader.value.on(Callbacks.FileRemove, (file, _fileList) => { + console.log(`FileRemove ---- ${file.name} ---- ${file.status}`) + }) + + uploader.value.on(Callbacks.FileProgress, (file, _fileList) => { + console.log(`FileProgress ---- ${file.name} ---- ${file.status}---- ${file.progress}`) + }) + + uploader.value.on(Callbacks.FileFail, (file, _fileList) => { + console.log(`FileFail ---- ${file.name} ---- ${file.status}`) + }) + + uploader.value.on(Callbacks.FileUploadFail, (file, _fileList) => { + console.log(`FileUploadFail ---- ${file.name} ---- ${file.status}`) + }) + + uploader.value.on(Callbacks.FileUploadSuccess, (file, _fileList) => { + console.log(`FileUploadSuccess ---- ${file.name} ---- ${file.status}`) + }) + + uploader.value.on(Callbacks.FileSuccess, (file, _fileList) => { + console.log(`FileSuccess ---- ${file.name} ---- ${file.status}`) + }) + + uploader.value.on(Callbacks.FilePause, (file, _fileList) => { + console.log(`FilePause ---- ${file.name} ---- ${file.status}`) + }) + + uploader.value.on(Callbacks.FileResume, (file, _fileList) => { + console.log(`FileResume ---- ${file.name} ---- ${file.status}`) + }) + + uploader.value.on(Callbacks.AllFileSuccess, (fileList) => { + console.log(`AllFileSuccess ---- `, fileList) + }) + + uploader.value.setDefaultFileList([ + { + id: '1', + name: 'default.png', + url: 'http://baidu.com' + } + ]) + + watch( + () => options, + () => { + uploader.value.setOption({ + ...options, + chunkSize: options.chunkSize * 1024 * 1024 + }) + }, + { deep: true } + ) + + const submit = () => { + uploader.value.submit() + } + + const clear = () => { + uploader.value.clear() + } + + const remove = (file) => { + uploader.value.remove(file) + } + + const pause = (file) => { + uploader.value.pause(file) + } + + const resume = (file) => { + uploader.value.resume(file) + } + + const retry = (file) => { + uploader.value.retry(file) + } + + return { + FileStatus, + CheckStatus, + actionList, + drawer, + options, + files, + submit, + clear, + remove, + pause, + resume, + retry + } + } +}) +app.use(ElementPlus) +app.mount('#app') diff --git a/packages/sdk/examples/quick-start/request.js b/packages/sdk/examples/quick-start/request.js new file mode 100644 index 0000000..922044a --- /dev/null +++ b/packages/sdk/examples/quick-start/request.js @@ -0,0 +1,84 @@ +const queryString = (object) => { + let str = '' + for (const [key, value] of Object.entries(object)) { + str += `&${key}=${value}` + } + return str +} + +export const requestSucceed = (response) => { + const { status } = response + if (status >= 200 && status < 300) { + return true + } + return false +} + +export const customRequest = (options) => { + const { action, data, query, headers, name, withCredentials, onSuccess, onFail, onProgress } = + options + const realData = { + fileHashCode: data.hash, + uploadId: data.fileId, + chunkNumber: data.index + 1, + chunkSize: data.size, + totalChunks: data.totalChunks, + [name]: data[name], + hash: data.hash, + filename: data.filename, + index: data.index, + ...query + } + const formData = new FormData() + + Object.keys(realData).forEach((key) => { + formData.append(key, realData[key]) + }) + const CancelToken = axios.CancelToken + const source = CancelToken.source() + + axios({ + url: 'http://localhost:3000/upload', + method: 'POST', + data: formData, + headers: headers, + cancelToken: source.token, + withCredentials: withCredentials, + onUploadProgress: onProgress + }) + .then((res) => { + onSuccess(action, res) + }) + .catch((e) => { + onFail(e) + }) + + return { + abort() { + source.cancel('Operation canceled by the user.') + } + } +} + +export const checkRequest = async (file, query) => { + const params = { + hash: file.hash, + filename: file.name, + status: 'none', + ...query + } + const data = await fetch(`http://localhost:3000/check?${queryString(params)}`) + + return await data.json() +} + +export const mergeRequest = async (file, query) => { + const params = { + hash: file.hash, + filename: file.name, + ...query + } + const data = await fetch(`http://localhost:3000/merge?${queryString(params)}`) + const json = await data.json() + return json.data +} diff --git a/packages/sdk/example/style.css b/packages/sdk/examples/style.css similarity index 67% rename from packages/sdk/example/style.css rename to packages/sdk/examples/style.css index 592dca3..c736c4b 100644 --- a/packages/sdk/example/style.css +++ b/packages/sdk/examples/style.css @@ -7,9 +7,20 @@ margin-right: var(--default-margin); } +.uploader-drag { + width: 300px; + height: 160px; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + border: 1px dashed #000; + margin-top: 10px; + border-radius: 3px; +} + .file-list { margin-top: 10px; - max-width: 660px; } .file { @@ -21,10 +32,11 @@ border: 1px darkgray solid; border-radius: 4px; cursor: pointer; + font-size: 14px; } .file-name { - width: 200px; + width: 300px; overflow: hidden; text-overflow: ellipsis; margin-right: var(--default-margin); @@ -37,14 +49,18 @@ } .file-progress { - width: 100px; + width: 160px; } .action-container { - min-width: 200px; + min-width: 300px; text-align: right; } .action { margin-right: var(--default-margin); } + +.uploader-submit { + margin-right: 20px; +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json index e735635..073c362 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,7 +1,7 @@ { "name": "@tinyuploader/sdk", "version": "2.1.1", - "description": "", + "description": "大文件分片上传解决方案sdk, 可用于各种UI框架", "private": false, "publishConfig": { "access": "public" @@ -9,12 +9,17 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "dev": "vite", - "dev:play": "vite build --watch", - "build": "vite build" + "build": "vite build", + "preview": "vite build --watch" }, "keywords": [ "uploader", "upload", + "file", + "big file", + "retry", + "pause", + "resume", "上传", "分片上传", "大文件", diff --git a/packages/sdk/src/core/Chunk.js b/packages/sdk/src/core/Chunk.js index 47c5195..588cf12 100644 --- a/packages/sdk/src/core/Chunk.js +++ b/packages/sdk/src/core/Chunk.js @@ -1,135 +1,135 @@ -import { generateUid, each } from '@/shared' -import { Status } from './constans.js' +import { generateUid, isFunction, parseData } from '../shared' +import { ChunkStatus, FileStatus, ProcessType } from './constants' +import { request } from './request' export default class Chunk { constructor(file, index) { this.uploader = file.uploader - this.opts = file.uploader.opts + this.options = file.uploader.options + this.file = file - this.filename = file.name + this.rawFile = file.rawFile this.fileId = file.uid + this.fileHash = file.hash + this.filename = file.name this.totalSize = file.size - this.chunkSize = this.opts.chunkSize + this.chunkSize = this.options.chunkSize + this.totalChunks = file.totalChunks - this.fileHash = this.file.hash - this.uid = generateUid('chunk_id') - this.stardByte = this.chunkSize * index - this.endByte = Math.min(this.stardByte + this.chunkSize, this.totalSize) - this.size = this.endByte - this.stardByte + this.uid = generateUid() this.chunkIndex = index + this.status = ChunkStatus.Ready + this.startByte = this.chunkSize * index + this.endByte = Math.min(this.startByte + this.chunkSize, this.totalSize) + this.size = this.endByte - this.startByte + + this.maxRetries = this.options.maxRetries - this.maxRetries = this.opts.maxRetries - this.xhr = null - this.promise = null - this.status = Status.Ready this.progress = 0 this.fakeProgress = 0 this.timer = null - } - prepareXhr() { - const data = new FormData() - this.xhr.responseType = 'json' - this.xhr.withCredentials = this.opts.withCredentials - const blob = this.file.rawFile.slice(this.stardByte, this.endByte) + this.request = null + this.customRequest = this.options.customRequest || request + } - data.append(this.opts.name, blob) - if (this.fileHash) { - data.append('hash', this.fileHash) + onSuccess(e, response, resolve) { + if (this.options.requestSucceed(response, this)) { + this.status = ChunkStatus.Success + this.file.removeUploadingChunk(this) + if (this.file.isUploading()) { + this.file.upload() + } + resolve(this) + } else { + this.onFail(e) } - data.append('id', this.uid) - data.append('fileId', this.fileId) - data.append('index', this.chunkIndex) - data.append('filename', this.filename) - data.append('size', this.size) - data.append('totalSize', this.totalSize) - data.append('timestamp', Date.now()) - each(this.opts.data, (val, key) => { - data.append(key, val) - }) - this.xhr.open('POST', this.opts.action, true) + } - // 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED - if ('setRequestHeader' in this.xhr) { - each(this.opts.headers, (val, key) => { - this.xhr.setRequestHeader(key, val) - }) + onFail(e, reject) { + this.progress = 0 + this.file.setProgress(this) + if (this.request.canceled) { + return + } + if (this.maxRetries <= 0) { + this.file.removeUploadingChunk(this) + this.status = ChunkStatus.Fail + if (this.file.isUploading()) { + this.file.upload() + } + reject(e, this) + } else { + this.timer = setTimeout(() => { + this.send() + this.maxRetries-- + clearTimeout(this.timer) + }, this.options.retryInterval) } - - return data } - async send() { - return new Promise(async (resolve, reject) => { - this.status = Status.Pending - - // eslint-disable-next-line no-unused-vars - const failHandler = (e) => { - this.progress = 0 - this.file.setProgress(this) - if (this.maxRetries <= 0) { - this.file.removeUploadingQueue(this) - this.status = Status.Fail - if (this.file.isUploading()) { - this.file.uploadFile() - } + onProgress(e) { + this.progress = Math.min(1, e.loaded / e.total) + this.fakeProgress = Math.max(this.progress, this.fakeProgress) - reject(this) - } else { - this.timer = setTimeout(() => { - this.send() - this.maxRetries-- - clearTimeout(this.timer) - }, this.uploader.opts.retryInterval) - } - } - const doneHandler = (e) => { - if (this.uploader.opts.requestSucceed(this.xhr)) { - this.status = Status.Success - this.file.removeUploadingQueue(this) - if (this.file.isUploading()) { - this.file.uploadFile() - } - resolve(this) - } else { - failHandler(e) - } - } + this.status = ChunkStatus.Uploading + this.file.changeStatus(FileStatus.Uploading) + this.file.setProgress(this) + } - const progressHandler = (e) => { - this.progress = Math.min(1, e.loaded / e.total) - this.fakeProgress = Math.max(this.progress, this.fakeProgress) + prepare() { + const { name, data, processData } = this.options + const { data: fileData } = this.file + const defaults = { + [name]: this.file.rawFile.slice(this.startByte, this.endByte), + hash: this.fileHash, + id: this.uid, + fileId: this.fileId, + index: this.chunkIndex, + filename: this.filename, + size: this.size, + totalSize: this.totalSize, + totalChunks: this.totalChunks, + ...parseData(data), + ...fileData + } + if (!isFunction(processData)) { + return defaults + } + return processData(defaults, ProcessType.Upload) || defaults + } - this.status = Status.Uploading - this.file.changeStatus(Status.Uploading) - this.file.setProgress(this) - } + send() { + this.status = ChunkStatus.Pending + const { action, headers, withCredentials, name } = this.options - this.xhr = new XMLHttpRequest() - const data = this.prepareXhr() - this.xhr.upload.addEventListener('progress', progressHandler) - this.xhr.addEventListener('load', doneHandler, false) - this.xhr.addEventListener('error', failHandler, false) - this.xhr.send(data) + return new Promise((resolve, reject) => { + this.request = this.customRequest({ + action, + name, + withCredentials, + headers: parseData(headers), + data: this.prepare(), + query: { + ...parseData(this.options.data), + ...this.file.data + }, + onSuccess: (e, response) => this.onSuccess(e, response, resolve), + onFail: (e) => this.onFail(e, reject), + onProgress: (e) => this.onProgress(e) + }) + this.request.canceled = false }) } abort() { - this.status = Status.Ready - if (this.xhr) { - this.xhr.abort() - // this.xhr = null 避免缓存xhr,使用旧的xhr重新发起导致请求失败 - this.xhr = null + this.status = ChunkStatus.Ready + if (this.request) { + this.request.canceled = true + this.request.abort() } if (this.timer) { clearTimeout(this.timer) } } - - cancel() { - if (this.status === Status.Pending || this.status === Status.Uploading) { - this.status = Status.Ready - this.abort() - } - } } diff --git a/packages/sdk/src/core/Container.js b/packages/sdk/src/core/Container.js index 455dc0c..aab66f1 100644 --- a/packages/sdk/src/core/Container.js +++ b/packages/sdk/src/core/Container.js @@ -1,73 +1,103 @@ -import { each, extend } from '@/shared' +import { each, extend } from '../shared/index.js' class Container { constructor(uploader) { this.uploader = uploader + this.listeners = [] // 存储事件监听器 + this.inputs = [] // 存储动态创建的 input 元素 } assignBrowse(domNode, attributes) { - let input - if (domNode.tagName === 'INPUT' && domNode.type === 'file') { - input = domNode - } else { - input = document.createElement('input') - input.setAttribute('type', 'file') - - extend(input.style, { - visibility: 'hidden', - position: 'absolute', - width: '1px', - height: '1px' - }) - - each(attributes, (val, key) => { - input.setAttribute(key, val) - }) - - if (attributes.multiple) { - input.setAttribute('multiple', 'multiple') - } else { - input.removeAttribute('multiple') - } - - domNode.appendChild(input) - domNode.addEventListener( - 'click', - () => { - input.click() - }, - false - ) - - input.addEventListener( - 'change', - (e) => { - this.uploader.addFiles(e.target.files, e) - e.target.value = '' - }, - false - ) + if (!domNode) { + console.warn('DOM 节点不存在') + return } + + const input = this.createOrGetInput(domNode) + this.setInputAttributes(input, attributes) + this.attachBrowseEvents(domNode, input) } assignDrop(domNode) { - const preventEvent = (e) => { - e.preventDefault() + if (!domNode) { + console.warn('DOM 节点不存在') + return } - const handles = { + + const preventEvent = (e) => e.preventDefault() + const eventHandlers = { dragover: preventEvent, dragenter: preventEvent, dragleave: preventEvent, - drop: (e) => { - e.stopPropagation() - e.preventDefault() - this.uploader.addFiles(e.dataTransfer.files, e) - } + drop: this.handleDrop.bind(this) } - each(handles, (handler, name) => { - domNode.addEventListener(name, handler, false) + + each(eventHandlers, (handler, event) => { + domNode.addEventListener(event, handler, { passive: false }) + this.listeners.push({ node: domNode, event, handler }) }) } + + createOrGetInput(domNode) { + if (domNode.tagName === 'INPUT' && domNode.type === 'file') { + return domNode + } + + const input = document.createElement('input') + input.type = 'file' + extend(input.style, { + visibility: 'hidden', + position: 'absolute', + width: '1px', + height: '1px' + }) + domNode.appendChild(input) + this.inputs.push(input) + return input + } + + setInputAttributes(input, attributes) { + each(attributes, (value, key) => input.setAttribute(key, value)) + input.toggleAttribute('multiple', !!attributes.multiple) + } + + attachBrowseEvents(domNode, input) { + const clickHandler = () => input.click() + const changeHandler = (e) => { + this.uploader.addFiles(e.target.files, e) + e.target.value = '' + } + + domNode.addEventListener('click', clickHandler, { passive: true }) + input.addEventListener('change', changeHandler, { passive: true }) + + this.listeners.push( + { node: domNode, event: 'click', handler: clickHandler }, + { node: input, event: 'change', handler: changeHandler } + ) + } + + handleDrop(e) { + e.preventDefault() + e.stopPropagation() + this.uploader.addFiles(e.dataTransfer.files, e) + } + + destroy() { + this.listeners.forEach(({ node, event, handler }) => { + node.removeEventListener(event, handler) + }) + this.listeners = [] + + this.inputs.forEach((input) => { + if (input.parentNode) { + input.parentNode.removeChild(input) + } + }) + this.inputs = [] + + return this + } } export default Container diff --git a/packages/sdk/src/core/Event.js b/packages/sdk/src/core/Event.js new file mode 100644 index 0000000..3716958 --- /dev/null +++ b/packages/sdk/src/core/Event.js @@ -0,0 +1,54 @@ +import { isFunction, isString } from '../shared' +export default class Event { + constructor() { + this.events = new Map() + } + + on(name, callback) { + if (!isFunction(callback)) return + + const callbacks = this.events.get(name) || [] + if (!callbacks.includes(callback)) { + callbacks.push(callback) + this.events.set(name, callbacks) + } + } + + off(name, callback) { + if (!this.events.has(name)) return + + if (!callback) { + this.events.set(name, []) + return + } + + const callbacks = this.events.get(name).filter((cb) => cb !== callback) + this.events.set(name, callbacks) + } + + emit(name, ...args) { + const callbacks = this.events.get(name) + if (callbacks && callbacks.length) { + callbacks.forEach((cb) => cb(...args)) + } + } + + once(name, callback) { + if (!isFunction(callback)) return + + const onceCallback = (...args) => { + callback(...args) + this.off(name, onceCallback) + } + this.on(name, onceCallback) + } + + clear(name) { + if (name === undefined) { + this.events.clear() + } else if (isString(name) && this.events.has(name)) { + this.events.set(name, []) + } + return this + } +} diff --git a/packages/sdk/src/core/File.js b/packages/sdk/src/core/File.js index 6f9086f..e3216d5 100644 --- a/packages/sdk/src/core/File.js +++ b/packages/sdk/src/core/File.js @@ -1,150 +1,322 @@ -import Chunk from './Chunk.js' -import { isFunction, generateUid, asyncComputedHash, isPromise, each } from '@/shared' -import { Status, Events, CheckStatus } from './constans.js' +import Chunk from './Chunk' +import { FileStatus, Callbacks, CheckStatus, ChunkStatus, ProcessType } from './constants' +import { + generateUid, + isFunction, + isBoolean, + asyncCancellableComputedHash, + each, + throttle, + renderSize, + parseData +} from '../shared' export default class File { - constructor(file, uploader) { - this.uploader = uploader || { opts: {} } - this.opts = this.uploader.opts + constructor(file, uploader, defaults) { + this.uploader = uploader + this.options = this.uploader.options + this.uid = this.generateId() + + this.prevStatusLastRecord = [] this.rawFile = file - if (isFunction(this.opts.customGenerateUid)) { - this.uid = this.opts.customGenerateUid(file) || generateUid('fid') - } else { - this.uid = generateUid('fid') - } - this.hash = '' - this.size = file.size this.name = file.name || file.fileName + this.size = file.size this.type = file.type - this.chunkSize = this.opts.chunkSize - this.changeStatus(file.status || Status.Init) + this.hash = '' + this.url = '' - this.progress = file.progress || 0 + this.progress = 0 + this.chunkSize = this.options.chunkSize this.chunks = [] - this.uploadingQueue = new Set() + this.totalChunks = 0 + this.uploadingChunks = new Set() this.readProgress = 0 - this.path = file.path || '' + this.errorMessage = '' + this.data = {} + + if (defaults) { + Object.keys(defaults).forEach((key) => { + this[key] = defaults[key] + }) + + this.name = defaults.name + this.url = defaults.url + this.readProgress = 1 + this.progress = 1 + this.changeStatus(FileStatus.Success) + } else { + this.changeStatus(FileStatus.Init) + } + } + + generateId() { + const { customGenerateUid } = this.options + if (!isFunction(customGenerateUid)) return generateUid() + return customGenerateUid(this) || generateUid() + } + + setErrorMessage(message) { + this.errorMessage = String(message) + return this + } + + setData(data) { + this.data = { ...this.data, ...data } + return this + } + + get renderSize() { + return renderSize(this.size) + } + + changeStatus(newStatus) { + if (newStatus !== this.status || newStatus === FileStatus.Reading) { + this.prevStatusLastRecord.push(this.status) + this.status = newStatus + // 兼容默认值时没有uploader实例 + if (this.uploader && this.uploader.emitCallback) { + this.uploader.emitCallback(Callbacks.FileChange, this) + } + } + } + + isInit() { + return this.status === FileStatus.Init + } + + isAddFail() { + return this.status === FileStatus.AddFail + } + + isReading() { + return this.status === FileStatus.Reading + } + + isReady() { + return this.status === FileStatus.Ready + } + + isCheckFail() { + return this.status === FileStatus.CheckFail + } + + isUploading() { + return this.status === FileStatus.Uploading + } + + isUploadSuccess() { + return this.status === FileStatus.UploadSuccess + } + + isUploadFail() { + return this.status === FileStatus.UploadFail + } + + isSuccess() { + return this.status === FileStatus.Success + } + + isFail() { + return this.status === FileStatus.Fail } - async start() { - await this.computedHash() + isPause() { + return this.status === FileStatus.Pause + } + + isResume() { + return this.status === FileStatus.Resume + } + + createChunks() { + this.totalChunks = Math.ceil(this.size / this.chunkSize) || 1 + this.chunks = Array.from({ length: this.totalChunks }, (_, i) => new Chunk(this, i)) + } + + async read() { + if (!this.options.withHash) { + this.createChunks() + this.changeStatus(FileStatus.Ready) + return + } + + this.uploader.emitCallback(Callbacks.FileReadStart, this) + this.changeStatus(FileStatus.Reading) + + try { + const startTime = Date.now() + const { hash, progress } = await this._computeHash() + this.hash = hash + this.readProgress = progress + this.uploader.emitCallback(Callbacks.FileReadEnd, this) + console.log( + `${this.options.useWebWoker ? 'Web Worker' : 'Main Thread'} read file took`, + (Date.now() - startTime) / 1000, + 's' + ) + } catch (error) { + this.setErrorMessage('File read failed') + this.changeStatus(FileStatus.Init) + this.uploader.emitCallback(Callbacks.FileReadFail, this) + throw error + } finally { + this.abortRead = null + } + this.createChunks() - if (this.opts.checkFileRequest) { - await this.checkRequest() + this.changeStatus(FileStatus.Ready) + } + + async _computeHash() { + const updateReadProgress = throttle((progress) => { + this.readProgress = progress + this.uploader.emitCallback(Callbacks.FileReadProgress, this) + }, 200) + + const { promise, abort } = asyncCancellableComputedHash( + { + file: this.rawFile, + chunkSize: this.chunkSize, + useWebWoker: this.options.useWebWoker + }, + ({ progress: readProgress }) => updateReadProgress(readProgress) + ) + + this.abortRead = abort + const hashResult = await promise + updateReadProgress(hashResult.progress) + return hashResult + } + + _processData(processType) { + const { data: optionData, processData } = this.options + const defaults = { ...parseData(optionData), ...this.data } + if (!isFunction(processData)) { + return defaults } + return processData(defaults, processType) || defaults } async checkRequest() { - try { - const { status: checkStatus, data } = await this.opts.checkFileRequest(this) - if (checkStatus === CheckStatus.Part) { + const { checkRequest: check } = this.options + + // 提前处理非函数情况 + if (!isFunction(check)) { + return Promise.resolve() + } + + // 统一状态修改方法 + const updateChunksStatus = (status) => { + this.chunks.forEach((chunk) => { + chunk.status = status + if (status === ChunkStatus.Success) { + chunk.progress = 1 + chunk.fakeProgress = 1 + } + }) + } + + // 状态处理策略模式 + const statusHandlers = { + [CheckStatus.Part]: (data) => { this.chunks.forEach((chunk) => { if (data.includes(chunk.chunkIndex)) { - chunk.status = Status.Success + chunk.status = ChunkStatus.Success chunk.progress = 1 chunk.fakeProgress = 1 } }) + }, + [CheckStatus.WaitMerge]: () => { + this.changeStatus(FileStatus.UploadSuccess) + updateChunksStatus(ChunkStatus.Success) + }, + [CheckStatus.Success]: (data) => { + this.changeStatus(FileStatus.Success) + updateChunksStatus(ChunkStatus.Success) + this.url = data + }, + [CheckStatus.None]: () => {} + } + + try { + const result = await Promise.resolve(check(this, this._processData(ProcessType.Check))) // 统一异步处理 + + // 验证响应格式 + if (!result || !result.status) { + throw new Error('Invalid check response format') } - if (checkStatus === CheckStatus.Success) { - this.success() - this.chunks.forEach((chunk) => { - chunk.status = Status.success - }) - this.path = data + + const handler = statusHandlers[result.status] + if (!handler) { + throw new Error(`Unknown check status: ${result.status}`) } - } catch { - // + + handler(result.data) + return Promise.resolve() + } catch (error) { + // 统一错误处理 + this.changeStatus(FileStatus.CheckFail) + this.uploader.upload() + + // 增强错误信息 + const enhancedError = new Error(`Check request failed: ${error.message}`) + enhancedError.originalError = error + throw enhancedError } } - computedHash() { - return new Promise((resolve, reject) => { - if (!this.opts.withHash) { - resolve() - } - this.changeStatus(Status.Reading) - asyncComputedHash( - { - file: this.rawFile, - chunkSize: this.chunkSize, - inWorker: this.opts.computedHashWorker - }, - ({ progress }) => { - this.readProgress = progress - } - ).then(({ hash }) => { - this.hash = hash - resolve() - }) - }) + addUploadingChunk(chunk) { + this.uploadingChunks.add(chunk) } - createChunks() { - const totalChunks = (this.totalChunks = Math.ceil(this.size / this.chunkSize)) - for (let i = 0; i < totalChunks; i++) { - this.chunks.push(new Chunk(this, i)) - } - this.changeStatus(Status.Ready) + removeUploadingChunk(chunk) { + this.uploadingChunks.delete(chunk) } - setProgress() { - const progress = this.chunks.reduce((total, chunk) => { - const p = this.opts.fakeProgress ? chunk.fakeProgress : chunk.progress - return (total += p * (chunk.size / this.size)) - }, 0) + async upload() { + if (this.isInit()) { + await this.read() + } - // this.progress = Math.max(Math.min(progress, 1), this.progress) - this.progress = Math.min(1, progress) + if (this.isReady() && this.options.checkRequest) { + await this.checkRequest() + } if (this.isUploadSuccess()) { - this.progress = 1 + return this.merge() } - this.uploader.emit(Events.FileProgress, this.progress, this, this.uploader.fileList) - } - - removeUploadingQueue(chunk) { - this.uploadingQueue.delete(chunk) - } - - addUploadingQueue(chunk) { - this.uploadingQueue.add(chunk) - } - - uploadFile() { if (this.isSuccess()) { - this.success() - return + return this.success() } - const readyChunks = this.chunks.filter((chunk) => chunk.status === Status.Ready) + const readyChunks = this.chunks.filter((chunk) => chunk.status === ChunkStatus.Ready) each(readyChunks, () => { - if (this.uploadingQueue.size >= this.opts.maxConcurrency) { - return false // false时break; + if (this.uploadingChunks.size >= this.options.maxConcurrency) { + return false } const chunk = readyChunks.shift() if (chunk) { - this.addUploadingQueue(chunk) + this.addUploadingChunk(chunk) } else { return false } }) - if (this.uploadingQueue.size) { + if (this.uploadingChunks.size > 0) { // 有的chunk处于pending状态,但是已经准备发起请求了,就不在后面调用其send方法 - const readyInUploadQueue = [...this.uploadingQueue].filter( - (chunk) => chunk.status === Status.Ready + const readyInUploadQueue = [...this.uploadingChunks].filter( + (chunk) => chunk.status === ChunkStatus.Ready ) Promise.race(readyInUploadQueue.map((chunk) => chunk.send())) return } - - const hasErrorChunk = this.chunks.some((chunk) => chunk.status === Status.Fail) + const hasErrorChunk = this.chunks.some((chunk) => chunk.status === ChunkStatus.Fail) if (hasErrorChunk) { this.uploadFail() } else { @@ -154,131 +326,142 @@ export default class File { } } - changeStatus(status) { - this.status = status - // 兼容默认值时没有uploader实例 - if (this.uploader && this.uploader.emit) { - this.uploader.emit(Events.FileChange, this, this.uploader.fileList) + setProgress() { + const progress = this.chunks.reduce((total, chunk) => { + const p = this.options.fakeProgress ? chunk.fakeProgress : chunk.progress + return (total += p * (chunk.size / this.size)) + }, 0) + this.progress = Math.min(1, progress) + if (this.isUploadSuccess() || this.isSuccess()) { + this.progress = 1 } - } - - uploadSuccess() { - this.changeStatus(Status.UploadSuccess) - this.uploader.emit(Events.FileUploadSuccess, this, this.uploader.fileList) + this.uploader.emitCallback(Callbacks.FileProgress, this) } uploadFail() { - this.changeStatus(Status.UploadFail) - this.uploader.emit(Events.FileUploadFail, this, this.uploader.fileList) - this.uploader.upload() + this.changeStatus(FileStatus.UploadFail) + this.uploader.emitCallback(Callbacks.FileUploadFail, this) + this._continueUpload() } - success() { - this.changeStatus(Status.Success) - this.progress = 1 - this.uploader.emit(Events.FileSuccess, this, this.uploader.fileList) - this.uploader.upload() + uploadSuccess() { + this.changeStatus(FileStatus.UploadSuccess) + this.uploader.emitCallback(Callbacks.FileUploadSuccess, this) } - mergeFail() { - this.changeStatus(Status.Fail) - this.uploader.emit(Events.FileFail, this, this.uploader.fileList) - this.uploader.upload() - } + async merge() { + const { mergeRequest: merge } = this.options - merge() { - const merge = this.opts.mergeRequest + // 非函数处理提前终止 if (!isFunction(merge)) { - this.success() - return + return this.success() } - const result = merge(this) - - if (isPromise(result)) { - result.then( - () => this.success(), - () => this.mergeFail() - ) - } else { - result ? this.success() : this.mergeFail() + try { + const result = merge(this, this._processData(ProcessType.Merge)) + const data = await Promise.resolve(result) + if (isBoolean(data)) { + data ? this.success() : this.mergeFail() + } else { + this.url = data + this.success() + } + } catch (error) { + console.log(error) + this.mergeFail() } } - retry() { - if (this.isUploadSuccess() || this.isFail()) { - this.merge() - return - } - if (this.isUploadFail()) { - each(this.chunks, (chunk) => { - console.log(chunk) - if (chunk.status === Status.Fail) { - chunk.status = Status.Ready - chunk.maxRetries = this.opts.maxRetries - } - }) - - this.uploadFile() - } + mergeFail() { + this.changeStatus(FileStatus.Fail) + this.uploader.emitCallback(Callbacks.FileFail, this) + this._continueUpload() } - remove() { - this.chunks = [] - this.uploadingQueue.forEach((chunk) => { - chunk.abort() - }) + success() { + this.changeStatus(FileStatus.Success) + this.progress = 1 + this.uploader.emitCallback(Callbacks.FileSuccess, this) + this._continueUpload() } - pause() { - this.uploadingQueue.forEach((chunk) => { - chunk.abort() - }) - this.changeStatus(Status.Pause) + _continueUpload() { + const firstPauseFile = this.uploader.fileList.find((file) => file.isPause()) + if (firstPauseFile) { + firstPauseFile.resume() + } this.uploader.upload() } - resume() { - if (this.isPause()) { - this.changeStatus(Status.Resume) - this.uploader.pauseUploadingFiles() - this.uploader.upload() - } + cancel() { + this.uploadingChunks.forEach((chunk) => chunk.abort()) + this.uploadingChunks.clear() } - isInited() { - return this.status === Status.Init - } + async remove() { + if (this.abortRead) { + this.abortRead() + } - isReady() { - return this.status === Status.Ready - } + setTimeout(() => { + this.cancel() + this.chunks = [] + this.changeStatus('removed') - isUploading() { - return this.status === Status.Uploading + const index = this.uploader.fileList.indexOf(this) + if (index > -1) { + this.uploader.fileList.splice(index, 1) + } + this.uploader.emitCallback(Callbacks.FileRemove, this) + this.uploader.upload() + }, 0) } - isPause() { - return this.status === Status.Pause + pause() { + if (this.abortRead) { + this.abortRead() + } + setTimeout(() => { + this.cancel() + this.changeStatus(FileStatus.Pause) + this.uploader.emitCallback(Callbacks.FilePause, this) + this.uploader.upload() + }, 0) } - isResume() { - return this.status === Status.Resume + resume() { + if (this.isPause()) { + this.changeStatus(FileStatus.Resume) + this.uploader.emitCallback(Callbacks.FileResume, this) + this.uploader.upload() + } } - isUploadSuccess() { - return this.status === Status.UploadSuccess - } + retry() { + if (this.isAddFail()) { + return + } - isUploadFail() { - return this.status === Status.UploadFail - } + if (this.isCheckFail()) { + this.changeStatus(FileStatus.Ready) + this.upload() + return + } - isFail() { - return this.status === Status.Fail - } + if (this.isUploadSuccess() || this.isFail()) { + this.merge() + return + } - isSuccess() { - return this.status === Status.Success + if (this.isUploadFail()) { + each(this.chunks, (chunk) => { + if (chunk.status === ChunkStatus.Fail) { + chunk.status = ChunkStatus.Ready + chunk.maxRetries = chunk.options.maxRetries + } + }) + + this.upload() + } } } diff --git a/packages/sdk/src/core/Uploader.js b/packages/sdk/src/core/Uploader.js index d006cdc..2c0f2d6 100644 --- a/packages/sdk/src/core/Uploader.js +++ b/packages/sdk/src/core/Uploader.js @@ -1,170 +1,175 @@ -import File from '@/core/File' -import Container from '@/core/Container' -import { Event, extend, isFunction, isPromise } from '@/shared' -import { defaults } from '@/core/defaults' -import { Status, Events, CheckStatus } from '@/core/constans' - -class Uploader { +import Container from './Container' +import Event from './Event' +import File from './File' +import { defaultOptions } from './defaults' +import { Callbacks, FileStatus } from './constants' +import { extend, isString, isArray, isFunction, isPromise } from '../shared' + +export default class Uploader { constructor(options) { this.container = new Container(this) this.event = new Event() - this.opts = extend({}, defaults, options) - let fileList = [] - if (this.opts.fileList && this.opts.fileList.length) { - fileList = this.opts.fileList.map( - (item) => - new File({ - name: item.name, - path: item.path, - status: Status.Success, - progress: 1 - }) - ) - } - this.fileList = fileList - this.status = Status.Init - // 只注册一次 - this.listenerFiles() + + this.options = extend(defaultOptions, options) + this.fileList = this.options.fileList || [] + this._setupFileListeners() } - on(name, func) { - this.event.on(name, func) + on(name, fn) { + this.event.on(name, fn) } emit(name, ...args) { this.event.emit(name, ...args) } - async addFiles(files) { - const { limit, beforeAdd, attributes } = this.opts + emitCallback(name, ...args) { + this.emit(name, ...args, this.fileList) + } + + updateData(data) { + this.options.data = data + } + + updateHeaders(headers) { + this.options.headers = headers + } + + setOption(options) { + this.options = extend(this.options, options) + } + + formatAccept(accept) { + if (isString(accept)) return accept + if (isArray(accept)) return accept.join(',') + return '' + } + + assignBrowse(domNode, userAttributes = {}) { + const { accept, ...attributes } = userAttributes + const defaults = { + multiple: this.options.multiple, + accept: this.formatAccept(accept || this.options.accept) + } + this.container.assignBrowse(domNode, extend({}, defaults, attributes)) + } - if (limit > 0) { - if (files.length + this.fileList.length > limit) { - this.emit(Events.Exceed, files, this.fileList) + assignDrop(domNode) { + this.container.assignDrop(domNode) + } + + _setupFileListeners() { + const checkAllSuccess = (file, fileList) => { + if (!fileList.length) { return } + const allSuccess = fileList.every((file) => file.isSuccess()) + if (allSuccess) { + this.emit(Callbacks.AllFileSuccess, this.fileList) + } } - let originFileList = [...files] + this.on(Callbacks.FileSuccess, checkAllSuccess) + this.on(Callbacks.FileRemove, checkAllSuccess) + } - if (!this.opts.multiple) { - originFileList = originFileList.slice(0, 1) - } + setDefaultFileList(fileList) { + fileList.forEach((file) => { + this.fileList.push(new File(file, this, file)) + }) + } + + async addFiles(arrayLike) { + const { limit, multiple, addFailToRemove, beforeAdd, autoUpload } = this.options + let originFiles = [...arrayLike] + + if (originFiles.length === 0) return - if (originFileList.length === 0) { + if (limit > 0 && originFiles.length + this.fileList.length > limit) { + this.emitCallback(Callbacks.Exceed, originFiles) return } - const newFileList = originFileList.map((file) => new File(file, this)) - this.fileList = [...this.fileList, ...newFileList] + if (!multiple) { + originFiles = originFiles.slice(0, 1) + } - if (beforeAdd) { - if (isFunction(beforeAdd)) { - for (let i = 0; i < newFileList.length; i++) { - const file = newFileList[i] - const before = beforeAdd(file) - if (isPromise(before)) { - before.then( - () => {}, - () => { - this.doRemove(file) - } - ) - } else if (before !== false) { - // - } else { - this.doRemove(file) - } - } + const newFileList = originFiles.map((file) => new File(file, this)) + await Promise.all(newFileList.map((file) => this._handleFileAdd(file), beforeAdd)) + + this.fileList = this.fileList.filter((file) => { + if (file.isAddFail() && addFailToRemove) { + this.doRemove(file) + return false } - } + return true + }) - this.emit(Events.FilesAdded, this.fileList) - this.status = Status.Ready + if (newFileList.length > 0) { + this.emitCallback(Callbacks.FilesAdded, this.fileList) + } - if (this.opts.autoUpload) { + if (autoUpload) { this.submit() } } - pauseUploadingFiles() { - const uploadingFiles = this.fileList.filter((file) => file.isUploading()) - uploadingFiles.forEach((file) => { - file.pause() - }) + async _handleFileAdd(file, beforeAdd) { + try { + // 如果是函数则进行结果判断,否则认为校验通过 + if (isFunction(beforeAdd)) { + const result = await beforeAdd(file) + // 如果返回的是false则失败,如果不返回或者返回为其他值如true则认为成功 + if (result === false) { + throw new Error('Before add rejected') + } + } + this.emitCallback(Callbacks.FileAdded, file) + } catch { + file.changeStatus(FileStatus.AddFail) + this.emitCallback(Callbacks.FileAddFail, file) + } + this.fileList.push(file) } async upload() { + if (this.fileList.length === 0) return + for (let i = 0; i < this.fileList.length; i++) { const file = this.fileList[i] - if (file.isUploading()) { - return + if (file.isAddFail() || file.isCheckFail()) { + continue } - if (file.isResume()) { - file.status = Status.Uploading - file.uploadFile() - return - } - if (file.isReady()) { - file.uploadFile() + + if (file.isUploading() || file.isReading()) { return } - } - } - async submit() { - const initedFileList = this.fileList.filter((file) => file.isInited()) - // 第一个file ready之后就开始上传,避免多个ready状态的file同时上传 - try { - await Promise.race(initedFileList.map((file) => file.start())) - } catch (e) { - console.log(e) - } - this.upload() - } + if (file.isResume()) { + // console.log(file.prevStatusLastRecord) + // [uploading, pause, resume] 回到pause之前的状态 + const prevStatus = file.prevStatusLastRecord[file.prevStatusLastRecord.length - 2] - listenerFiles() { - const emitAllSuccess = (fiel, fileList) => { - if (!fileList.length) { + if (prevStatus) { + file.changeStatus(prevStatus) + } + file.upload() return } - const allSuccess = fileList.every((file) => file.isSuccess()) - if (allSuccess) { - this.emit(Events.AllFileSuccess, this.fileList) - this.status = Status.Success - } - } - - this.on(Events.FileSuccess, emitAllSuccess) - this.on(Events.FileRemove, emitAllSuccess) - } - doRemove(file) { - if (!file) { - this.clear() - return - } - const index = this.fileList.indexOf(file) - if (index > -1) { - file.remove() - this.fileList.splice(index, 1) - this.emit(Events.FileRemove, file, this.fileList) - this.upload() + if (file.isReady() || file.isInit()) { + file.upload() + return + } } } - clear() { - for (let i = 0; i < this.fileList.length; i++) { - const file = this.fileList[i] - file.remove() - this.emit(Events.FileRemove, file, this.fileList) - } - this.fileList = [] - this.emit(Events.FileRemove, null, []) + submit() { + this.upload() } remove(file) { - const { beforeRemove } = this.opts + const { beforeRemove } = this.options if (!beforeRemove) { this.doRemove(file) } else if (isFunction(beforeRemove)) { @@ -179,15 +184,26 @@ class Uploader { } } - retry(file) { - const index = this.fileList.indexOf(file) - if (index > -1) { - file.retry() - this.upload() + clear() { + // 倒序删除 + for (let i = this.fileList.length - 1; i >= 0; i--) { + const file = this.fileList[i] + file.remove() + } + this.fileList = [] + } + + doRemove(file) { + if (!file) { + this.clear() + return } + + file.remove() } pause(file) { + if (!file) return const index = this.fileList.indexOf(file) if (index > -1) { file.pause() @@ -195,35 +211,35 @@ class Uploader { } resume(file) { + if (!file) return + const uploadingFiles = this.fileList.filter((file) => { + return file.isUploading() || file.isReading() + }) + + uploadingFiles.forEach((item) => { + item.pause() + }) + file.resume() + } + + retry(file) { + if (!file) return + const uploadingFiles = this.fileList.filter((file) => { + return file.isUploading() || file.isReading() + }) + + uploadingFiles.forEach((item) => { + item.pause() + }) const index = this.fileList.indexOf(file) if (index > -1) { - file.resume() + file.retry() } } - assignBrowse(domNode, attributes = {}) { - const attrs = extend( - {}, - { - accept: this.opts.accept, - multiple: this.opts.multiple - }, - attributes - ) - this.container.assignBrowse(domNode, attrs) - } - - assignDrop(domNode) { - this.container.assignDrop(domNode) + destroy() { + this.clear() + this.event.clear() + this.container.destroy() } } - -Uploader.Status = Status -Uploader.Events = Events -Uploader.File = File -Uploader.CheckStatus = CheckStatus -Uploader.create = (options) => { - return new Uploader(options) -} - -export default Uploader diff --git a/packages/sdk/src/core/constans.js b/packages/sdk/src/core/constans.js deleted file mode 100644 index c00be11..0000000 --- a/packages/sdk/src/core/constans.js +++ /dev/null @@ -1,35 +0,0 @@ -// File和Chunk的状态是共用,有重合 -export const Status = { - Init: 'init', // 文件初始化状态 - Reading: 'reading', // 计算hash,读取文件hash中 - Ready: 'ready', // 1. 文件hash计算完成;2. chunk初始化状态是Ready - Pending: 'pending', // 1. chunk的已经发起请求,Promise处于Pending状态 - Uploading: 'uploading', // 1. 文件在上传中 2. chunk上传中 - UploadSuccess: 'uploadSuccess', // 文件的所有chunk上传完成, 准备合并文件 - UploadFail: 'uploadFail', // 文件所有chunk已经请求上传接口,但是自动重试之后仍有失败时,文件状态为UploadFail - Success: 'success', // 1. 文件合并成功 2. chunk上传成功 - Fail: 'fail', // 1. 文件合并失败 2. chunk上传(所有重试都不成功)失败 - Pause: 'pause', // 暂停状态 - Resume: 'resume' // 恢复状态 -} - -export const Events = { - Exceed: 'exceed', - FilesAdded: 'filesAdded', - FileChange: 'fileChange', - FileRemove: 'fileRemove', - FileProgress: 'fileProgress', - FileFail: 'fileFail', - FileUploadFail: 'fileUploadFail', - FileUploadSuccess: 'fileUploadSuccess', - FileSuccess: 'fileSuccess', - // FileMergeFail: 'fileMergeFail', - AllFileSuccess: 'allFilesSuccess', - Change: 'change' -} - -export const CheckStatus = { - Part: 'part', - Success: 'success', - None: 'none' -} diff --git a/packages/sdk/src/core/constants.js b/packages/sdk/src/core/constants.js new file mode 100644 index 0000000..605bed6 --- /dev/null +++ b/packages/sdk/src/core/constants.js @@ -0,0 +1,161 @@ +// File和Chunk的状态是共用,有重合 +export const Status = { + Init: 'init', // 文件初始化状态 + Reading: 'reading', // 计算hash,读取文件hash中 + Ready: 'ready', // 1. 文件hash计算完成;2. chunk初始化状态是Ready + Pending: 'pending', // 1. chunk的已经发起请求,Promise处于Pending状态 + Uploading: 'uploading', // 1. 文件在上传中 2. chunk上传中 + UploadSuccess: 'uploadSuccess', // 文件的所有chunk上传完成, 准备合并文件 + UploadFail: 'uploadFail', // 文件所有chunk已经请求上传接口,但是自动重试之后仍有失败时,文件状态为UploadFail + Success: 'success', // 1. 文件合并成功 2. chunk上传成功 + Fail: 'fail', // 1. 文件合并失败 2. chunk上传(所有重试都不成功)失败 + Pause: 'pause', // 暂停状态 + Resume: 'resume' // 恢复状态 +} + +// 文件状态 +export const FileStatus = { + // 文件初始化状态 + Init: 'init', + + // 文件添加失败, 添加文件时允许beforeAdd中失败的文件添加到列表,但是状态为AddFail + AddFail: 'addFail', + + // 文件读取中(计算hash中) + Reading: 'reading', + + // 文件hash计算完成;准备上传 + Ready: 'ready', + + // checkRequest 存在时,切checkRequest失败 + CheckFail: 'checkFail', + + // 文件上传中 + Uploading: 'uploading', + + // 文件上传完成;所有chunk上传完成,准备合并文件 + UploadSuccess: 'uploadSuccess', + + // 文件上传失败;有chunk上传失败 + UploadFail: 'uploadFail', + + // 文件上传成功 且 合并成功 + Success: 'success', + + // 文件合并失败 + Fail: 'fail', + + // 文件暂停上传 + Pause: 'pause', + + // 文件恢复上传 + Resume: 'resume' +} + +// chunk状态 +export const ChunkStatus = { + // chunk初始化状态是Ready + Ready: 'ready', + + // chunk创建请求成功,Promise处于Pending状态 + Pending: 'pending', + + // chunk上传中 + Uploading: 'uploading', + + // chunk上传成功 + Success: 'success', + + // chunk上传失败(所有重试次数完成后 都不成功) + Fail: 'fail' +} + +export const Events = { + Exceed: 'exceed', + FilesAdded: 'filesAdded', + FileChange: 'fileChange', + FileRemove: 'fileRemove', + FileProgress: 'fileProgress', + FileFail: 'fileFail', + FileUploadFail: 'fileUploadFail', + FileUploadSuccess: 'fileUploadSuccess', + FileSuccess: 'fileSuccess', + // FileMergeFail: 'fileMergeFail', + AllFileSuccess: 'allFilesSuccess', + Change: 'change' +} + +// 回调函数名称 +export const Callbacks = { + // 文件超出limit限制 + Exceed: 'exceed', + + // 单个文件添加成功 + FileAdded: 'fileAdded', + + // 文件添加失败 + FileAddFail: 'fileAddFail', + + // 所有文件添加成功 + FilesAdded: 'filesAdded', + + // 文件状态改变 + FileChange: 'fileChange', + + // 文件删除 + FileRemove: 'fileRemove', + + // 文件开始计算hash + FileReadStart: 'fileReadStart', + + // 文件计算进度 + FileReadProgress: 'fileReadProgress', + + // 文件hash计算完成 + FileReadEnd: 'fileReadEnd', + + FileReadFail: 'fileReadFail', + + // 文件上传进度 + FileProgress: 'fileProgress', + + // 文件上传成功 + FileUploadSuccess: 'fileUploadSuccess', + + // 文件上传失败 + FileUploadFail: 'fileUploadFail', + + // 文件合并成功 + FileSuccess: 'fileSuccess', + + // 文件上传失败合并失败 + FileFail: 'fileFail', + + // 所有文件上传成功 + AllFileSuccess: 'allFileSuccess' +} + +// check 文件上传状态 +export const CheckStatus = { + // 文件还没上传 + None: 'none', + + // 部分上传成功 + Part: 'part', + + // 准备合并 + WaitMerge: 'waitMerge', + + // 上传成功 + Success: 'success' +} + +// 文件上传进程 +export const ProcessType = { + // check接口 + Check: 'check', + // upload chunk接口 + Upload: 'upload', + // merge接口 + Merge: 'merge' +} diff --git a/packages/sdk/src/core/defaults.js b/packages/sdk/src/core/defaults.js index c280f2b..628e919 100644 --- a/packages/sdk/src/core/defaults.js +++ b/packages/sdk/src/core/defaults.js @@ -1,68 +1,35 @@ -import { sleep } from '@/shared' -import { CheckStatus } from './constans' +import { CheckStatus } from './constants.js' -export const defaults = { - multipart: true, // TODO: 是否分片上传,false时单文件上传 +export const defaultOptions = { + // input 属性相关 + accept: '*', + multiple: true, + + // 文件相关 + fileList: [], + limit: 10, + autoUpload: true, + customGenerateUid: null, + beforeAdd: (_file) => true, + beforeRemove: (_file) => true, + addFailToRemove: true, + chunkSize: 2 * 1024 * 1024, // 2M + fakeProgress: true, + withHash: true, + useWebWoker: false, - /** - * request - */ - action: 'https://jsonplaceholder.typicode.com/posts', + // 上传逻辑相关 + name: 'file', + action: '', + customRequest: null, withCredentials: true, headers: {}, data: {}, + requestSucceed: (xhr) => [200, 201, 202, 206].includes(xhr.status), maxConcurrency: 6, - requestSucceed(xhr) { - return [200, 201, 202].includes(xhr.status) - }, maxRetries: 3, retryInterval: 1000, - async checkFileRequest(file) { - // await sleep(1000) - // 成功 - // return { - // status: 'success', - // data: 'http://google.com' - // } - - // 没上传 - return { - status: CheckStatus.None - } - - // 部分成功 - // return { - // status: CheckStatus.Part, - // data: [0, 1, 2, 3, 4, 5, 6] - // } - }, - mergeRequest: async (file) => { - // await sleep(5000) - // file.path = 'http://baidu.com' - return true - }, - - /** - * file option - */ - chunkSize: 1024 * 4, - autoUpload: true, - name: 'file', - limit: 10, - withHash: true, - computedHashWorker: true, - fakeProgress: true, - customGenerateUid: null, - beforeAdd(file) { - return true - }, - beforeRemove(file) { - return true - }, - - /** - * input - */ - multiple: true, - accept: '*' + checkRequest: (_file) => ({ status: CheckStatus.None }), + mergeRequest: (_file) => true, + processData: (data, _processType) => data } diff --git a/packages/sdk/src/core/request.js b/packages/sdk/src/core/request.js new file mode 100644 index 0000000..402dd79 --- /dev/null +++ b/packages/sdk/src/core/request.js @@ -0,0 +1,48 @@ +export function request(options) { + const { + method = 'POST', + withCredentials = true, + responseType = 'json', + action, + data, + // query, + headers, + // name, + onSuccess, + onFail, + onProgress + } = options + + let xhr = new XMLHttpRequest() + xhr.responseType = responseType + xhr.withCredentials = withCredentials + xhr.open(method, action, true) + + const formData = new FormData() + Object.entries(data).forEach(([key, value]) => formData.append(key, value)) + + // 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED + if ('setRequestHeader' in xhr) { + Object.entries(headers).forEach(([key, value]) => xhr.setRequestHeader(key, value)) + } + + xhr.addEventListener('timeout', () => onFail(new Error('Request timed out'), xhr)) + xhr.upload.addEventListener('progress', onProgress) + xhr.addEventListener('error', onFail, false) + xhr.addEventListener('readystatechange', (e) => { + if (xhr.readyState !== 4) return + if (xhr.status < 200 || xhr.status >= 300) { + onFail(new Error(`xhr: status === ${xhr.status}`), xhr) + return + } + onSuccess(e, xhr) + }) + xhr.send(formData) + + return { + abort() { + xhr.abort() + xhr = null + } + } +} diff --git a/packages/sdk/src/index.js b/packages/sdk/src/index.js index f7a657f..48cc107 100644 --- a/packages/sdk/src/index.js +++ b/packages/sdk/src/index.js @@ -1,10 +1,11 @@ -import Uploader from '@/core/Uploader.js' -import File from '@/core/File' -import Chunk from '@/core/Chunk' -import { Status, Events, CheckStatus } from '@/core/constans' +import Uploader from './core/Uploader.js' +import File from './core/File.js' +import Chunk from './core/Chunk.js' +import * as Utils from './shared/index.js' +import { Callbacks, FileStatus, ChunkStatus, CheckStatus } from './core/constants.js' + +const create = (options) => new Uploader(options) -const create = (options) => { - return new Uploader(options) -} export default Uploader -export { File, Status, Events, CheckStatus, Chunk, create } + +export { create, File, Chunk, Utils, FileStatus, ChunkStatus, Callbacks, CheckStatus } diff --git a/packages/sdk/src/shared/blob.js b/packages/sdk/src/shared/blob.js index 850b5d2..54cd554 100644 --- a/packages/sdk/src/shared/blob.js +++ b/packages/sdk/src/shared/blob.js @@ -1 +1,12 @@ export const slice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice + +export const renderSize = (value) => { + if (!value || isNaN(value) || value <= 0) return '0 B' + + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + const base = 1024 + const index = Math.floor(Math.log(value) / Math.log(base)) + const size = (value / Math.pow(base, index)).toFixed(2) + + return `${size} ${units[Math.min(index, units.length - 1)]}` +} diff --git a/packages/sdk/src/shared/event.js b/packages/sdk/src/shared/event.js deleted file mode 100644 index a55751b..0000000 --- a/packages/sdk/src/shared/event.js +++ /dev/null @@ -1,58 +0,0 @@ -export class Event { - constructor() { - this.event = {} - } - - on(name, func) { - if (!this.event) { - this.event = {} - } - - if (!this.event[name]) { - this.event[name] = [] - } - - // 避免通过函数多次被调用 - if (this.event[name].indexOf(func) > -1) { - return - } - - this.event[name].push(func) - } - - off(name, func) { - if (!func) { - this.event[name] = [] - return - } - if (this.event && this.event[name]) { - for (let i = 0; i < this.event[name].length; i++) { - if (func === this.event[name]) { - this.event.splice(i, 1) - return - } - } - } - } - - emit(name, ...args) { - if (!this.event) { - this.event = {} - } - if (!this.event[name]) { - return - } - this.event[name].forEach((func) => { - func(...args) - }) - } - - once(name, func) { - function on(...args) { - func.apply(this, ...args) - this.off(name, on) - } - on.func = func - this.on(name, on) - } -} diff --git a/packages/sdk/src/shared/hash-worker.js b/packages/sdk/src/shared/hash-worker.js index a7a8a58..5e3f418 100644 --- a/packages/sdk/src/shared/hash-worker.js +++ b/packages/sdk/src/shared/hash-worker.js @@ -731,7 +731,7 @@ const md5 = ` export const workerCode = `self.onmessage = (e) => { const { file, chunkSize, type } = e.data if(type === 'DONE') { - // console.log('worker closed !') + console.log('Hash calculation closed !') self.close() return } @@ -743,7 +743,18 @@ export const workerCode = `self.onmessage = (e) => { const startTime = Date.now() let currentChunk = 0 + // 创建 AbortController 用于中断 + const controller = new AbortController() + const signal = controller.signal + + signal.addEventListener('abort', () => { + fileReader.abort() // 中断 FileReader + self.postMessage({ error: new Error('Hash calculation cancelled'), progress: 0 }) + }) + fileReader.onload = (e) => { + if (signal.aborted) return + spark.append(e.target.result) currentChunk++ @@ -762,11 +773,13 @@ export const workerCode = `self.onmessage = (e) => { } fileReader.onerror = (error) => { + if (signal.aborted) return console.warn('oops, something went wrong.') - self.postMessage({ error }) + self.postMessage({ error, progress: 0 }) } function loadNext() { + if (signal.aborted) return const start = currentChunk * chunkSize const end = start + chunkSize >= file.size ? file.size : start + chunkSize @@ -774,33 +787,53 @@ export const workerCode = `self.onmessage = (e) => { } loadNext() + + return { + abort: () => controller.abort() + } } ` export const isSupportWorker = !!window.Worker export const computedHashWorker = ({ file, chunkSize }, callback) => { console.log('In Web Worker') - const worker = new Worker(URL.createObjectURL(new Blob([workerCode]))) + let abortComputedHash + const worker = new Worker(URL.createObjectURL(new Blob([workerCode]))) const close = () => { worker.postMessage({ type: 'DONE' }) worker.terminate() } - worker.postMessage({ file, chunkSize }) + const promise = new Promise((resolve, reject) => { + worker.postMessage({ file, chunkSize }) - worker.onmessage = (e) => { - const { error, progress, hash, time } = e.data - if (error) { - callback(error) - close() - return + worker.onmessage = (e) => { + const { error, progress, hash, time } = e.data + if (error) { + callback(error) + close() + reject(error) + return + } + if (progress === 100) { + close() + resolve({ progress, hash, time }) + callback(null, { progress, hash, time }) + return + } + callback(null, { progress }) } - if (progress === 100) { - close() - callback(null, { progress, hash, time }) - return + + abortComputedHash = { reject } + }) + + return { + promise, + abort: () => { + if (!abortComputedHash) return + worker.terminate() + abortComputedHash.reject() } - callback(null, { progress }) } } diff --git a/packages/sdk/src/shared/hash.js b/packages/sdk/src/shared/hash.js index 36f35ca..5ac4bed 100644 --- a/packages/sdk/src/shared/hash.js +++ b/packages/sdk/src/shared/hash.js @@ -1,65 +1,6 @@ import * as SparkMD5 from 'spark-md5' import { isSupportWorker, computedHashWorker } from './hash-worker' -export const getHash = (data) => { - return new Promise((resolve, reject) => { - const fileReader = new FileReader() - fileReader.onload = (e) => { - const fileHash = SparkMD5.ArrayBuffer.hash(e.target.result) - resolve(fileHash) - } - fileReader.onerror = () => { - reject('文件读取失败') - } - fileReader.readAsArrayBuffer(data) - }) -} - -export const computedHash = ({ file, chunkSize, totalChunks }, callback) => { - return new Promise((resolve, reject) => { - const spark = new SparkMD5.ArrayBuffer() - const fileReader = new FileReader() - let currentChunk = 0 - const startTime = Date.now() - - fileReader.onload = function (e) { - spark.append(e.target.result) - currentChunk++ - - if (currentChunk < totalChunks) { - loadNext() - callback && - callback({ - progress: (currentChunk / totalChunks) * 100 - }) - } else { - console.info('computed hash') - const result = { - hash: spark.end(), - time: Date.now() - startTime, - progress: 100 - } - callback && callback(result) - resolve(result) - } - } - - fileReader.onerror = function () { - console.warn('oops, something went wrong.') - reject() - } - - function loadNext() { - const start = currentChunk * chunkSize - const end = start + chunkSize >= file.size ? file.size : start + chunkSize - - fileReader.readAsArrayBuffer(slice.call(file, start, end)) - } - - loadNext() - }) -} - const computedHashNormal = ({ file, chunkSize }, callback) => { const slice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice const spark = new SparkMD5.ArrayBuffer() @@ -68,10 +9,20 @@ const computedHashNormal = ({ file, chunkSize }, callback) => { const startTime = Date.now() let currentChunk = 0 + // 创建 AbortController 用于中断 + const controller = new AbortController() + const signal = controller.signal + + signal.addEventListener('abort', () => { + fileReader.abort() // 中断 FileReader + callback(new Error('Hash calculation cancelled'), { progress: 0 }) + }) + fileReader.onload = function (e) { + if (signal.aborted) return + spark.append(e.target.result) currentChunk++ - if (currentChunk < totalChunks) { loadNext() callback(null, { @@ -84,15 +35,18 @@ const computedHashNormal = ({ file, chunkSize }, callback) => { progress: 100 } callback(null, result) + return } } fileReader.onerror = function (error) { - console.warn('oops, something went wrong.') - callback(error) + if (signal.aborted) return + console.warn('Hash calculation error') + callback(error, { progress: 0 }) } function loadNext() { + if (signal.aborted) return const start = currentChunk * chunkSize const end = start + chunkSize >= file.size ? file.size : start + chunkSize @@ -100,12 +54,22 @@ const computedHashNormal = ({ file, chunkSize }, callback) => { } loadNext() + + return { + abort: () => controller.abort() + } } -export const asyncComputedHash = ({ file, chunkSize, inWorker }, callback) => { - return new Promise((resolve, reject) => { - const computedHash = isSupportWorker && inWorker ? computedHashWorker : computedHashNormal - computedHash( +export const asyncCancellableComputedHash = ( + { file, chunkSize, useWebWoker = false }, + callback +) => { + let abortComputedHash + + const promise = new Promise((resolve, reject) => { + const computedHash = isSupportWorker && useWebWoker ? computedHashWorker : computedHashNormal + + const { abort } = computedHash( { file, chunkSize @@ -120,5 +84,15 @@ export const asyncComputedHash = ({ file, chunkSize, inWorker }, callback) => { callback && callback({ progress }) } ) + abortComputedHash = { abort, reject } }) + + return { + promise, + abort: () => { + if (!abortComputedHash) return + abortComputedHash.abort() + abortComputedHash.reject() + } + } } diff --git a/packages/sdk/src/shared/index.js b/packages/sdk/src/shared/index.js index 87987f0..24e1609 100644 --- a/packages/sdk/src/shared/index.js +++ b/packages/sdk/src/shared/index.js @@ -7,10 +7,25 @@ export const sleep = (time = 600, mockError = false) => { }) } +export const throttle = (fn, wait = 300) => { + // 上一次执行 fn 的时间 + let previous = 0 + // 将 throttle 处理结果当作函数返回 + return function (...args) { + // 获取当前时间,转换成时间戳,单位毫秒 + let now = +new Date() + // 将当前时间和上一次执行函数的时间进行对比 + // 大于等待时间就把 previous 设置为当前时间并执行函数 fn + if (now - previous > wait) { + previous = now + fn.apply(this, args) + } + } +} + export * from './uid' export * from './types' export * from './array' export * from './object' -export * from './event' export * from './hash' export * from './blob' diff --git a/packages/sdk/src/shared/object.js b/packages/sdk/src/shared/object.js index 686d2fc..7dee7cb 100644 --- a/packages/sdk/src/shared/object.js +++ b/packages/sdk/src/shared/object.js @@ -57,3 +57,15 @@ export function extend() { } return target } + +export const parseData = (data) => { + if (isFunction(data)) { + return data() || {} + } + + if (isPlainObject(data)) { + return data + } + + return {} +} diff --git a/packages/sdk/src/shared/types.js b/packages/sdk/src/shared/types.js index 3d18be1..3594ffc 100644 --- a/packages/sdk/src/shared/types.js +++ b/packages/sdk/src/shared/types.js @@ -22,3 +22,13 @@ export const isArray = export const isPromise = (promise) => { return promise && isFunction(promise.then) } + +export const isString = function (a) { + return typeof a === 'string' +} + +export const isBoolean = function (a) { + return typeof a === 'boolean' +} + + diff --git a/packages/sdk/vite.config.js b/packages/sdk/vite.config.js index b33917a..010468f 100644 --- a/packages/sdk/vite.config.js +++ b/packages/sdk/vite.config.js @@ -8,12 +8,11 @@ export default defineConfig({ } }, server: { - open: '/example/index.html' + open: '/examples/quick-start/index.html' }, build: { lib: { entry: './src/index.js', - // formats: ['es', 'umd'], name: 'UploaderSdk', fileName: 'sdk' }, diff --git a/packages/vue/example/App.vue b/packages/vue/example/App.vue index 7cadcb3..1732587 100644 --- a/packages/vue/example/App.vue +++ b/packages/vue/example/App.vue @@ -1,31 +1,36 @@