preface

This article will take you through ES6 based object-oriented, using native JS framework, from design to code to implement a Uploader base class, and then put into use. In this article, you can see how a utility class, Lib, can be properly constructed based on your requirements in general.

Requirements describe

I’m sure many of you have used/written upload logic to create an input[type=file] tag, listen for the onchange event, and add it to FormData to initiate the request.

However, if you want to introduce an open source tool, you feel that it adds a lot of volume and customization is not enough, and you write a lot of redundant code every time you write the upload logic. On different toC businesses, you have to rewrite your own upload component style.

At this point to write a Uploader base class for business components secondary encapsulation, it is necessary.

Let’s analyze the usage scenarios and functions below:

  • After selecting the file, you can upload the file automatically or manually according to the configuration, customize the parameter data transmission, receive and return.
  • You can control the selected files, such as the number of files, formatting, exceeding the size limit and so on.
  • Operations on existing files, such as secondary addition, failed retransmission, deletion, etc.
  • Provide upload status feedback, such as upload progress and upload success/failure.
  • Can be used to expand more functions, such as: drag upload, image preview, large file sharding and so on.

Then, we can roughly design the desired API effect based on the requirements, and derive the internal implementation from the API.

Can be instantiated by configuration

const uploader = new Uploader({
  url: ' '.// A container for automatically adding input tags
  wrapper: null.// Configure the function, multi-select, accept file types, automatic upload, etc
  multiple: true.accept: The '*'.limit: - 1.// Number of files
  autoUpload: false
  
  / / XHR configuration
  header: {}, // For JWT check
  data: {} // Add extra parameters
  withCredentials: false
});
Copy the code

Status/event listening

// Chain calls are more elegant
uploader
  .on('choose', files => {
    // The files used to accept the selection are filtered according to business rules
  })
  .on('change', files => {
    // Trigger hooks for adding and deleting files to update views
    // A state change after the request is made is also triggered
  })
  .on('progress', e => {
    // Return the upload progress
  })
  .on('success', ret => {/ *... * /})
  .on('error', ret => {/ *... * /})
Copy the code

External call method

Here we expose some functions that might be triggered by interaction, such as file selection, manual uploading, etc

uploader.chooseFile();

// Add file function separately for easy expansion
// You can pass in an array of slice files and drag and drop to add files
uploader.loadFiles(files);

// Related operations
uploader.removeFile(file);
uploader.clearFiles()

// Anything that involves dynamically adding dom, event binding
// The destruction API should be provided
uploader.destroy();
Copy the code

At this point, we can design roughly what we want uploader to look like, and then implement it internally according to the API.

Internal implementation

Use ES6 class to build uploader class, the function of the internal method split, use the underscore to identify the internal method.

The following general internal interfaces can then be given:

class Uploader {
  // constructor, when new, merges default configuration
  constructor (option = {}) {}
  // Bind events according to the configuration initialization
  _init () {}
  
  // Bind hooks with triggers
  on (evt) {}
  _callHook (evt) {}
  
  // Interactive methods
  chooseFile () {}
  loadFiles (files) {}
  removeFile (file) {}
  clear () {}
  
  // Upload processing
  upload (file) {}
  // Core Ajax initiates the request
  _post (file) {}
}
Copy the code

Constructor

The code is relatively simple, and the main goal here is to define the default parameters, merge the parameters, and then call the initialization function

class Uploader {
  constructor (option = {}) {
    const defaultOption = {
      url: ' '.// If no wrapper is declared, the body element is used by default
      wrapper: document.body,
      multiple: false.limit: - 1.autoUpload: true.accept: The '*'.headers: {},
      data: {},
      withCredentials: false
    }
    this.setting = Object.assign(defaultOption, option)
    this._init()
  }
}
Copy the code

Initialize -_init

This initialization does a few things: maintain an internal file array called uploadFiles, build the INPUT tag, bind the events of the INPUT tag, and mount the DOM.

Why do we need an array to maintain files? Because we need a state for each file to track, we choose to maintain an array internally rather than hand over file objects directly to the upper-level logic.

Initinputelement initializes the input attribute.

class Uploader {
  // ...
  
  _init () {
    this.uploadFiles = [];
    this.input = this._initInputElement(this.setting);
    // Input onchange event handler
    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;
    })' '
    returnel; }}Copy the code

After looking at the above implementation, there are two things to note:

  1. In consideration ofdestroy()The realization we need inthisProperty transientinputTag and bound events. The subsequent convenience of directly fetching, untying events and removing dom.
  2. In fact theinputEvent functionschangeHandlerSeparate out can also be more convenient maintenance. But we have this pointing problem, because in this handler we want to point this to the instance itself, so if we pull it out we need to use thisbindBind the current context.

