ZooMze's World

vuePress-theme-reco ZooMze    2018 - 2021
ZooMze's World ZooMze's World

Choose mode

  • dark
  • auto
  • light
主页
分类
  • 基础
  • 备忘
  • 教程
  • 扩展
  • 框架
  • 组件
  • 季度分享
标签
时光轴
GitHub
author-avatar

ZooMze

35

Article

23

Tag

主页
分类
  • 基础
  • 备忘
  • 教程
  • 扩展
  • 框架
  • 组件
  • 季度分享
标签
时光轴
GitHub
  • 2021-Q2 技术分享与探讨

    • 文件分批异步上传
      • 文件的类型校验
        • 色码的转换函数
          • 将图片添加到canvas并输出为base64
            • 事件捕获, 事件冒泡
              • target、currentTarget
              • 快问快答
              • 阻止事件传播 和 阻止默认行为
              • 事件委托(冒泡的应用)
            • 百度地图 BaiduMap GIF Marker(Icon)类
              • 1. 渲染并到目标元素
              • 2. 添加用于区分的属性
              • 3. 在生命周期中渲染

          2021-Q2 技术分享与探讨

          vuePress-theme-reco ZooMze    2018 - 2021

          2021-Q2 技术分享与探讨


          ZooMze 2021-07-02 异步上传

          # 文件分批异步上传

          假设每次上传文件至服务器就调用一次接口, 如果需要批量上传N个文件则需要调用N次接口, 并获取返回值:

          实现上述需求非常简单, 首先 <el-uploader> 中将文件选中, 然后在 file-list 中遍历调用上传接口即可实现批量上传

          这样会伴随一个问题: 这些文件何时全部上传完毕?

          解决的方式也很简单, 使用到了 Promise.all() 函数

          Promise.all() 接受一个数组, 数组内为需要批量调用的 Promise, 全部调用完成后进入.then()回调, 此时执行上传全部完成的逻辑即可

          上述解决办法在文件数量在小批量范围内(数量级为百)是完全可行的, 然而新的问题是: 如果一次上传数量级为千甚至更多的文件会发生什么?

          请求将会发送失败, 错误信息: 'net::ERR_INSUFFICIENT_RESOURCES' (网络错误, 资源不足), 当然后台也有一定的并发限制等问题

          为了上传这几千个文件, 需要结合 Promise.all() 和 递归 来解决

          由于文件数量庞大, 所以操作逻辑设计为先选择好文件再统一点击上传, 而不是选择文件后立即自动上传, 减少服务器压力

          上代码:































































           
           
           
           
           





































            // template d
          
            <el-upload
              :file-list="fileList"
              :action="''"
              :on-change="handleChange"
              :on-remove="handleRemove"
              multiple
              :limit="5000"
              :auto-upload="false"
            >
              <el-button size="small" type="primary">点击选择文件</el-button>
            </el-upload>
            <el-button v-if="fileList.length > 0" @click="submitFiles">立即上传</el-button>
          
            
            // 引入API
            import API from '@/...'
            
            // data
            data() {
              return {
                importForm: {
                  files: [], // 实际在form表单中暂存的已上传的文件url数组
                }
                fileList: [] // upload组件中保存的file数组
              }
            }
          
            // methods
            // 文件选择发生变化时调用, 使用钩子函数控制更新fileList, 同时也可以在此处理选择的文件
            handleChange((file, fileList) {
              // 这里以图片类型为例
              if (file.raw.type !== 'image/jpeg' && file.raw.type !== 'image/png' && file.raw.type !== 'image/bmp') {
                this.$message.warning('只支持 PNG/JPG/BMP 格式的图片')
                fileList.pop()
                return
              }
              // 文件大小限制, 超出限制的图片将自动移除
              if (file.size > 5242880) {
                this.$message.info('上传的图片不能大于5MB')
                fileList.pop()
              } else {
                // 确认无误则更新fileList
                this.fileList = fileList
              }
            },
            handleRemove(file, fileList) {
              this.fileList = fileList
            },
            submitFiles() {
              this.uploadBySteps(10, 0) // 从第0批开始, 一次上传10个文件
            },
            // 分布上传函数(递归调用)
            // step: 每一批次上传的文件个数
            // index: 当前批次的序列, 表示当前加载第(index+1)批次的文件
            uploadBySteps(step, index) {
              const currentLoadArray = [] // 当前批次的Promise数组
              for ( let i = 0; i < step; i++) {
                this.fileList[step * index + i] && currentLoadArray.push(this.uploadSingleFile(this.fileList[step * index + i]))
              }
          
              Promise.all(currentLoadArray).then(() => {
                if (this.fileList[(index + 1) * step]) { // 递归调用
                  this.uploadBySteps(step, index + 1)
                }
              })
            },
            // 上传单个文件, 此处的file为upload组件中直接获取的file对象
            uploadSingleFile(file) {
              return new Promise((resolve, reject) => {
                const formData = new FormData()
                formData.append('label', 'nice')
                formData.append('file', file.raw)
                API.uploadFile(formData) // 这一步调用后台提供的上传接口即可
                  .then(res => {
                    // 调用成功即向importForm.files中添加url
                    this.importForm.files.push({
                      url: res.data
                    })
                    // 当importForm.files中的url数量和fileList中一致时, 则上传全部完成了, 执行后续操作即可
                    if (this.importForm.files.length == this.fileList.length) {
                      API.filesInsert(this.importForm).then(res => {
                        this.globalLoading.close()
                        if (res.status === 200) {
                          this.$message.success('全部上传成功, 数据已导入!')
                          // 如果是在dialog中时, 最好将数据清空
                          this.importForm.files = []
                          this.fileList = []
                        } else {
                          // 导入数据失败时也需要清空files
                          this.importForm.files = []
                          this.fileList = []
                        }
                      })
                    }
                    resolve('success')
                  })
                  .catch(error => {
                    reject(error)
                  })
              })
            }
          
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          16
          17
          18
          19
          20
          21
          22
          23
          24
          25
          26
          27
          28
          29
          30
          31
          32
          33
          34
          35
          36
          37
          38
          39
          40
          41
          42
          43
          44
          45
          46
          47
          48
          49
          50
          51
          52
          53
          54
          55
          56
          57
          58
          59
          60
          61
          62
          63
          64
          65
          66
          67
          68
          69
          70
          71
          72
          73
          74
          75
          76
          77
          78
          79
          80
          81
          82
          83
          84
          85
          86
          87
          88
          89
          90
          91
          92
          93
          94
          95
          96
          97
          98
          99
          100
          101
          102
          103

          # 文件的类型校验

          在此列举总结常见文件的类型方便查阅:

          文件类型 后缀名 编码
          图片 .jpg/.jpeg image/jpeg
          图片 .png image/png
          图片 .gif image/gif

          视频|.mp4|video/mp4 视频|.avi|video/avi 视频|.mpg|video/mpeg 视频|.wmv|video/x-ms-wmv 视频|.mov|video/quicktime

          音频|.mp3|audio/audio/mpeg 音频|.aac|audio/vnd.dlna.adts 音频|.flac|audio/flac 音频|.m4a|audio/audio/x-m4a 音频|.wav|audio/wav 音频|.wma|audio/x-ms-wma

          压缩文件|.zip|application/x-zip-compressed 压缩文件|.rar|`` 压缩文件|.tar|application/x-tar 压缩文件|.tar.gz|application/x-gzip

          # 色码的转换函数

          // 由十六进制色码转化为rgba格式的字符串
          hexToRgba(hex, opacity = 1) {
            const reg = /^#([0-9a-fA-f]{3}|[0-9a-fA-f]{6})$/
            var sColor = hex.toLowerCase()
            if (sColor && reg.test(sColor)) {
              if (sColor.length === 4) {
                var sColorNew = '#'
                for (var i = 1; i < 4; i += 1) {
                  sColorNew += sColor.slice(i, i + 1).concat(sColor.slice(i, i + 1))
                }
                sColor = sColorNew
              }
              //处理六位的颜色值
              var sColorChange = []
              for (let i = 1; i < 7; i += 2) {
                sColorChange.push(parseInt('0x' + sColor.slice(i, i + 2)))
              }
              return `rgba(${sColorChange.join(',')},${opacity})`
            } else {
              return sColor
            }
          }
          
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          16
          17
          18
          19
          20
          21
          22
          hexToRgba('#0f0', 0.5)    // >> rgba(0,255,0,0.5)
          hexToRgba('#00ff00', 0.5) // >> rgba(0,255,0,0.5)
          
          1
          2

          # 将图片添加到canvas并输出为base64

          核心是直接创建一个canvas元素, 此元素不插入至文档流中, 方法结束后会被清理

          这里依赖了v-viewer , 并且为了避免重复多次预渲染, 故采用API的方式调用, 由click事件触发

          <img src="http://xxx.xxx.xxx" @click="openViewer">
          
          1
          // data为包含url以及其余需要绘制信息的数据
          openViewer(data) {
            const self = this
            const loading = this.$loading({
              lock: true,
              text: '图片渲染中...',
              spinner: 'el-icon-loading',
              background: 'rgba(0, 0, 0, 0.8)'
            })
          
            var url = data.url // 图片URL, 如果未配置代理需要完整地址
            var img = new Image()
            img.crossOrigin = 'Anonymous' // 解决Canvas.toDataURL 图片跨域问题
            img.src = url
          
            // 这里以绘制标注框为例
            // data.rects = [{"x": 300,"y": 600,"width": 100,"height": 200}]
            
            function createCanvas (width, height) {
              const canvas = document.createElement('canvas')
              canvas.setAttribute('width', width)
              canvas.setAttribute('height', height)
              const context = canvas.getContext('2d')
          
              context.drawImage(img, 0, 0, width, height)
              context.fillStyle = "rgba(255,0,0,0.3)"
              context.strokeStyle = "rgb(255,0,0)"
              context.lineWidth = 5
              if (data.rects.length > 0) {
                data.rects.forEach(rect => {
                  context.strokeRect(rect.x, rect.y, rect.width, rect.height)
                  context.fillRect(rect.x, rect.y, rect.width, rect.height)
                })
              }
              // 导出
              const base64Img = canvas.toDataURL('image/jpg')
              // 如果使用`app.use`进行全局安装, 你就可以像这样直接调用`this.$viewerApi`
              const $viewer = self.$viewerApi({
                images: [base64Img]
              })
              loading.close()
            }
          
            img.onload = (event) => {
              // 这里用path来读取img元素渲染后的内容
              createCanvas(event.path[0].width, event.path[0].height)
            }
          
            img.onerror = () => {
              loading.close()
              this.$message.error('图片加载失败!')
            }
          }
          
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          16
          17
          18
          19
          20
          21
          22
          23
          24
          25
          26
          27
          28
          29
          30
          31
          32
          33
          34
          35
          36
          37
          38
          39
          40
          41
          42
          43
          44
          45
          46
          47
          48
          49
          50
          51
          52
          53

          # 事件捕获, 事件冒泡

          在DOM事件绑定中, 通常会使用到target和currentTarget这两个属性, 区分这两个属性之前理一下JavaScript的事件流程

          • 捕获(capture) > 目标(target) > 冒泡(bubbling)

          历史上微软和网景为了是究竟是捕获还是冒泡争执不下, 后来W3C表示这别打了这俩种方式都支持

          addEvenetListener(eventName, function, useCapture)
          
          1

          这第三个参数useCapture就是用来区分到底是在冒泡还是在捕获流程中触发事件函数 true: 捕获 false: 冒泡(默认)

          # target、currentTarget

          <div id="div">
            <p id="p">
              <span id="span">
                我是span
              </span>
            </p>
          </div>
          
          1
          2
          3
          4
          5
          6
          7
           document.getElementById('div').addEventListener(
            'click',
            event => {
              console.log('div', event.eventPhase, event.target, event.currentTarget)
            },
            true
          )
          
          document.getElementById('p').addEventListener(
            'click',
            event => {
              console.log('p', event.eventPhase, event.target, event.currentTarget)
            },
            true
          )
          
          document.getElementById('span').addEventListener(
            'click',
            event => {
              console.log('span', event.eventPhase, event.target, event.currentTarget)
            },
            true
          )
          
          document.getElementById('div').addEventListener(
            'click',
            event => {
              console.log('div', event.eventPhase, event.target, event.currentTarget)
            },
            false
          )
          
          document.getElementById('p').addEventListener(
            'click',
            event => {
              console.log('p', event.eventPhase, event.target, event.currentTarget)
            },
            false
          )
          
          document.getElementById('span').addEventListener(
            'click',
            event => {
              console.log('span', event.eventPhase, event.target, event.currentTarget)
            },
            false
          )
          
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          16
          17
          18
          19
          20
          21
          22
          23
          24
          25
          26
          27
          28
          29
          30
          31
          32
          33
          34
          35
          36
          37
          38
          39
          40
          41
          42
          43
          44
          45
          46
          47

          点击 我是span 元素, 控制台输出如下

          图片

          这里的eventPhase属性值就说明了当前事件处理程序的阶段

          • 1: 捕获阶段
          • 2: 目标阶段
          • 3: 冒泡阶段

          可以看到事件实际上的顺序就是最开始提到的顺序:

          1. div 1 (捕获)
          2. p 1 (捕获)
          3. span 2 (目标)
          4. span 2 (目标)
          5. p 3 (冒泡)
          6. div 3 (冒泡)
          • 目标过程其实执行了两遍, 捕获的结尾和冒泡的开始都是目标过程

          所以target是事件真实触发的元素, 而currentTarget是事件流进行过程中的当前元素

          再换成更常见的vue写法, 这里就不再输出eventPhase属性了:

          <div id="div" @click="handleDivClicked">
            <p id="p" @click="handlePClicked">
              <span id="span" @click="handleSpanClicked">
                我是span
              </span>
            </p>
          </div>
          
          1
          2
          3
          4
          5
          6
          7
          methods: {
            handleDivClicked(event) {
              console.log('div', event.target, event.currentTarget)
            },
            handlePClicked(event) {
              console.log('p', event.target, event.currentTarget)
            },
            handleSpanClicked(event) {
              console.log('span', event.target, event.currentTarget)
            }
          }
          
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11

          点击 我是span, 然后再观察控制台输出:

          图片

          • 由于事件默认是按冒泡的方式, 所以先由触发元素本身响应, 再向上冒泡依次响应
          • 因为target是真实触发的元素, 所以三个target全部输出为span元素;
          • handleSpanClicked事件由于是其本身触发, currentTarget与target一致;
          • p和div都是冒泡触发, 他们并不是本身触发了事件, 所以这两个事件的target都是真正触发的源头span元素, 而currentTarget才是这两个元素本身

          # 快问快答

          思考下方几种情况下, 点击 我是高级span 的三个事件的输出以及顺序

          <div id="div" @click.capture="handleDivClicked">
            <p id="p" @click="handlePClicked">
              <span id="span" @click.stop="handleSpanClicked">
                我是高级span
              </span>
            </p>
          </div>
          
          1
          2
          3
          4
          5
          6
          7
          结果
          1. div
          2. span
          <div id="div" @click.self="handleDivClicked">
            <p id="p" @click.capture="handlePClicked">
              <span id="span" @click="handleSpanClicked">
                我是高级span
              </span>
            </p>
          </div>
          
          1
          2
          3
          4
          5
          6
          7
          结果
          1. p
          2. span
          <div id="div" @click.capture="handleDivClicked">
            <p id="p" @click.stop="handlePClicked">
              <span id="span" @click.capture="handleSpanClicked">
                我是高级span
              </span>
            </p>
          </div>
          
          1
          2
          3
          4
          5
          6
          7
          结果
          1. div
          2. span
          3. p

          # 阻止事件传播 和 阻止默认行为

          当需要停止冒泡时, 除了vue中的.stop事件修饰符, 在addEventListener(event, function)中最后调用event.stopPropagation(), 这样函数就不会冒泡或者被捕获了

          同样, 有的元素会有默认行为, 例如点击a标签会跳转, 拖拽图片可以在新标签页中打开它, <input type=submit />会自动提交表单等, 当需要阻止默认行为时, 调用event内的方法event.preventDefault()

          # 事件委托(冒泡的应用)

          如果需要给多个元素绑定事件, 或许会采用这种方式:

          <ul id="ul">
            <li class="li">li 1</>
            <li class="li">li 2</>
            <li class="li">li 3</>
            <li class="li">li 4</>
          </ul>
          
          1
          2
          3
          4
          5
          6
          const lis = document.getElementsByClassName('li')
          lis.forEach(li => {
            li.addEventListener('click', event => {
              console.log(event.target.innerHTML)
            })
          })
          
          1
          2
          3
          4
          5
          6

          这样会重复添加很多重复的事件, 利用事件冒泡的特性, 可以委托给ul元素, 让ul通过冒泡来执行事件

          const ul = document.getElementById('ul')
          ul.addEventListener('click', event => {
            console.log(event.target.innerHTML)
          })
          
          1
          2
          3
          4
          • 事件委托的另一个优点是如果新加入的子元素, 无需重复添加事件, 同样能被捕获冒泡响应到

          # 百度地图 BaiduMap GIF Marker(Icon)类

          百度地图JavaScript API - Marker类

          百度地图JavaScript API - Icon类

          Baidu地图可以根据经纬度坐标( lat(Latitude纬度0 ~ 90), lng(Longtitude经度0 ~ 180) )生生成 Marker 类放置在地图上, 但是百度实现方式是将用到的图片资源压缩成了一张sprite图(在设置offset属性如果值不合适时会显示出其他图片资源), 这就导致了 Icon 类并不能直接设置动态GIF图

          实现方法: 给已经生成的Marker元素追加样式CSS

          # 1. 渲染并到目标元素

          首先先创建一个Marker元素并渲染在Map上

          // 渲染地图部分略(BMapGL)...
          
          const markerPoint = new BMapGL.Point(Number(118.888888), Number(28.888888));
          // 这里的代码都是从项目中复制的
          const myIcon = new BMapGL.Icon(iconUrl, new BMapGL.Size(20, 20), {
            anchor: new BMapGL.Size(10, 25),
            imageOffset: new BMapGL.Size(0, 0),
            imageSize: new BMapGL.Size(20, 20)
          });
          const marker = new BMapGL.Marker(markerPoint, { icon: myIcon });
          marker.setTitle(data.name);
          this.mapObj.addOverlay(marker);
          
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12

          这样渲染出来的Marker DOM就成功显示出来了, 但是还有点问题, 就是所有的Marker对象元素都是同样的class="BMap_Marker", 查阅了一下API, 没有给其Marker单独赋值class的方法, 好在天无绝人之路, 这个Marker可以赋值一个title属性:

          # 2. 添加用于区分的属性

          mapMarker

          那就只有在这个title属性上做文章, 给需要放上gif的Marker的title中添加一些特殊的标志性文本: "(轻度)", "(中度)", "(重度)" 这种不会跟Marker本身代表的含义出现重叠的文字

          // ...
          let nameSuffix = "";
          if (data.untreatedCount > 0) {
            if (data.untreatedCount < this.mapForm.lowLimit) {
              nameSuffix = "(轻度)";
            } else if (
              data.untreatedCount >= this.mapForm.lowLimit &&
              data.untreatedCount < this.mapForm.highLimit
            ) {
              nameSuffix = "(中度)";
            } else {
              nameSuffix = "(重度)";
            }
          }
          marker.setTitle(data.name + nameSuffix);
          
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15

          # 3. 在生命周期中渲染

          然后的事情就是根据这个程度来执行不同的渲染逻辑

          // 在marker添加完成后的回调中重新设置样式
          this.mapObj.addEventListener("tilesloaded", e => {
            if (this.mapObj.getOverlays() && this.mapObj.getOverlays().length > 0) {
              document.getElementsByClassName("BMap_Marker").forEach(item => {
                if (item.title.indexOf("轻度") != -1) {
                  item.style.backgroundImage = `url(${iconGreen.base64})`;
                  item.style.backgroundSize = "100% 100%";
                }
                if (item.title.indexOf("中度") != -1) {
                  item.style.backgroundImage = `url(${iconYellow.base64})`;
                  item.style.backgroundSize = "100% 100%";
                }
                if (item.title.indexOf("重度") != -1) {
                  item.style.backgroundImage = `url(${iconRed.base64})`;
                  item.style.backgroundSize = "100% 100%";
                }
              });
            }
          });
          
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          16
          17
          18
          19

          后话: 高德地图, 腾讯地图直接可以使用GIF...