文件下載,搞懂這9種場景就夠了
在 文件上傳,搞懂這8種場景就夠了 這篇文章發(fā)布之后,阿寶哥收到了挺多掘友的留言,感謝掘友們一直以來的鼓勵與支持。其中掘友 @我的煙雨不在江南 和 @rainx 在文章底部分別發(fā)了以下留言:


既然掘友有要求,連標題也幫阿寶哥想好了,那我們就來整一篇文章,總結(jié)一下文件下載的場景。
一般在我們工作中,主要會涉及到 9 種文件下載的場景,每一種場景背后都使用不同的技術(shù),其中也有很多細節(jié)需要我們額外注意。今天阿寶哥就來帶大家總結(jié)一下這 9 種場景,讓大家能夠輕松地應(yīng)對各種下載場景。閱讀本文后,你將會了解以下的內(nèi)容:

在瀏覽器端處理文件的時候,我們經(jīng)常會用到 Blob 。比如圖片本地預(yù)覽、圖片壓縮、大文件分塊上傳及文件下載。在瀏覽器端文件下載的場景中,比如我們今天要講到的 a 標簽下載、showSaveFilePicker API 下載、Zip 下載 等場景中,都會使用到 Blob ,所以我們有必要在學習具體應(yīng)用前,先掌握它的相關(guān)知識,這樣可以幫助我們更好地了解示例代碼。
一、基礎(chǔ)知識
1.1 了解 Blob
Blob(Binary Large Object)表示二進制類型的大對象。在數(shù)據(jù)庫管理系統(tǒng)中,將二進制數(shù)據(jù)存儲為一個單一個體的集合。Blob 通常是影像、聲音或多媒體文件。在 JavaScript 中 Blob 類型的對象表示一個不可變、原始數(shù)據(jù)的類文件對象。 它的數(shù)據(jù)可以按文本或二進制的格式進行讀取,也可以轉(zhuǎn)換成 ReadableStream 用于數(shù)據(jù)操作。
Blob 對象由一個可選的字符串 type(通常是 MIME 類型)和 blobParts 組成:
在 JavaScript 中你可以通過 Blob 的構(gòu)造函數(shù)來創(chuàng)建 Blob 對象,Blob 構(gòu)造函數(shù)的語法如下:
const aBlob = new Blob(blobParts, options);
相關(guān)的參數(shù)說明如下:
blobParts:它是一個由 ArrayBuffer,ArrayBufferView,Blob,DOMString 等對象構(gòu)成的數(shù)組。DOMStrings 會被編碼為 UTF-8。 options:一個可選的對象,包含以下兩個屬性: type —— 默認值為 "",它代表了將會被放入到 blob 中的數(shù)組內(nèi)容的 MIME 類型。endings —— 默認值為 "transparent",用于指定包含行結(jié)束符\n的字符串如何被寫入。 它是以下兩個值中的一個:"native",代表行結(jié)束符會被更改為適合宿主操作系統(tǒng)文件系統(tǒng)的換行符,或者"transparent",代表會保持 blob 中保存的結(jié)束符不變。
1.2 了解 Blob URL
Blob URL/Object URL 是一種偽協(xié)議,允許 Blob 和 File 對象用作圖像、下載二進制數(shù)據(jù)鏈接等的 URL 源。在瀏覽器中,我們使用 URL.createObjectURL 方法來創(chuàng)建 Blob URL,該方法接收一個 Blob 對象,并為其創(chuàng)建一個唯一的 URL,其形式為 blob:<origin>/<uuid>,對應(yīng)的示例如下:
blob:http://localhost:3000/53acc2b6-f47b-450f-a390-bf0665e04e59
瀏覽器內(nèi)部為每個通過 URL.createObjectURL 生成的 URL 存儲了一個 URL → Blob 映射。因此,此類 URL 較短,但可以訪問 Blob。生成的 URL 僅在當前文檔打開的狀態(tài)下才有效。它允許引用 <img>、<a> 中的 Blob,但如果你訪問的 Blob URL 不再存在,則會從瀏覽器中收到 404 錯誤。
上述的 Blob URL 看似很不錯,但實際上它也有副作用。 雖然存儲了 URL → Blob 的映射,但 Blob 本身仍駐留在內(nèi)存中,瀏覽器無法釋放它。映射在文檔卸載時自動清除,因此 Blob 對象隨后被釋放。但是,如果應(yīng)用程序壽命很長,那么 Blob 在短時間內(nèi)將無法被瀏覽器釋放。因此,如果你創(chuàng)建一個 Blob URL,即使不再需要該 Blob,它也會存在內(nèi)存中。
針對這個問題,你可以調(diào)用 URL.revokeObjectURL(url) 方法,從內(nèi)部映射中刪除引用,從而允許刪除 Blob(如果沒有其他引用),并釋放內(nèi)存。
現(xiàn)在你已經(jīng)了解了 Blob 和 Blob URL,如果你還意猶未盡,想深入理解 Blob 的話,可以閱讀 你不知道的 Blob 這篇文章。下面我們開始介紹客戶端文件下載的場景。
隨著 Web 技術(shù)的不斷發(fā)展,瀏覽器的功能也越來越強大。這些年出現(xiàn)了很多在線 Web 設(shè)計工具,比如在線 PS、在線海報設(shè)計器或在線自定義表單設(shè)計器等。這些 Web 設(shè)計器允許用戶在完成設(shè)計之后,把生成的文件保存到本地,其中有一部分設(shè)計器就是利用瀏覽器提供的 Web API 來實現(xiàn)客戶端文件下載。下面阿寶哥先來介紹客戶端下載中,最常見的 a 標簽下載 方案。
二、a 標簽下載
html
<h3>a 標簽下載示例</h3>
<div>
<img src="../images/body.png" />
<img src="../images/eyes.png" />
<img src="../images/mouth.png" />
</div>
<img id="mergedPic" src="http://via.placeholder.com/256" />
<button onclick="merge()">圖片合成</button>
<button onclick="download()">圖片下載</button>
在以上代碼中,我們通過 img 標簽引用了以下 3 張素材:

