前言

本文将带你基于ES6的面向对象,脱离框架使用原生JS,从设计到代码实现一个Uploader基础类,再到实际投入使用。通过本文,你可以了解到一般情况下根据需求是如何合理构造出一个工具类lib。

需求描述

相信很多人都用过/写过上传的逻辑,无非就是创建input[type=file]标签,监听onchange事件,添加到FormData发起请求。

但是,想引入开源的工具时觉得增加了许多体积且定制性不满足,每次写上传逻辑又会写很多冗余性代码。在不同的toC业务上,还要重新编写自己的上传组件样式。

此时编写一个Uploader基础类,供于业务组件二次封装,就显得很有必要。

下面我们来分析下使用场景与功能:

  • 选择文件后可根据配置,自动/手动上传,定制化传参数据,接收返回。
  • 可对选择的文件进行控制,如:文件个数,格式不符,超出大小限制等等。
  • 操作已有文件,如:二次添加、失败重传、删除等等。
  • 提供上传状态反馈,如:上传中的进度、上传成功/失败。
  • 可用于拓展更多功能,如:拖拽上传、图片预览、大文件分片等。

然后,我们可以根据需求,大概设计出想要的API效果,再根据API推导出内部实现。

可通过配置实例化

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 => {/*...*/})

外部调用方法

这里主要暴露一些可能通过交互才触发的功能,如选择文件、手动上传等

uploader.chooseFile();// 独立出添加文件函数,方便拓展// 可传入slice大文件后的数组、拖拽添加文件uploader.loadFiles(files);// 相关操作uploader.removeFile(file);uploader.clearFiles()// 凡是涉及到动态添加dom,事件绑定// 应该提供销毁APIuploader.destroy();

至此,可以大概设计完我们想要的uploader的大致效果,接着根据API进行内部实现。

内部实现

使用ES6的class构建uploader类,把功能进行内部方法拆分,使用下划线开头标识内部方法。

然后可以给出以下大概的内部接口:

class Uploader {  // 构造器,new的时候,合并默认配置  constructor (option = {}) {}  // 根据配置初始化,绑定事件  _init () {}    // 绑定钩子与触发  on (evt) {}  _callHook (evt) {}    // 交互方法  chooseFile () {}  loadFiles (files) {}  removeFile (file) {}  clear () {}    // 上传处理  upload (file) {}  // 核心ajax发起请求  _post (file) {}}

构造器 - constructor

代码比较简单,这里目标主要是定义默认参数,进行参数合并,然后调用初始化函数

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()  }}

初始化 - _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;  }}

看完上面的实现,有两点需要说明一下:

  1. 为了考虑到destroy()的实现,我们需要在this属性上暂存input标签与绑定的事件。后续方便直接取来,解绑事件与去除dom。
  2. 其实把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;  }}

装载文件列表 - loadFiles

传进来文件列表参数,判断个数响应事件,其次就是要封装出内部列表的数据格式,方便追踪状态和对应对象,这里我们要用一个外部变量生成id,再根据autoUpload参数选择是否自动上传。

let uid = 1class 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

这里可根据传进来的参数,判断是上传当前列表,还是单独重传一个,建议是每一个文件单独走一次接口(有助于失败时的文件追踪)。

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,结果正确。

发起请求 - _post

这个是比较关键的函数,我们用原生XHR实现,因为fetch并不支持progress事件。简单描述下要做的事:

  1. 构建FormData,将文件与配置中的data进行添加。
  2. 构建xhr,设置配置中的header、withCredentials,配置相关事件
  • onload事件:处理响应的状态,返回数据并改写文件列表中的状态,响应外部change等相关状态事件。
  • onerror事件:处理错误状态,改写文件列表,抛出错误,响应外部error事件
  • onprogress事件:根据返回的事件,计算好百分比,响应外部onprogress事件
  1. 因为xhr的返回格式不太友好,我们需要额外编写两个函数处理http响应: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)}
parseSuccess

将响应体尝试JSON反序列化,失败的话再返回原样文本

const parseSuccess = xhr => {  let response = xhr.responseText  if (response) {    try {      return JSON.parse(response)    } catch (error) {}  }  return response}
parseError

同样的,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实现了一下,会发现将业务代码跟基础代码分开实现后,简洁明了不少

拓展拖拽上传

拖拽上传注意两个事情就是

  1. 监听drop事件,获取e.dataTransfer.files
  2. 监听dragover事件,并执行preventDefault(),防止浏览器弹窗。
更改客户端代码如下:
效果图GIF

优化与总结

本文涉及的全部源代码以及测试代码均已上传到github仓库中,有兴趣的同学可自行查阅。

代码当中还存在不少需要的优化项以及争论项,等待各位读者去斟酌改良:

  • 文件大小判断是否应该结合到类里面?看需求,因为有时候可能会有根据.zip压缩包的文件,可以允许更大的体积。
  • 是否应该提供可重写ajax函数的配置项?
  • 参数是否应该可传入一个函数动态确定?
  • ...

