文件下載,搞懂這9種場景就夠了

⚠️本文爲掘金社區首發簽約文章,未獲受權禁止轉載javascript

文件上傳,搞懂這8種場景就夠了(1452個👍) 這篇文章發佈以後,阿寶哥收到了挺多掘友的留言,感謝掘友們一直以來的鼓勵與支持。其中掘友 @個人煙雨不在江南@rainx 在文章底部分別發了如下留言:html


既然掘友有要求,連標題也幫阿寶哥想好了,那咱們就來整一篇文章,總結一下文件下載的場景。前端

通常在咱們工做中,主要會涉及到 9 種文件下載的場景,每一種場景背後都使用不一樣的技術,其中也有不少細節須要咱們額外注意。今天阿寶哥就來帶你們總結一下這 9 種場景,讓你們可以輕鬆地應對各類下載場景。閱讀本文後,你將會了解如下的內容:html5

在瀏覽器端處理文件的時候,咱們常常會用到 Blob 。好比圖片本地預覽、圖片壓縮、大文件分塊上傳及文件下載。在瀏覽器端文件下載的場景中,好比咱們今天要講到的 a 標籤下載showSaveFilePicker API 下載Zip 下載 等場景中,都會使用到 Blob ,因此咱們有必要在學習具體應用前,先掌握它的相關知識,這樣能夠幫助咱們更好地瞭解示例代碼。java

1、基礎知識

1.1 瞭解 Blob

Blob(Binary Large Object)表示二進制類型的大對象。在數據庫管理系統中,將二進制數據存儲爲一個單一個體的集合。Blob 一般是影像、聲音或多媒體文件。在 JavaScript 中 Blob 類型的對象表示一個不可變、原始數據的類文件對象。 它的數據能夠按文本或二進制的格式進行讀取,也能夠轉換成 ReadableStream 用於數據操做。node

Blob 對象由一個可選的字符串 type(一般是 MIME 類型)和 blobParts 組成:ios

在 JavaScript 中你能夠經過 Blob 的構造函數來建立 Blob 對象,Blob 構造函數的語法以下:git

const aBlob = new Blob(blobParts, options);
複製代碼

相關的參數說明以下:github

  • blobParts:它是一個由 ArrayBuffer,ArrayBufferView,Blob,DOMString 等對象構成的數組。DOMStrings 會被編碼爲 UTF-8。
  • options:一個可選的對象,包含如下兩個屬性:
    • type —— 默認值爲 "",它表明了將會被放入到 blob 中的數組內容的 MIME 類型。
    • endings —— 默認值爲 "transparent",用於指定包含行結束符 \n 的字符串如何被寫入。 它是如下兩個值中的一個: "native",表明行結束符會被更改成適合宿主操做系統文件系統的換行符,或者 "transparent",表明會保持 blob 中保存的結束符不變。

1.2 瞭解 Blob URL

Blob URL/Object URL 是一種僞協議,容許 Blob 和 File 對象用做圖像、下載二進制數據連接等的 URL 源。在瀏覽器中,咱們使用 URL.createObjectURL 方法來建立 Blob URL,該方法接收一個 Blob 對象,併爲其建立一個惟一的 URL,其形式爲 blob:<origin>/<uuid>,對應的示例以下:web

blob:http://localhost:3000/53acc2b6-f47b-450f-a390-bf0665e04e59
複製代碼

瀏覽器內部爲每一個經過 URL.createObjectURL 生成的 URL 存儲了一個 URL → Blob 映射。所以,此類 URL 較短,但能夠訪問 Blob。生成的 URL 僅在當前文檔打開的狀態下才有效。它容許引用 <img><a> 中的 Blob,但若是你訪問的 Blob URL 再也不存在,則會從瀏覽器中收到 404 錯誤。

