Develop a Picture Uploading Tool in a Modern Way

  es6, Front end, frontend, html5, javascript

We must be familiar with uploading pictures. Recently, I met with the content about the picture upload in my work. I took this opportunity to study it carefully. I went out of control and finally figured out one thing. I have a lot of experience in the process of development, so I plan to write an article to share my experience.
This article will take this name asDoluThis article takes the project of Microsoft as an example to introduce step by step how I built the environment, designed the code and actually developed it. There are many contents, please read them patiently.

Project address:https://github.com/jrainlau/dolu

I. Environmental Construction

This project uses the latestwebpack 2Andes7To carry out development, so the building of environment is essential. However, since this project is relatively simple, the construction of the environment is also very simple, with only onewebpack.config.jsDocuments:

var path = require('path')
 var webpack = require('webpack')
 
 module.exports = {
 Entry: './src/main.js', // for development mode
 // entry: './src/dolu.js', // for production mode
 output: {
 path: path.resolve(__dirname, './dist'),
 publicPath: '/dist/',
 Filename: 'build.js', // for development mode
 // filename: 'index.js', // for production mode
 libraryTarget: 'umd'
 },
 module: {
 rules: [
 {
 test: /\.js$/,
 exclude: /node_modules|dist/,
 use: [
 'babel-loader',
 'eslint-loader'
 ]
 }
 ]
 },
 devServer: {
 historyApiFallback: true,
 noInfo: true,
 host: '0.0.0.0'
 },
 performance: {
 hints: false
 },
 devtool: '#eval-source-map'
 }
 
 if (process.env.NODE_ENV === 'production') {
 module.exports.devtool = '#source-map'
 module.exports.plugins = (module.exports.plugins || []).concat([
 new webpack.DefinePlugin({
 'process.env': {
 NODE_ENV: '"production"'
 }
 }),
 new webpack.optimize.UglifyJsPlugin({
 sourceMap: true,
 compress: {
 warnings: false
 }
 }),
 new webpack.LoaderOptionsPlugin({
 minimize: true
 })
 ])
 }

Considering that the “production mode” is not used many times, no distinction is made.devAndprodMode, but manually annotate the corresponding content to switch.

After defining the import file and output path, I used thebabel-loaderAndeslint-loader. The roles of these two loader are not introduced much, and it is worth noting that they should be cultivated and used.eslintThe habit is excellent, which can effectively reduce code errors and break many bad habits. At the same time in the editor (I use VSCODE) can also be real-time code check, very convenient.

In order to use the latestes7, we also need to configure a root directory.babelrcDocuments:

{
 "presets": [
 ["latest", {
 "es2015": { "modules": false }
 }]
 ],
 "plugins": [
 ["transform-runtime"]
 ]
 }

Readywebpack.config.jsAnd.babelrcLater, we opened itpackage.json, to see what dependencies need to be installed:

"devDependencies": {
 "babel-core": "^6.24.0",
 "babel-loader": "^6.4.1",
 "babel-plugin-transform-runtime": "^6.23.0",
 "babel-polyfill": "^6.23.0",
 "babel-preset-latest": "^6.24.0",
 "cors": "^2.8.3",
 "cross-env": "^3.2.4",
 "eslint": "^3.19.0",
 "eslint-config-standard": "^10.2.1",
 "eslint-loader": "^1.7.1",
 "eslint-plugin-import": "^2.2.0",
 "eslint-plugin-node": "^4.2.2",
 "eslint-plugin-promise": "^3.5.0",
 "eslint-plugin-standard": "^3.0.1",
 "multer": "^1.3.0",
 "webpack": "^2.3.1",
 "webpack-dev-server": "^2.4.2"
 }

midcorsModules andmulterThe module is what we need to build the node server later, and everything else is what we need to run.

Then write down some commands we need in “scripts”:

"scripts": {
 "dev": "cross-env NODE_ENV=development webpack-dev-server --hot",
 "build": "cross-env NODE_ENV=production webpack --progress --hide-modules",
 "server": "node ./server/index.js"
 },

