聊一聊 15.5K 的 FileSaver,是如何工作的?
FileSaver.js 是在客戶端保存文件的解決方案,非常適合在客戶端上生成文件的 Web 應(yīng)用程序。它簡單易用且兼容大多數(shù)瀏覽器,被作為項(xiàng)目依賴應(yīng)用在 6.3 萬的項(xiàng)目中。在近期的項(xiàng)目中,阿寶哥再一次使用到了它,所以就想寫篇文章來聊一聊這個(gè)優(yōu)秀的開源項(xiàng)目。
一、FileSaver.js 簡介
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ù)表示配置對象(可選)。如果你需要 FlieSaver.js 自動提供 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("https://httpbin.org/image",?"image.jpg");
如果下載的 URL 地址與當(dāng)前站點(diǎn)是同域的,則將使用 a[download] 方式下載。否則,會先使用 同步的 HEAD 請求 來判斷是否支持 CORS 機(jī)制,若支持的話,將進(jìn)行數(shù)據(jù)下載并使用 Blob URL 實(shí)現(xiàn)文件下載。如果不支持 CORS 機(jī)制的話,將會嘗試使用 a[download] 方式下載。
標(biāo)準(zhǔn)的 W3C File API Blob 接口并非在所有瀏覽器中都可用,對于這個(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() 方法并非在所有瀏覽器中都可用,對于這個(gè)問題,你可以考慮使用 canvas-toBlob.js 來解決兼容性問題。

(圖片來源:https://caniuse.com/?search=toBlob)
在以上的示例中,我們多次見到 Blob 的身影,因此在介紹 FileSaver.js 源碼時(shí),阿寶哥先來簡單介紹一下 Blob 的相關(guān)知識。
二、Blob 簡介
Blob(Binary Large Object)表示二進(jìn)制類型的大對象。在數(shù)據(jù)庫管理系統(tǒng)中,將二進(jìn)制數(shù)據(jù)存儲為一個(gè)單一個(gè)體的集合。Blob 通常是影像、聲音或多媒體文件。在 JavaScript 中 Blob 類型的對象表示不可變的類似文件對象的原始數(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í)候,瀏覽器會自動使用指定應(yīng)用程序來打開。多用于指定一些客戶端自定義的文件名,以及一些媒體文件打開方式。
常見的 MIME 類型有:超文本標(biāo)記語言文本 .html text/html、PNG 圖像 .png image/png、普通文本 .txt text/plain 等。
在 JavaScript 中我們可以通過 Blob 的構(gòu)造函數(shù)來創(chuàng)建 Blob 對象,Blob 構(gòu)造函數(shù)的語法如下:
var?aBlob?=?new?Blob(blobParts,?options);
相關(guān)的參數(shù)說明如下:
blobParts:它是一個(gè)由 ArrayBuffer,ArrayBufferView,Blob,DOMString 等對象構(gòu)成的數(shù)組。DOMStrings 會被編碼為 UTF-8。 options:一個(gè)可選的對象,包含以下兩個(gè)屬性: type —— 默認(rèn)值為 "",它代表了將會被放入到 blob 中的數(shù)組內(nèi)容的 MIME 類型。endings —— 默認(rèn)值為 "transparent",用于指定包含行結(jié)束符\n的字符串如何被寫入。它是以下兩個(gè)值中的一個(gè):"native",代表行結(jié)束符會被更改為適合宿主操作系統(tǒng)文件系統(tǒng)的換行符,或者"transparent",代表會保持 blob 中保存的結(jié)束符不變。
介紹完 Blob 之后,我們再來介紹一下 Blob URL。
2.2 Blob URL
Blob URL/Object URL 是一種偽協(xié)議,允許 Blob 和 File 對象用作圖像,下載二進(jìn)制數(shù)據(jù)鏈接等的 URL 源。在瀏覽器中,我們使用 URL.createObjectURL 方法來創(chuàng)建 Blob URL,該方法接收一個(gè) Blob 對象,并為其創(chuàng)建一個(gè)唯一的 URL,其形式為 blob:,對應(yīng)的示例如下:
blob:https://example.org/40a5fb5a-d56d-4a33-b4e2-0acf6a8e5f641
瀏覽器內(nèi)部為每個(gè)通過 URL.createObjectURL 生成的 URL 存儲了一個(gè) URL → Blob 映射。因此,此類 URL 較短,但可以訪問 Blob。生成的 URL 僅在當(dāng)前文檔打開的狀態(tài)下才有效。它允許引用 、 中的 Blob,但如果你訪問的 Blob URL 不再存在,則會從瀏覽器中收到 404 錯(cuò)誤。
上述的 Blob URL 看似很不錯(cuò),但實(shí)際上它也有副作用。雖然存儲了 URL → Blob 的映射,但 Blob 本身仍駐留在內(nèi)存中,瀏覽器無法釋放它。映射在文檔卸載時(shí)自動清除,因此 Blob 對象隨后被釋放。但是,如果應(yīng)用程序壽命很長,那不會很快發(fā)生。因此,如果我們創(chuàng)建一個(gè) Blob URL,即使不再需要該 Blob,它也會存在內(nèi)存中。
針對這個(gè)問題,我們可以調(diào)用 URL.revokeObjectURL(url) 方法,從內(nèi)部映射中刪除引用,從而允許刪除 Blob(如果沒有其他引用),并釋放內(nèi)存。
好的,現(xiàn)在我們已經(jīng)介紹了 Blob 和 Blob URL。如果你還意猶未盡,想深入理解 Blob 的話,可以閱讀 你不知道的 Blob 這篇文章,接下來我們開始分析 FileSaver.js 的源碼。
如果你想了解閱讀源碼的思路與技巧,可以閱讀 使用這些思路與技巧,我讀懂了多個(gè)優(yōu)秀的開源項(xiàng)目 這篇文章。
三、FileSaver.js 源碼解析
在 FileSaver.js 內(nèi)部提供了三種方案來實(shí)現(xiàn)文件保存,因此接下來我們將分別來介紹這三種方案。
3.1 方案一
當(dāng) FileSaver.js 在保存文件時(shí),如果當(dāng)前平臺中 a 標(biāo)簽支持 download 屬性且非 MacOS WebView 環(huán)境,則會優(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)是非同域的,則會先使用 同步的 HEAD 請求 來判斷是否支持 CORS 機(jī)制,若支持的話,就會調(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)很簡單,就是通過 XMLHttpRequest API 發(fā)起一個(gè)同步的 HEAD 請求,然后判斷返回的狀態(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)也很簡單,也是通過 XMLHttpRequest API 來發(fā)起 HTTP 請求,與大家熟悉的 ?JSON 格式不同的是,我們需要設(shè)置 responseType 的類型為 blob。此外,因?yàn)榉祷氐慕Y(jié)果是 blob 類型的數(shù)據(jù),所以在成功回調(diào)函數(shù)內(nèi)部會繼續(xù)調(diào)用 saveAs 方法來實(shí)現(xiàn)文件保存。
而對于不支持 CORS 機(jī)制或同域的情形,它會調(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)部,會優(yōu)先調(diào)用 node 對象上的 dispatchEvent 方法來派發(fā) click 事件。當(dāng)出現(xiàn)異常的時(shí)候,會在 catch 語句進(jìn)行相應(yīng)的異常處理,catch 語句中的 MouseEvent.initMouseEvent() 方法用于初始化鼠標(biāo)事件的值。但需要注意的是,該特性已經(jīng)從 Web 標(biāo)準(zhǔn)中刪除,雖然一些瀏覽器目前仍然支持它,但也許會在未來的某個(gè)時(shí)間停止支持,請盡量不要使用該特性。
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);
??}
}
對于 blob 類型的參數(shù),首先會通過 createObjectURL 方法來創(chuàng)建 Object URL,然后在通過 click 方法執(zhí)行文件保存。為了能及時(shí)釋放內(nèi)存,在 else 處理分支中,會啟動一個(gè)定時(shí)器來執(zhí)行清理操作。此時(shí),方案一我們已經(jīng)介紹完了,接下去要介紹的方案二主要是為了兼容 IE 瀏覽器。
3.2 方案二
在 Internet Explorer 10 瀏覽器中,msSaveBlob 和 msSaveOrOpenBlob 方法允許用戶在客戶端上保存文件,其中 msSaveBlob 方法只提供一個(gè)保存按鈕,而 msSaveOrOpenBlob 方法提供了保存和打開按鈕,對應(yīng)的使用方式如下所示:
window.navigator.msSaveBlob(blobObject,?'msSaveBlob_hello.txt');
window.navigator.msSaveOrOpenBlob(blobObject,?'msSaveBlobOrOpenBlob_hello.txt');
了解完上述的知識和方案一中介紹的 corsEnabled、download 和 click 方法后,再來看方案二的代碼,就很清晰明了。
在滿足 "msSaveOrOpenBlob" in navigator 條件時(shí), FileSaver.js 會使用方案二來實(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 就會降級使用 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ù)
對于 blob 類型的參數(shù)來說,在 saveAs 方法內(nèi)部會根據(jù)不同的環(huán)境選用不同的方案,比如在 Safari 瀏覽器環(huán)境中,它會利用 FileReader API 先把 Blob 對象轉(zhuǎn)換為 Data URL,然后再把該 Data URL 地址賦值給新開的窗口或當(dāng)前窗口的 location 對象,具體的代碼如下:
//?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í)對于 FileReader API 來說,除了支持把 File/Blob 對象轉(zhuǎn)換為 Data URL 之外,它還提供了 readAsArrayBuffer() 和 readAsText() 方法,用于把 File/Blob 對象轉(zhuǎn)換為其它的數(shù)據(jù)格式。
在 玩轉(zhuǎn)前端二進(jìn)制 文章中,阿寶哥詳細(xì)介紹了 FileReader API 在前端圖片處理場景中的應(yīng)用,閱讀完該文章之后,你們將能輕松看懂以下轉(zhuǎn)換關(guān)系圖:

最后我們再來看一下 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è)庫的源碼已經(jīng)分析完成了,跟著阿寶哥閱讀上述源碼之后,是不是覺得寫一個(gè)兼容性好、簡單易用的第三方庫是多么不容易。
在實(shí)際項(xiàng)目中,如果你需要保存超過 blob 大小限制的超大文件,或者沒有足夠的內(nèi)存空間,你可以考慮使用更高級的 StreamSaver.js 庫來實(shí)現(xiàn)文件保存功能。
四、參考資源
你不知道的 Blob 玩轉(zhuǎn)前端二進(jìn)制 MDN - FileReader API 百度百科 - 字節(jié)順序標(biāo)記(ByteOrderMark)
掃碼關(guān)注公眾號,訂閱更多精彩內(nèi)容。