上述的 Blob URL 看似很不錯,但實際上它也有反作用。 雖然存儲了 URL → Blob 的映射,但 Blob 自己仍駐留在內存中,瀏覽器沒法釋放它。映射在文檔卸載時自動清除,所以 Blob 對象隨後被釋放。可是,若是應用程序壽命很長,那麼 Blob 在短期內將沒法被瀏覽器釋放。所以,若是你建立一個 Blob URL,即便再也不須要該 Blob,它也會存在內存中。

針對這個問題,你能夠調用 URL.revokeObjectURL(url) 方法,從內部映射中刪除引用,從而容許刪除 Blob(若是沒有其餘引用),並釋放內存。

如今你已經瞭解了 Blob 和 Blob URL,若是你還意猶未盡,想深刻理解 Blob 的話,能夠閱讀 你不知道的 Blob 這篇文章。下面咱們開始介紹客戶端文件下載的場景。

隨着 Web 技術的不斷髮展,瀏覽器的功能也愈來愈強大。這些年出現了不少在線 Web 設計工具,好比在線 PS、在線海報設計器或在線自定義表單設計器等。這些 Web 設計器容許用戶在完成設計以後,把生成的文件保存到本地,其中有一部分設計器就是利用瀏覽器提供的 Web API 來實現客戶端文件下載。下面阿寶哥先來介紹客戶端下載中,最多見的 a 標籤下載 方案。

2、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 容器中。在圖片成功合成以後,用戶能夠經過點擊 圖片下載 按鈕把已合成的圖片下載到本地。對應的操做流程以下圖所示:

由上圖可知,總體的操做流程相對簡單。接下來,咱們來看一下 圖片合成圖片下載 的實現邏輯。

js

圖片合成的功能,阿寶哥是直接使用 Github 上 merge-images 這個第三方庫來實現。利用該庫提供的 mergeImages(images, [options]) 方法,咱們能夠輕鬆地實現圖片合成的功能。調用該方法後,會返回一個 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;
}
複製代碼

而圖片下載的功能是藉助 dataUrlToBlobsaveFile 這兩個函數來實現。它們分別用於實現 Data URLs => Blob 的轉換和文件的保存,具體的代碼以下所示:

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 函數。在該函數內部,咱們使用了 HTMLAnchorElement.download 屬性,該屬性值表示下載文件的名稱。若是該名稱不是操做系統的有效文件名,瀏覽器將會對其進行調整。此外,該屬性的做用是代表連接的資源將被下載,而不是顯示在瀏覽器中。

須要注意的是,download 屬性存在兼容性問題,好比 IE 11 及如下的版本不支持該屬性,具體以下圖所示:

(圖片來源:caniuse.com/download)

當設置好 a 元素的 download 屬性以後,咱們會調用 URL.createObjectURL 方法來建立 Object URL,並把返回的 URL 賦值給 a 元素的 href 屬性。接着經過調用 a 元素的 click 方法來觸發文件的下載操做,最後還會調用一次 URL.revokeObjectURL 方法,從內部映射中刪除引用,從而容許刪除 Blob(若是沒有其餘引用),並釋放內存。

關於 a 標籤下載 的內容就介紹到這,下面咱們來介紹如何使用新的 Web API —— showSaveFilePicker 實現文件下載。

a 標籤下載示例:a-tag

github.com/semlinker/f…

3、showSaveFilePicker API 下載

showSaveFilePicker API 是 Window 接口中定義的方法,調用該方法後會顯示容許用戶選擇保存路徑的文件選擇器。該方法的簽名以下所示:

let FileSystemFileHandle = Window.showSaveFilePicker(options);
複製代碼

showSaveFilePicker 方法支持一個對象類型的可選參數,可包含如下屬性:

  • excludeAcceptAllOption:布爾類型,默認值爲 false。默認狀況下,選擇器應包含一個不該用任何文件類型過濾器的選項(由下面的 types 選項啓用)。將此選項設置爲 true 意味着 types 選項不可用。
  • types:數組類型,表示容許保存的文件類型列表。數組中的每一項是包含如下屬性的配置對象:
    • description(可選):用於描述容許保存文件類型類別。
    • accept:是一個對象,該對象的 keyMIME 類型,值是文件擴展名列表。