ChangeHanler, above, to analyze the implementation separately, here we read the file, respond to the choose event instance, and pass the list of files as a parameter to loadFiles.

To better align with business requirements, an event can be returned as a result to determine whether to interrupt or proceed to the next process.

this.changeHandler = e= > {
  const files = e.target.files;
  const ret = this._callHook('choose', files);
  if(ret ! = =false) {
    this.loadFiles(ret || e.target.files); }};Copy the code

Through such implementation, explicit returns false, if we do not respond to the next process, otherwise take returns the | | file list. So we leave the logic of determining formatting mismatches, exceeding size limits, and so on to the upper level implementation, response style control. For example:

uploader.on('choose', files => {
  const overSize = [].some.call(files, item => item.size > 1024 * 1024 * 10)
  if (overSize) {
    setTips('File out of size limit')
    return false;
  }
  return files;
});
Copy the code

State event bindings and responses

Simply implement the _callHook mentioned above, attaching events to instance properties. Because a single CHOOSE event outcome control is involved. Instead of following the standard publish/subscribe event center, those interested can look at the implementation of 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; }}Copy the code

Load file list – loadFiles

Pass in the file list parameters, determine the number of response events, the second is to package the data format of the internal list, convenient tracking status and corresponding objects, here we want to use an external variable to generate ID, and then according to the autoUpload parameter to choose whether to upload automatically.

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;
    }
    // Build the agreed data format
    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}}Copy the code

It’s not perfect at this point, because loadFiles can be used to add files in other scenarios, so let’s add some type determination code.

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
  }
}
Copy the code

Upload file list – upload

Here, you can determine whether to upload the current list or re-upload a separate list according to the parameters passed in. It is recommended that each file go through the interface separately (to facilitate file tracking in case of failure).

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)
    })
  }
}
Copy the code

We will implement the _POST function separately below.

Interactive method

Here are some methods to supply external operations, the implementation is relatively simple to directly on the code.

class Uploader {
  // ...
  chooseFile () {
    // You need to empty value every time, otherwise the same file will not trigger 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)
  }
  // ...
}
Copy the code

One thing to note is that calling chooseFile actively requires user interaction to trigger the select file box, that is, calling chooseFile in some button click event callback. Otherwise, the following prompt will appear:

Let’s try it out here and print the internal uploadList from our existing code. It works.

Initiate a request – _POST

This is the key function, which we implement using native XHR because FETCH does not support progress events. A brief description of what to do:

  1. buildFormData, combine the file with the configurationdataAdd.
  2. buildxhrTo set the headers and withCredentials in the configuration, and configure related events
  • Onload event: Handles the state of the response, returns data and overwrites the state in the list of files, and responds externallychangeAnd other related status events.
  • Onerror event: Handles error status, overwrites the file list, throws an error, responds externallyerrorThe event
  • Onprogress event: Based on the events returned, calculate the percentage and respond externallyonprogressThe event
  1. Because XHR’s return format is not very friendly, we need to write two additional functions to handle the HTTP response: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 =(a)= > {
    /* Process the response */
    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= > {
    /* Failed to process */
    file.status = 'error'
    this._callHook('error', parseError(xhr), file, this.uploadFiles)
  }
 
  xhr.upload.onprogress = e= > {
    /* Handle upload progress */
    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)
}
Copy the code
parseSuccess

Attempt JSON deserialization of the response body, and return the original text if it fails

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

Again, JSON deserialization, where an error is thrown, logs the error message.

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
}
Copy the code

So far, a complete Upload class has been constructed, and more than 200 lines of code have been integrated. Due to space problems, the complete code has been put in my personal Github.

Testing and Practice

Write a good class, of course, is hands-on practice, because the test code is not the key of this article, so use the way of screenshots. For good results, set The network in Chrome to custom slow down and shut down the network if the test fails to retransmit.

The service side

Node is used to set up a small HTTP server to handle file reception using multiparty.

The client

Simple with HTML combined with VUE implementation, will find that the business code and the basic code separate implementation, concise and clear

Expand drag upload

Two things to notice about drag upload

  1. Obtained by listening to the drop evente.dataTransfer.files
  2. Listen for the dragover event and executepreventDefault()To prevent browser pop-ups.
Change the client code as follows:

Rendering the GIF

Optimization and summary

All the source code and test code involved in this article have been uploaded to github repository, interested students can check.

There are still many optimizations and arguments in the code, waiting for readers to consider the improvement:

  • Should file size judgments be incorporated into classes? Look at the demand, because sometimes there may be grounds.zipCompressed packages of files can allow for greater volume.
  • Should you provide configuration items that can override Ajax functions?
  • Should parameters be dynamically determinable by passing in a function?
  • .