作者:Chaser

实现option上下移动_ES6原生实战Uploader工具类(从设计到实现)相关推荐

  1. webbrowser实现input tab事件_如何合理构造一个Uploader工具类(设计到实现)

    作者:Chaser (本文来自作者投稿) 原文地址:https://juejin.im/post/5e5badce51882549652d55c2 源码地址:https://github.com/im ...

  2. 原生JDBC和工具类的基本实现

    工具: IDEA MySQL8.0或之前版本 文章目录 JDBC原理 JDBC入门案例 JDBC的API详解 JDBC增删改查操作 JDBC工具类 JDBC是Java访问数据库的标准规范,可以为不同的 ...

  3. java隐藏字符_Java原生隐藏字符-工具类

    package com.seesun2012.common.util; /** 隐藏字符-工具类 @author seesun2012@163.com */ public class HiddenCh ...

  4. 盘点 2022 云原生实战峰会产品发布

    今天,2022 云原生实战峰会正式开幕! 大会上,阿里云正式发布业内首个多活领域开源项目 AppActive,AppActive 建立在阿里巴巴大规模生产应用系统实践之上,经过 9 年的演进,内部实践 ...

  5. 看云原生实战就来这里,侬晓得伐?

    云原生从最开始的互联网行业起步,已经逐渐扩散到金融.政务.物流等各行各业,形成了一种千行百业拥抱云原生的现象. 云原生的技术和产品帮助企业把基本服务先打通,提升了技术效率,降低了资源成本,提高了研发效 ...

  6. 邀请函|2021 云原生实战峰会,邀请您免费现场参会报名

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XSoZZJ6j-1638454968318)(https://p3-juejin.byteimg.com/tos-cn- ...

  7. 2020 阿里云原生实战峰会即将开幕 云原生落地的正确姿势

    来源|阿里巴巴云原生公众号 2020 的各种意外,让数字创新成为应对不确定性最为确定的发展动能.一方面,数字化成为提升企业核心竞争力的关键:另一方面,云原生取代传统 IT 成为企业数字化最短路径,加速 ...

  8. 2020 阿里云原生实战峰会开幕 云原生落地的正确姿势

    简介:让数字创新成为应对不确定性最为确定的发展动能. 来源|阿里巴巴云原生公众号 2020 的各种意外,让数字创新成为应对不确定性最为确定的发展动能.一方面,数字化成为提升企业核心竞争力的关键:另一方 ...

  9. 尚硅谷云原生实战视频教程发布

    Linux基金会与edX联合发布的<2021开源工作报告>显示:云和容器技术首次超越了Linux.云原生不仅颠覆了软件行业的技术栈,其背后的招聘需求也在不断激增.为了进一步推进云原生技术的 ...

最新文章

  1. 【Ghost Blog】如何给Ghost Blog添加背景音乐
  2. JavaScript实现存储HTML字符串
  3. 第五十三篇、OC利用AFN上传视频到服务器
  4. MSSQL同时操作数据加锁问题
  5. java西游记壹_岩浆数码再现手机RPG游戏--西游记壹
  6. echarts代码格式化_echarts水球图格式化Format使用
  7. Java演示手机发送短信验证码功能实现
  8. 106. 从中序与后序遍历序列构造二叉树
  9. Java中获得了方法名称的字符串,怎么样调用该方法
  10. word度量单位无效_ABBYY FineReader 12的具体使用方法和word排版设置
  11. 试验笔记 - 使用7-ZIP压缩来减小APK安装包体积
  12. AdventNet 网管管理平台
  13. ubuntu18.04安装nvidia显卡驱动的正确方法
  14. python3传智播客_3.Ubuntu安装以及配置(传智播客.黑马程序员python学科)
  15. 基于IR2136的三相电机控制
  16. java命令行打包war_手工命令行打包java工程为war包
  17. 编程小知识之 struct 构造函数(C#)
  18. 贵州支教之第二天(11月8日)
  19. Ubuntu 20.04添加临时/永久路由
  20. C语言函数递归—经典递归问题

热门文章

  1. Leetcode每日一题:17.letter-combinations-of-a-phone-number(电话号码的字母组合) 0ms通过
  2. 花书+吴恩达深度学习(二五)直面配分函数(CD, SML, SM, RM, NCE)
  3. 一文了解Innodb中的锁
  4. ES5实现ES6的一些方法-call,bind,is,promise
  5. Windows与Linux之间的文件自动同步
  6. jira服务断电导致索引文件损坏问题解决
  7. html管道符需要转义么,为什么String.split需要管道分隔符进行转义?
  8. oracle获取行的值给产量,递归oracle sql识别值
  9. JSR 168 and Portlet
  10. Linux FTP服务安装与账号设置