UNPKG

@mofiter/osc-file-uploader

Version:

文件上传模块

793 lines (749 loc) 24.6 kB
import utils from './utils' import event from './event' import File from './file' import Chunk from './chunk' import xxhash from 'xxhash-wasm' import SparkMD5 from 'spark-md5' // 检查文件是否存在 url var TEST_URL = 'http://localhost:8082/rest/store/v1/store/upload' // 上传文件 url var UPLOAD_URL = 'http://localhost:8082/rest/store/v1/store/upload' // 合并文件 url var MERGE_URL = 'http://localhost:8082/rest/store/v1/store/merge' // 文件上传状态 const uploadState = { Success: 1, // 上传成功:秒传成功:合并成功 AllowMerge: 2, // 上传成功,可以合并 CurrentChunkFailed: 3, // 当前分片上传失败,请重传 BreakpointContinuation: 4, // 断点续传 CurrentChunkSuccess: 5, // 当前分片上传成功 DenyMerge: 6, // 未上传完成,禁止合并 MergeFailed: 7, // 合并失败 VerifyFailed: 8, // 校验唯一标识(xxh)失败 CurrentChunkLocked: 9, // 当前分片已经被锁 Uploading: 10, // 上传中 UploadMinIOFailed: 11, // 合并完成,上传minio失败 MinioCreatBucketFailed: 12, // 操作minio创建桶失败 CreateLocalTempDirFailed: 13, // 创建本地临时目录失败 InitDbFailed: 14, // 初始化数据库失败 } var version = '__VERSION__' var isServer = typeof window === 'undefined' // ie10+ var ie10plus = isServer ? false : window.navigator.msPointerEnabled var support = (function () { if (isServer) { return false } var sliceName = 'slice' var _support = utils.isDefined(window.File) && utils.isDefined(window.Blob) && utils.isDefined(window.FileList) var bproto = null if (_support) { bproto = window.Blob.prototype utils.each(['slice', 'webkitSlice', 'mozSlice'], function (n) { if (bproto[n]) { sliceName = n return false } }) _support = !!bproto[sliceName] } if (_support) Uploader.sliceName = sliceName bproto = null return _support })() var supportDirectory = (function () { if (isServer) { return false } var input = window.document.createElement('input') input.type = 'file' var sd = 'webkitdirectory' in input || 'directory' in input input = null return sd })() function Uploader (opts) { this.support = support /* istanbul ignore if */ if (!this.support) { return } this.supportDirectory = supportDirectory utils.defineNonEnumerable(this, 'filePaths', {}) this.opts = utils.extend({}, Uploader.defaults, opts || {}) this.preventEvent = utils.bind(this._preventEvent, this) File.call(this, this) } /** * Default read function using the webAPI * * @function webAPIFileRead(fileObj, fileType, startByte, endByte, chunk) * */ var webAPIFileRead = function (fileObj, fileType, startByte, endByte, chunk) { chunk.readFinished(fileObj.file[Uploader.sliceName](startByte, endByte, fileType)) } Uploader.version = version Uploader.defaults = { chunkSize: 5 * 1024 * 1024, forceChunkSize: false, autoUpload: false, simultaneousUploads: 3, singleFile: false, fileParameterName: 'file', progressCallbacksInterval: 500, speedSmoothingFactor: 0.1, query: {}, headers: {}, withCredentials: false, hashType: 'xxhash', preprocess: function (chunk) { if (chunk.file.hash === '' || chunk.file.hash == undefined) { // 计算 hash,计算完成后调用 chunk.preprocessFinished() var $ = chunk.uploader if ($.opts.hashType === 'md5') { $._calcMd5(chunk) } else { $._calcXxhash(chunk) } } else { chunk.preprocessFinished() } }, method: 'multipart', testMethod: 'GET', uploadMethod: 'POST', mergeMethod: 'POST', prioritizeFirstAndLastChunk: false, allowDuplicateUploads: false, target: function (file, chunk, isTest, isMerge) { if (isTest) { return TEST_URL } else if (isMerge) { return MERGE_URL } else { return UPLOAD_URL } }, testChunks: true, generateUniqueIdentifier: null, generateUniqueFilePath: null, maxChunkRetries: 3, chunkRetryInterval: null, permanentErrors: [400, 404, 415, 500, 501, 502, 503], successStatuses: [200, 201, 202], onDropStopPropagation: false, initFileFn: null, readFileFn: webAPIFileRead, checkChunkUploadedByResponse: function (chunk, response) { response = JSON.parse(response) // 上传成功,秒传 if (response.state === uploadState.Success) { chunk.needUpload = false if (!chunk.file.merged) { chunk.file.merged = true } return true } // 可以合并 if (response.state === uploadState.AllowMerge) { chunk.needUpload = false if (!chunk.file.canMerge) { chunk.file.canMerge = true } return true } // 断点续传~ if (response.state === uploadState.BreakpointContinuation) { if (!response.notYetUpload.includes(chunk.offset + 1)) { chunk.needUpload = false return true } } return false }, initialPaused: true, processTest: function(response, cb, file) { try { const res = JSON.parse(response) if (res.state === uploadState.MinioCreatBucketFailed || res.state === uploadState.CreateLocalTempDirFailed || res.state === uploadState.InitDbFailed) { cb(true, response) } else { cb(null, response) } } catch (error) { cb(true, response) } }, processResponse: function (response, cb, file, chunk) { cb(null, response) }, processMerge: function(response, cb, file, chunk) { try { const res = JSON.parse(response) if (res.state === uploadState.DenyMerge || res.state === uploadState.MergeFailed || res.state === uploadState.VerifyFailed) { cb(true, response) } else { file.merged = true cb(null, response) } } catch (error) { cb(true, response) } }, processParams: function (params, file, chunk, isTest, isMerge) { if (isTest) { return { fileName: params.filename, identifier: params.identifier, chunkNumber: params.chunkNumber, totalChunks: params.totalChunks, totalSize: params.totalSize, clientId: '', } } else if (isMerge) { return { fileName: params.filename, identifier: params.identifier, totalChunks: params.totalChunks, totalSize: params.totalSize, clientId: '', } } else { return { fileName: params.filename, identifier: params.identifier, chunkNumber: params.chunkNumber, totalChunks: params.totalChunks, totalSize: params.totalSize, currentChunkSize: params.currentChunkSize, clientId: '', } } }, statusText: { success: '上传成功', error: '上传出错', uploading: '上传中', paused: '上传暂停', pending: '等待上传', // 非自动上传模式下,新添加到文件列表中的文件状态 waiting: '等待上传', // 自动上传模式下,新添加到文件列表中的文件状态 calculating: '正在计算唯一 id', merging: '合并中' } } Uploader.utils = utils Uploader.event = event Uploader.File = File Uploader.Chunk = Chunk // inherit file Uploader.prototype = utils.extend({}, File.prototype) // inherit event utils.extend(Uploader.prototype, event) utils.extend(Uploader.prototype, { constructor: Uploader, _trigger: function (name) { var args = utils.toArray(arguments) var preventDefault = !this.trigger.apply(this, arguments) if (name !== 'catchAll') { args.unshift('catchAll') preventDefault = !this.trigger.apply(this, args) || preventDefault } return !preventDefault }, _triggerAsync: function () { var args = arguments utils.nextTick(function () { this._trigger.apply(this, args) }, this) }, addFiles: function (files, evt) { var _files = [] var oldFileListLen = this.fileList.length utils.each(files, function (file) { // Uploading empty file IE10/IE11 hangs indefinitely // Directories have size `0` and name `.` // Ignore already added files if opts.allowDuplicateUploads is set to false if ((!ie10plus || ie10plus && file.size > 0) && !(file.size % 4096 === 0 && (file.name === '.' || file.fileName === '.'))) { var uniqueFilePath = this.generateUniqueFilePath(file) if (this.opts.allowDuplicateUploads || !this.getFromUniqueFilePath(uniqueFilePath)) { var _file = new File(this, file, this) _file.uniqueFilePath = uniqueFilePath if (this._trigger('fileAdded', _file, evt)) { _files.push(_file) } else { File.prototype.removeFile.call(this, _file) } } } }, this) // get new fileList var newFileList = this.fileList.slice(oldFileListLen) if (this._trigger('filesAdded', _files, newFileList, evt)) { utils.each(_files, function (file) { if (this.opts.singleFile && this.files.length > 0) { this.removeFile(this.files[0]) } this.files.push(file) this._trigger('fileStatus', file, file.status, this.opts.statusText[file.status]) }, this) this._trigger('filesSubmitted', _files, newFileList, evt) if (this.opts.autoUpload) { this.resume() } } else { utils.each(newFileList, function (file) { File.prototype.removeFile.call(this, file) }, this) } }, addFile: function (file, evt) { this.addFiles([file], evt) }, cancel: function () { for (var i = this.fileList.length - 1; i >= 0; i--) { this.fileList[i].cancel() } }, removeFile: function (file) { File.prototype.removeFile.call(this, file) this._trigger('fileRemoved', file) }, generateUniqueFilePath: function (file) { var custom = this.opts.generateUniqueFilePath if (utils.isFunction(custom)) { return custom(file) } var relativePath = file.relativePath || file.webkitRelativePath || file.fileName || file.name return relativePath }, getFromUniqueFilePath: function(uniqueFilePath) { var ret = false utils.each(this.files, function (file) { if (file.uniqueFilePath === uniqueFilePath) { ret = file return false } }) return ret }, generateUniqueIdentifier: function (file) { var custom = this.opts.generateUniqueIdentifier if (utils.isFunction(custom)) { return custom(file) } /* istanbul ignore next */ // Some confusion in different versions of Firefox var relativePath = file.relativePath || file.webkitRelativePath || file.fileName || file.name /* istanbul ignore next */ return file.size + '-' + relativePath.replace(/[^0-9a-zA-Z_-]/img, '') }, getFromUniqueIdentifier: function (uniqueIdentifier) { var ret = false utils.each(this.files, function (file) { if (file.uniqueIdentifier === uniqueIdentifier) { ret = file return false } }) return ret }, uploadNextChunk: function (preventEvents) { var found = false var pendingStatus = Chunk.STATUS.PENDING var checkChunkUploaded = this.uploader.opts.checkChunkUploadedByResponse if (this.opts.prioritizeFirstAndLastChunk) { utils.each(this.files, function (file) { if (file.paused) { return } if (checkChunkUploaded && !file._firstResponse && file.isUploading()) { // waiting for current file's first chunk response return } if (file.chunks.length && file.chunks[0].status() === pendingStatus) { file.chunks[0].send() found = true return false } if (file.chunks.length > 1 && file.chunks[file.chunks.length - 1].status() === pendingStatus) { file.chunks[file.chunks.length - 1].send() found = true return false } }) if (found) { return found } } var $ = this // Now, simply look for the next, best thing to upload utils.each(this.files, function (file) { if (!file.paused && file.status !== File.FILE_STATUS.SUCCESS && file.status !== File.FILE_STATUS.ERROR) { if (checkChunkUploaded && !file._firstResponse && file.isUploading()) { // waiting for current file's first chunk response return } utils.each(file.chunks, function (chunk) { if (chunk.status() === pendingStatus) { chunk.send() found = true return false } }) // change start if (!file.merged) { if (file.canMerge) { if (file.status !== File.FILE_STATUS.MERGING) { $._trigger('fileMergeStart', file) file.status = File.FILE_STATUS.MERGING $._trigger('fileStatus', file, file.status, $.opts.statusText[file.status]) file.chunks[0] && file.chunks[0].merge() } } else { var checkBeforeMerge = true utils.each(file.chunks, function (chunk) { if (chunk.needUpload) { checkBeforeMerge = false return false } }) if (checkBeforeMerge && !file.isTesting) { file.isTesting = true file.chunks[0] && file.chunks[0].test() } } } // change end } if (found) { return false } }) if (found) { return true } // The are no more outstanding chunks to upload, check is everything is done var outstanding = false utils.each(this.files, function (file) { if (!file.isComplete()) { outstanding = true return false } }) // should check files now // if now files in list // should not trigger complete event if (!outstanding && !preventEvents && this.files.length) { // All chunks have been uploaded, complete this._triggerAsync('complete') } return outstanding }, upload: function (preventEvents) { // Make sure we don't start too many uploads at once var ret = this._shouldUploadNext() if (ret === false) { return } !preventEvents && this._trigger('uploadStart') var started = false for (var num = 1; num <= this.opts.simultaneousUploads - ret; num++) { started = this.uploadNextChunk(!preventEvents) || started if (!started && preventEvents) { // completed break } } if (!started && !preventEvents) { this._triggerAsync('complete') } }, /** * should upload next chunk * @function * @returns {Boolean|Number} */ _shouldUploadNext: function () { var num = 0 var should = true var simultaneousUploads = this.opts.simultaneousUploads var uploadingStatus = Chunk.STATUS.UPLOADING utils.each(this.files.filter(file => file.status !== 'success'), function (file) { utils.each(file.chunks, function (chunk) { if (chunk.status() === uploadingStatus) { num++ if (num >= simultaneousUploads) { should = false return false } } }) return should }) // if should is true then return uploading chunks's length return should && num }, _calcXxhash: function (chunk) { var $ = chunk.uploader new Promise(async resolove => { $._trigger('calcHashStart', chunk.file) chunk.file.status = File.FILE_STATUS.CALCULATING $._trigger('fileStatus', chunk.file, chunk.file.status, $.opts.statusText[chunk.file.status]) const { create64 } = await xxhash(); let c64 = create64() let time = new Date().getTime() let currentChunk = 0 const chunkSize = 5 * 1024 * 1024 let chunks = Math.ceil(chunk.file.size / chunkSize) let fileReader = new FileReader() fileReader.onload = e => { // 需要转换类型 const bytes = new Uint8Array(e.target.result) c64.update(bytes) // 如果当前对分片数小于总分片数,就继续处理 if (currentChunk < chunks) { currentChunk++ loadNext() } else { // 当前分片数已经不小于总分片数,说明处理完了~ let hash = c64.digest().toString(16).padStart(16, "0") resolove(hash) } } // FileReader读取不到文件时报错~ fileReader.onerror = function () { } // 计算出下一个用于截取文件的开始和结束的位置 function loadNext () { let start = currentChunk * chunkSize let end = ((start + chunkSize) >= chunk.file.size) ? chunk.file.size : start + chunkSize fileReader.readAsArrayBuffer(chunk.file.file[Uploader.sliceName].call(chunk.file.file, start, end)) } loadNext() }).then(res => { chunk.file.hash = chunk.file.uniqueIdentifier = res $._trigger('calcHashEnd', chunk.file, res) chunk.file.status = File.FILE_STATUS.UPLOADING $._trigger('fileStatus', chunk.file, chunk.file.status, $.opts.statusText[chunk.file.status]) chunk.preprocessFinished() }) }, _calcMd5: function (chunk) { var $ = chunk.uploader new Promise(resolove => { $._trigger('calcHashStart', chunk.file) chunk.file.status = File.FILE_STATUS.CALCULATING $._trigger('fileStatus', chunk.file, chunk.file.status, $.opts.statusText[chunk.file.status]) let spark = new SparkMD5.ArrayBuffer(); let time = new Date().getTime() let currentChunk = 0 const chunkSize = 5 * 1024 * 1024 let chunks = Math.ceil(chunk.file.size / chunkSize) let fileReader = new FileReader() fileReader.onload = e => { spark.append(e.target.result); // 如果当前对分片数小于总分片数,就继续处理 if (currentChunk < chunks) { currentChunk++ loadNext() } else { // 当前分片数已经不小于总分片数,说明处理完了~ let hash = spark.end() resolove(hash) } } // FileReader读取不到文件时报错~ fileReader.onerror = function () { } // 计算出下一个用于截取文件的开始和结束的位置 function loadNext () { let start = currentChunk * chunkSize let end = ((start + chunkSize) >= chunk.file.size) ? chunk.file.size : start + chunkSize fileReader.readAsArrayBuffer(chunk.file.file[Uploader.sliceName].call(chunk.file.file, start, end)) } loadNext() }).then(res => { chunk.file.hash = chunk.file.uniqueIdentifier = res $._trigger('calcHashEnd', chunk.file, res) chunk.file.status = File.FILE_STATUS.UPLOADING $._trigger('fileStatus', chunk.file, chunk.file.status, $.opts.statusText[chunk.file.status]) chunk.preprocessFinished() }) }, /** * Assign a browse action to one or more DOM nodes. * @function * @param {Element|Array.<Element>} domNodes * @param {boolean} isDirectory Pass in true to allow directories to * @param {boolean} singleFile prevent multi file upload * @param {Object} attributes set custom attributes: * http://www.w3.org/TR/html-markup/input.file.html#input.file-attributes * eg: accept: 'image/*' * be selected (Chrome only). */ assignBrowse: function (domNodes, isDirectory, singleFile, attributes) { if (typeof domNodes.length === 'undefined') { domNodes = [domNodes] } utils.each(domNodes, function (domNode) { var input if (domNode.tagName === 'INPUT' && domNode.type === 'file') { input = domNode } else { input = document.createElement('input') input.setAttribute('type', 'file') // display:none - not working in opera 12 utils.extend(input.style, { visibility: 'hidden', position: 'absolute', width: '1px', height: '1px' }) // for opera 12 browser, input must be assigned to a document domNode.appendChild(input) // https://developer.mozilla.org/en/using_files_from_web_applications) // event listener is executed two times // first one - original mouse click event // second - input.click(), input is inside domNode domNode.addEventListener('click', function (e) { if (domNode.tagName.toLowerCase() === 'label') { return } input.click() }, false) } if (!this.opts.singleFile && !singleFile) { input.setAttribute('multiple', 'multiple') } if (isDirectory) { input.setAttribute('webkitdirectory', 'webkitdirectory') } attributes && utils.each(attributes, function (value, key) { input.setAttribute(key, value) }) // When new files are added, simply append them to the overall list var that = this input.addEventListener('change', function (e) { that._trigger(e.type, e) if (e.target.value) { that.addFiles(e.target.files, e) e.target.value = '' } }, false) }, this) }, onDrop: function (evt) { this._trigger(evt.type, evt) if (this.opts.onDropStopPropagation) { evt.stopPropagation() } evt.preventDefault() this._parseDataTransfer(evt.dataTransfer, evt) }, _parseDataTransfer: function (dataTransfer, evt) { if (dataTransfer.items && dataTransfer.items[0] && dataTransfer.items[0].webkitGetAsEntry) { this.webkitReadDataTransfer(dataTransfer, evt) } else { this.addFiles(dataTransfer.files, evt) } }, webkitReadDataTransfer: function (dataTransfer, evt) { var self = this var queue = dataTransfer.items.length var files = [] utils.each(dataTransfer.items, function (item) { var entry = item.webkitGetAsEntry() if (!entry) { decrement() return } if (entry.isFile) { // due to a bug in Chrome's File System API impl - #149735 fileReadSuccess(item.getAsFile(), entry.fullPath) } else { readDirectory(entry.createReader()) } }) function readDirectory (reader) { reader.readEntries(function (entries) { if (entries.length) { queue += entries.length utils.each(entries, function (entry) { if (entry.isFile) { var fullPath = entry.fullPath entry.file(function (file) { fileReadSuccess(file, fullPath) }, readError) } else if (entry.isDirectory) { readDirectory(entry.createReader()) } }) readDirectory(reader) } else { decrement() } }, readError) } function fileReadSuccess (file, fullPath) { // relative path should not start with "/" file.relativePath = fullPath.substring(1) files.push(file) decrement() } function readError (fileError) { throw fileError } function decrement () { if (--queue === 0) { self.addFiles(files, evt) } } }, _assignHelper: function (domNodes, handles, remove) { if (typeof domNodes.length === 'undefined') { domNodes = [domNodes] } var evtMethod = remove ? 'removeEventListener' : 'addEventListener' utils.each(domNodes, function (domNode) { utils.each(handles, function (handler, name) { domNode[evtMethod](name, handler, false) }, this) }, this) }, _preventEvent: function (e) { utils.preventEvent(e) this._trigger(e.type, e) }, /** * Assign one or more DOM nodes as a drop target. * @function * @param {Element|Array.<Element>} domNodes */ assignDrop: function (domNodes) { this._onDrop = utils.bind(this.onDrop, this) this._assignHelper(domNodes, { dragover: this.preventEvent, dragenter: this.preventEvent, dragleave: this.preventEvent, drop: this._onDrop }) }, /** * Un-assign drop event from DOM nodes * @function * @param domNodes */ unAssignDrop: function (domNodes) { this._assignHelper(domNodes, { dragover: this.preventEvent, dragenter: this.preventEvent, dragleave: this.preventEvent, drop: this._onDrop }, true) this._onDrop = null } }) export default Uploader