zoukankan      html  css  js  c++  java
  • 用现代化的方式开发一个图片上传工具

    对于图片上传,大家一定不陌生。最近工作中遇到了关于图片上传的内容,借此机会认真研究了一番,遂一发不可收拾,最后琢磨了一个东西出来。在开发的过程中有不少的体会,于是打算写一篇文章分享一下心得体会。
    本文将会以这个名为Dolu的项目为例子,一步步介绍我是如何进行环境搭建、代码设计以及实际开发的。内容较多,还请耐心读完。

    项目地址:https://github.com/jrainlau/dolu

    一、环境搭建

    本项目使用目前最新的webpack 2es7进行开发,所以环境的搭建必不可少。但是由于这个项目比较简单,所以环境的搭建也是非常简单的,只有一个webpack.config.js文件:

    var path = require('path')
    var webpack = require('webpack')
    
    module.exports = {
      entry: './src/main.js', // 开发模式用
      // entry: './src/dolu.js', // 生产模式用
      output: {
        path: path.resolve(__dirname, './dist'),
        publicPath: '/dist/',
        filename: 'build.js',  // 开发模式用
        // filename: 'index.js',  // 生产模式用
        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
        })
      ])
    }

    考虑到“生产模式”使用的次数不多,所以并没有区分devprod模式,而是手动注释对应的内容进行切换。

    定义好入口文件和输出路径后,我使用了babel-loadereslint-loader。这两个loader的作用就不多作介绍了,值得注意的是养成使用eslint的习惯是极好的,能够有效减少代码的错误,并且能够改掉很多坏习惯。同时在编辑器里(我用VSCODE)中也能够实时进行代码检查,非常方便。

    为了使用最新的es7,我们也需要在根目录下配置一份.babelrc文件:

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

    配置好了webpack.config.js.babelrc以后,我们打开package.json,来看看需要安装的依赖都有哪些:

      "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"
      }

    当中的cors模块和multer模块为我们之后搭建node服务器需要用的,其他都是运行所需。

    然后在"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"
      },

    分别对应开发模式生产模式启动本地后台服务器

    然后我们在根目录下新建一个src目录,一个index.html,一个/src/main.js。这时候整个项目的目录结构如下:

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

    至此,我们的开发环境已经搭建完毕。

    二、功能设计

    基本的流程及功能如上图所示,其中的每一步我们都将以模块的方式进行开发。

    当然,我们不能满足于这么一点点的功能,我们需要考虑更多的情况更多的可能,扩展一下,也许我们可以这么做:

    比如我们在获取图片之后先不进行上传,也许我们还要对转出来的base64进行处理或使用,也许我们能够直接上传一堆由第三方提供的base64甚至formdata。另外我们还需要对上传的方法进行自定义,又或者可以选择多张图片什么的……除此之外,可能还有许许多多的场景,为了开发一个通用的组件,我们需要思考的地方实在有很多很多。

    当然,这一次我们的任务比较简单,上面这么多功能已经够我们玩的了,下面我们进入实际的开发。

    三、开始coding!

    /src目录下新建一个dolu.js文件,这将会是我们整个项目的核心。

    首先定义一个类:

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

    然后我们按照上一节脑图的思路,先完成“图片选取”相关的功能。

    在这个类里面我们定义一个名为_pickFile()的私有方法,这个方法我们不希望被外部调用,只是作为Dolu内置的方法。

      _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!')
          }
    
          /*
           * 这时候我们已经拿到了文件数组files,可以马上进行转码
           * _transformer()函数是另一个私有方法,用于格式转码
           */
          this._transformer(files)
          
          /*
           * 加入这一行以实现重复选中同一张图片
           */
          picker.value = null
        })
      }

    然后写一个初始化的方法,让Dolu实例能够自动开启文件选取功能:

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

    只要在constructor里面调用这个方法就可以了。

    选择完图片,我们就要对它进行转码了。为了更好地组织我们的代码,我们把这个“图片转成base64”的函数封装成一个模块。在/src目录下新建fileToBase64.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
    

    代码内容只有15行,其输入为一个图片文件,输出为一串base64编码。返回一个Promise方便接下来我们使用async/await语法。

    同样的道理,我们新建一个base64ToBlob.js文件,以实现输入为base64,输出为formdata的功能:

    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
    

    接下来我们利用这两个模块,构建我们的_transformer()方法:

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

    可以看到,这个方法会遍历整个files数组,通过筛选保证其文件类型为图片,然后连续转码生成formdata格式数据,作为参数传入_uploader()方法中。另外为了方便扩展和使用,同时传入了图片的下标。图片的下标能够方便在上传函数中让用户知道“现在是第几张图片被处理”。

    _upload()函数将会直接调用Dolu实例中所定义的上传方法,这个稍后再述。

    到这里,我们已经完成了上一节第一张图片的几个“基本功能”了,和外面一捞一大把的教程相差无几。别急,我们马上进入对扩展功能的开发。

    四、实现向外输出完整的base64字符串数组

    我们重新把目光投向上一节的_transformer()函数。这个函数接受一个数组,在内部使用.forEach()方法遍历每一个文件,对它进行转码处理。为了向外输出完整的转码后的数组,关键的步骤在于如何确定转码已经完成了。从最简单的想法开始,在forEach循环体的外部直接把数组抛出去行不行?比如这样:

      _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
      }

    看起来没有问题,但是在实际的测试中,传入this.config.getDataUrls中的dataUrlArr首先会是一个空数组,过一会儿才会有数据。为了验证这个结论,我们在/src名录下新建一个文件main.js,写入如下内容:

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

    运行一下,发现输出结果如下:

    只有一个空数组,而且forEach()循环并没有打印出任何东西。这个例子不直观,我们现在把开发者工具关掉,然后重新打开,看看会发生什么:

    仅仅是重新打开开发者工具,就发现刚才的空数组变成了一个有内容的数组,特别奇怪。

    其实原因也很简单,因为_transformer()内部的forEach()循环,并不能保证图片已经转码完毕,这涉及到浏览器任务队列的知识(此处理解可能有误,欢迎指出),在这里就不展开讨论了。

    那么我们只能等待图片转码完毕,才调用this.config.getDataUrls()方法。要实现这个目的,我们有许多种方法,最简单粗暴的就是利用setInterval()进行轮询,当dataUrlArr.length === files.length,则立即调用,但是这种做法一点儿也不优雅。我们能不能让函数发送一个通知,当.push()方法执行并成功的时候就判断dataUrlArr.length =?= files.length,若条件符合则进行相应的处理。

    这时候我们可以考虑使用es6新增语法Proxy来解决。关于Proxy的使用可以查阅我的另外一篇文章
    《使用ES6的新特性Proxy来实现一个数据绑定实例》,然后我们一起来步入正题吧!

    五、使用Proxy实现数据绑定

    /src目录下的utils.js里,我们加入一个新的工具方法:

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

    回到dolu.js文件,改写一下_transformer()方法:

      _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
      }

    这样,我们每一次转码过后,都会调用代理数组dataUrlArrProxy中的.push()方法,这时候代理数组就会自动判断target.length =?= files.length然后调用相应的方法。

    尝试运行一下,发现结果符合预期。同样的方式,我们可以为formDataArr也设置一个代理数组,以实现向外抛出formdata数组的目的。

    六、服务器搭建

    把前端这边的图片选取、图片转码都已经做完了,那么我们是时候搭建一个后台服务器,去测试以formdata格式上传图片是否有效了。

    进入根目录下的/server文件夹,我们新建一个/imgs目录以及一个index.js文件,内容如下:

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

    该服务器将会运行于本地8888端口,通过post方法发送到localhost:8888/upload,然后图片会保存到server/imgs目录下。

    回到dolu.js,我们写一个_uploader()方法,该方法会调用config里面的自定义设置,调用设置中具体的上传方法:

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

    main.js中,我们使用axios作为上传的工具:

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

    激动人心的时刻来了,我们来测试一下吧!

    七、实际运行测试

    打开开发者工具当中的Network,随便选几张图片进行上传,看看效果如何:

    点击去看看发送的是什么东西:

    如上图所示,是一个formdata数据。打开./server/imgs目录,我们应该就能看到三个文件了:

    上传成功!而且符合我们以“formdata上传的二进制格式”的需求。

    八、后续工作

    至此已经基本完成了我们整个图片上传组件,还有几个细节需要注意,比如所发送图片的命名、对图片通过canvas进行压缩等等,这些坑以后有空再填。比较完善的代码可以直接查看我的仓库

    感谢您的阅读,欢迎对文章内容提出批评指导建议!

  • 相关阅读:
    类型:NodeJs;问题:默认IE的编码为utf8;结果:IE不能自动选择UTF-8编码解决办法
    类型:Java;问题:eclipse配置maven;结果:eclipse配置maven
    类型:Oracle;问题:oracle 时间加减;结果:ORACLE 日期加减操作
    类型:Ajax;问题:ajax调用ashx参数获取不到;结果:ashx文件获取$.ajax()方法发送的数据
    类型:JQuery;问题:ajax调用ashx文件;结果:ashx文件怎么获取$.ajax()方法发送的json数据
    类型:Jquery;问题:jquery调用后台带参数方法;结果:利用JQuery的$.ajax()可以很方便的调用asp.net的后台方法。
    问题:asp.net 点击button按钮调到页面顶部;结果:asp.net点击一个按钮,使页面跳转到本面页上的指定位置
    问题:ClientIDMode属性;结果:ASP.NET 4.0的ClientIDMode属性
    金木水火土五行查询表
    并发编程的基础
  • 原文地址:https://www.cnblogs.com/libin-1/p/6727289.html
Copyright © 2011-2022 走看看