<output id="qn6qe"></output>

    1. <output id="qn6qe"><tt id="qn6qe"></tt></output>
    2. <strike id="qn6qe"></strike>

      亚洲 日本 欧洲 欧美 视频,日韩中文字幕有码av,一本一道av中文字幕无码,国产线播放免费人成视频播放,人妻少妇偷人无码视频,日夜啪啪一区二区三区,国产尤物精品自在拍视频首页,久热这里只有精品12

      打造一款簡單易用功能全面的圖片上傳組件

      多年前我曾搞過Winform,也被WPF折磨得死去活來。后來我學會了對她們冷眼旁觀,就算老鴇巨硬說又推了一個新頭牌UWP,問我要不要試試,我也不再回應。時代變了,她們古板的舞步已經失去了往日的魅力,那些為了適應潮流勉強加上的幾個動作反而顯得更加可笑、和可悲。我四處流浪,跟著年輕的小伙們去到遠處的移動村、微服務村、AI村,一呆就是幾月幾年。直到某天有人告訴我,有位妙齡女郎孤身一人在那座荒廢的村落安頓下來,她的名字叫——electron。


      場景

      博主十一宅家寫了一個圖文發布器,關鍵是圖片上傳區域,如下:

      該區域功能相對獨立,完全可以封裝為組件以供其它項目使用,且易于維護。本人計劃包含的功能如下:

      1. 可拖拽圖片和文件夾到上傳區域
      2. 圖片可拖拽調整順序
      3. 可刪除,可設為封面
      4. 上傳圖片至OSS
      5. 根據圖片大小生成若干比率壓縮圖,同樣上傳至OSS(用戶對此無感知)
      6. 若圖片大小超過閾值,自動分片,分片上傳為不同文件(為后續并行下載做好準備)
      7. 加密后上傳(防盜鏈、防和諧)
      8. [壓縮、加密、分片、上傳]進度顯示
      9. 秒傳或提示沖突不予上傳(需服務端接口)
      10. 暫停、錯誤提示、重傳等輔助功能

      以上功能需求前8條基本完成,如果要封裝為組件供第三方使用的話,最好還要支持:

      1. 國際化&本地化
      2. 插件機制
      3. 可自定義模板&皮膚

      造輪子?

      博主是一個拿來主義者,對盲目造輪子的行為一向嗤之以鼻。考慮到互聯網這么多年,一般網站都有文件/圖片上傳功能,開源出來的應該不在少數,選一兩款優良的自己再稍微改改,分分鐘搞定。結果網上搜了一圈,出乎意料,都不是很滿意,少數幾個知名點的,要么是工具而非組件形式不好集成(如PicGo),要么功能太簡單(如Layui,不知道上傳組件是否開源,不過我是他家的會員),要么太過復雜和花里胡哨(如bootstrap-fileinput)。其實按照我的要求,就算找到勉強湊合的,也要深度改造過,有這時間還不如老老實實自己擼。

      當然就算現成的輪子不好轉,借鑒還是可以的。由于幾年前我曾使用bootstrap-fileinput上傳文件到oss,對它還算有一點了解。github上看了下,發現這個組件一直在更新,官方文檔比記憶中要稍顯清晰些,但巨多的配置項依舊讓我眼花繚亂。深入其源碼,核心文件的代碼行數已經6000+,要理清短時間內是不可能了。而且其中關鍵的異步任務(主要是上傳)基于jQuery.Deferred,jQuery.Deferred又是對Promise的封裝,bootstrap-fileinput用起來復雜許多。而我們的異步任務除了上傳外,至少還有壓縮、加密、分片,本著實操ES6之Promise一文打下的良好基礎,這部分代碼就自己寫好了(迷之自信:)。所以,剩下能借鑒的就只有邊角料的UI、拖拽代碼了,而這兩塊也著實可以再剪幾刀。


      實現

      由于本組件一開始是在Nodejs/Electron環境下開發的,所以就沒考慮過一些古老瀏覽器的感受,而是假設執行環境支持File/FileReader/FormData等類型及相關API。

      文件拖拽選擇

      重點是在用戶“拖”著[若干]文件[夾],在拖拽區域內釋放時,如何獲取相關文件信息,代碼如下:

          _zoneDrop: async function (e) {
              let dataTransfer = e.originalEvent.dataTransfer,
                  files = dataTransfer.files, items = dataTransfer.items, folderCount = this._getDragDropFolderCount(items)
              e.preventDefault()
              if (this._isEmpty(files)) {
                  return
              }
              if (folderCount > 0) {
                  files = []
                  for (let i = 0; i < items.length; i++) {
                      let item = items[i].webkitGetAsEntry()
                      if (item) {
                          await this._scanDroppedItems(item, files)
                      }
                  }
              }
              this.$dropZone.removeClass('file-highlighted')
      
              this.$dropZone.trigger("filesChanged", files)
          }
      

      若拖拽的項目不包含文件夾,那么直接返回dataTransfer.files,否則遞歸加載所有文件:

          _scanDroppedItems: async function (item, files, path) {
              path = path || ''
              let self = this
              if (item.isFile) {
                  let task = new Promise((resolve, reject) => {
                      item.file(function (file) {
                          if (path) {
                              file.relativePath = path + file.name;
                          }
                          resolve(file)
                      }, e => reject(e))
                  })
                  let file = await task.catch(e => { throw e })
                  files.push(file)
              } else {
                  if (item.isDirectory) {
                      let i, dirReader = item.createReader()
                      let readDir = function () {
                          return new Promise((resolve, reject) => {
                              dirReader.readEntries(async function (entries) {
                                  if (entries && entries.length > 0) {
                                      let tasks = []
                                      for (i = 0; i < entries.length; i++) {
                                          tasks.push(self._scanDroppedItems(entries[i], files, path + item.name + '/'))
                                      }
                                      Promise.all(tasks).then(() => resolve()).catch(e => reject(e))
                                      // recursively call readDir() again, since browser can only handle first 100 entries.
                                      await readDir().catch(e => { throw e })
                                  } else
                                      resolve()
                              }, e => reject(e))
                          })
                      }
                      await readDir()
                  }
              }
          }
      

      這里理解上的難點是異步遞歸調用,且同時使用了Promist.then(不阻塞)及await(阻塞)模式,且同時有兩個函數交錯遞歸——_scanDroppedItemsreadDir。老實說,這個函數當時也是憑感覺寫,此處就不展開講了,道可道,非常道:)

      壓縮

      使用了compressorjs庫,代碼如下:

          compress: async function (file, level, quality = 0.8) {
              //以下若干情況不需要壓縮,直接返回原file
              switch (true) {
                  case file.size < 51200:
                  case file.size < 524288 && level != 'thumbnail':
                  case file.size < 1048576 && level == 'big':
                      file.asLevels = file.asLevels || []
                      file.asLevels.push(level)
                      return file;
              }
      
              let opt = {
                  quality: quality
              }
              let img = await utility.getImage(file.path) //轉成img以得到width/height屬性
              let scale = Math.min(img.width, img.height, this.levels[level])
              opt[img.width < img.height ? 'width' : 'height'] = scale
              return new Promise((resolve, reject) => {
                  Object.assign(opt, {
                      success(result) {
                          result.level = level
                          resolve(result)
                      },
                      error(err) {
                          reject(err)
                      },
                  })
                  new Cmp(file, opt)
              })
          }
      

      看注釋,不是所有圖片過來都無腦壓縮,本身size已經在壓縮級別內了就直接返回。另外scale變量表示短邊長度,是業務需求,可無視。

      加密

      使用AES加密標準,首先要知道,AES是基于數據塊的加密方式,每個加密塊大小為128位。它又有幾種實現方式:

      • ECB:是一種基礎的加密方式,明文被分割成分組長度相等的塊(不足補齊),然后單獨一個個加密,一個個輸出組成密文。
      • CBC:是一種循環模式,前一個分組的密文和當前分組的明文異或操作后再加密,這樣做的目的是增強破解難度。需要初始化向量IV,參看加密算法IV的作用
      • CFB/OFB實際上是一種反饋模式,目的也是增強破解的難度。

      使用crypto庫的AES加密。

          _encrypt: async function (file, key, iv, destDir = 'temp') {
              key = key || await this._md5(file) //128bit length
              key = Buffer.from(key, 'hex')
              iv = iv || "stringwith16byte"
              iv = Buffer.from(iv, 'utf8')
              let cipher = crypto.createCipheriv('aes-128-cbc', key, iv)
              cipher.setAutoPadding(true)
      
              let stm = file.stream()      
              let writerStream = fs.createWriteStream(path.join(__dirname, destDir,file.name))
              stm.pipe(cipher).pipe(writerStream)
          }
      

      cipher.setAutoPadding(true)表示明文分塊后位數不足自動補足,標準的補足算法有多種,crypto使用PKCS7。設為false的話,就要自己考慮如何補足。參看Node.js Crypto, what's the default padding for AES?

      上述代碼采用的是CBC模式,如果考慮到效率,可使用ECB模式,明文分塊之后,各個塊之間相互獨立,互不影響,可并行計算加密,但安全性稍差,不過在我們的場景下夠用了。

      ps:OSS提供了對上傳文件的服務端加密(需要設置x-oss-server-side-encryption)。當下載時,OSS會先在服務端解密再傳輸,整個加解密過程可以做到用戶端無感,所以它的目的只是保證文件在OSS服務器上的安全,怕服務器被盜?還是對OSS本身的存儲安全性不自信?不是很懂OSS工程師的想法。

      分片

      網上資料欠缺,不知Blob是否把數據全部加載進內存中,而沒有其它內存方面的考量,至少以URL形式獲取的Blob是如此,參看https://javascript.info/blob#blob-as-url。同時,若手動構造Blob,也只能將所有數據一股腦給出[到內存中],而不是更簡單更高效的方式比如傳遞文件路徑,然后按需獲取數據。當然,這應該是安全方面考量,避免js隨意調動本地文件。但對我們現在的場景來說就有點麻煩了。

      用戶選擇要上傳的文件后,上一步我們對它們進行了加密,并另存為臨時文件到磁盤中,此時要再將該文件主動轉為Blob或File對象[用于后續上傳]就比較麻煩。在Nodejs下還好,大不了將文件全部加載到內存中,通過字節數組轉換,但在瀏覽器環境下由于屏蔽了對本地文件的讀寫,這是不可能的。

      fs.createReadStream()可接受Buffer類型的參數,然而并不是用于傳遞文件內容的,而仍然只能是文件路徑。You can apparently pass the path in a Buffer object, but it still must be an acceptable OS path when the Buffer is converted to a string.

      為了滿足不同場景下的使用,并考慮到開銷問題,最好能以流的形式,邊加密邊上傳,然而OSS的PostObject似乎不支持流模式(PutObject倒是可以,參看流式上傳)。不過我們可以實現stream.Writable模擬流上傳,其實內部是分片上傳,但這種方式并不推薦,參看下面stream一節。

      所以目前來說最簡單直接有效的方式還是基于Blob.slice()分片,如下:

              let pieces
              if (this.pieceSize && !file.level && file.size > this.pieceSize) { //目前只對原圖進行分片處理
                  pieces = []
                  let startIndex = 0
                  do {
                      pieces.push(file.slice(startIndex, startIndex += this.pieceSize))
                  }
                  while (startIndex < file.size)
              } else
                  pieces = [file]
      

      前面說到,分片的目的之一是并行下載。其實Http1.1(RFC2616)引入的Range & Content-Range開始支持獲取文件的部分內容,這已經為對整個文件的并行下載以及斷點續傳提供了技術支持。上傳前分片似乎多此一舉了,其實不盡然。現在很多文件服務提供商會限制單用戶的連接數和傳輸速率,如果基于Http1.1 Range做并行下載,假設服務器限制了同時最多3個連接,就算你開10個線程也于事無補;而我們的物理分片可以將一個文件拆分到不同的服務器甚至不同服務商,自主可控,同時也提高了盜鏈和爬蟲的難度。

      上傳

          uploadFile: async function (file, uploadUrl, opt) {
              uploadUrl = uploadUrl || await this._get(this._getOssUploadUrl)
              opt = opt || {
                  headers: {
                      "Cache-Control": "max-age=2592000",
                      'Content-Type': 'multipart/form-data'
                  }
              }
              let policy = await this._getPolicy()
              let key = utility.getRandomString(20)
              let formData = new FormData()
              formData.append('Cache-Control', 'max-age=2592000')
              formData.append('key', key)
              for (let k in policy) {
                  formData.append(k, policy[k])
              }
              formData.append('file', file)
      
              opt.onUploadProgress = evt => opt.processCallback(evt, key)
              axios.post(uploadUrl, formData, opt).then((data) => {
                  console.info(data)
              })
          }
      

      其中policy是服務端上傳策略加上簽名返回給前端的,OSS用其鑒別請求的合法性。

      上傳進度采用axios.post回調實現,特別注意onUploadProgress的參數ProgressEvent,它的total和待上傳文件的size是不同的,會多個1.4k左右,猜測是加了請求頭等信息的字節數。所以我們在計算上傳百分比時需按ProgressEvent.total而非文件本身的size。


      其它

      stream

      nodejs中,stream有pipe,管道的概念,說白了就是鏈式處理,只不過這里處理的是stream罷了。以前大家都使用through2庫自定義處理器,nodejs在v1.2.0開始引入了Simplified Stream Construction,可以替代through2。它聲明了stream.Writablestream.Readablestream.Duplexstream.Transform四種流類型。

      注意stream.Duplexstream.Transform 的區別:stream.Duplex不要求輸入輸出流有關系,它們可以沒一毛錢關系,只要實現stream.Duplex的類既能read又能write就可以了;stream.Transform繼承自stream.Duplex,從字面意思上說就是轉換,很明顯,輸入流經過某種轉換轉變為輸出流,輸入輸出是有關系的。上述加密一節用到的Cipher就實現了stream.Transform,因此我們可以方便地將源文件加密并另存為一個新文件。

      我們要區分Nodejs的stream定義和HTML5的stream Web API,兩者有相似之處,但不能混用。以ReadableStream為例,前者pipe(WritableStream),返回的是傳遞的可寫流,后者pipeTo(WritableStream),返回的是Promise對象,且雖然它們都叫ReadableStream或WritableStream,但它們不是同一個東西。目前也沒有發現能方便轉換它倆的方法。

      stream.Writable

      原本想通過實現stream.Writable模擬流上傳的形式實現分片上傳,但在我們的場景下其實沒有必要,反而可能影響效率。不過看一下如何實現也無妨。

      1. 在實現類的構造函數中增加一行Writable.call(this, this._options.streamOpts)

      2. 給實現類定義_write函數,比如:

            _write: function (chunk, _, callback) {
                console.info(chunk.length) //65536/64k max
                this.block += chunk
                if (block.length >= 1048576) //當到達1M時,開始上傳
                {
                    // 上傳代碼,注意可能需要阻塞直到上傳完成,避免block的變化影響到上傳數據
                    let blob = new Blob(this.block) //偽代碼
                    this._upload(blob, () => {
                        this.block.clear() //偽代碼
                        callback() // 必須,告知已順利執行,否則_write只會被調用一次
                    }) 
                }
            }
        

        每處理一段數據,就要callback一次,告知程序可以開始處理下一段數據了。如果全局block的變化會影響到上傳,那么我們就必須等待本次上傳成功之后再進行下一個分片的上傳,這就降低了效率。
        如果給callback傳遞了參數,則是表明本次處理發生了錯誤。

        注意上游的ReadableStream并不知道你處理數據的速度,所以如果未做處理的話,可能出現數據積壓(back pressure)的問題,即數據源源不斷地往內存輸入卻得不到及時處理的情況,此時highWaterMark選項就派上了用場。當積壓的數據大小超過highWaterMark預設值的話,WritableStream.write()會返回false,用于告知上游,上游就可以暫停喂數據。同時上游監聽下游的drain事件,當待處理數據大小小于 highWaterMark 時下游會觸發 drain 事件,上游就可以重啟輸出。pipe函數內部已經實現了這部分邏輯。參看NodeJS Stream 四:Writable

        所以,highWaterMark指的并不是單次處理數據的大小。測試發現,單次write傳入的chunk大小<=64k,這是為啥呢?在w3c項目中也有人對此提出了疑問,參看Define chunk size for ReadableStream created by blob::stream() #144

        還可以實現_writev()函數,用于有積壓數據時一次性處理完所有積壓數據,當然,何時調用不需要我們關心,WritableStream會自動處理。具體來說, If implemented and if there is buffered data from previous writes, _writev() will be called instead of _write().`

      3. util.inherits(實現類, Writable)

      stream.Readable

      上面說到,ReadableStream在w3c標準里和Nodejs里都有,但是不同的類,不能通用。那如果要將Blob.stream()轉成Nodejs里的ReadableStream怎么辦呢?至少我沒找到一鍵轉換的方法。以下是借助ArrayBufferBuffer的轉換實現的。

          let ab = await file.arrayBuffer()
          let buf = Buffer.from(ab)
          let stm = new Readable({
              read() {
                  // 空實現
              }
          })
          stm.push(buf)
      

      其實這種方式失去了strem本身的意義,因為數據都已經全部在內存中了,直接操作反而來得更加方便。這也是分片實現為什么不這么做的原因之一,期待w3c和Nodejs在stream方面統一的那天吧。

      Electron

      原本本文是圍繞Electron展開的,題目都取了一陣子了——“Electron構建桌面應用程序實戰指南之實現酷炫圖文發布器”。后來發現其實Electron沒啥好寫的,難點還是在業務的實現,不過有些坑仍然值得一提。

      npm與cnpm

      npm是 Node.js 標準的軟件包管理器。但由于默認的倉庫地址位于國外,package的下載速度可能會比較慢。

      淘寶團隊做了一個npm官方倉庫的鏡像倉庫,同步頻率目前為10分鐘。地址是https://registry.npm.taobao.org。使用npm install -g cnpm --registry=https://registry.npm.taobao.org安裝cnpm命令即可。
      一般來說,只要使用npm config set registry https://registry.npm.taobao.org改變默認倉庫地址,就可以使下載速度加快。

      打包

      打包出現打包過慢(幾個小時),原因很可能是因為依賴包都是通過cnpm安裝,刪除cnpm安裝的依賴包,替換成npm安裝的依賴包即可。詳情參看electron打包:electron-packager及electron-builder兩種方式實現(for Windows)

      [使用electron-packager]打包后所有的代碼及資源文件會在ProductName\resources\app下,若代碼中是以相對路徑定位依賴文件,將以ProductName為基目錄查找,會報找不到的錯誤。因此我們一般在代碼中使用path.join(__dirname, 'xxxx'),使得在開發過程中還是部署之后都能正確定位文件。

      打包后就可以生成安裝包,參看【Electron】 NSIS 打包 Electron 生成exe安裝包(asar的步驟可以跳過)。如果覺得安裝包的體積過大,可在electron-packager打包前刪除package-lock.json文件,這將極大地減少node_modules目錄體積,進而減小最終生成的安裝包大小(網上說的其它一些方法有點復雜,沒有太去了解)。

      奇怪的問題

      本人使用一個名叫node-stream-zip的庫解析zip包,將其中一些操作封裝為Promise模式,然后發現初次加載時可以正常執行,reload后狀態就一直pending了。遇到這種問題可以嘗試設置app.allowRendererProcessReuse = false,猜測是由于electron重用渲染層進程導致某些類庫異常。相關鏈接https://github.com/electron/electron/issues/18397
      但這又會使得node-stream-zip第一次加載無法按預期執行,后來采用先預加載一個空白頁(其它頁面也可以)解決,如下:

      window.loadURL('about:blank').then(() => { //同上
      	window.loadFile(path.join(__dirname, "package.html"))
      })
      

      alert bug

      electron有個bug一直沒有得到解決——原生alert彈出框會導致頁面失去焦點,文本框無法輸入,需要整個窗口重新激活下才可以(比如最小化一下再還原,或者鼠標點擊其它應用后再返回)。可以重定義alert覆蓋原生實現,如下示例:

      window.alert = function () {
          let $alert = $(`
          <div id="alert" class="modal" tabindex="-1">
              <div class="modal-dialog modal-dialog-centered">
                  <div class="modal-content">
                      <div class="modal-body">
                          <p class="alert-msg text-info"></p>
                      </div>
                      <div class="modal-footer">
                          <button type="button" class="btn btn-info" data-dismiss="modal">確定</button>
                      </div>
                  </div>
              </div>
          </div>`).appendTo('body')
          let fun = msg => {
              $alert.find('.alert-msg').text(msg)
              $alert.modal('show')
          }
          return fun
      }()
      

      是否開源

      由于本組件寫的較為倉促,尚有不完善的地方,一些計劃的功能尚未實現或代碼較為丑陋(丑陋主要是因為依賴的框架、庫和協議標準不一致,各自的“缺陷”使然),且和OSS關聯較為緊密,運行環境也框死在Nodejs下,沒有達到博主心中開源的標準。若關注的朋友較多,那么等忙完了這一陣,空閑時候再考慮完善后開源。


      參考資料

      Stream highWaterMark misunderstanding
      多線程下載一個大文件的速度更快的真正原因是什么?

      posted @ 2020-10-28 11:03  萊布尼茨  閱讀(1219)  評論(3)    收藏  舉報
      主站蜘蛛池模板: 日韩国产成人精品视频| 激情伊人五月天久久综合| 在线播放国产精品三级网| 中文字幕人妻精品在线| 午夜福利日本一区二区无码| 日本一道一区二区视频| 日韩狼人精品在线观看| 嫩草院一区二区乱码| 金山区| 欧美成人精品| XXXXXHD亚洲日本HD| 久久人人妻人人做人人爽| 亚洲av成人一区国产精品| 无码国内精品久久人妻蜜桃| 国产又色又爽又高潮免费| 国产国产人免费人成免费| 最新AV中文字幕无码专区| 千阳县| 亚洲情色av一区二区| 婷婷色香五月综合缴缴情香蕉| 稷山县| 国产精品亚洲mnbav网站| 午夜福利在线观看入口| 亚洲国产成人资源在线 | 日本韩国一区二区精品| 偷窥盗摄国产在线视频| 免费人成在线观看网站| 青春草公开在线视频日韩| 欧美寡妇xxxx黑人猛交| 国产亚洲精品久久久久久无亚洲 | 亚洲国产片一区二区三区| 国产精品午夜福利精品| 狠狠躁夜夜躁人人爽天天| 久久精品无码一区二区三区| 777奇米四色成人影视色区| 久久66热人妻偷产精品| 亚洲中文无码手机永久| 综合久久av一区二区三区| 亚洲国产精品久久久天堂麻豆宅男| 国产成人精品亚洲日本片| 亚洲高清WWW色好看美女|