調用 showSaveFilePicker 方法以後,會返回一個 FileSystemFileHandle 對象。有了該對象,你就能夠調用該對象上的方法來操做文件。好比調用該對象上的 createWritable 方法以後,就會返回 FileSystemWritableFileStream 對象,就能夠把數據寫入到文件中。具體的使用方式以下所示:

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 函數,來保存已合成的圖片時,會顯示如下保存文件選擇器:

由上圖可知,相比 a 標籤下載 的方式,showSaveFilePicker API 容許你選擇文件的下載目錄、選擇文件的保存格式和更改存儲的文件名稱。看到這裏是否是以爲 showSaveFilePicker API 功能挺強大的,不過惋惜的是該 API 目前的兼容性還不是很好,具體以下圖所示:

(圖片來源:caniuse.com/?search=sho…

其實 showSaveFilePickerFile System Access API 中定義的方法,除了 showSaveFilePicker 以外,還有 showOpenFilePickershowDirectoryPicker 等方法。若是你想在實際項目中使用這些 API 的話,能夠考慮使用 GoogleChromeLabs 開源的 browser-fs-access 這個庫,該庫可讓你在支持平臺上更方便地使用 File System Access API,對於不支持的平臺會自動降級使用 <input type="file"><a download> 的方式。

可能你們對 browser-fs-access 這個庫會比較陌生,可是若是換成是 FileSaver.js 這個庫的話,應該就比較熟悉了。接下來,咱們來介紹如何利用 FileSaver.js 這個庫實現客戶端文件下載。

showSaveFilePicker API 下載示例:save-file-picker

github.com/semlinker/f…

4、FileSaver 下載

FileSaver.js 是在客戶端保存文件的解決方案,很是適合在客戶端上生成文件的 Web 應用程序。它是 HTML5 版本的 saveAs() FileSaver 實現,支持大多數主流的瀏覽器,其兼容性以下圖所示:

(圖片來源:github.com/eligrey/Fil…

在引入 FileSaver.js 這個庫以後,咱們就可使用它提供的 saveAs 方法來保存文件。該方法對應的簽名以下所示:

FileSaver saveAs(
 Blob/File/Url, 
 optional DOMString filename, 
 optional Object { autoBom }
)
複製代碼

saveAs 方法支持 3 個參數,第 1 個參數表示它支持 Blob/File/Url 三種類型,第 2 個參數表示文件名(可選),而第 3 個參數表示配置對象(可選)。若是你須要 FlieSaver.js 自動提供 Unicode 文本編碼提示(參考:字節順序標記),則須要設置 { 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 機制,若支持的話,將進行數據下載並使用 Blob URL 實現文件下載。若是不支持 CORS 機制的話,將會嘗試使用 a[download] 方式下載。

標準的 W3C File API Blob 接口並不是在全部瀏覽器中均可用,對於這個問題,你能夠考慮使用 Blob.js 來解決兼容性問題。

(圖片來源:caniuse.com/?search=blo…

3. 保存 canvas 畫布內容

let canvas = document.getElementById("my-canvas");
canvas.toBlob(function(blob) {
  saveAs(blob, "abao.png");
});
複製代碼

須要注意的是 canvas.toBlob() 方法並不是在全部瀏覽器中均可用,對於這個問題,你能夠考慮使用 canvas-toBlob.js 來解決兼容性問題。

(圖片來源:caniuse.com/?search=toB…

介紹完 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

github.com/semlinker/f…

5、Zip 下載

文件上傳,搞懂這8種場景就夠了 這篇文章中,阿寶哥介紹瞭如何利用 JSZip 這個庫提供的 API,把待上傳目錄下的全部文件壓縮成 ZIP 文件,而後再把生成的 ZIP 文件上傳到服務器。一樣,利用 JSZip 這個庫,咱們能夠實如今客戶端同時下載多個文件,而後把已下載的文件壓縮成 Zip 包,並下載到本地的功能。對應的操做流程以下圖所示:

在以上 Gif 圖中,阿寶哥演示了把 3 張素材圖,打包成 Zip 文件並下載到本地的過程。接下來,咱們來介紹如何使用 JSZip 這個庫實現以上的功能。

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上下載文件內容
function getFileContent(fileUrl) {
  return new JSZip.external.Promise(function (resolve, reject) {
    // 調用jszip-utils庫提供的getBinaryContent方法獲取文件內容
    JSZipUtils.getBinaryContent(fileUrl, function (err, data) {
      if (err) {
        reject(err);
      } else {
        resolve(data);
      }
    });
  });
}
複製代碼

在以上代碼中,當用戶點擊 打包下載 按鈕時,就會調用 download 函數。在該函數內部,會先調用 JSZip 構造函數建立 JSZip 對象,而後使用 Promise.all 函數來確保全部的文件都下載完成後,再調用 file(name, data [,options]) 方法,把已下載的文件添加到前面建立的 JSZip 對象中。最後經過 zip.generateAsync 函數來生成 Zip 文件並使用 FileSaver.js 提供的 saveAs 方法保存 Zip 文件。

Zip 下載示例:Zip

github.com/semlinker/f…

6、附件形式下載

在服務端下載的場景中,附件形式下載是一種比較常見的場景。在該場景下,咱們經過設置 Content-Disposition 響應頭來指示響應的內容以何種形式展現,是之內聯(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 個參數老是固定不變的 form-data;附加的參數不區分大小寫,而且擁有參數值,參數名與參數值用等號(=)鏈接,參數值用雙引號括起來。參數之間用分號(;)分隔。

瞭解完 Content-Disposition 的做用以後,咱們來看一下如何實現以附件形式下載的功能。Koa 是一個簡單易用的 Web 框架,它的特色是優雅、簡潔、輕量、自由度高。因此咱們選擇它來搭建文件服務,並使用 @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" ? "文件不存在" : "服務器開小差";
  }
});
app.use(router.routes()).use(router.allowedMethods());

app.listen(PORT, () => {
  console.log(`應用已經啓動:http://localhost:${PORT}/`);
});
複製代碼

以上的代碼被保存在 attachment 目錄下的 file-server.js 文件中,該目錄下還有一個 static 子目錄用於存放靜態資源。目前 static 目錄下包含如下 3 個 png 文件。

├── file-server.js
└── static
    ├── body.png
    ├── eyes.png
    └── mouth.png
複製代碼

當你運行 node file-server.js 命令成功啓動文件服務器以後,就能夠經過正確的 URL 地址來下載 static 目錄下的文件。好比在瀏覽器中打開 http://localhost:3000/file?filename=mouth.png 這個地址,你就會開始下載 mouth.png 文件。而若是指定的文件不存在的話,就會返回文件不存在。

Koa 內核很簡潔,擴展功能都是經過中間件來實現。好比經常使用的路由、CORS、靜態資源處理等功能都是經過中間件實現。所以要想掌握 Koa 這個框架,核心是掌握它的中間件機制。若你想深刻了解 Koa 的話,能夠閱讀 如何更好地理解中間件和洋蔥模型 這篇文章。

在編寫 HTML 網頁時,對於一些簡單圖片,一般會選擇將圖片內容直接內嵌在網頁中,從而減小沒必要要的網絡請求,可是圖片數據是二進制數據,該怎麼嵌入呢?絕大多數現代瀏覽器都支持一種名爲 Data URLs 的特性,容許使用 Base64 對圖片或其餘文件的二進制數據進行編碼,將其做爲文本字符串嵌入網頁中。因此文件也能夠經過 Base64 的格式進行傳輸,接下來咱們將介紹如何下載 Base64 格式的圖片。

附件形式下載示例:attachment

github.com/semlinker/f…

7、base64 格式下載

Base64 是一種基於 64 個可打印字符來表示二進制數據的表示方法。因爲 2⁶ = 64 ,因此每 6 個比特爲一個單元,對應某個可打印字符。3 個字節有 24 個比特,對應於 4 個 base64 單元,即 3 個字節可由 4 個可打印字符來表示。相應的轉換過程以下圖所示:

Base64 經常使用在處理文本數據的場合,表示、傳輸、存儲一些二進制數據,包括 MIME 的電子郵件及 XML 的一些複雜數據。MIME 格式的電子郵件中,base64 能夠用來將二進制的字節序列數據編碼成 ASCII 字符序列構成的文本。使用時,在傳輸編碼方式中指定 base64。使用的字符包括大小寫拉丁字母各 26 個、數字 10 個、加號 + 和斜槓 /,共 64 個字符,等號 = 用來做爲後綴用途。

Base64 的相關內容就先介紹到這,若是你想進一步瞭解 Base64 的話,能夠閱讀 一文讀懂base64編碼 這篇文章。下面咱們來看一下具體實現代碼:

7.1 前端代碼

html

在如下 HTML 代碼中,咱們經過 select 元素來讓用戶選擇要下載的圖片。當用戶切換不一樣的圖片時,img#imgPreview 元素中顯示的圖片會隨之發生變化。

<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);
  }
}
複製代碼

