大家好,我是零一 。 文件上傳,搞懂這8種場(chǎng)景就夠了 這篇文章發(fā)布之后,阿寶哥收到了挺多掘友的留言,感謝掘友們一直以來(lái)的鼓勵(lì)與支持。其中掘友 @我的煙雨不在江南 和 @rainx 在文章底部分別發(fā)了以下留言:
既然掘友有要求,連標(biāo)題也幫阿寶哥想好了,那我們就來(lái)整一篇文章,總結(jié)一下文件下載的場(chǎng)景。
一般在我們工作中,主要會(huì)涉及到 9 種文件下載的場(chǎng)景,每一種場(chǎng)景背后都使用不同的技術(shù),其中也有很多細(xì)節(jié)需要我們額外注意。今天阿寶哥就來(lái)帶大家總結(jié)一下這 9 種場(chǎng)景,讓大家能夠輕松地應(yīng)對(duì)各種下載場(chǎng)景。閱讀本文后,你將會(huì)了解以下的內(nèi)容:
在瀏覽器端處理文件的時(shí)候,我們經(jīng)常會(huì)用到 Blob 。比如圖片本地預(yù)覽、圖片壓縮、大文件分塊上傳及文件下載。在瀏覽器端文件下載的場(chǎng)景中,比如我們今天要講到的 a 標(biāo)簽下載 、showSaveFilePicker API 下載 、Zip 下載 等場(chǎng)景中,都會(huì)使用到 Blob ,所以我們有必要在學(xué)習(xí)具體應(yīng)用前,先掌握它的相關(guān)知識(shí),這樣可以幫助我們更好地了解示例代碼。
一、基礎(chǔ)知識(shí) 1.1 了解 Blob Blob(Binary Large Object)表示二進(jìn)制類型的大對(duì)象。在數(shù)據(jù)庫(kù)管理系統(tǒng)中,將二進(jìn)制數(shù)據(jù)存儲(chǔ)為一個(gè)單一個(gè)體的集合。Blob 通常是影像、聲音或多媒體文件。在 JavaScript 中 Blob 類型的對(duì)象表示一個(gè)不可變、原始數(shù)據(jù)的類文件對(duì)象。 它的數(shù)據(jù)可以按文本或二進(jìn)制的格式進(jìn)行讀取,也可以轉(zhuǎn)換成 ReadableStream 用于數(shù)據(jù)操作。
Blob
對(duì)象由一個(gè)可選的字符串 type
(通常是 MIME 類型)和 blobParts
組成:
在 JavaScript 中你可以通過(guò) Blob 的構(gòu)造函數(shù)來(lái)創(chuàng)建 Blob 對(duì)象,Blob 構(gòu)造函數(shù)的語(yǔ)法如下:
const aBlob = new Blob(blobParts, options);
相關(guān)的參數(shù)說(shuō)明如下:
blobParts:它是一個(gè)由 ArrayBuffer,ArrayBufferView,Blob,DOMString 等對(duì)象構(gòu)成的數(shù)組。DOMStrings 會(huì)被編碼為 UTF-8。 options:一個(gè)可選的對(duì)象,包含以下兩個(gè)屬性: type —— 默認(rèn)值為 ''
,它代表了將會(huì)被放入到 blob 中的數(shù)組內(nèi)容的 MIME 類型。 endings —— 默認(rèn)值為 'transparent'
,用于指定包含行結(jié)束符 \n
的字符串如何被寫(xiě)入。 它是以下兩個(gè)值中的一個(gè): 'native'
,代表行結(jié)束符會(huì)被更改為適合宿主操作系統(tǒng)文件系統(tǒng)的換行符,或者 'transparent'
,代表會(huì)保持 blob 中保存的結(jié)束符不變。 1.2 了解 Blob URL Blob URL/Object URL 是一種偽協(xié)議,允許 Blob 和 File 對(duì)象用作圖像、下載二進(jìn)制數(shù)據(jù)鏈接等的 URL 源。在瀏覽器中,我們使用 URL.createObjectURL
方法來(lái)創(chuàng)建 Blob URL,該方法接收一個(gè) Blob
對(duì)象,并為其創(chuàng)建一個(gè)唯一的 URL,其形式為 blob:<origin>/<uuid>
,對(duì)應(yīng)的示例如下:
blob:http://localhost:3000/53acc2b6-f47b-450f-a390-bf0665e04e59
瀏覽器內(nèi)部為每個(gè)通過(guò) URL.createObjectURL
生成的 URL 存儲(chǔ)了一個(gè) URL → Blob 映射。因此,此類 URL 較短,但可以訪問(wèn) Blob
。生成的 URL 僅在當(dāng)前文檔打開(kāi)的狀態(tài)下才有效。它允許引用 <img>
、<a>
中的 Blob
,但如果你訪問(wèn)的 Blob URL 不再存在,則會(huì)從瀏覽器中收到 404 錯(cuò)誤。
上述的 Blob URL 看似很不錯(cuò),但實(shí)際上它也有副作用。 雖然存儲(chǔ)了 URL → Blob 的映射,但 Blob 本身仍駐留在內(nèi)存中,瀏覽器無(wú)法釋放它。映射在文檔卸載時(shí)自動(dòng)清除,因此 Blob 對(duì)象隨后被釋放 。但是,如果應(yīng)用程序壽命很長(zhǎng),那么 Blob 在短時(shí)間內(nèi)將無(wú)法被瀏覽器釋放。因此,如果你創(chuàng)建一個(gè) Blob URL,即使不再需要該 Blob,它也會(huì)存在內(nèi)存中。
針對(duì)這個(gè)問(wèn)題,你可以調(diào)用 URL.revokeObjectURL(url)
方法,從內(nèi)部映射中刪除引用,從而允許刪除 Blob(如果沒(méi)有其他引用),并釋放內(nèi)存。
現(xiàn)在你已經(jīng)了解了 Blob 和 Blob URL,如果你還意猶未盡,想深入理解 Blob 的話,可以閱讀 你不知道的 Blob 這篇文章。下面我們開(kāi)始介紹客戶端文件下載的場(chǎng)景。
隨著 Web 技術(shù)的不斷發(fā)展,瀏覽器的功能也越來(lái)越強(qiáng)大。這些年出現(xiàn)了很多在線 Web 設(shè)計(jì)工具,比如在線 PS、在線海報(bào)設(shè)計(jì)器或在線自定義表單設(shè)計(jì)器等。這些 Web 設(shè)計(jì)器允許用戶在完成設(shè)計(jì)之后,把生成的文件保存到本地,其中有一部分設(shè)計(jì)器就是利用瀏覽器提供的 Web API 來(lái)實(shí)現(xiàn)客戶端文件下載。下面阿寶哥先來(lái)介紹客戶端下載中,最常見(jiàn)的 a 標(biāo)簽下載 方案。
二、a 標(biāo)簽下載 html
<h3 > a 標(biāo)簽下載示例</h3 > <div > <img src ='../images/body.png' /> <img src ='../images/eyes.png' /> <img src ='../images/mouth.png' /> </div > <img id ='mergedPic' src ='http://via./256' /> <button onclick ='merge()' > 圖片合成</button > <button onclick ='download()' > 圖片下載</button >
在以上代碼中,我們通過(guò) img
標(biāo)簽引用了以下 3 張素材:
當(dāng)用戶點(diǎn)擊 圖片合成 按鈕時(shí),會(huì)將合成的圖片顯示在 img#mergedPic
容器中。在圖片成功合成之后,用戶可以通過(guò)點(diǎn)擊 圖片下載 按鈕把已合成的圖片下載到本地。對(duì)應(yīng)的操作流程如下圖所示:
由上圖可知,整體的操作流程相對(duì)簡(jiǎn)單。接下來(lái),我們來(lái)看一下 圖片合成 和 圖片下載 的實(shí)現(xiàn)邏輯。
js
圖片合成的功能,阿寶哥是直接使用 Github 上 merge-images 這個(gè)第三方庫(kù)來(lái)實(shí)現(xiàn)。利用該庫(kù)提供的 mergeImages(images, [options])
方法,我們可以輕松地實(shí)現(xiàn)圖片合成的功能。調(diào)用該方法后,會(huì)返回一個(gè) Promise 對(duì)象,當(dāng)異步操作完成后,合成的圖片會(huì)以 Data URLs 的格式返回。
const mergePicEle = document .querySelector('#mergedPic' );const images = ['/body.png' , '/eyes.png' , '/mouth.png' ].map( (path ) => '../images' + path );let imgDataUrl = null ;async function merge ( ) { imgDataUrl = await mergeImages(images); mergePicEle.src = imgDataUrl; }
而圖片下載的功能是借助 dataUrlToBlob
和 saveFile
這兩個(gè)函數(shù)來(lái)實(shí)現(xiàn)。它們分別用于實(shí)現(xiàn) Data URLs => Blob 的轉(zhuǎn)換和文件的保存,具體的代碼如下所示:
function dataUrlToBlob (base64, mimeType ) { let bytes = window .atob(base64.split(',' )[1 ]); let ab = new ArrayBuffer (bytes.length); let ia = new Uint8Array (ab); for (let i = 0 ; i < bytes.length; i++) { ia[i] = bytes.charCodeAt(i); } return new Blob([ab], { type : mimeType }); }// 保存文件 function saveFile (blob, filename ) { const a = document .createElement('a' ); a.download = filename; a.href = URL.createObjectURL(blob); a.click(); URL.revokeObjectURL(a.href) }
因?yàn)楸疚牡闹黝}是介紹文件下載,所以我們來(lái)重點(diǎn)分析 saveFile
函數(shù)。在該函數(shù)內(nèi)部,我們使用了 HTMLAnchorElement.download 屬性,該屬性值表示下載文件的名稱。如果該名稱不是操作系統(tǒng)的有效文件名,瀏覽器將會(huì)對(duì)其進(jìn)行調(diào)整。此外,該屬性的作用是表明鏈接的資源將被下載,而不是顯示在瀏覽器中。
需要注意的是,download
屬性存在兼容性問(wèn)題,比如 IE 11 及以下的版本不支持該屬性,具體如下圖所示:
(圖片來(lái)源:https:///download)
當(dāng)設(shè)置好 a 元素的 download
屬性之后,我們會(huì)調(diào)用 URL.createObjectURL
方法來(lái)創(chuàng)建 Object URL,并把返回的 URL 賦值給 a 元素的 href
屬性。接著通過(guò)調(diào)用 a 元素的 click
方法來(lái)觸發(fā)文件的下載操作,最后還會(huì)調(diào)用一次 URL.revokeObjectURL
方法,從內(nèi)部映射中刪除引用,從而允許刪除 Blob(如果沒(méi)有其他引用),并釋放內(nèi)存。
關(guān)于 a 標(biāo)簽下載 的內(nèi)容就介紹到這,下面我們來(lái)介紹如何使用新的 Web API —— showSaveFilePicker
實(shí)現(xiàn)文件下載。
a 標(biāo)簽下載示例:a-tag
https://github.com/semlinker/file-download-demos/tree/main/a-tag
三、showSaveFilePicker API 下載 showSaveFilePicker API 是 Window
接口中定義的方法,調(diào)用該方法后會(huì)顯示允許用戶選擇保存路徑的文件選擇器。該方法的簽名如下所示:
let FileSystemFileHandle = Window.showSaveFilePicker(options);
showSaveFilePicker 方法支持一個(gè)對(duì)象類型的可選參數(shù),可包含以下屬性:
excludeAcceptAllOption
:布爾類型,默認(rèn)值為 false
。默認(rèn)情況下,選擇器應(yīng)包含一個(gè)不應(yīng)用任何文件類型過(guò)濾器的選項(xiàng)(由下面的 types
選項(xiàng)啟用)。將此選項(xiàng)設(shè)置為 true
意味著 types
選項(xiàng)不可用。types
:數(shù)組類型,表示允許保存的文件類型列表。數(shù)組中的每一項(xiàng)是包含以下屬性的配置對(duì)象:description(可選)
:用于描述允許保存文件類型類別。accept
:是一個(gè)對(duì)象,該對(duì)象的 key
是 MIME 類型,值是文件擴(kuò)展名列表。調(diào)用 showSaveFilePicker 方法之后,會(huì)返回一個(gè) FileSystemFileHandle 對(duì)象。有了該對(duì)象,你就可以調(diào)用該對(duì)象上的方法來(lái)操作文件。比如調(diào)用該對(duì)象上的 createWritable 方法之后,就會(huì)返回 FileSystemWritableFileStream 對(duì)象,就可以把數(shù)據(jù)寫(xiě)入到文件中。具體的使用方式如下所示:
async function saveFile (blob, filename ) { try { const handle = await window .showSaveFilePicker({ suggestedName : filename, types : [ { description : 'PNG file' , accept : { 'image/png' : ['.png' ], }, }, { description : 'Jpeg file' , accept : { 'image/jpeg' : ['.jpeg' ], }, }, ], }); const writable = await handle.createWritable(); await writable.write(blob); await writable.close(); return handle; } catch (err) { console .error(err.name, err.message); } }function download ( ) { if (!imgDataUrl) { alert('請(qǐng)先合成圖片' ); return ; } const imgBlob = dataUrlToBlob(imgDataUrl, 'image/png' ); saveFile(imgBlob, 'face.png' ); }
當(dāng)你使用以上更新后的 saveFile
函數(shù),來(lái)保存已合成的圖片時(shí),會(huì)顯示以下保存文件選擇器:
由上圖可知,相比 a 標(biāo)簽下載 的方式,showSaveFilePicker API 允許你選擇文件的下載目錄、選擇文件的保存格式和更改存儲(chǔ)的文件名稱。看到這里是不是覺(jué)得 showSaveFilePicker API 功能挺強(qiáng)大的,不過(guò)可惜的是該 API 目前的兼容性還不是很好,具體如下圖所示:
(圖片來(lái)源:https:///?search=showSaveFilePicker)
其實(shí) showSaveFilePicker 是 File System Access API 中定義的方法,除了 showSaveFilePicker 之外,還有 showOpenFilePicker 和 showDirectoryPicker 等方法。如果你想在實(shí)際項(xiàng)目中使用這些 API 的話,可以考慮使用 GoogleChromeLabs 開(kāi)源的 browser-fs-access 這個(gè)庫(kù),該庫(kù)可以讓你在支持平臺(tái)上更方便地使用 File System Access API,對(duì)于不支持的平臺(tái)會(huì)自動(dòng)降級(jí)使用 <input type='file'>
和 <a download>
的方式。
可能大家對(duì) browser-fs-access 這個(gè)庫(kù)會(huì)比較陌生,但是如果換成是 FileSaver.js 這個(gè)庫(kù)的話,應(yīng)該就比較熟悉了。接下來(lái),我們來(lái)介紹如何利用 FileSaver.js 這個(gè)庫(kù)實(shí)現(xiàn)客戶端文件下載。
showSaveFilePicker API 下載示例:save-file-picker
https://github.com/semlinker/file-download-demos/tree/main/save-file-picker
四、FileSaver 下載 FileSaver.js 是在客戶端保存文件的解決方案,非常適合在客戶端上生成文件的 Web 應(yīng)用程序。它是 HTML5 版本的 saveAs() FileSaver 實(shí)現(xiàn),支持大多數(shù)主流的瀏覽器,其兼容性如下圖所示:
(圖片來(lái)源:https://github.com/eligrey/FileSaver.js)
在引入 FileSaver.js 這個(gè)庫(kù)之后,我們就可以使用它提供的 saveAs
方法來(lái)保存文件。該方法對(duì)應(yīng)的簽名如下所示:
FileSaver saveAs( Blob/File/Url, optional DOMString filename, optional Object { autoBom } )
saveAs 方法支持 3 個(gè)參數(shù),第 1 個(gè)參數(shù)表示它支持 Blob/File/Url
三種類型,第 2 個(gè)參數(shù)表示文件名(可選),而第 3 個(gè)參數(shù)表示配置對(duì)象(可選)。如果你需要 FlieSaver.js 自動(dòng)提供 Unicode 文本編碼提示(參考:字節(jié)順序標(biāo)記),則需要設(shè)置 { autoBom: true}
。
了解完 saveAs 方法之后,我們來(lái)舉 3 個(gè)具體的使用示例:
1. 保存文本
let blob = new Blob(['大家好,我是阿寶哥!' ], { type : 'text/plain;charset=utf-8' }); saveAs(blob, 'hello.txt' );
2. 保存線上資源
saveAs('https:///image' , 'image.jpg' );
如果下載的 URL 地址與當(dāng)前站點(diǎn)是同域的,則將使用 a[download]
方式下載。否則,會(huì)先使用 同步的 HEAD 請(qǐng)求 來(lái)判斷是否支持 CORS 機(jī)制,若支持的話,將進(jìn)行數(shù)據(jù)下載并使用 Blob URL 實(shí)現(xiàn)文件下載。如果不支持 CORS 機(jī)制的話,將會(huì)嘗試使用 a[download]
方式下載。
標(biāo)準(zhǔn)的 W3C File API Blob 接口并非在所有瀏覽器中都可用,對(duì)于這個(gè)問(wèn)題,你可以考慮使用 Blob.js 來(lái)解決兼容性問(wèn)題。
(圖片來(lái)源:https:///?search=blob)
3. 保存 canvas 畫(huà)布內(nèi)容
let canvas = document .getElementById('my-canvas' ); canvas.toBlob(function (blob ) { saveAs(blob, 'abao.png' ); });
需要注意的是 canvas.toBlob()
方法并非在所有瀏覽器中都可用,對(duì)于這個(gè)問(wèn)題,你可以考慮使用 canvas-toBlob.js 來(lái)解決兼容性問(wèn)題。
(圖片來(lái)源:https:///?search=toBlob)
介紹完 saveAs 方法的使用示例之后,我們來(lái)更新前面示例中的 download
方法:
function download ( ) { if (!imgDataUrl) { alert('請(qǐng)先合成圖片' ); return ; } const imgBlob = dataUrlToBlob(imgDataUrl, 'image/png' ); saveAs(imgBlob, 'face.png' ); }
很明顯,使用 saveAs 方法之后,下載已合成的圖片就很簡(jiǎn)單了。如果你對(duì) FileSaver.js 的工作原理感興趣的話,可以閱讀 聊一聊 15.5K 的 FileSaver,是如何工作的? 這篇文章。前面介紹的場(chǎng)景都是直接下載單個(gè)文件,其實(shí)我們也可以在客戶端同時(shí)下載多個(gè)文件,然后把已下載的文件壓縮成 Zip 包并下載到本地。
FileSaver 下載示例:file-saver
https://github.com/semlinker/file-download-demos/tree/main/file-saver
五、Zip 下載 在 文件上傳,搞懂這8種場(chǎng)景就夠了 這篇文章中,阿寶哥介紹了如何利用 JSZip 這個(gè)庫(kù)提供的 API,把待上傳目錄下的所有文件壓縮成 ZIP 文件,然后再把生成的 ZIP 文件上傳到服務(wù)器。同樣,利用 JSZip 這個(gè)庫(kù),我們可以實(shí)現(xiàn)在客戶端同時(shí)下載多個(gè)文件,然后把已下載的文件壓縮成 Zip 包,并下載到本地的功能。對(duì)應(yīng)的操作流程如下圖所示:
在以上 Gif 圖中,阿寶哥演示了把 3 張素材圖,打包成 Zip 文件并下載到本地的過(guò)程。接下來(lái),我們來(lái)介紹如何使用 JSZip 這個(gè)庫(kù)實(shí)現(xiàn)以上的功能。
html
<h3 > Zip 下載示例</h3 > <div > <img src ='../images/body.png' /> <img src ='../images/eyes.png' /> <img src ='../images/mouth.png' /> </div > <button onclick ='download()' > 打包下載</button >
js
const images = ['body.png' , 'eyes.png' , 'mouth.png' ];const imageUrls = images.map((name ) => '../images/' + name);async function download ( ) { let zip = new JSZip(); Promise .all(imageUrls.map(getFileContent)).then((contents ) => { contents.forEach((content, i ) => { zip.file(images[i], content); }); zip.generateAsync({ type : 'blob' }).then(function (blob ) { saveAs(blob, 'material.zip' ); }); }); }// 從指定的url上下載文件內(nèi)容 function getFileContent (fileUrl ) { return new JSZip.external.Promise(function (resolve, reject ) { // 調(diào)用jszip-utils庫(kù)提供的getBinaryContent方法獲取文件內(nèi)容 JSZipUtils.getBinaryContent(fileUrl, function (err, data ) { if (err) { reject(err); } else { resolve(data); } }); }); }
在以上代碼中,當(dāng)用戶點(diǎn)擊 打包下載 按鈕時(shí),就會(huì)調(diào)用 download
函數(shù)。在該函數(shù)內(nèi)部,會(huì)先調(diào)用 JSZip
構(gòu)造函數(shù)創(chuàng)建 JSZip
對(duì)象,然后使用 Promise.all 函數(shù)來(lái)確保所有的文件都下載完成后,再調(diào)用 file(name, data [,options])
方法,把已下載的文件添加到前面創(chuàng)建的 JSZip
對(duì)象中。最后通過(guò) zip.generateAsync
函數(shù)來(lái)生成 Zip 文件并使用 FileSaver.js 提供的 saveAs
方法保存 Zip 文件。
Zip 下載示例:Zip
https://github.com/semlinker/file-download-demos/tree/main/jszip
六、附件形式下載 在服務(wù)端下載的場(chǎng)景中,附件形式下載是一種比較常見(jiàn)的場(chǎng)景。在該場(chǎng)景下,我們通過(guò)設(shè)置 Content-Disposition
響應(yīng)頭來(lái)指示響應(yīng)的內(nèi)容以何種形式展示,是以內(nèi)聯(lián)(inline)的形式,還是以附件(attachment)的形式下載并保存到本地。
Content-Disposition: inline Content-Disposition: attachment Content-Disposition: attachment; filename='mouth.png'
而在 HTTP 表單的場(chǎng)景下, Content-Disposition
也可以作為 multipart body 中的消息頭:
Content-Disposition: form-data Content-Disposition: form-data; name='fieldName' Content-Disposition: form-data; name='fieldName'; filename='filename.jpg'
第 1 個(gè)參數(shù)總是固定不變的 form-data
;附加的參數(shù)不區(qū)分大小寫(xiě),并且擁有參數(shù)值,參數(shù)名與參數(shù)值用等號(hào)(=
)連接,參數(shù)值用雙引號(hào)括起來(lái)。參數(shù)之間用分號(hào)(;
)分隔。
了解完 Content-Disposition
的作用之后,我們來(lái)看一下如何實(shí)現(xiàn)以附件形式下載的功能。Koa 是一個(gè)簡(jiǎn)單易用的 Web 框架,它的特點(diǎn)是優(yōu)雅、簡(jiǎn)潔、輕量、自由度高。所以我們選擇它來(lái)搭建文件服務(wù),并使用 @koa/router 中間件來(lái)處理路由:
// attachment/file-server.js const fs = require ('fs' );const path = require ('path' );const Koa = require ('koa' );const Router = require ('@koa/router' );const app = new Koa();const router = new Router();const PORT = 3000 ;const STATIC_PATH = path.join(__dirname, './static/' );// http://localhost:3000/file?filename=mouth.png router.get('/file' , async (ctx, next) => { const { filename } = ctx.query; const filePath = STATIC_PATH + filename; const fStats = fs.statSync(filePath); ctx.set({ 'Content-Type' : 'application/octet-stream' , 'Content-Disposition' : `attachment; filename=${filename} ` , 'Content-Length' : fStats.size, }); ctx.body = fs.createReadStream(filePath); });// 注冊(cè)中間件 app.use(async (ctx, next) => { try { await next(); } catch (error) { // ENOENT(無(wú)此文件或目錄):通常是由文件操作引起的,這表明在給定的路徑上無(wú)法找到任何文件或目錄 ctx.status = error.code === 'ENOENT' ? 404 : 500 ; ctx.body = error.code === 'ENOENT' ? '文件不存在' : '服務(wù)器開(kāi)小差' ; } }); app.use(router.routes()).use(router.allowedMethods()); app.listen(PORT, () => { console .log(`應(yīng)用已經(jīng)啟動(dòng):http://localhost:${PORT} /` ); });
以上的代碼被保存在 attachment
目錄下的 file-server.js
文件中,該目錄下還有一個(gè) static
子目錄用于存放靜態(tài)資源。目前 static
目錄下包含以下 3 個(gè) png 文件。
├── file-server.js └── static ├── body.png ├── eyes.png └── mouth.png
當(dāng)你運(yùn)行 node file-server.js
命令成功啟動(dòng)文件服務(wù)器之后,就可以通過(guò)正確的 URL 地址來(lái)下載 static
目錄下的文件。比如在瀏覽器中打開(kāi) http://localhost:3000/file?filename=mouth.png
這個(gè)地址,你就會(huì)開(kāi)始下載 mouth.png
文件。而如果指定的文件不存在的話,就會(huì)返回文件不存在。
Koa 內(nèi)核很簡(jiǎn)潔,擴(kuò)展功能都是通過(guò)中間件來(lái)實(shí)現(xiàn)。比如常用的路由、CORS、靜態(tài)資源處理等功能都是通過(guò)中間件實(shí)現(xiàn)。因此要想掌握 Koa 這個(gè)框架,核心是掌握它的中間件機(jī)制。若你想深入了解 Koa 的話,可以閱讀 如何更好地理解中間件和洋蔥模型 這篇文章。
在編寫(xiě) HTML 網(wǎng)頁(yè)時(shí),對(duì)于一些簡(jiǎn)單圖片,通常會(huì)選擇將圖片內(nèi)容直接內(nèi)嵌在網(wǎng)頁(yè)中,從而減少不必要的網(wǎng)絡(luò)請(qǐng)求,但是圖片數(shù)據(jù)是二進(jìn)制數(shù)據(jù),該怎么嵌入呢?絕大多數(shù)現(xiàn)代瀏覽器都支持一種名為 Data URLs 的特性,允許使用 Base64 對(duì)圖片或其他文件的二進(jìn)制數(shù)據(jù)進(jìn)行編碼,將其作為文本字符串嵌入網(wǎng)頁(yè)中。所以文件也可以通過(guò) Base64 的格式進(jìn)行傳輸,接下來(lái)我們將介紹如何下載 Base64 格式的圖片。
附件形式下載示例:attachment
https://github.com/semlinker/file-download-demos/tree/main/attachment
七、base64 格式下載 Base64 是一種基于 64 個(gè)可打印字符來(lái)表示二進(jìn)制數(shù)據(jù)的表示方法。由于 2? = 64 ,所以每 6 個(gè)比特為一個(gè)單元,對(duì)應(yīng)某個(gè)可打印字符。3 個(gè)字節(jié)有 24 個(gè)比特,對(duì)應(yīng)于 4 個(gè) base64 單元,即 3 個(gè)字節(jié)可由 4 個(gè)可打印字符來(lái)表示。相應(yīng)的轉(zhuǎn)換過(guò)程如下圖所示:
Base64 常用在處理文本數(shù)據(jù)的場(chǎng)合,表示、傳輸、存儲(chǔ)一些二進(jìn)制數(shù)據(jù),包括 MIME 的電子郵件及 XML 的一些復(fù)雜數(shù)據(jù)。 在 MIME 格式的電子郵件中,base64 可以用來(lái)將二進(jìn)制的字節(jié)序列數(shù)據(jù)編碼成 ASCII 字符序列構(gòu)成的文本。使用時(shí),在傳輸編碼方式中指定 base64。使用的字符包括大小寫(xiě)拉丁字母各 26 個(gè)、數(shù)字 10 個(gè)、加號(hào) + 和斜杠 /,共 64 個(gè)字符,等號(hào) = 用來(lái)作為后綴用途。
Base64 的相關(guān)內(nèi)容就先介紹到這,如果你想進(jìn)一步了解 Base64 的話,可以閱讀 一文讀懂base64編碼 這篇文章。下面我們來(lái)看一下具體實(shí)現(xiàn)代碼:
7.1 前端代碼 html
在以下 HTML 代碼中,我們通過(guò) select
元素來(lái)讓用戶選擇要下載的圖片。當(dāng)用戶切換不同的圖片時(shí),img#imgPreview
元素中顯示的圖片會(huì)隨之發(fā)生變化。
<h3 > base64 下載示例</h3 > <img id ='imgPreview' src ='./static/body.png' /> <select id ='picSelect' > <option value ='body' > body.png</option > <option value ='eyes' > eyes.png</option > <option value ='mouth' > mouth.png</option > </select > <button onclick ='download()' > 下載</button >
js
const picSelectEle = document .querySelector('#picSelect' );const imgPreviewEle = document .querySelector('#imgPreview' ); picSelectEle.addEventListener('change' , (event) => { imgPreviewEle.src = './static/' + picSelectEle.value + '.png' ; });const request = axios.create({ baseURL : 'http://localhost:3000' , timeout : 60000 , });async function download ( ) { const response = await request.get('/file' , { params : { filename : picSelectEle.value + '.png' , }, }); if (response && response.data && response.data.code === 1 ) { const fileData = response.data.data; const { name, type, content } = fileData; const imgBlob = base64ToBlob(content, type); saveAs(imgBlob, name); } }
在用戶選擇好需要下載的圖片并點(diǎn)擊下載按鈕時(shí),就會(huì)調(diào)用以上代碼中的 download
函數(shù)。在該函數(shù)內(nèi)部,我們利用 axios 實(shí)例的 get
方法發(fā)起 HTTP 請(qǐng)求來(lái)獲取指定的圖片。因?yàn)榉祷氐氖?base64 格式的圖片,所以在調(diào)用 FileSaver 提供的 saveAs
方法前,我們需要將 base64 字符串轉(zhuǎn)換成 blob 對(duì)象,該轉(zhuǎn)換是通過(guò)以下的 base64ToBlob
函數(shù)來(lái)完成,該函數(shù)的具體實(shí)現(xiàn)如下所示:
function base64ToBlob (base64, mimeType ) { let bytes = window .atob(base64); let ab = new ArrayBuffer (bytes.length); let ia = new Uint8Array (ab); for (let i = 0 ; i < bytes.length; i++) { ia[i] = bytes.charCodeAt(i); } return new Blob([ab], { type : mimeType }); }
7.2 服務(wù)端代碼 // base64/file-server.js const fs = require ('fs' );const path = require ('path' );const mime = require ('mime' );const Koa = require ('koa' );const cors = require ('@koa/cors' );const Router = require ('@koa/router' );const app = new Koa();const router = new Router();const PORT = 3000 ;const STATIC_PATH = path.join(__dirname, './static/' ); router.get('/file' , async (ctx, next) => { const { filename } = ctx.query; const filePath = STATIC_PATH + filename; const fileBuffer = fs.readFileSync(filePath); ctx.body = { code : 1 , data : { name : filename, type : mime.getType(filename), content : fileBuffer.toString('base64' ), }, }; });// 注冊(cè)中間件 app.use(async (ctx, next) => { try { await next(); } catch (error) { ctx.body = { code : 0 , msg : '服務(wù)器開(kāi)小差' , }; } }); app.use(cors()); app.use(router.routes()).use(router.allowedMethods()); app.listen(PORT, () => { console .log(`應(yīng)用已經(jīng)啟動(dòng):http://localhost:${PORT} /` ); });
在以上代碼中,對(duì)圖片進(jìn)行 Base64 編碼的操作是定義在 /file
路由對(duì)應(yīng)的路由處理器中。當(dāng)該服務(wù)器接收到客戶端發(fā)起的文件下載請(qǐng)求,比如 GET /file?filename=body.png HTTP/1.1
時(shí),就會(huì)從 ctx.query
對(duì)象上獲取 filename
參數(shù)。該參數(shù)表示文件的名稱,在獲取到文件的名稱之后,我們就可以拼接出文件的絕對(duì)路徑,然后通過(guò) Node.js 平臺(tái)提供的 fs.readFileSync
方法讀取文件的內(nèi)容,該方法會(huì)返回一個(gè) Buffer 對(duì)象。在成功讀取文件的內(nèi)容之后,我們會(huì)繼續(xù)調(diào)用 Buffer 對(duì)象的 toString
方法對(duì)文件內(nèi)容進(jìn)行 Base64 編碼,最終所下載的圖片將以 Base64 格式返回到客戶端。
base64 格式下載示例:base64
https://github.com/semlinker/file-download-demos/tree/main/base64
八、chunked 下載 分塊傳輸編碼主要應(yīng)用于如下場(chǎng)景,即要傳輸大量的數(shù)據(jù),但是在請(qǐng)求在沒(méi)有被處理完之前響應(yīng)的長(zhǎng)度是無(wú)法獲得的。例如,當(dāng)需要用從數(shù)據(jù)庫(kù)中查詢獲得的數(shù)據(jù)生成一個(gè)大的 HTML 表格的時(shí)候,或者需要傳輸大量的圖片的時(shí)候。
要使用分塊傳輸編碼,則需要在響應(yīng)頭配置 Transfer-Encoding
字段,并設(shè)置它的值為 chunked
或 gzip, chunked
:
Transfer-Encoding: chunked Transfer-Encoding: gzip, chunked
響應(yīng)頭 Transfer-Encoding
字段的值為 chunked
,表示數(shù)據(jù)以一系列分塊的形式進(jìn)行發(fā)送。需要注意的是 Transfer-Encoding
和 Content-Length
這兩個(gè)字段是互斥的,也就是說(shuō)響應(yīng)報(bào)文中這兩個(gè)字段不能同時(shí)出現(xiàn)。下面我們來(lái)看一下分塊傳輸?shù)木幋a規(guī)則:
每個(gè)分塊包含分塊長(zhǎng)度和數(shù)據(jù)塊兩個(gè)部分; 分塊長(zhǎng)度使用 16 進(jìn)制數(shù)字表示,以 \r\n
結(jié)尾; 數(shù)據(jù)塊緊跟在分塊長(zhǎng)度后面,也使用 \r\n
結(jié)尾,但數(shù)據(jù)不包含 \r\n
; 終止塊是一個(gè)常規(guī)的分塊,表示塊的結(jié)束。不同之處在于其長(zhǎng)度為 0,即 0\r\n\r\n
。 了解完分塊傳輸?shù)木幋a規(guī)則,我們來(lái)看如何利用分塊傳輸編碼實(shí)現(xiàn)文件下載。
8.1 前端代碼 html5
<h3 > chunked 下載示例</h3 > <button onclick ='download()' > 下載</button >
js
const chunkedUrl = 'http://localhost:3000/file?filename=file.txt' ;function download ( ) { return fetch(chunkedUrl) .then(processChunkedResponse) .then(onChunkedResponseComplete) .catch(onChunkedResponseError); }function processChunkedResponse (response ) { let text = '' ; let reader = response.body.getReader(); let decoder = new TextDecoder(); return readChunk(); function readChunk ( ) { return reader.read().then(appendChunks); } function appendChunks (result ) { let chunk = decoder.decode(result.value || new Uint8Array (), { stream : !result.done, }); console .log('已接收到的數(shù)據(jù):' , chunk); console .log('本次已成功接收' , chunk.length, 'bytes' ); text += chunk; console .log('目前為止共接收' , text.length, 'bytes\n' ); if (result.done) { return text; } else { return readChunk(); } } }function onChunkedResponseComplete (result ) { let blob = new Blob([result], { type : 'text/plain;charset=utf-8' , }); saveAs(blob, 'hello.txt' ); }function onChunkedResponseError (err ) { console .error(err); }
當(dāng)用戶點(diǎn)擊 下載 按鈕時(shí),就會(huì)調(diào)用以上代碼中的 download
函數(shù)。在該函數(shù)內(nèi)部,我們會(huì)使用 Fetch API 來(lái)執(zhí)行下載操作。因?yàn)榉?wù)端的數(shù)據(jù)是以一系列分塊的形式進(jìn)行發(fā)送,所以在瀏覽器端我們是通過(guò)流的形式進(jìn)行接收。即通過(guò) response.body
獲取可讀的 ReadableStream,然后用 ReadableStream.getReader()
創(chuàng)建一個(gè)讀取器,最后調(diào)用 reader.read
方法來(lái)讀取已返回的分塊數(shù)據(jù)。
因?yàn)?file.txt
文件的內(nèi)容是普通文本,且 result.value
的值是 Uint8Array 類型的數(shù)據(jù),所以在處理返回的分塊數(shù)據(jù)時(shí),我們使用了 TextDecoder 文本解碼器。一個(gè)解碼器只支持一種特定文本編碼,例如 utf-8
、iso-8859-2
、koi8
、cp1261
,gbk
等等。
如果收到的分塊非 終止塊 ,result.done
的值是 false
,則會(huì)繼續(xù)調(diào)用 readChunk
方法來(lái)讀取分塊數(shù)據(jù)。而當(dāng)接收到 終止塊 之后,表示分塊數(shù)據(jù)已傳輸完成。此時(shí),result.done
屬性就會(huì)返回 true
。從而會(huì)自動(dòng)調(diào)用 onChunkedResponseComplete
函數(shù),在該函數(shù)內(nèi)部,我們以解碼后的文本作為參數(shù)來(lái)創(chuàng)建 Blob 對(duì)象。之后,繼續(xù)使用 FileSaver 庫(kù)提供的 saveAs
方法實(shí)現(xiàn)文件下載。
這里我們用 Wireshark 網(wǎng)絡(luò)包分析工具,抓了個(gè)數(shù)據(jù)包。具體如下圖所示:
從圖中我們可以清楚地看到在 HTTP chunked response 下面包含了 Data chunk(數(shù)據(jù)塊) 和 End of chunked encoding(終止塊) 。接下來(lái),我們來(lái)看一下服務(wù)端的代碼。
8.2 服務(wù)端代碼 const fs = require ('fs' );const path = require ('path' );const Koa = require ('koa' );const cors = require ('@koa/cors' );const Router = require ('@koa/router' );const app = new Koa();const router = new Router();const PORT = 3000 ; router.get('/file' , async (ctx, next) => { const { filename } = ctx.query; const filePath = path.join(__dirname, filename); ctx.set({ 'Content-Type' : 'text/plain;charset=utf-8' , }); ctx.body = fs.createReadStream(filePath); });// 注冊(cè)中間件 app.use(async (ctx, next) => { try { await next(); } catch (error) { // ENOENT(無(wú)此文件或目錄):通常是由文件操作引起的,這表明在給定的路徑上無(wú)法找到任何文件或目錄 ctx.status = error.code === 'ENOENT' ? 404 : 500 ; ctx.body = error.code === 'ENOENT' ? '文件不存在' : '服務(wù)器開(kāi)小差' ; } }); app.use(cors()); app.use(router.routes()).use(router.allowedMethods()); app.listen(PORT, () => { console .log(`應(yīng)用已經(jīng)啟動(dòng):http://localhost:${PORT} /` ); });
在 /file
路由處理器中,我們先通過(guò) ctx.query
獲得 filename
文件名,接著拼接出該文件的絕對(duì)路徑,然后通過(guò) Node.js 平臺(tái)提供的 fs.createReadStream
方法創(chuàng)建可讀流。最后把已創(chuàng)建的可讀流賦值給 ctx.body
屬性,從而向客戶端返回圖片數(shù)據(jù)。
現(xiàn)在我們已經(jīng)知道可以利用分塊傳輸編碼(Transfer-Encoding)實(shí)現(xiàn)數(shù)據(jù)的分塊傳輸,那么有沒(méi)有辦法獲取指定范圍內(nèi)的文件數(shù)據(jù)呢?對(duì)于這個(gè)問(wèn)題,我們可以利用 HTTP 協(xié)議的范圍請(qǐng)求。接下來(lái),我們將介紹如何利用 HTTP 范圍請(qǐng)求來(lái)下載指定范圍的數(shù)據(jù)。
chunked 下載示例:chunked
https://github.com/semlinker/file-download-demos/tree/main/chunked
九、范圍下載 HTTP 協(xié)議范圍請(qǐng)求允許服務(wù)器只發(fā)送 HTTP 消息的一部分到客戶端。范圍請(qǐng)求在傳送大的媒體文件,或者與文件下載的斷點(diǎn)續(xù)傳功能搭配使用時(shí)非常有用。如果在響應(yīng)中存在 Accept-Ranges
首部(并且它的值不為 “none”),那么表示該服務(wù)器支持范圍請(qǐng)求。
在一個(gè) Range 首部中,可以一次性請(qǐng)求多個(gè)部分,服務(wù)器會(huì)以 multipart 文件的形式將其返回。如果服務(wù)器返回的是范圍響應(yīng),需要使用 206 Partial Content 狀態(tài)碼。假如所請(qǐng)求的范圍不合法,那么服務(wù)器會(huì)返回 416 Range Not Satisfiable 狀態(tài)碼,表示客戶端錯(cuò)誤。服務(wù)器允許忽略 Range 首部,從而返回整個(gè)文件,狀態(tài)碼用 200 。
Range 語(yǔ)法:
Range: <unit>=<range-start>- Range: <unit>=<range-start>-<range-end> Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end> Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>, <range-start>-<range-end>
unit
:范圍請(qǐng)求所采用的單位,通常是字節(jié)(bytes)。<range-start>
:一個(gè)整數(shù),表示在特定單位下,范圍的起始值。<range-end>
:一個(gè)整數(shù),表示在特定單位下,范圍的結(jié)束值。這個(gè)值是可選的,如果不存在,表示此范圍一直延伸到文檔結(jié)束。 了解完 Range
語(yǔ)法之后,我們來(lái)看一下實(shí)際的使用示例:
# 單一范圍 $ curl http://i./z4d4kWk.jpg -i -H 'Range: bytes=0-1023' # 多重范圍 $ curl http://www. -i -H 'Range: bytes=0-50, 100-150'
9.1 前端代碼 html
<h3 > 范圍下載示例</h3 > <button onclick ='download()' > 下載</button >
js
async function download ( ) { try { let rangeContent = await getBinaryContent( 'http://localhost:3000/file.txt' , 0 , 100 , 'text' ); const blob = new Blob([rangeContent], { type : 'text/plain;charset=utf-8' , }); saveAs(blob, 'hello.txt' ); } catch (error) { console .error(error); } }function getBinaryContent (url, start, end, responseType = 'arraybuffer' ) { return new Promise ((resolve, reject ) => { try { let xhr = new XMLHttpRequest(); xhr.open('GET' , url, true ); xhr.setRequestHeader('range' , `bytes=${start} -${end} ` ); xhr.responseType = responseType; xhr.onload = function ( ) { resolve(xhr.response); }; xhr.send(); } catch (err) { reject(new Error (err)); } }); }
當(dāng)用戶點(diǎn)擊 下載 按鈕時(shí),就會(huì)調(diào)用 download
函數(shù)。在該函數(shù)內(nèi)部會(huì)通過(guò)調(diào)用 getBinaryContent
函數(shù)來(lái)發(fā)起范圍請(qǐng)求。對(duì)應(yīng)的 HTTP 請(qǐng)求報(bào)文如下所示:
GET /file.txt HTTP/1.1 Host: localhost:3000 Connection: keep-alive User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36 Accept: */* Accept-Encoding: identity Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,id;q=0.7 Range: bytes=0-100
而當(dāng)服務(wù)器接收到該范圍請(qǐng)求之后,會(huì)返回對(duì)應(yīng)的 HTTP 響應(yīng)報(bào)文:
HTTP/1.1 206 Partial Content Vary: Origin Access-Control-Allow-Origin: null Accept-Ranges: bytes Last-Modified: Fri, 09 Jul 2021 00:17:00 GMT Cache-Control: max-age=0 Content-Type: text/plain; charset=utf-8 Date: Sat, 10 Jul 2021 02:19:39 GMT Connection: keep-alive Content-Range: bytes 0-100/2590 Content-Length: 101
從以上的 HTTP 響應(yīng)報(bào)文中,我們見(jiàn)到了前面介紹的 206 狀態(tài)碼和 Accept-Ranges 首部。此外,通過(guò) Content-Range
首部,我們就知道了文件的總大小。在成功獲取到范圍請(qǐng)求的響應(yīng)體之后,我們就可以使用返回的內(nèi)容作為參數(shù),調(diào)用 Blob 構(gòu)造函數(shù)創(chuàng)建對(duì)應(yīng)的 Blob 對(duì)象,進(jìn)而使用 FileSaver 庫(kù)提供的 saveAs 方法來(lái)下載文件了。
9.2 服務(wù)端代碼 const Koa = require ('koa' );const cors = require ('@koa/cors' );const serve = require ('koa-static' );const range = require ('koa-range' );const PORT = 3000 ;const app = new Koa();// 注冊(cè)中間件 app.use(cors()); app.use(range); app.use(serve('.' )); app.listen(PORT, () => { console .log(`應(yīng)用已經(jīng)啟動(dòng):http://localhost:${PORT} /` ); });
服務(wù)端的代碼相對(duì)比較簡(jiǎn)單,范圍請(qǐng)求是通過(guò) koa-range 中間件來(lái)實(shí)現(xiàn)的。由于篇幅有限,阿寶哥就不展開(kāi)介紹了。感興趣的小伙伴,可以自行閱讀該中間件的源碼。其實(shí)范圍請(qǐng)求還可以應(yīng)用在大文件下載的場(chǎng)景,如果文件服務(wù)器支持范圍請(qǐng)求的話,客戶端在下載大文件的時(shí)候,就可以考慮使用大文件分塊下載的方案。
范圍下載示例:range
https://github.com/semlinker/file-download-demos/tree/main/range
十、大文件分塊下載 相信有些小伙伴已經(jīng)了解大文件上傳的解決方案,在上傳大文件時(shí),為了提高上傳的效率,我們一般會(huì)使用 Blob.slice 方法對(duì)大文件按照指定的大小進(jìn)行切割,然后在開(kāi)啟多線程進(jìn)行分塊上傳,等所有分塊都成功上傳后,再通知服務(wù)端進(jìn)行分塊合并。
那么對(duì)大文件下載來(lái)說(shuō),我們能否采用類似的思想呢?其實(shí)在服務(wù)端支持 Range
請(qǐng)求首部的條件下,我們也是可以實(shí)現(xiàn)大文件分塊下載的功能,具體處理方案如下圖所示:
因?yàn)樵?JavaScript 中如何實(shí)現(xiàn)大文件并發(fā)下載? 這篇文章中,阿寶哥已經(jīng)詳細(xì)介紹了大文件并發(fā)下載的方案,所以這里就不展開(kāi)介紹了。我們只回顧一下大文件并發(fā)下載的完整流程:
其實(shí)在大文件分塊下載的場(chǎng)景中,我們使用了 async-pool 這個(gè)庫(kù)來(lái)實(shí)現(xiàn)并發(fā)控制。該庫(kù)提供了 ES7 和 ES6 兩種不同版本的實(shí)現(xiàn),代碼很簡(jiǎn)潔優(yōu)雅。如果你想了解 async-pool 是如何實(shí)現(xiàn)并發(fā)控制的,可以閱讀 JavaScript 中如何實(shí)現(xiàn)并發(fā)控制? 這篇文章。
大文件分塊下載示例:big-file
https://github.com/semlinker/file-download-demos/tree/main/big-file
十一、總結(jié) 本文阿寶哥詳細(xì)介紹了文件下載的 9 種場(chǎng)景,希望閱讀完本文后,你對(duì) 9 種場(chǎng)景背后使用的技術(shù)有一定的了解。其實(shí)在傳輸文件的過(guò)程中,為了提高傳輸效率,我們可以使用 gzip
、deflate
或 br
等壓縮算法對(duì)文件進(jìn)行壓縮。由于篇幅有限,阿寶哥就不展開(kāi)介紹了,如果你感興趣的話,可以閱讀 HTTP 傳輸大文件的幾種方案 這篇文章。
有了文件下載的場(chǎng)景,怎么能缺少文件上傳的場(chǎng)景呢?如果你還沒(méi)閱讀過(guò) 文件上傳,搞懂這 8 種場(chǎng)景就夠了 這篇文章,建議你有空的時(shí)候,可以一起了解一下。這里再次感謝掘友們一直以來(lái)的支持,如果你們還想了解其他方面的內(nèi)容,歡迎給阿寶哥留言喲。
十二、參考資源 MDN — Content-Disposition The File System Access API: simplifying access to local files Reading and writing files and directories with the browser-fs-access library