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

萊布尼茨發表於2020-10-28

多年前我曾搞過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似乎不支援流模式(PubObject倒是可以,參看流式上傳)。不過我們可以實現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用其鑑別請求的合法性。


其它

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
多執行緒下載一個大檔案的速度更快的真正原因是什麼?

相關文章