在用戶選擇好須要下載的圖片並點擊下載按鈕時,就會調用以上代碼中的 download 函數。在該函數內部,咱們利用 axios 實例的 get 方法發起 HTTP 請求來獲取指定的圖片。由於返回的是 base64 格式的圖片,因此在調用 FileSaver 提供的 saveAs 方法前,咱們須要將 base64 字符串轉換成 blob 對象,該轉換是經過如下的 base64ToBlob 函數來完成,該函數的具體實現以下所示:

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 服務端代碼

// 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: "服務器開小差",
    };
  }
});
app.use(cors());
app.use(router.routes()).use(router.allowedMethods());

app.listen(PORT, () => {
  console.log(`應用已經啓動:http://localhost:${PORT}/`);
});
複製代碼

在以上代碼中,對圖片進行 Base64 編碼的操做是定義在 /file 路由對應的路由處理器中。當該服務器接收到客戶端發起的文件下載請求,好比 GET /file?filename=body.png HTTP/1.1 時,就會從 ctx.query 對象上獲取 filename 參數。該參數表示文件的名稱,在獲取到文件的名稱以後,咱們就能夠拼接出文件的絕對路徑,而後經過 Node.js 平臺提供的 fs.readFileSync 方法讀取文件的內容,該方法會返回一個 Buffer 對象。在成功讀取文件的內容以後,咱們會繼續調用 Buffer 對象的 toString 方法對文件內容進行 Base64 編碼,最終所下載的圖片將以 Base64 格式返回到客戶端。