當用戶點擊 圖片合成 按鈕時,會將合成的圖片顯示在 img#mergedPic 容器中。在圖片成功合成之后,用戶可以通過點擊 圖片下載 按鈕把已合成的圖片下載到本地。對應(yīng)的操作流程如下圖所示:

由上圖可知,整體的操作流程相對簡單。接下來,我們來看一下 圖片合成 和 圖片下載 的實現(xiàn)邏輯。
js
圖片合成的功能,阿寶哥是直接使用 Github 上 merge-images 這個第三方庫來實現(xiàn)。利用該庫提供的 mergeImages(images, [options]) 方法,我們可以輕松地實現(xiàn)圖片合成的功能。調(diào)用該方法后,會返回一個 Promise 對象,當異步操作完成后,合成的圖片會以 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 這兩個函數(shù)來實現(xiàn)。它們分別用于實現(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)
}
因為本文的主題是介紹文件下載,所以我們來重點分析 saveFile 函數(shù)。在該函數(shù)內(nèi)部,我們使用了 HTMLAnchorElement.download 屬性,該屬性值表示下載文件的名稱。如果該名稱不是操作系統(tǒng)的有效文件名,瀏覽器將會對其進行調(diào)整。此外,該屬性的作用是表明鏈接的資源將被下載,而不是顯示在瀏覽器中。
需要注意的是,download 屬性存在兼容性問題,比如 IE 11 及以下的版本不支持該屬性,具體如下圖所示:

(圖片來源:https://caniuse.com/download)
當設(shè)置好 a 元素的 download 屬性之后,我們會調(diào)用 URL.createObjectURL 方法來創(chuàng)建 Object URL,并把返回的 URL 賦值給 a 元素的 href 屬性。接著通過調(diào)用 a 元素的 click 方法來觸發(fā)文件的下載操作,最后還會調(diào)用一次 URL.revokeObjectURL 方法,從內(nèi)部映射中刪除引用,從而允許刪除 Blob(如果沒有其他引用),并釋放內(nèi)存。
關(guān)于 a 標簽下載 的內(nèi)容就介紹到這,下面我們來介紹如何使用新的 Web API —— showSaveFilePicker 實現(xiàn)文件下載。
a 標簽下載示例:a-tag
https://github.com/semlinker/file-download-demos/tree/main/a-tag
三、showSaveFilePicker API 下載
showSaveFilePicker API 是 Window 接口中定義的方法,調(diào)用該方法后會顯示允許用戶選擇保存路徑的文件選擇器。該方法的簽名如下所示:
let FileSystemFileHandle = Window.showSaveFilePicker(options);
showSaveFilePicker 方法支持一個對象類型的可選參數(shù),可包含以下屬性:
excludeAcceptAllOption:布爾類型,默認值為false。默認情況下,選擇器應(yīng)包含一個不應(yīng)用任何文件類型過濾器的選項(由下面的types選項啟用)。將此選項設(shè)置為true意味著types選項不可用。types:數(shù)組類型,表示允許保存的文件類型列表。數(shù)組中的每一項是包含以下屬性的配置對象:description(可選):用于描述允許保存文件類型類別。accept:是一個對象,該對象的key是 MIME 類型,值是文件擴展名列表。
調(diào)用 showSaveFilePicker 方法之后,會返回一個 FileSystemFileHandle 對象。有了該對象,你就可以調(diào)用該對象上的方法來操作文件。比如調(diào)用該對象上的 createWritable 方法之后,就會返回 FileSystemWritableFileStream 對象,就可以把數(shù)據(jù)寫入到文件中。具體的使用方式如下所示:
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("請先合成圖片");
return;
}
const imgBlob = dataUrlToBlob(imgDataUrl, "image/png");
saveFile(imgBlob, "face.png");
}
當你使用以上更新后的 saveFile 函數(shù),來保存已合成的圖片時,會顯示以下保存文件選擇器:

由上圖可知,相比 a 標簽下載 的方式,showSaveFilePicker API 允許你選擇文件的下載目錄、選擇文件的保存格式和更改存儲的文件名稱??吹竭@里是不是覺得 showSaveFilePicker API 功能挺強大的,不過可惜的是該 API 目前的兼容性還不是很好,具體如下圖所示:

(圖片來源:https://caniuse.com/?search=showSaveFilePicker)
其實 showSaveFilePicker 是 File System Access API 中定義的方法,除了 showSaveFilePicker 之外,還有 showOpenFilePicker 和 showDirectoryPicker 等方法。如果你想在實際項目中使用這些 API 的話,可以考慮使用 GoogleChromeLabs 開源的 browser-fs-access 這個庫,該庫可以讓你在支持平臺上更方便地使用 File System Access API,對于不支持的平臺會自動降級使用 <input type="file"> 和 <a download> 的方式。
可能大家對 browser-fs-access 這個庫會比較陌生,但是如果換成是 FileSaver.js 這個庫的話,應(yīng)該就比較熟悉了。接下來,我們來介紹如何利用 FileSaver.js 這個庫實現(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 實現(xiàn),支持大多數(shù)主流的瀏覽器,其兼容性如下圖所示:

(圖片來源:https://github.com/eligrey/FileSaver.js)
在引入 FileSaver.js 這個庫之后,我們就可以使用它提供的 saveAs 方法來保存文件。該方法對應(yīng)的簽名如下所示:
FileSaver saveAs(
Blob/File/Url,
optional DOMString filename,
optional Object { autoBom }
)
saveAs 方法支持 3 個參數(shù),第 1 個參數(shù)表示它支持 Blob/File/Url 三種類型,第 2 個參數(shù)表示文件名(可選),而第 3 個參數(shù)表示配置對象(可選)。如果你需要 FlieSaver.js 自動提供 Unicode 文本編碼提示(參考:字節(jié)順序標記),則需要設(shè)置 { autoBom: true}。
了解完 saveAs 方法之后,我們來舉 3 個具體的使用示例:
1. 保存文本
let blob = new Blob(["大家好,我是阿寶哥!"], { type: "text/plain;charset=utf-8" });
saveAs(blob, "hello.txt");
2. 保存線上資源
saveAs("https://httpbin.org/image", "image.jpg");
如果下載的 URL 地址與當前站點是同域的,則將使用 a[download] 方式下載。否則,會先使用 同步的 HEAD 請求 來判斷是否支持 CORS 機制,若支持的話,將進行數(shù)據(jù)下載并使用 Blob URL 實現(xiàn)文件下載。如果不支持 CORS 機制的話,將會嘗試使用 a[download] 方式下載。
標準的 W3C File API Blob 接口并非在所有瀏覽器中都可用,對于這個問題,你可以考慮使用 Blob.js 來解決兼容性問題。

(圖片來源:https://caniuse.com/?search=blob)
3. 保存 canvas 畫布內(nèi)容
let canvas = document.getElementById("my-canvas");
canvas.toBlob(function(blob) {
saveAs(blob, "abao.png");
});
需要注意的是 canvas.toBlob() 方法并非在所有瀏覽器中都可用,對于這個問題,你可以考慮使用 canvas-toBlob.js 來解決兼容性問題。

(圖片來源:https://caniuse.com/?search=toBlob)
介紹完 saveAs 方法的使用示例之后,我們來更新前面示例中的 download 方法:
function download() {
if (!imgDataUrl) {
alert("請先合成圖片");
return;
}
const imgBlob = dataUrlToBlob(imgDataUrl, "image/png");
saveAs(imgBlob, "face.png");
}
很明顯,使用 saveAs 方法之后,下載已合成的圖片就很簡單了。如果你對 FileSaver.js 的工作原理感興趣的話,可以閱讀 聊一聊 15.5K 的 FileSaver,是如何工作的? 這篇文章。前面介紹的場景都是直接下載單個文件,其實我們也可以在客戶端同時下載多個文件,然后把已下載的文件壓縮成 Zip 包并下載到本地。
FileSaver 下載示例:file-saver
https://github.com/semlinker/file-download-demos/tree/main/file-saver
五、Zip 下載
在 文件上傳,搞懂這8種場景就夠了 這篇文章中,阿寶哥介紹了如何利用 JSZip 這個庫提供的 API,把待上傳目錄下的所有文件壓縮成 ZIP 文件,然后再把生成的 ZIP 文件上傳到服務(wù)器。同樣,利用 JSZip 這個庫,我們可以實現(xiàn)在客戶端同時下載多個文件,然后把已下載的文件壓縮成 Zip 包,并下載到本地的功能。對應(yīng)的操作流程如下圖所示:

在以上 Gif 圖中,阿寶哥演示了把 3 張素材圖,打包成 Zip 文件并下載到本地的過程。接下來,我們來介紹如何使用 JSZip 這個庫實現(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庫提供的getBinaryContent方法獲取文件內(nèi)容
JSZipUtils.getBinaryContent(fileUrl, function (err, data) {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
}
在以上代碼中,當用戶點擊 打包下載 按鈕時,就會調(diào)用 download 函數(shù)。在該函數(shù)內(nèi)部,會先調(diào)用 JSZip 構(gòu)造函數(shù)創(chuàng)建 JSZip 對象,然后使用 Promise.all 函數(shù)來確保所有的文件都下載完成后,再調(diào)用 file(name, data [,options]) 方法,把已下載的文件添加到前面創(chuàng)建的 JSZip 對象中。最后通過 zip.generateAsync 函數(shù)來生成 Zip 文件并使用 FileSaver.js 提供的 saveAs 方法保存 Zip 文件。
Zip 下載示例:Zip
https://github.com/semlinker/file-download-demos/tree/main/jszip
六、附件形式下載
在服務(wù)端下載的場景中,附件形式下載是一種比較常見的場景。在該場景下,我們通過設(shè)置 Content-Disposition 響應(yīng)頭來指示響應(yīng)的內(nèi)容以何種形式展示,是以內(nèi)聯(lián)(inline)的形式,還是以附件(attachment)的形式下載并保存到本地。
Content-Disposition: inline
Content-Disposition: attachment
Content-Disposition: attachment; filename="mouth.png"
而在 HTTP 表單的場景下, Content-Disposition 也可以作為 multipart body 中的消息頭:
Content-Disposition: form-data
Content-Disposition: form-data; name="fieldName"
Content-Disposition: form-data; name="fieldName"; filename="filename.jpg"
第 1 個參數(shù)總是固定不變的 form-data;附加的參數(shù)不區(qū)分大小寫,并且擁有參數(shù)值,參數(shù)名與參數(shù)值用等號(=)連接,參數(shù)值用雙引號括起來。參數(shù)之間用分號(;)分隔。
了解完 Content-Disposition 的作用之后,我們來看一下如何實現(xiàn)以附件形式下載的功能。Koa 是一個簡單易用的 Web 框架,它的特點是優(yōu)雅、簡潔、輕量、自由度高。所以我們選擇它來搭建文件服務(wù),并使用 @koa/router 中間件來處理路由:
// 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);
});
// 注冊中間件
app.use(async (ctx, next) => {
try {
await next();
} catch (error) {
// ENOENT(無此文件或目錄):通常是由文件操作引起的,這表明在給定的路徑上無法找到任何文件或目錄
ctx.status = error.code === "ENOENT" ? 404 : 500;
ctx.body = error.code === "ENOENT" ? "文件不存在" : "服務(wù)器開小差";
}
});
app.use(router.routes()).use(router.allowedMethods());
app.listen(PORT, () => {
console.log(`應(yīng)用已經(jīng)啟動:http://localhost:${PORT}/`);
});
以上的代碼被保存在 attachment 目錄下的 file-server.js 文件中,該目錄下還有一個 static 子目錄用于存放靜態(tài)資源。目前 static 目錄下包含以下 3 個 png 文件。
├── file-server.js
└── static
├── body.png
├── eyes.png
└── mouth.png
當你運行 node file-server.js 命令成功啟動文件服務(wù)器之后,就可以通過正確的 URL 地址來下載 static 目錄下的文件。比如在瀏覽器中打開 http://localhost:3000/file?filename=mouth.png 這個地址,你就會開始下載 mouth.png 文件。而如果指定的文件不存在的話,就會返回文件不存在。
Koa 內(nèi)核很簡潔,擴展功能都是通過中間件來實現(xiàn)。比如常用的路由、CORS、靜態(tài)資源處理等功能都是通過中間件實現(xiàn)。因此要想掌握 Koa 這個框架,核心是掌握它的中間件機制。若你想深入了解 Koa 的話,可以閱讀 如何更好地理解中間件和洋蔥模型 這篇文章。
在編寫 HTML 網(wǎng)頁時,對于一些簡單圖片,通常會選擇將圖片內(nèi)容直接內(nèi)嵌在網(wǎng)頁中,從而減少不必要的網(wǎng)絡(luò)請求,但是圖片數(shù)據(jù)是二進制數(shù)據(jù),該怎么嵌入呢?絕大多數(shù)現(xiàn)代瀏覽器都支持一種名為 Data URLs 的特性,允許使用 Base64 對圖片或其他文件的二進制數(shù)據(jù)進行編碼,將其作為文本字符串嵌入網(wǎng)頁中。所以文件也可以通過 Base64 的格式進行傳輸,接下來我們將介紹如何下載 Base64 格式的圖片。
附件形式下載示例:attachment
https://github.com/semlinker/file-download-demos/tree/main/attachment
七、base64 格式下載
Base64 是一種基于 64 個可打印字符來表示二進制數(shù)據(jù)的表示方法。由于 2? = 64 ,所以每 6 個比特為一個單元,對應(yīng)某個可打印字符。3 個字節(jié)有 24 個比特,對應(yīng)于 4 個 base64 單元,即 3 個字節(jié)可由 4 個可打印字符來表示。相應(yīng)的轉(zhuǎn)換過程如下圖所示:

Base64 常用在處理文本數(shù)據(jù)的場合,表示、傳輸、存儲一些二進制數(shù)據(jù),包括 MIME 的電子郵件及 XML 的一些復(fù)雜數(shù)據(jù)。 在 MIME 格式的電子郵件中,base64 可以用來將二進制的字節(jié)序列數(shù)據(jù)編碼成 ASCII 字符序列構(gòu)成的文本。使用時,在傳輸編碼方式中指定 base64。使用的字符包括大小寫拉丁字母各 26 個、數(shù)字 10 個、加號 + 和斜杠 /,共 64 個字符,等號 = 用來作為后綴用途。
Base64 的相關(guān)內(nèi)容就先介紹到這,如果你想進一步了解 Base64 的話,可以閱讀 一文讀懂base64編碼 這篇文章。下面我們來看一下具體實現(xiàn)代碼:
7.1 前端代碼
html
在以下 HTML 代碼中,我們通過 select 元素來讓用戶選擇要下載的圖片。當用戶切換不同的圖片時,img#imgPreview 元素中顯示的圖片會隨之發(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ào)用以上代碼中的 download 函數(shù)。在該函數(shù)內(nèi)部,我們利用 axios 實例的 get 方法發(fā)起 HTTP 請求來獲取指定的圖片。因為返回的是 base64 格式的圖片,所以在調(diào)用 FileSaver 提供的 saveAs 方法前,我們需要將 base64 字符串轉(zhuǎn)換成 blob 對象,該轉(zhuǎn)換是通過以下的 base64ToBlob 函數(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"),
},
};
});
// 注冊中間件
app.use(async (ctx, next) => {
try {
await next();
} catch (error) {
ctx.body = {
code: 0,
msg: "服務(wù)器開小差",
};
}
});
app.use(cors());
app.use(router.routes()).use(router.allowedMethods());
app.listen(PORT, () => {
console.log(`應(yīng)用已經(jīng)啟動:http://localhost:${PORT}/`);
});
在以上代碼中,對圖片進行 Base64 編碼的操作是定義在 /file 路由對應(yīng)的路由處理器中。當該服務(wù)器接收到客戶端發(fā)起的文件下載請求,比如 GET /file?filename=body.png HTTP/1.1 時,就會從 ctx.query 對象上獲取 filename 參數(shù)。該參數(shù)表示文件的名稱,在獲取到文件的名稱之后,我們就可以拼接出文件的絕對路徑,然后通過 Node.js 平臺提供的 fs.readFileSync 方法讀取文件的內(nèi)容,該方法會返回一個 Buffer 對象。在成功讀取文件的內(nèi)容之后,我們會繼續(xù)調(diào)用 Buffer 對象的 toString 方法對文件內(nèi)容進行 Base64 編碼,最終所下載的圖片將以 Base64 格式返回到客戶端。
base64 格式下載示例:base64
https://github.com/semlinker/file-download-demos/tree/main/base64
八、chunked 下載
分塊傳輸編碼主要應(yīng)用于如下場景,即要傳輸大量的數(shù)據(jù),但是在請求在沒有被處理完之前響應(yīng)的長度是無法獲得的。例如,當需要用從數(shù)據(jù)庫中查詢獲得的數(shù)據(jù)生成一個大的 HTML 表格的時候,或者需要傳輸大量的圖片的時候。
要使用分塊傳輸編碼,則需要在響應(yīng)頭配置 Transfer-Encoding 字段,并設(shè)置它的值為 chunked 或 gzip, chunked:
Transfer-Encoding: chunked
Transfer-Encoding: gzip, chunked
響應(yīng)頭 Transfer-Encoding 字段的值為 chunked,表示數(shù)據(jù)以一系列分塊的形式進行發(fā)送。需要注意的是 Transfer-Encoding 和 Content-Length 這兩個字段是互斥的,也就是說響應(yīng)報文中這兩個字段不能同時出現(xiàn)。下面我們來看一下分塊傳輸?shù)木幋a規(guī)則:
每個分塊包含分塊長度和數(shù)據(jù)塊兩個部分; 分塊長度使用 16 進制數(shù)字表示,以 \r\n結(jié)尾;數(shù)據(jù)塊緊跟在分塊長度后面,也使用 \r\n結(jié)尾,但數(shù)據(jù)不包含\r\n;終止塊是一個常規(guī)的分塊,表示塊的結(jié)束。不同之處在于其長度為 0,即 0\r\n\r\n。
了解完分塊傳輸?shù)木幋a規(guī)則,我們來看如何利用分塊傳輸編碼實現(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);
}
當用戶點擊 下載 按鈕時,就會調(diào)用以上代碼中的 download 函數(shù)。在該函數(shù)內(nèi)部,我們會使用 Fetch API 來執(zhí)行下載操作。因為服務(wù)端的數(shù)據(jù)是以一系列分塊的形式進行發(fā)送,所以在瀏覽器端我們是通過流的形式進行接收。即通過 response.body 獲取可讀的 ReadableStream,然后用 ReadableStream.getReader() 創(chuàng)建一個讀取器,最后調(diào)用 reader.read 方法來讀取已返回的分塊數(shù)據(jù)。
因為 file.txt 文件的內(nèi)容是普通文本,且 result.value 的值是 Uint8Array 類型的數(shù)據(jù),所以在處理返回的分塊數(shù)據(jù)時,我們使用了 TextDecoder 文本解碼器。一個解碼器只支持一種特定文本編碼,例如 utf-8、iso-8859-2、koi8、cp1261,gbk 等等。

