聊一聊 15.5K 的 FileSaver,是如何工作的?
本文轉(zhuǎn)載自微信公眾號(hào)「全棧修仙之路」,作者阿寶哥。轉(zhuǎn)載本文請(qǐng)聯(lián)系全棧修仙之路公眾號(hào)。
FileSaver.js 是在客戶端保存文件的解決方案,非常適合在客戶端上生成文件的 Web 應(yīng)用程序。它簡(jiǎn)單易用且兼容大多數(shù)瀏覽器,被作為項(xiàng)目依賴應(yīng)用在 6.3 萬的項(xiàng)目中。在近期的項(xiàng)目中,阿寶哥再一次使用到了它,所以就想寫篇文章來聊一聊這個(gè)優(yōu)秀的開源項(xiàng)目。
一、FileSaver.js 簡(jiǎn)介
FileSaver.js 是 HTML5 的 saveAs() FileSaver 實(shí)現(xiàn)。它支持大多數(shù)主流的瀏覽器,其兼容性如下圖所示:
(圖片來源:https://github.com/eligrey/FileSaver.js)
1.1 saveAs API
- FileSaver saveAs(Blob/File/Url, optional DOMString filename, optional Object { autoBom })
saveAs 方法支持三個(gè)參數(shù),第一個(gè)參數(shù)表示它支持 Blob/File/Url 三種類型,第二個(gè)參數(shù)表示文件名(可選),而第三個(gè)參數(shù)表示配置對(duì)象(可選)。如果你需要 FlieSaver.js 自動(dòng)提供 Unicode 文本編碼提示(參考:字節(jié)順序標(biāo)記),則需要設(shè)置 { autoBom: true}。
1.2 保存文本
let blob = new Blob(["大家好,我是阿寶哥!"], { type: "text/plain;charset=utf-8" });FileSaver.saveAs(blob, "hello.txt");
1.3 保存線上資源
- FileSaver saveAs(Blob/File/Url, optional DOMString filename, optional Object { autoBom })
如果下載的 URL 地址與當(dāng)前站點(diǎn)是同域的,則將使用 a[download] 方式下載。否則,會(huì)先使用 同步的 HEAD 請(qǐng)求 來判斷是否支持 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è)問題,你可以考慮使用 Blob.js 來解決兼容性問題。
(圖片來源:https://caniuse.com/?search=blob)
1.4 保存 Canvas 畫布內(nèi)容
- let canvas = document.getElementById("my-canvas");
- canvas.toBlob(function(blob) {
- saveAs(blob, "abao.png");
- });
需要注意的是 canvas.toBlob() 方法并非在所有瀏覽器中都可用,對(duì)于這個(gè)問題,你可以考慮使用 canvas-toBlob.js 來解決兼容性問題。
(圖片來源:https://caniuse.com/?search=toBlob)
在以上的示例中,我們多次見到 Blob 的身影,因此在介紹 FileSaver.js 源碼時(shí),阿寶哥先來簡(jiǎn)單介紹一下 Blob 的相關(guān)知識(shí)。
二、Blob 簡(jiǎn)介
Blob(Binary Large Object)表示二進(jìn)制類型的大對(duì)象。在數(shù)據(jù)庫(kù)管理系統(tǒng)中,將二進(jìn)制數(shù)據(jù)存儲(chǔ)為一個(gè)單一個(gè)體的集合。Blob 通常是影像、聲音或多媒體文件。在 JavaScript 中 Blob 類型的對(duì)象表示不可變的類似文件對(duì)象的原始數(shù)據(jù)。
2.1 Blob 構(gòu)造函數(shù)
Blob 由一個(gè)可選的字符串 type(通常是 MIME 類型)和 blobParts 組成:
MIME(Multipurpose Internet Mail Extensions)多用途互聯(lián)網(wǎng)郵件擴(kuò)展類型,是設(shè)定某種擴(kuò)展名的文件用一種應(yīng)用程序來打開的方式類型,當(dāng)該擴(kuò)展名文件被訪問的時(shí)候,瀏覽器會(huì)自動(dòng)使用指定應(yīng)用程序來打開。多用于指定一些客戶端自定義的文件名,以及一些媒體文件打開方式。
常見的 MIME 類型有:超文本標(biāo)記語言文本 .html text/html、PNG 圖像 .png image/png、普通文本 .txt text/plain 等。
在 JavaScript 中我們可以通過 Blob 的構(gòu)造函數(shù)來創(chuàng)建 Blob 對(duì)象,Blob 構(gòu)造函數(shù)的語法如下:
- var aBlob = new Blob(blobParts, options);
相關(guān)的參數(shù)說明如下:
- 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 的字符串如何被寫入。它是以下兩個(gè)值中的一個(gè):"native",代表行結(jié)束符會(huì)被更改為適合宿主操作系統(tǒng)文件系統(tǒng)的換行符,或者 "transparent",代表會(huì)保持 blob 中保存的結(jié)束符不變。
介紹完 Blob 之后,我們?cè)賮斫榻B一下 Blob URL。
2.2 Blob URL
Blob URL/Object URL 是一種偽協(xié)議,允許 Blob 和 File 對(duì)象用作圖像,下載二進(jìn)制數(shù)據(jù)鏈接等的 URL 源。在瀏覽器中,我們使用 URL.createObjectURL 方法來創(chuàng)建 Blob URL,該方法接收一個(gè) Blob 對(duì)象,并為其創(chuàng)建一個(gè)唯一的 URL,其形式為 blob:
blob:https://example.org/40a5fb5a-d56d-4a33-b4e2-0acf6a8e5f641
瀏覽器內(nèi)部為每個(gè)通過 URL.createObjectURL 生成的 URL 存儲(chǔ)了一個(gè) URL → Blob 映射。因此,此類 URL 較短,但可以訪問 Blob。生成的 URL 僅在當(dāng)前文檔打開的狀態(tài)下才有效。它允許引用 、 中的 Blob,但如果你訪問的 Blob URL 不再存在,則會(huì)從瀏覽器中收到 404 錯(cuò)誤。
上述的 Blob URL 看似很不錯(cuò),但實(shí)際上它也有副作用。雖然存儲(chǔ)了 URL → Blob 的映射,但 Blob 本身仍駐留在內(nèi)存中,瀏覽器無法釋放它。映射在文檔卸載時(shí)自動(dòng)清除,因此 Blob 對(duì)象隨后被釋放。但是,如果應(yīng)用程序壽命很長(zhǎng),那不會(huì)很快發(fā)生。因此,如果我們創(chuàng)建一個(gè) Blob URL,即使不再需要該 Blob,它也會(huì)存在內(nèi)存中。
針對(duì)這個(gè)問題,我們可以調(diào)用 URL.revokeObjectURL(url) 方法,從內(nèi)部映射中刪除引用,從而允許刪除 Blob(如果沒有其他引用),并釋放內(nèi)存。
好的,現(xiàn)在我們已經(jīng)介紹了 Blob 和 Blob URL。如果你還意猶未盡,想深入理解 Blob 的話,可以閱讀 你不知道的 Blob 這篇文章,接下來我們開始分析 FileSaver.js 的源碼。
三、FileSaver.js 源碼解析
在 FileSaver.js 內(nèi)部提供了三種方案來實(shí)現(xiàn)文件保存,因此接下來我們將分別來介紹這三種方案。
3.1 方案一
當(dāng) FileSaver.js 在保存文件時(shí),如果當(dāng)前平臺(tái)中 a 標(biāo)簽支持 download 屬性且非 MacOS WebView 環(huán)境,則會(huì)優(yōu)先使用 a[download] 來實(shí)現(xiàn)文件保存。在具體使用過程中,我們是通過調(diào)用 saveAs 方法來保存文件,該方法的定義如下:
- FileSaver saveAs(Blob/File/Url, optional DOMString filename, optional Object { autoBom })
通過觀察 saveAs 方法的簽名,我們可知該方法支持字符串和 Blob 兩種類型的參數(shù),因此在 saveAs 方法內(nèi)部需要分別處理這兩種類型的參數(shù),下面我們先來分析字符串參數(shù)的情形。
3.1.1 字符串類型參數(shù)
在前面的示例中,我們演示了如何利用 saveAs 方法來保存線上的圖片:
- FileSaver.saveAs("https://httpbin.org/image", "image.jpg");
在方案一中,saveAs 方法的處理邏輯如下所示:
- // Use download attribute first if possible (#193 Lumia mobile) unless this is a macOS WebView
- function saveAs(blob, name, opts) {
- var URL = _global.URL || _global.webkitURL;
- var a = document.createElement("a");
- name = name || blob.name || "download";
- a.download = name;
- a.rel = "noopener";
- if (typeof blob === "string") {
- a.href = blob;
- if (a.origin !== location.origin) { // (1)
- corsEnabled(a.href)
- ? download(blob, name, opts)
- : click(a, (a.target = "_blank"));
- } else { // (2)
- click(a);
- }
- } else {
- // 省略處理Blob類型參數(shù)
- }
- }
在以上代碼中,如果發(fā)現(xiàn)下載資源的 URL 地址與當(dāng)前站點(diǎn)是非同域的,則會(huì)先使用 同步的 HEAD 請(qǐng)求 來判斷是否支持 CORS 機(jī)制,若支持的話,就會(huì)調(diào)用 download 方法進(jìn)行文件下載。首先我們先來分析 corsEnabled 方法:
- function corsEnabled(url) {
- var xhr = new XMLHttpRequest();
- xhr.open("HEAD", url, false);
- try {
- xhr.send();
- } catch (e) {}
- return xhr.status >= 200 && xhr.status <= 299;
- }
corsEnabled 方法的實(shí)現(xiàn)很簡(jiǎn)單,就是通過 XMLHttpRequest API 發(fā)起一個(gè)同步的 HEAD 請(qǐng)求,然后判斷返回的狀態(tài)碼是否在 [200 ~ 299] 的范圍內(nèi)。接著我們來看一下download 方法的具體實(shí)現(xiàn):
- function download(url, name, opts) {
- var xhr = new XMLHttpRequest();
- xhr.open("GET", url);
- xhr.responseType = "blob";
- xhr.onload = function () {
- saveAs(xhr.response, name, opts);
- };
- xhr.onerror = function () {
- console.error("could not download file");
- };
- xhr.send();
- }
同樣 download 方法的實(shí)現(xiàn)也很簡(jiǎn)單,也是通過 XMLHttpRequest API 來發(fā)起 HTTP 請(qǐng)求,與大家熟悉的 JSON 格式不同的是,我們需要設(shè)置 responseType 的類型為 blob。此外,因?yàn)榉祷氐慕Y(jié)果是 blob 類型的數(shù)據(jù),所以在成功回調(diào)函數(shù)內(nèi)部會(huì)繼續(xù)調(diào)用 saveAs方法來實(shí)現(xiàn)文件保存。
而對(duì)于不支持 CORS 機(jī)制或同域的情形,它會(huì)調(diào)用內(nèi)部的 click 方法來完成下載功能,該方法的具體實(shí)現(xiàn)如下:
- // `a.click()` doesn't work for all browsers (#465)
- function click(node) {
- try {
- node.dispatchEvent(new MouseEvent("click"));
- } catch (e) {
- var evt = document.createEvent("MouseEvents");
- evt.initMouseEvent(
- "click", true, true, window, 0, 0, 0, 80, 20,
- false, false, false, false, 0, null
- );
- node.dispatchEvent(evt);
- }
- }
在 click 方法內(nèi)部,會(huì)優(yōu)先調(diào)用 node 對(duì)象上的 dispatchEvent 方法來派發(fā) click 事件。當(dāng)出現(xiàn)異常的時(shí)候,會(huì)在 catch 語句進(jìn)行相應(yīng)的異常處理,catch 語句中的 MouseEvent.initMouseEvent() 方法用于初始化鼠標(biāo)事件的值。但需要注意的是,該特性已經(jīng)從 Web 標(biāo)準(zhǔn)中刪除,雖然一些瀏覽器目前仍然支持它,但也許會(huì)在未來的某個(gè)時(shí)間停止支持,請(qǐng)盡量不要使用該特性。
3.1.2 blob 類型參數(shù)
同樣,在前面的示例中,我們演示了如何利用 saveAs 方法來保存 Blob 類型數(shù)據(jù):
- let blob = new Blob(["大家好,我是阿寶哥!"], { type: "text/plain;charset=utf-8" });
- FileSaver.saveAs(blob, "hello.txt");
blob 類型參數(shù)的處理邏輯,被定義在 saveAs 方法體的 else 分支中:
- // Use download attribute first if possible (#193 Lumia mobile) unless this is a macOS WebView
- function saveAs(blob, name, opts) {
- var URL = _global.URL || _global.webkitURL;
- var a = document.createElement("a");
- name = name || blob.name || "download";
- a.download = name;
- a.rel = "noopener";
- if (typeof blob === "string") {
- // 省略處理字符串類型參數(shù)
- } else {
- a.href = URL.createObjectURL(blob);
- setTimeout(function () {
- URL.revokeObjectURL(a.href);
- }, 4e4); // 40s
- setTimeout(function () {
- click(a);
- }, 0);
- }
- }
對(duì)于 blob 類型的參數(shù),首先會(huì)通過 createObjectURL 方法來創(chuàng)建 Object URL,然后在通過 click 方法執(zhí)行文件保存。為了能及時(shí)釋放內(nèi)存,在 else 處理分支中,會(huì)啟動(dòng)一個(gè)定時(shí)器來執(zhí)行清理操作。此時(shí),方案一我們已經(jīng)介紹完了,接下去要介紹的方案二主要是為了兼容 IE 瀏覽器。
3.2 方案二
在 Internet Explorer 10 瀏覽器中,msSaveBlob 和 msSaveOrOpenBlob 方法允許用戶在客戶端上保存文件,其中 msSaveBlob 方法只提供一個(gè)保存按鈕,而 msSaveOrOpenBlob 方法提供了保存和打開按鈕,對(duì)應(yīng)的使用方式如下所示:
- window.navigator.msSaveBlob(blobObject, 'msSaveBlob_hello.txt');
- window.navigator.msSaveOrOpenBlob(blobObject, 'msSaveBlobOrOpenBlob_hello.txt');
了解完上述的知識(shí)和方案一中介紹的 corsEnabled、download 和 click 方法后,再來看方案二的代碼,就很清晰明了。
在滿足 "msSaveOrOpenBlob" in navigator 條件時(shí), FileSaver.js 會(huì)使用方案二來實(shí)現(xiàn)文件保存。跟前面一樣,我們先來分析 字符串類型參數(shù) 的處理邏輯。
3.2.1 字符串類型參數(shù)
- // Use msSaveOrOpenBlob as a second approach
- function saveAs(blob, name, opts) {
- name = name || blob.name || "download";
- if (typeof blob === "string") {
- if (corsEnabled(blob)) { // 判斷是否支持CORS
- download(blob, name, opts);
- } else {
- var a = document.createElement("a");
- a.href = blob;
- a.target = "_blank";
- setTimeout(function () {
- click(a);
- });
- }
- } else {
- // 省略處理Blob類型參數(shù)
- }
- }
3.2.2 blob 類型參數(shù)
- // Use msSaveOrOpenBlob as a second approach
- function saveAs(blob, name, opts) {
- name = name || blob.name || "download";
- if (typeof blob === "string") {
- // 省略處理字符串類型參數(shù)
- } else {
- navigator.msSaveOrOpenBlob(bom(blob, opts), name); // 提供了保存和打開按鈕
- }
- }
3.3 方案三
如果方案一和方案二都不支持的話,F(xiàn)ileSaver.js 就會(huì)降級(jí)使用 FileReader API 和 open API 新開窗口來實(shí)現(xiàn)文件保存。
3.3.1 字符串類型參數(shù)
- // Fallback to using FileReader and a popup
- function saveAs(blob, name, opts, popup) {
- // Open a popup immediately do go around popup blocker
- // Mostly only available on user interaction and the fileReader is async so...
- popup = popup || open("", "_blank");
- if (popup) {
- popup.document.title = popup.document.body.innerText = "downloading...";
- }
- if (typeof blob === "string") return download(blob, name, opts);
- // 處理Blob類型參數(shù)
- }
3.3.2 blob 類型參數(shù)
對(duì)于 blob 類型的參數(shù)來說,在 saveAs 方法內(nèi)部會(huì)根據(jù)不同的環(huán)境選用不同的方案,比如在 Safari 瀏覽器環(huán)境中,它會(huì)利用 FileReader API 先把 Blob 對(duì)象轉(zhuǎn)換為 Data URL,然后再把該 Data URL 地址賦值給新開的窗口或當(dāng)前窗口的 location 對(duì)象,具體的代碼如下:
- // Fallback to using FileReader and a popup
- function saveAs(blob, name, opts, popup) {
- // Open a popup immediately do go around popup blocker
- // Mostly only available on user interaction and the fileReader is async so...
- popup = popup || open("", "_blank");
- if (popup) { // 設(shè)置新開窗口的標(biāo)題
- popup.document.title = popup.document.body.innerText = "downloading...";
- }
- if (typeof blob === "string") return download(blob, name, opts);
- var force = blob.type === "application/octet-stream"; // 二進(jìn)制流數(shù)據(jù)
- var isSafari = /constructor/i.test(_global.HTMLElement) || _global.safari;
- var isChromeIOS = /CriOS\/[\d]+/.test(navigator.userAgent);
- if (
- (isChromeIOS || (force && isSafari) || isMacOSWebView) &&
- typeof FileReader !== "undefined"
- ) {
- // Safari doesn't allow downloading of blob URLs
- var reader = new FileReader();
- reader.onloadend = function () {
- var url = reader.result;
- url = isChromeIOS
- ? url
- : url.replace(/^data:[^;]*;/, "data:attachment/file;"); // 處理成附件的形式
- if (popup) popup.location.href = url;
- else location = url;
- popup = null; // reverse-tabnabbing #460
- };
- reader.readAsDataURL(blob);
- } else {
- // 省略O(shè)bject URL的處理邏輯
- }
- }
其實(shí)對(duì)于 FileReader API 來說,除了支持把 File/Blob 對(duì)象轉(zhuǎn)換為 Data URL 之外,它還提供了 readAsArrayBuffer() 和 readAsText() 方法,用于把 File/Blob 對(duì)象轉(zhuǎn)換為其它的數(shù)據(jù)格式。
在 玩轉(zhuǎn)前端二進(jìn)制 文章中,阿寶哥詳細(xì)介紹了 FileReader API 在前端圖片處理場(chǎng)景中的應(yīng)用,閱讀完該文章之后,你們將能輕松看懂以下轉(zhuǎn)換關(guān)系圖:
最后我們?cè)賮砜匆幌?else 分支的代碼:
- function saveAs(blob, name, opts, popup) {
- popup = popup || open("", "_blank");
- if (popup) {
- popup.document.title = popup.document.body.innerText = "downloading...";
- }
- // 處理字符串類型參數(shù)
- if (typeof blob === "string") return download(blob, name, opts);
- if (
- (isChromeIOS || (force && isSafari) || isMacOSWebView) &&
- typeof FileReader !== "undefined"
- ) {
- // 省略FileReader API處理邏輯
- } else {
- var URL = _global.URL || _global.webkitURL;
- var url = URL.createObjectURL(blob);
- if (popup) popup.location = url;
- else location.href = url;
- popup = null; // reverse-tabnabbing #460
- setTimeout(function () {
- URL.revokeObjectURL(url);
- }, 4e4); // 40s
- }
- }
到這里 FileSaver.js 這個(gè)庫(kù)的源碼已經(jīng)分析完成了,跟著阿寶哥閱讀上述源碼之后,是不是覺得寫一個(gè)兼容性好、簡(jiǎn)單易用的第三方庫(kù)是多么不容易。
在實(shí)際項(xiàng)目中,如果你需要保存超過 blob 大小限制的超大文件,或者沒有足夠的內(nèi)存空間,你可以考慮使用更高級(jí)的 StreamSaver.js 庫(kù)來實(shí)現(xiàn)文件保存功能。