base64 格式下載示例:base64

github.com/semlinker/f…

8、chunked 下載

分塊傳輸編碼主要應用於以下場景,即要傳輸大量的數據,可是在請求在沒有被處理完以前響應的長度是沒法得到的。例如,當須要用從數據庫中查詢得到的數據生成一個大的 HTML 表格的時候,或者須要傳輸大量的圖片的時候。

要使用分塊傳輸編碼,則須要在響應頭配置 Transfer-Encoding 字段,並設置它的值爲 chunkedgzip, chunked

Transfer-Encoding: chunked
Transfer-Encoding: gzip, chunked
複製代碼

響應頭 Transfer-Encoding 字段的值爲 chunked,表示數據以一系列分塊的形式進行發送。須要注意的是 Transfer-EncodingContent-Length 這兩個字段是互斥的,也就是說響應報文中這兩個字段不能同時出現。下面咱們來看一下分塊傳輸的編碼規則:

  • 每一個分塊包含分塊長度和數據塊兩個部分;
  • 分塊長度使用 16 進制數字表示,以 \r\n 結尾;
  • 數據塊緊跟在分塊長度後面,也使用 \r\n 結尾,但數據不包含 \r\n
  • 終止塊是一個常規的分塊,表示塊的結束。不一樣之處在於其長度爲 0,即 0\r\n\r\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("已接收到的數據:", 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);
}
複製代碼

