天天看點

如何合理構造一個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

。否則會出現以下這樣的提示:

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

寫到這裡,我們可以根據已有代碼嘗試一下,列印

upload

時的内部

uploadList

,結果正确。

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

發起請求 - _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調成自定義降速,并在測試失敗重傳時,關閉網絡。

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

服務端

這裡用node搭建了一個小的http伺服器,用

multiparty

處理檔案接收。

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

用戶端

簡單的用html結合vue實作了一下,會發現将業務代碼跟基礎代碼分開實作後,簡潔明了不少

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

拓展拖拽上傳

拖拽上傳注意兩個事情就是

  1. 監聽drop事件,擷取

    e.dataTransfer.files

  2. 監聽dragover事件,并執行

    preventDefault()

    ,防止浏覽器彈窗。
更改用戶端代碼如下:
如何合理構造一個Uploader工具類(設計到實作)
效果圖GIF
如何合理構造一個Uploader工具類(設計到實作)

優化與總結

本文涉及的全部源代碼以及測試代碼均已上傳到github倉庫中,有興趣的同學可自行查閱。

代碼當中還存在不少需要的優化項以及争論項,等待各位讀者去斟酌改良:

  • 檔案大小判斷是否應該結合到類裡面?看需求,因為有時候可能會有根據

    .zip

    壓縮包的檔案,可以允許更大的體積。
  • 是否應該提供可重寫ajax函數的配置項?
  • 參數是否應該可傳入一個函數動态确定?
  • ...
源碼位址:https://github.com/impeiran/Blog/tree/master/uploader