本文将带你基于ES6的面向对象,脱离框架使用原生JS,从设计到代码实现一个Uploader基础类,再到实际投入使用。经过本文,你能够了解到通常状况下根据需求是如何合理构造出一个工具类lib。javascript
相信不少人都用过/写过上传的逻辑,无非就是建立input[type=file]
标签,监听onchange
事件,添加到FormData
发起请求。html
可是,想引入开源的工具时以为增长了许多体积且定制性不知足,每次写上传逻辑又会写不少冗余性代码。在不一样的toC业务上,还要从新编写本身的上传组件样式。vue
此时编写一个Uploader基础类,供于业务组件二次封装,就显得颇有必要。java
下面咱们来分析下使用场景与功能:node
而后,咱们能够根据需求,大概设计出想要的API效果,再根据API推导出内部实现。git
const uploader = new Uploader({
url: '',
// 用于自动添加input标签的容器
wrapper: null,
// 配置化的功能,多选、接受文件类型、自动上传等等
multiple: true,
accept: '*',
limit: -1, // 文件个数
autoUpload: false
// xhr配置
header: {}, // 适用于JWT校验
data: {} // 添加额外参数
withCredentials: false
});
复制代码
// 链式调用更优雅
uploader
.on('choose', files => {
// 用于接受选择的文件,根据业务规则过滤
})
.on('change', files => {
// 添加、删除文件时的触发钩子,用于更新视图
// 发起请求后状态改变也会触发
})
.on('progress', e => {
// 回传上传进度
})
.on('success', ret => {/*...*/})
.on('error', ret => {/*...*/})
复制代码
这里主要暴露一些可能经过交互才触发的功能,如选择文件、手动上传等github
uploader.chooseFile();
// 独立出添加文件函数,方便拓展
// 可传入slice大文件后的数组、拖拽添加文件
uploader.loadFiles(files);
// 相关操做
uploader.removeFile(file);
uploader.clearFiles()
// 凡是涉及到动态添加dom,事件绑定
// 应该提供销毁API
uploader.destroy();
复制代码
至此,能够大概设计完咱们想要的uploader的大体效果,接着根据API进行内部实现。ajax
使用ES6的class构建uploader类,把功能进行内部方法拆分,使用下划线开头标识内部方法。chrome
而后能够给出如下大概的内部接口:数组
class Uploader {
// 构造器,new的时候,合并默认配置
constructor (option = {}) {}
// 根据配置初始化,绑定事件
_init () {}
// 绑定钩子与触发
on (evt) {}
_callHook (evt) {}
// 交互方法
chooseFile () {}
loadFiles (files) {}
removeFile (file) {}
clear () {}
// 上传处理
upload (file) {}
// 核心ajax发起请求
_post (file) {}
}
复制代码
代码比较简单,这里目标主要是定义默认参数,进行参数合并,而后调用初始化函数
class Uploader {
constructor (option = {}) {
const defaultOption = {
url: '',
// 若无声明wrapper, 默认为body元素
wrapper: document.body,
multiple: false,
limit: -1,
autoUpload: true,
accept: '*',
headers: {},
data: {},
withCredentials: false
}
this.setting = Object.assign(defaultOption, option)
this._init()
}
}
复制代码
这里初始化作了几件事:维护一个内部文件数组uploadFiles
,构建input
标签,绑定input
标签的事件,挂载dom。
为何须要用一个数组去维护文件,由于从需求上看,咱们的每一个文件须要一个状态去追踪,因此咱们选择内部维护一个数组,而不是直接将文件对象交给上层逻辑。
因为逻辑比较混杂,分多了一个函数_initInputElement
进行初始化input
的属性。
class Uploader {
// ...
_init () {
this.uploadFiles = [];
this.input = this._initInputElement(this.setting);
// input的onchange事件处理函数
this.changeHandler = e => {
// ...
};
this.input.addEventListener('change', this.changeHandler);
this.setting.wrapper.appendChild(this.input);
}
_initInputElement (setting) {
const el = document.createElement('input');
Object.entries({
type: 'file',
accept: setting.accept,
multiple: setting.multiple,
hidden: true
}).forEach(([key, value]) => {
el[key] = value;
})''
return el;
}
}
复制代码
看完上面的实现,有两点须要说明一下:
destroy()
的实现,咱们须要在this
属性上暂存input
标签与绑定的事件。后续方便直接取来,解绑事件与去除dom。input
事件函数changeHandler
单独抽离出去也能够,更方便维护。可是会有this指向问题,由于handler里咱们但愿将this指向自己实例,若抽离出去就须要使用bind
绑定一下当前上下文。上文中的changeHanler
,来单独分析实现,这里咱们要读取文件,响应实例choose事件,将文件列表做为参数传递给loadFiles
。
为了更加贴合业务需求,能够经过事件返回结果来判断是中断,仍是进入下一流程。
this.changeHandler = e => {
const files = e.target.files;
const ret = this._callHook('choose', files);
if (ret !== false) {
this.loadFiles(ret || e.target.files);
}
};
复制代码
经过这样的实现,若是显式返回false
,咱们则不响应下一流程,不然拿返回结果||文件列表。这样咱们就将判断格式不符,超出大小限制等等这样的逻辑交给上层实现,响应样式控制。如如下例子:
uploader.on('choose', files => {
const overSize = [].some.call(files, item => item.size > 1024 * 1024 * 10)
if (overSize) {
setTips('有文件超出大小限制')
return false;
}
return files;
});
复制代码
简单实现上文提到的_callHook
,将事件挂载在实例属性上。由于要涉及到单个choose事件结果控制。没有按照标准的发布/订阅模式的事件中心来作,有兴趣的同窗能够看看tiny-emitter的实现。
class Uploader {
// ...
on (evt, cb) {
if (evt && typeof cb === 'function') {
this['on' + evt] = cb;
}
return this;
}
_callHook (evt, ...args) {
if (evt && this['on' + evt]) {
return this['on' + evt].apply(this, args);
}
return;
}
}
复制代码
传进来文件列表参数,判断个数响应事件,其次就是要封装出内部列表的数据格式,方便追踪状态和对应对象,这里咱们要用一个外部变量生成id,再根据autoUpload
参数选择是否自动上传。
let uid = 1
class Uploader {
// ...
loadFiles (files) {
if (!files) return false;
if (this.limit !== -1 &&
files.length &&
files.length + this.uploadFiles.length > this.limit
) {
this._callHook('exceed', files);
return false;
}
// 构建约定的数据格式
this.uploadFiles = this.uploadFiles.concat([].map.call(files, file => {
return {
uid: uid++,
rawFile: file,
fileName: file.name,
size: file.size,
status: 'ready'
}
}))
this._callHook('change', this.uploadFiles);
this.setting.autoUpload && this.upload()
return true
}
}
复制代码
到这里其实还没完善,由于loadFiles
能够用于别的场景下添加文件,咱们再增长些许类型判断代码。
class Uploader {
// ...
loadFiles (files) {
if (!files) return false;
+ const type = Object.prototype.toString.call(files)
+ if (type === '[object FileList]') {
+ files = [].slice.call(files)
+ } else if (type === '[object Object]' || type === '[object File]') {
+ files = [files]
+ }
if (this.limit !== -1 &&
files.length &&
files.length + this.uploadFiles.length > this.limit
) {
this._callHook('exceed', files);
return false;
}
+ this.uploadFiles = this.uploadFiles.concat(files.map(file => {
+ if (file.uid && file.rawFile) {
+ return file
+ } else {
return {
uid: uid++,
rawFile: file,
fileName: file.name,
size: file.size,
status: 'ready'
}
}
}))
this._callHook('change', this.uploadFiles);
this.setting.autoUpload && this.upload()
return true
}
}
复制代码
这里可根据传进来的参数,判断是上传当前列表,仍是单独重传一个,建议是每个文件单独走一次接口(有助于失败时的文件追踪)。
upload (file) {
if (!this.uploadFiles.length && !file) return;
if (file) {
const target = this.uploadFiles.find(
item => item.uid === file.uid || item.uid === file
)
target && target.status !== 'success' && this._post(target)
} else {
this.uploadFiles.forEach(file => {
file.status === 'ready' && this._post(file)
})
}
}
复制代码
当中涉及到的_post
函数,咱们往下再单独实现。
这里都是些供给外部操做的方法,实现比较简单就直接上代码了。
class Uploader {
// ...
chooseFile () {
// 每次都须要清空value,不然同一文件不触发change
this.input.value = ''
this.input.click()
}
removeFile (file) {
const id = file.id || file
const index = this.uploadFiles.findIndex(item => item.id === id)
if (index > -1) {
this.uploadFiles.splice(index, 1)
this._callHook('change', this.uploadFiles);
}
}
clear () {
this.uploadFiles = []
this._callHook('change', this.uploadFiles);
}
destroy () {
this.input.removeEventHandler('change', this.changeHandler)
this.setting.wrapper.removeChild(this.input)
}
// ...
}
复制代码
有一点要注意的是,主动调用chooseFile
,须要在用户交互之下才会触发选择文件框,就是说要在某个按钮点击事件回调里,进行调用chooseFile
。不然会出现如下这样的提示:
写到这里,咱们能够根据已有代码尝试一下,打印upload
时的内部uploadList
,结果正确。
这个是比较关键的函数,咱们用原生XHR
实现,由于fetch
并不支持progress
事件。简单描述下要作的事:
FormData
,将文件与配置中的data
进行添加。xhr
,设置配置中的header、withCredentials,配置相关事件change
等相关状态事件。error
事件onprogress
事件parseSuccess
、parseError
_post (file) {
if (!file.rawFile) return
const { headers, data, withCredentials } = this.setting
const xhr = new XMLHttpRequest()
const formData = new FormData()
formData.append('file', file.rawFile, file.fileName)
Object.keys(data).forEach(key => {
formData.append(key, data[key])
})
Object.keys(headers).forEach(key => {
xhr.setRequestHeader(key, headers[key])
})
file.status = 'uploading'
xhr.withCredentials = !!withCredentials
xhr.onload = () => {
/* 处理响应 */
if (xhr.status < 200 || xhr.status >= 300) {
file.status = 'error'
this._callHook('error', parseError(xhr), file, this.uploadFiles)
} else {
file.status = 'success'
this._callHook('success', parseSuccess(xhr), file, this.uploadFiles)
}
}
xhr.onerror = e => {
/* 处理失败 */
file.status = 'error'
this._callHook('error', parseError(xhr), file, this.uploadFiles)
}
xhr.upload.onprogress = e => {
/* 处理上传进度 */
const { total, loaded } = e
e.percent = total > 0 ? loaded / total * 100 : 0
this._callHook('progress', e, file, this.uploadFiles)
}
xhr.open('post', this.setting.url, true)
xhr.send(formData)
}
复制代码
将响应体尝试JSON反序列化,失败的话再返回原样文本
const parseSuccess = xhr => {
let response = xhr.responseText
if (response) {
try {
return JSON.parse(response)
} catch (error) {}
}
return response
}
复制代码
一样的,JSON反序列化,此处还要抛出个错误,记录错误信息。
const parseError = xhr => {
let msg = ''
let { responseText, responseType, status, statusText } = xhr
if (!responseText && responseType === 'text') {
try {
msg = JSON.parse(responseText)
} catch (error) {
msg = responseText
}
} else {
msg = `${status} ${statusText}`
}
const err = new Error(msg)
err.status = status
return err
}
复制代码
至此,一个完整的Upload类已经构造完成,整合下来大概200行代码多点,因为篇幅问题,完整的代码已放在我的github里。
写好一个类,固然是上手实践一下,因为测试代码并非本文关键,因此采用截图的方式呈现。为了呈现良好的效果,把chrome里的network调成自定义降速,并在测试失败重传时,关闭网络。
这里用node搭建了一个小的http服务器,用multiparty
处理文件接收。
简单的用html结合vue实现了一下,会发现将业务代码跟基础代码分开实现后,简洁明了很多
拖拽上传注意两个事情就是
e.dataTransfer.files
preventDefault()
,防止浏览器弹窗。本文涉及的所有源代码以及测试代码均已上传到github仓库中,有兴趣的同窗可自行查阅。
代码当中还存在很多须要的优化项以及争论项,等待各位读者去斟酌改良:
.zip
压缩包的文件,能够容许更大的体积。