當用戶點擊 下載 按鈕時,就會調用以上代碼中的 download 函數。在該函數內部,咱們會使用 Fetch API 來執行下載操做。由於服務端的數據是以一系列分塊的形式進行發送,因此在瀏覽器端咱們是經過流的形式進行接收。即經過 response.body 獲取可讀的 ReadableStream,而後用 ReadableStream.getReader() 建立一個讀取器,最後調用 reader.read 方法來讀取已返回的分塊數據。

由於 file.txt 文件的內容是普通文本,且 result.value 的值是 Uint8Array 類型的數據,因此在處理返回的分塊數據時,咱們使用了 TextDecoder 文本解碼器。一個解碼器只支持一種特定文本編碼,例如 utf-8iso-8859-2koi8cp1261gbk 等等。

若是收到的分塊非 終止塊result.done 的值是 false,則會繼續調用 readChunk 方法來讀取分塊數據。而當接收到 終止塊 以後,表示分塊數據已傳輸完成。此時,result.done 屬性就會返回 true。從而會自動調用 onChunkedResponseComplete 函數,在該函數內部,咱們以解碼後的文本做爲參數來建立 Blob 對象。以後,繼續使用 FileSaver 庫提供的 saveAs 方法實現文件下載。

這裏咱們用 Wireshark 網絡包分析工具,抓了個數據包。具體以下圖所示:

從圖中咱們能夠清楚地看到在 HTTP chunked response 下面包含了 Data chunk(數據塊)End of chunked encoding(終止塊)。接下來,咱們來看一下服務端的代碼。

8.2 服務端代碼

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" ? "文件不存在" : "服務器開小差";
  }
});
app.use(cors());
app.use(router.routes()).use(router.allowedMethods());

app.listen(PORT, () => {
  console.log(`應用已經啓動:http://localhost:${PORT}/`);
});
複製代碼

/file 路由處理器中,咱們先經過 ctx.query 得到 filename 文件名,接着拼接出該文件的絕對路徑,而後經過 Node.js 平臺提供的 fs.createReadStream 方法建立可讀流。最後把已建立的可讀流賦值給 ctx.body 屬性,從而向客戶端返回圖片數據。

如今咱們已經知道能夠利用分塊傳輸編碼(Transfer-Encoding)實現數據的分塊傳輸,那麼有沒有辦法獲取指定範圍內的文件數據呢?對於這個問題,咱們能夠利用 HTTP 協議的範圍請求。接下來,咱們將介紹如何利用 HTTP 範圍請求來下載指定範圍的數據。

chunked 下載示例:chunked

github.com/semlinker/f…

9、範圍下載

HTTP 協議範圍請求容許服務器只發送 HTTP 消息的一部分到客戶端。範圍請求在傳送大的媒體文件,或者與文件下載的斷點續傳功能搭配使用時很是有用。若是在響應中存在 Accept-Ranges 首部(而且它的值不爲 「none」),那麼表示該服務器支持範圍請求。

