如何合理構造一個Uploader工具類(設計到實現)

作者:Chaser (本文來自作者投稿) 原文地址:https://juejin.im/post/5e5badce51882549652d55c2 源碼地址:https://github.com/impeiran/Blog/tree/master/uploader

前言

本文將帶你基於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,事件綁定  // 應該提供銷毀API  uploader.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 = 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

這裡可根據傳進來的參數,判斷是上傳當前列表,還是單獨重傳一個,建議是每一個文件單獨走一次介面(有助於失敗時的文件追蹤)。

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響應:parseSuccessparseError
_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函數的配置項?
  • 參數是否應該可傳入一個函數動態確定?

源碼地址:https://github.com/impeiran/Blog/tree/master/uploader