@mofiter/osc-file-uploader
Version:
文件上传模块
793 lines (749 loc) • 24.6 kB
JavaScript
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