Respectively correspondingDevelopment mode,Production mode,Start the local background server.

Then we create a new one under the root directory.srcDirectory, oneindex.htmlOne/src/main.js. At this time, the directory structure of the whole project is as follows:

├──  index.html
 ├── package.json
 ├── src
 │   └── main.js
 ├── webpack.config.js
 └── .babelrc

So far, our development environment has been built.

Second, functional design

clipboard.png

The basic process and functions are shown in the above figure. We will develop each step in a modular way.

Of course, we cannot be satisfied with such a little function. We need to consider more situations and more possibilities. Expand it. Maybe we can do this:

clipboard.png

For example, we won’t upload the pictures after we get them, and maybe we have to transfer them out.base64For processing or use, perhaps we can upload a bunch of directly provided by a third party.base64Even ..formdata. In addition, we also need to customize the uploading method, or we can choose multiple pictures or something … In addition, there may be many scenes. In order to develop a common component, we need to think about a lot.

Of course, this time our task is relatively simple, the above multi-function is enough for us to play, and below we enter the actual development.

Three, start coding!

In/srcCreate a new one under the directorydolu.jsDocument, this will be the core of our entire project.

First define a class:

class Dolulu {
 constructor (config = {}) {}
 }

Then we will complete the relevant functions of “picture selection” according to the thinking of the brain map in the previous section.

In this class we define a class called_pickFile()We don’t want to be called externally, just asDoluBuilt-in method.

_pickFile () {
 const picker = document.querySelector(this.config.picker)
 
 picker.addEventListener('change', () => {
 if (!  picker.files.length) {
 return
 }
 const files = [...picker.files]
 
 if (files.length > this.config.quantity) {
 throw new Error('Out of file quantity limit!'  )
 }
 
 /*
 * At this time, we have already got the file array files and can transcode it immediately.
 The * _transformer () function is another private method for format transcoding
 */
 this._transformer(files)
 
 /*
 * Add this line to repeat the selection of the same picture.
 */
 picker.value = null
 })
 }

Then write an initialization method to letDoluThe instance can automatically open the document selection function:

_init () {
 if (this.config.picker) {
 return this._pickFile()
 }
 }

As long as inconstructorJust call this method inside.

After selecting the picture, we will transcode it. In order to better organize our code, we packaged this “image to base64” function into a module. In/srcNew under directoryfileToBase64.js

const fileToBase64 = (file) => {
 const reader = new FileReader()
 
 reader.readAsDataURL(file)
 
 return new Promise((resolve) => {
 reader.addEventListener('load', () => {
 const result = reader.result
 resolve(result)
 })
 })
 }
 
 export default fileToBase64

The code content is only 15 lines. Its input is a picture file and its output is a series of base64 codes. Return a Promise for us to use later.async/awaitGrammar.

By the same token, we will build a new one.base64ToBlob.jsFile, in order to realize the input is base64, the output is formdata function:

const base64ToBlob = (base64) => {
 const byteString = atob(base64.split(',')[1])
 const mimeString = base64.split(',')[0].split(':')[1].split(';'  )[0]
 const ab = new ArrayBuffer(byteString.length)
 const ia = new Uint8Array(ab)
 for (let i = 0, len = byteString.length;   i < len;  i += 1) {
 ia[i] = byteString.charCodeAt(i)
 }
 
 let Builder = window.WebKitBlobBuilder || window.MozBlobBuilder
 let blobUrl
 
 if (Builder) {
 const builder = new Builder()
 builder.append(ab)
 blobUrl = builder.getBlob(mimeString)
 } else {
 blobUrl = new window.Blob([ab], { type: mimeString })
 }
 
 const fd = new FormData()
 fd.append('file', blobUrl)
 
 return fd
 }
 
 export default base64ToBlob

Next we use these two modules to build our_transformer()Methods:

_transformer (files, manually = false) {
 files.forEach(async (file, index) => {
 if (isObject(file)) {
 if (!  /\/(?  :jpeg|png|gif)/i.test(file.type)) {
 return
 }
 
 const dataUrl = await fileToBase64(file)
 const formData = await base64ToBlob(dataUrl)
 
 if (this.config.autoSend || manually) {
 this._uploader(formData, index)
 }
 }
 })

As you can see, this method traverses the entire files array, ensures that its file type is a picture through filtering, and then continuously transcodes to generate formdata format data, which is passed in as a parameter_uploader()In the method. In addition, in order to facilitate expansion and use, the subscript of the picture was also introduced. The subscript of the picture can easily let the user know in the upload function “which picture is being processed now”.

_upload()The function will be called directlyDoluThe upload method defined in the example will be described later.

By this time, we have already completed several “basic functions” of the first picture in the previous section, which is almost the same as the tutorials that have made a lot of money outside. Don’t worry, we will immediately enter the development of extended functions.

Fourthly, the complete base64 string array is output outwards

We are looking back at the previous section_transformer()Function. This function accepts anarrayFor internal use.forEach()The method traverses each file and transcodes it. In order to output the complete transcoded array, the key step is how to ensure that the transcoding has been completed. Starting with the simplest idea, atforEachIs it okay to throw the array directly outside the loop? For example:

_transformer (files, manually = false) {
 files.forEach(async (file, index) => {
 if (isObject(file)) {
 if (!  /\/(?  :jpeg|png|gif)/i.test(file.type)) {
 return
 }
 
 const dataUrl = await fileToBase64(file)
 const formData = await base64ToBlob(dataUrl)
 
 this.dataUrlArr.push(dataUrl)
 
 if (this.config.autoSend || manually) {
 this._uploader(formData, index)
 }
 }
 })
 
 this.config.getDataUrls(this.dataUrlArr)
 
 return this
 }

There seems to be no problem, but in the actual test, incomingthis.config.getDataUrlshit the targetdataUrlArrFirst, it will be an empty array, and data will not be available for a while. In order to verify this conclusion, we are/srcCreate a new file under the directorymain.js, write the following:

import Dolu from './dolu'
 
 const dolu = new Dolu({
 picker: '#picker',
 getDataUrls (arr) {
 console.info(arr)
 arr.forEach((dataUrl) => {
 console.log(dataUrl)
 })
 }
 })

Run it and find the output is as follows:

clipboard.png

There is only one empty array, andforEach()The loop did not print anything. This example is not intuitive, let’s turn off the developer tool and then turn it back on to see what will happen:

clipboard.png

Just by reopening the developer tool, I found that the empty array just now has become an array with contents, which is very strange.

In fact, the reason is very simple, because_transformer()InternalforEach()Loop, does not guarantee that the picture has been transcoded, this involves the knowledge of browser task queue (understanding here may be wrong, welcome to point out), so we will not discuss it here.

Then we can onlyWaitAfter transcoding the picture, callthis.config.getDataUrls()Methods. To achieve this goal, we have many ways, the simplest and cruelest of which is to make use of it.setInterval()Polling, whendataUrlArr.length === files.lengthBut this is not elegant at all. Can we have the function send anoticeWhen.push()Method is executed and judged when it succeeds.dataUrlArr.length =? = files.lengthIf the conditions are met, the corresponding treatment will be carried out.

At this time, we can consider using es6 to add syntaxProxyTo solve. AboutProxyThe use of can refer to another article of mine.
“Implementation of a Data Binding Instance Using ES6′ s New Feature Proxy”, and then let’s get down to business!

V. useProxyImplement data binding

In/srcUnder the directoryutils.js, we add a new tool method:

function proxier (props, callback) {
 const waitProxy = new Proxy(props, {
 set (target, property, value) {
 target[property] = value
 callback(target, property, value)
 return true
 }
 })
 return waitProxy
 }

Go back todolu.jsFile, rewrite it_transformer()Methods:

_transformer (files, manually = false) {
 const dataUrlArrProxy = proxier(this.dataUrlArr, (target, property, value) => {
 if (property === 'length') {
 if (target.length === files.length) {
 this.config.getDataUrls(this.dataUrlArr)
 }
 }
 })
 
 files.forEach(async (file, index) => {
 if (isObject(file)) {
 if (!  /\/(?  :jpeg|png|gif)/i.test(file.type)) {
 return
 }
 
 const dataUrl = await fileToBase64(file)
 const formData = await base64ToBlob(dataUrl)
 
 dataUrlArrProxy.push(dataUrl)
 
 if (this.config.autoSend || manually) {
 this._uploader(formData, index)
 }
 }
 })
 
 return this
 }

In this way, after each transcoding, we will callProxy arraydataUrlArrProxyhit the target.push()Method, the proxy array will automatically determine at this timetarget.length =? = files.lengthThen call the corresponding method.

Try to run it and find that the result meets the expectation. In the same way, we canformDataArrAlso set up a proxy array to implement the outward throwformdataThe purpose of the array.

VI. Server Construction

Having finished the picture selection and transcoding on the front end, it is time for us to build a background server to testformdataWhether the format upload picture is valid.

Into the root directory/serverFolder, we create a new one/imgsDirectory and oneindex.jsThe document reads as follows:

const express = require('express')
 const multer = require('multer')
 const cors = require('cors')
 
 const app = express()
 app.use(express.static('./public'))
 app.use(cors())
 
 app.listen(process.env.PORT || 8888)
 console.log('Node.js Ajax Upload File running at: http://0.0.0.0:8888')
 
 app.post('/upload', (req, res) => {
 const store = multer.diskStorage({
 destination: './server/imgs'
 })
 const upload = multer({
 storage: store
 }).any()
 
 upload(req, res, function (err) {
 if (err) {
 console.log(err)
 return res.end('Error')
 } else {
 console.log(req.body)
 req.files.forEach(function (item) {
 console.log(item)
 })
 res.end('File uploaded')
 }
 })
 })

The server will run locally8888Port, viapostMethod tolocalhost:8888/upload, and the picture will be saved toserver/imgsUnder the directory.

Go back todolu.js, we write a._uploader()Method, which calls theconfigThe user-defined settings inside call the specific upload method in the settings:

_uploader (formData, index) {
 this.config.uploader(formData, index)
 }

Inmain.jsWe useaxiosAs an upload tool:

const dolu = new Dolu({
 picker: '#picker',
 autoSend: true,
 uploader (data, index) {
 axios({
 method: 'post',
 url: 'http://0.0.0.0:8888/upload',
 data: data,
 onUploadProgress: (e) => {
 const percent = Math.round((e.loaded * 100) / e.total)
 console.log(percent, index)
 }
 }).then((res) => {
 console.log(res)
 }).catch((err) => {
 console.log(err)
 })
 }
 })

The exciting moment has come, let’s test it!

Seven, the actual operation test

Open theNetwork, literally choose a few pictures to upload, see how the effect is:

clipboard.png

clipboard.png

Click to see what was sent:

clipboard.png

As shown in the above figure, it is a formdata data data. Open./server/imgsDirectory, we should be able to see three files:

clipboard.png

clipboard.png

Upload successful! But also meets our requirement of “the binary format uploaded by formdata”.

Viii. follow-up work

So far, we have basically completed our entire picture upload component, and there are several details that need to be paid attention to, such as the name of the sent picture, the compression of the picture through canvas, etc. these pits will be filled in later when available. More perfect code can be directly viewedMy warehouse.

Thank you for your reading, and welcome to put forward suggestions for criticism and guidance on the content of the article!


References:

  1. Mobile Front End-Picture Compression Upload Practice

  2. HTML5 to Upload Pictures

  3. How to detect input type=file “change” for the same file?