如果收到的分塊非 終止塊,result.done 的值是 false,則會繼續(xù)調(diào)用 readChunk 方法來讀取分塊數(shù)據(jù)。而當接收到 終止塊 之后,表示分塊數(shù)據(jù)已傳輸完成。此時,result.done 屬性就會返回 true。從而會自動調(diào)用 onChunkedResponseComplete 函數(shù),在該函數(shù)內(nèi)部,我們以解碼后的文本作為參數(shù)來創(chuàng)建 Blob 對象。之后,繼續(xù)使用 FileSaver 庫提供的 saveAs 方法實現(xiàn)文件下載。
這里我們用 Wireshark 網(wǎng)絡(luò)包分析工具,抓了個數(shù)據(jù)包。具體如下圖所示:

從圖中我們可以清楚地看到在 HTTP chunked response 下面包含了 Data chunk(數(shù)據(jù)塊) 和 End of chunked encoding(終止塊)。接下來,我們來看一下服務(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);
});
// 注冊中間件
app.use(async (ctx, next) => {
try {
await next();
} catch (error) {
// ENOENT(無此文件或目錄):通常是由文件操作引起的,這表明在給定的路徑上無法找到任何文件或目錄
ctx.status = error.code === "ENOENT" ? 404 : 500;
ctx.body = error.code === "ENOENT" ? "文件不存在" : "服務(wù)器開小差";
}
});
app.use(cors());
app.use(router.routes()).use(router.allowedMethods());
app.listen(PORT, () => {
console.log(`應(yīng)用已經(jīng)啟動:http://localhost:${PORT}/`);
});
在 /file 路由處理器中,我們先通過 ctx.query 獲得 filename 文件名,接著拼接出該文件的絕對路徑,然后通過 Node.js 平臺提供的 fs.createReadStream 方法創(chuàng)建可讀流。最后把已創(chuàng)建的可讀流賦值給 ctx.body 屬性,從而向客戶端返回圖片數(shù)據(jù)。
現(xiàn)在我們已經(jīng)知道可以利用分塊傳輸編碼(Transfer-Encoding)實現(xiàn)數(shù)據(jù)的分塊傳輸,那么有沒有辦法獲取指定范圍內(nèi)的文件數(shù)據(jù)呢?對于這個問題,我們可以利用 HTTP 協(xié)議的范圍請求。接下來,我們將介紹如何利用 HTTP 范圍請求來下載指定范圍的數(shù)據(jù)。
chunked 下載示例:chunked
https://github.com/semlinker/file-download-demos/tree/main/chunked
九、范圍下載
HTTP 協(xié)議范圍請求允許服務(wù)器只發(fā)送 HTTP 消息的一部分到客戶端。范圍請求在傳送大的媒體文件,或者與文件下載的斷點續(xù)傳功能搭配使用時非常有用。如果在響應(yīng)中存在 Accept-Ranges 首部(并且它的值不為 “none”),那么表示該服務(wù)器支持范圍請求。
在一個 Range 首部中,可以一次性請求多個部分,服務(wù)器會以 multipart 文件的形式將其返回。如果服務(wù)器返回的是范圍響應(yīng),需要使用 206 Partial Content 狀態(tài)碼。假如所請求的范圍不合法,那么服務(wù)器會返回 416 Range Not Satisfiable 狀態(tài)碼,表示客戶端錯誤。服務(wù)器允許忽略 Range 首部,從而返回整個文件,狀態(tài)碼用 200 。
Range 語法:
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:范圍請求所采用的單位,通常是字節(jié)(bytes)。<range-start>:一個整數(shù),表示在特定單位下,范圍的起始值。<range-end>:一個整數(shù),表示在特定單位下,范圍的結(jié)束值。這個值是可選的,如果不存在,表示此范圍一直延伸到文檔結(jié)束。
了解完 Range 語法之后,我們來看一下實際的使用示例:
# 單一范圍
$ curl http://i.imgur.com/z4d4kWk.jpg -i -H "Range: bytes=0-1023"
# 多重范圍
$ curl http://www.example.com -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));
}
});
}
當用戶點擊 下載 按鈕時,就會調(diào)用 download 函數(shù)。在該函數(shù)內(nèi)部會通過調(diào)用 getBinaryContent 函數(shù)來發(fā)起范圍請求。對應(yīng)的 HTTP 請求報文如下所示:
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
而當服務(wù)器接收到該范圍請求之后,會返回對應(yīng)的 HTTP 響應(yīng)報文:
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)報文中,我們見到了前面介紹的 206 狀態(tài)碼和 Accept-Ranges 首部。此外,通過 Content-Range 首部,我們就知道了文件的總大小。在成功獲取到范圍請求的響應(yīng)體之后,我們就可以使用返回的內(nèi)容作為參數(shù),調(diào)用 Blob 構(gòu)造函數(shù)創(chuàng)建對應(yīng)的 Blob 對象,進而使用 FileSaver 庫提供的 saveAs 方法來下載文件了。
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();
// 注冊中間件
app.use(cors());
app.use(range);
app.use(serve("."));
app.listen(PORT, () => {
console.log(`應(yīng)用已經(jīng)啟動:http://localhost:${PORT}/`);
});
服務(wù)端的代碼相對比較簡單,范圍請求是通過 koa-range 中間件來實現(xiàn)的。由于篇幅有限,阿寶哥就不展開介紹了。感興趣的小伙伴,可以自行閱讀該中間件的源碼。其實范圍請求還可以應(yīng)用在大文件下載的場景,如果文件服務(wù)器支持范圍請求的話,客戶端在下載大文件的時候,就可以考慮使用大文件分塊下載的方案。
范圍下載示例:range
https://github.com/semlinker/file-download-demos/tree/main/range
十、大文件分塊下載
相信有些小伙伴已經(jīng)了解大文件上傳的解決方案,在上傳大文件時,為了提高上傳的效率,我們一般會使用 Blob.slice 方法對大文件按照指定的大小進行切割,然后在開啟多線程進行分塊上傳,等所有分塊都成功上傳后,再通知服務(wù)端進行分塊合并。
那么對大文件下載來說,我們能否采用類似的思想呢?其實在服務(wù)端支持 Range 請求首部的條件下,我們也是可以實現(xiàn)大文件分塊下載的功能,具體處理方案如下圖所示:

因為在 JavaScript 中如何實現(xiàn)大文件并發(fā)下載? 這篇文章中,阿寶哥已經(jīng)詳細介紹了大文件并發(fā)下載的方案,所以這里就不展開介紹了。我們只回顧一下大文件并發(fā)下載的完整流程:

其實在大文件分塊下載的場景中,我們使用了 async-pool 這個庫來實現(xiàn)并發(fā)控制。該庫提供了 ES7 和 ES6 兩種不同版本的實現(xiàn),代碼很簡潔優(yōu)雅。如果你想了解 async-pool 是如何實現(xiàn)并發(fā)控制的,可以閱讀 JavaScript 中如何實現(xiàn)并發(fā)控制? 這篇文章。
大文件分塊下載示例:big-file
https://github.com/semlinker/file-download-demos/tree/main/big-file
十一、總結(jié)
本文阿寶哥詳細介紹了文件下載的 9 種場景,希望閱讀完本文后,你對 9 種場景背后使用的技術(shù)有一定的了解。其實在傳輸文件的過程中,為了提高傳輸效率,我們可以使用 gzip、deflate 或 br 等壓縮算法對文件進行壓縮。由于篇幅有限,阿寶哥就不展開介紹了,如果你感興趣的話,可以閱讀 HTTP 傳輸大文件的幾種方案 這篇文章。
有了文件下載的場景,怎么能缺少文件上傳的場景呢?如果你還沒閱讀過 文件上傳,搞懂這 8 種場景就夠了 這篇文章,建議你有空的時候,可以一起了解一下。這里再次感謝掘友們一直以來的支持,如果你們還想了解其他方面的內(nèi)容,歡迎給阿寶哥留言喲。
十二、參考資源
MDN — showSaveFilePicker 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 文件上傳,搞懂這8種場景就夠了 JavaScript 中如何實現(xiàn)大文件并發(fā)下載?