在一個 Range 首部中,能夠一次性請求多個部分,服務器會以 multipart 文件的形式將其返回。若是服務器返回的是範圍響應,須要使用 206 Partial Content 狀態碼。假如所請求的範圍不合法,那麼服務器會返回 416 Range Not Satisfiable 狀態碼,表示客戶端錯誤。服務器容許忽略 Range 首部,從而返回整個文件,狀態碼用 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:範圍請求所採用的單位,一般是字節(bytes)。
  • <range-start>:一個整數,表示在特定單位下,範圍的起始值。
  • <range-end>:一個整數,表示在特定單位下,範圍的結束值。這個值是可選的,若是不存在,表示此範圍一直延伸到文檔結束。

瞭解完 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));
    }
  });
}
複製代碼

當用戶點擊 下載 按鈕時,就會調用 download 函數。在該函數內部會經過調用 getBinaryContent 函數來發起範圍請求。對應的 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
複製代碼

而當服務器接收到該範圍請求以後,會返回對應的 HTTP 響應報文:

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 響應報文中,咱們見到了前面介紹的 206 狀態碼和 Accept-Ranges 首部。此外,經過 Content-Range 首部,咱們就知道了文件的總大小。在成功獲取到範圍請求的響應體以後,咱們就可使用返回的內容做爲參數,調用 Blob 構造函數建立對應的 Blob 對象,進而使用 FileSaver 庫提供的 saveAs 方法來下載文件了。

9.2 服務端代碼

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(`應用已經啓動:http://localhost:${PORT}/`);
});
複製代碼

服務端的代碼相對比較簡單,範圍請求是經過 koa-range 中間件來實現的。因爲篇幅有限,阿寶哥就不展開介紹了。感興趣的小夥伴,能夠自行閱讀該中間件的源碼。其實範圍請求還能夠應用在大文件下載的場景,若是文件服務器支持範圍請求的話,客戶端在下載大文件的時候,就能夠考慮使用大文件分塊下載的方案。

範圍下載示例:range

github.com/semlinker/f…

10、大文件分塊下載

相信有些小夥伴已經瞭解大文件上傳的解決方案,在上傳大文件時,爲了提升上傳的效率,咱們通常會使用 Blob.slice 方法對大文件按照指定的大小進行切割,而後在開啓多線程進行分塊上傳,等全部分塊都成功上傳後,再通知服務端進行分塊合併。

那麼對大文件下載來講,咱們可否採用相似的思想呢?其實在服務端支持 Range 請求首部的條件下,咱們也是能夠實現大文件分塊下載的功能,具體處理方案以下圖所示:

由於在 JavaScript 中如何實現大文件併發下載? 這篇文章中,阿寶哥已經詳細介紹了大文件併發下載的方案,因此這裏就不展開介紹了。咱們只回顧一下大文件併發下載的完整流程:

其實在大文件分塊下載的場景中,咱們使用了 async-pool 這個庫來實現併發控制。該庫提供了 ES7 和 ES6 兩種不一樣版本的實現,代碼很簡潔優雅。若是你想了解 async-pool 是如何實現併發控制的,能夠閱讀 JavaScript 中如何實現併發控制? 這篇文章。

大文件分塊下載示例:big-file

github.com/semlinker/f…

11、總結

本文阿寶哥詳細介紹了文件下載的 9 種場景,但願閱讀完本文後,你對 9 種場景背後使用的技術有必定的瞭解。其實在傳輸文件的過程當中,爲了提升傳輸效率,咱們可使用 gzipdeflatebr 等壓縮算法對文件進行壓縮。因爲篇幅有限,阿寶哥就不展開介紹了,若是你感興趣的話,能夠閱讀 HTTP 傳輸大文件的幾種方案 這篇文章。

有了文件下載的場景,怎麼能缺乏文件上傳的場景呢?若是你還沒閱讀過 文件上傳,搞懂這 8 種場景就夠了 這篇文章,建議你有空的時候,能夠一塊兒瞭解一下。這裏再次感謝掘友們一直以來的支持,若是大家還想了解其餘方面的內容,歡迎給阿寶哥留言喲。

12、參考資源

相關文章
相關標籤/搜索