打造一款簡單易用功能全面的圖片上傳元件
阿新 • • 發佈:2020-10-28
*多年前我曾搞過Winform,也被[WPF](https://www.cnblogs.com/newton/tag/WPF/)折磨得死去活來。後來我學會了對她們冷眼旁觀,就算老鴇巨硬說又推了一個新頭牌UWP,問我要不要試試,我也不再回應。時代變了,她們古板的舞步已經失去了往日的魅力,那些為了適應潮流勉強加上的幾個動作反而顯得更加可笑、和可悲。我四處流浪,跟著年輕的小夥們去到遠處的移動村、微服務村、AI村,一呆就是幾月幾年。直到某天有人告訴我,有位妙齡女郎孤身一人在那座荒廢的村落安頓下來,她的名字叫——electron。*
---
## 場景
博主十一宅家寫了一個圖文釋出器,關鍵是圖片上傳區域,如下:
![](https://img2020.cnblogs.com/blog/474041/202010/474041-20201027230844393-720182786.gif)
該區域功能相對獨立,完全可以封裝為元件以供其它專案使用,且易於維護。本人計劃包含的功能如下:
1. 可拖拽圖片和資料夾到上傳區域
2. 圖片可拖拽調整順序
3. 可刪除,可設為封面
4. 上傳圖片至OSS
5. 根據圖片大小生成若干比率壓縮圖,同樣上傳至OSS(使用者對此無感知)
6. 若圖片大小超過閾值,自動分片,分片上傳為不同檔案(為後續並行下載做好準備)
7. 加密後上傳(防盜鏈、防和諧)
8. [壓縮、加密、分片、上傳]進度顯示
9. 秒傳或提示衝突不予上傳(需服務端介面)
10. 暫停、錯誤提示、重傳等輔助功能
以上功能需求前8條基本完成,如果要封裝為元件供第三方使用的話,最好還要支援:
11. 國際化&本地化
12. 外掛機制
13. 可自定義模板&面板
### 造輪子?
博主是一個拿來主義者,對盲目造輪子的行為一向嗤之以鼻。考慮到網際網路這麼多年,一般網站都有檔案/圖片上傳功能,開源出來的應該不在少數,選一兩款優良的自己再稍微改改,分分鐘搞定。結果網上搜了一圈,出乎意料,都不是很滿意,少數幾個知名點的,要麼是工具而非元件形式不好整合(如PicGo),要麼功能太簡單(如Layui,不知道上傳元件是否開源,不過我是他家的會員),要麼太過複雜和花裡胡哨(如bootstrap-fileinput)。其實按照我的要求,就算找到勉強湊合的,也要深度改造過,有這時間還不如老老實實自己擼。
當然就算現成的輪子不好轉,借鑑還是可以的。由於幾年前我曾使用[bootstrap-fileinput上傳檔案到oss](https://www.cnblogs.com/newton/p/6066020.html),對它還算有一點了解。github上看了下,發現這個元件一直在更新,官方文件比記憶中要稍顯清晰些,但巨多的配置項依舊讓我眼花繚亂。深入其原始碼,核心檔案的程式碼行數已經6000+,要理清短時間內是不可能了。而且其中關鍵的非同步任務(主要是上傳)基於`jQuery.Deferred`,jQuery.Deferred又是對`Promise`的封裝,bootstrap-fileinput用起來複雜許多。而我們的非同步任務除了上傳外,至少還有壓縮、加密、分片,本著[實操ES6之Promise](https://www.cnblogs.com/newton/p/13630800.html)一文打下的良好基礎,這部分程式碼就自己寫好了(迷之自信:)。所以,剩下能借鑑的就只有邊角料的UI、拖拽程式碼了,而這兩塊也著實可以再剪幾刀。
---
## 實現
*由於本元件一開始是在Nodejs/Electron環境下開發的,所以就沒考慮過一些古老瀏覽器的感受,而是假設執行環境支援File/FileReader/FormData等型別及相關API。*
### 檔案拖拽選擇
重點是在使用者“拖”著[若干]檔案[夾],在拖拽區域內釋放時,如何獲取相關檔案資訊,程式碼如下:
```js
_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`,否則遞迴載入所有檔案:
```js
_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`(阻塞)模式,且同時有兩個函式交錯遞迴——`_scanDroppedItems`和`readDir`。老實說,這個函式當時也是憑感覺寫,此處就不展開講了,道可道,非常道:)
### 壓縮
使用了`compressorjs`庫,程式碼如下:
```js
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的作用](https://www.cnblogs.com/Fly-sky/p/7506499.html)
+ `CFB`/`OFB`實際上是一種反饋模式,目的也是增強破解的難度。
使用`crypto`庫的AES加密。
```js
_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?](https://stackoverflow.com/questions/50701311/node-js-crypto-whats-the-default-padding-for-aes)
上述程式碼採用的是CBC模式,如果考慮到效率,可使用ECB模式,明文分塊之後,各個塊之間相互獨立,互不影響,可平行計算加密,但安全性稍差,不過在我們的場景下夠用了。
*ps:OSS提供了對上傳檔案的[服務端加密](https://help.aliyun.com/document_detail/31871.html)(需要設定`x-oss-server-side-encryption`)。當下載時,OSS會先在服務端解密再傳輸,整個加解密過程可以做到使用者端無感,所以它的目的只是保證檔案在OSS伺服器上的安全,怕伺服器被盜?還是對OSS本身的儲存安全性不自信?不是很懂OSS工程師的想法。*
### 分片
網上資料欠缺,不知Blob是否把資料全部載入進記憶體中,而沒有其它記憶體方面的考量,至少以URL形式獲取的Blob是如此,參看[https://javascript.info/blob#blob-as-url](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倒是可以,參看[流式上傳](https://help.aliyun.com/document_detail/111267.html))。不過我們可以實現`stream.Writable`模擬流上傳,其實內部是分片上傳,但這種方式並不推薦,參看下面stream一節。
所以目前來說最簡單直接有效的方式還是基於`Blob.slice()`分片,如下:
```js
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個執行緒也於事無補;而我們的物理分片可以將一個檔案拆分到不同的伺服器甚至不同服務商,自主可控,同時也提高了盜鏈和爬蟲的難度。*
### 上傳
```js
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`](https://www.npmjs.com/package/through2)庫自定義處理器,nodejs在v1.2.0開始引入了[`Simplified Stream Construction`](https://nodejs.org/api/stream.html#stream_simplified_construction),可以替代through2。它聲明瞭`stream.Writable`, `stream.Readable`, `stream.Duplex`,`stream.Transform`四種流型別。
注意`stream.Duplex` 和 `stream.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`函式,比如:
```js
_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](https://www.cnblogs.com/dolphinX/p/6295535.html)
所以,highWaterMark指的並不是單次處理資料的大小。測試發現,單次write傳入的chunk大小<=64k,這是為啥呢?在`w3c`專案中也有人對此提出了疑問,參看[Define chunk size for ReadableStream created by `blob::stream()` #144](https://github.com/w3c/FileAPI/issues/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怎麼辦呢?至少我沒找到一鍵轉換的方法。以下是藉助`ArrayBuffer`到`Buffer`的轉換實現的。
```js
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)](https://segmentfault.com/a/1190000013924153/)。
[使用electron-packager]打包後所有的程式碼及資原始檔會在`ProductName\resources\app`下,若程式碼中是以相對路徑定位依賴檔案,將以ProductName為基目錄查詢,會報找不到的錯誤。因此我們一般在程式碼中使用`path.join(__dirname, 'xxxx')`,使得在開發過程中還是部署之後都能正確定位檔案。
打包後就可以生成安裝包,參看[【Electron】 NSIS 打包 Electron 生成exe安裝包](https://blog.csdn.net/yu17310133443/article/details/79496499)(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](https://github.com/electron/electron/issues/18397)
但這又會使得node-stream-zip第一次載入無法按預期執行,後來採用先預載入一個空白頁(其它頁面也可以)解決,如下:
```js
window.loadURL('about:blank').then(() => { //同上
window.loadFile(path.join(__dirname, "package.html"))
})
```
#### alert bug
electron有個bug一直沒有得到解決——原生alert彈出框會導致頁面失去焦點,文字框無法輸入,需要整個視窗重新啟用下才可以(比如最小化一下再還原,或者滑鼠點選其它應用後再返回)。可以重定義alert覆蓋原生實現,如下示例:
```js
window.alert = function () {
let $alert = $(`
`).appendTo('body')
let fun = msg => {
$alert.find('.alert-msg').text(msg)
$alert.modal('show')
}
return fun
}()
```
### 是否開源
由於本元件寫的較為倉促,尚有不完善的地方,一些計劃的功能尚未實現或程式碼較為醜陋(醜陋主要是因為依賴的框架、庫和協議標準不一致,各自的“缺陷”使然),且和OSS關聯較為緊密,執行環境也框死在Nodejs下,沒有達到博主心中開源的標準。若關注的朋友較多,那麼等忙完了這一陣,空閒時候再考慮完善後開源。
---
## 參考資料
[Stream highWaterMark misunderstanding](https://stackoverflow.com/questions/35801568/stream-highwatermark-misunderstanding)
[多執行緒下載一個大檔案的速度更快的真正原因是什麼?](https://www.zhihu.com/question/376805151)