【Web 技术】818- 一文带你层层解锁「文件下载」的奥秘
大家好我是秋風,今天帶來的主題是關于文件下載,在我之前曾經發過一篇文件上傳的文章(一文了解文件上傳全過程(1.8w字深度解析,進階必備),反響還不錯,時隔多日,由于最近有研究一些媒體相關的工作,因此打算對下載做一個整理,因此他的兄弟篇誕生了,帶你領略文件下載的奧秘。本文會花費你較長的時間閱讀,建議先收藏/點贊,然后查看你感興趣的部分,平時也可以充當當做字典的效果來查詢。
:) 不整不知道,一整,居然整出這么多情況,我只是想簡單地做個頁面仔。
前言
一圖覽全文,可以先看看大綱適不適合自己,如果你喜歡則繼續往下閱讀。
一文了解文件下載這一節呢,主要介紹一些前置知識,對一些基礎知識的介紹,如果你覺得你是這個。??????,你可以跳過前言。
和榮耀王者說你嘛呢?_榮耀_王者表情前端的文件下載主要是通過 <a> ,再加上 download屬性,有了它們讓我們的下載變得簡單。
download此屬性指示瀏覽器下載 URL 而不是導航到它,因此將提示用戶將其保存為本地文件。如果屬性有一個值,那么此值將在下載保存過程中作為預填充的文件名(如果用戶需要,仍然可以更改文件名)。此屬性對允許的值沒有限制,但是 / 和 \ 會被轉換為下劃線。大多數文件系統限制了文件名中的標點符號,故此,瀏覽器將相應地調整建議的文件名。( 摘自 https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/a)
注意:
此屬性僅適用于同源 URL。
盡管 HTTP URL 需要位于同一源中,但是可以使用 blob: URL 和 data: URL ,以方便用戶下載使用 JavaScript 生成的內容(例如使用在線繪圖 Web 應用程序創建的照片)。
因此下載 url 主要有三種方式。(本文大部分以 blob 的方式進行演示)
image-20200830153314861兼容性
可以看到它的兼容性也非常的可觀(https://www.caniuse.com/#search=download)
image-20200817232216749為了避免很多代碼的重復性,因為我抽離出了幾個公共函數。(該部分可跳過,名字都比較可讀,之后若是遇到不明白則可以在這里尋找)
export?function?downloadDirect(url)?{const?aTag?=?document.createElement('a');aTag.download?=?url.split('/').pop();aTag.href?=?url;aTag.click() } export?function?downloadByContent(content,?filename,?type)?{const?aTag?=?document.createElement('a');aTag.download?=?filename;const?blob?=?new?Blob([content],?{?type?});const?blobUrl?=?URL.createObjectURL(blob);aTag.href?=?blobUrl;aTag.click();URL.revokeObjectURL(blob); } export?function?downloadByDataURL(content,?filename,?type)?{const?aTag?=?document.createElement('a');aTag.download?=?filename;const?dataUrl?=?`data:${type};base64,${window.btoa(unescape(encodeURIComponent(content)))}`;aTag.href?=?dataUrl;aTag.click(); } export?function?downloadByBlob(blob,?filename)?{const?aTag?=?document.createElement('a');aTag.download?=?filename;const?blobUrl?=?URL.createObjectURL(blob);aTag.href?=?blobUrl;aTag.click();URL.revokeObjectURL(blob); } export?function?base64ToBlob(base64,?type)?{const?byteCharacters?=?atob(base64);const?byteNumbers?=?new?Array(byteCharacters.length);for?(let?i?=?0;?i?<?byteCharacters.length;?i++)?{byteNumbers[i]?=?byteCharacters.charCodeAt(i);}const?buffer?=?Uint8Array.from(byteNumbers);const?blob?=?new?Blob([buffer],?{?type?});return?blob; }????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
(手動給不看以上內容的大佬畫分割線)
????????
所有示例Github地址: ?https://github.com/hua1995116/node-demo/tree/master/file-download
在線Demo: https://qiufeng.blue/demo/file-download/index.html
前端文件下載后端
本文后端所有示例均以 koa / 原生 js 實現。
后端返回文件流
這種情況非常簡單,我們只需要直接將后端返回的文件流以新的窗口打開,即可直接下載了。
//?前端代碼 <button?id="oBtnDownload">點擊下載</button> <script> oBtnDownload.onclick?=?function(){window.open('http://localhost:8888/api/download?filename=1597375650384.jpg',?'_blank') } </script> //?后端代碼 router.get('/api/download',?async?(ctx)?=>?{const?{?filename?}?=?ctx.query;const?fStats?=?fs.statSync(path.join(__dirname,?'./static/',?filename));ctx.set({'Content-Type':?'application/octet-stream','Content-Disposition':?`attachment;?filename=${filename}`,'Content-Length':?fStats.size});ctx.body?=?fs.readFileSync(path.join(__dirname,?'./static/',?filename)); })能夠讓瀏覽器自動下載文件,主要有兩種情況:
一種為使用了Content-Disposition屬性。
我們來看看該字段的描述。
在常規的HTTP應答中,Content-Disposition 響應頭指示回復的內容該以何種形式展示,是以內聯的形式(即網頁或者頁面的一部分),還是以附件的形式下載并保存到本地 ? --- 來源 MDN(https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Content-Disposition)
再來看看它的語法
Content-Disposition:?inline Content-Disposition:?attachment Content-Disposition:?attachment;?filename="filename.jpg"很簡單,只要設置成最后一種形態我就能成功讓文件從后端進行下載了。
另一種為瀏覽器無法識別的類型
例如輸入 http://localhost:8888/static/demo.sh,瀏覽器無法識別該類型,就會自動下載。
不知道小伙伴們有沒有遇到過這樣的一個情況,我們輸入一個正確的靜態 js 地址,沒有配置Content-Disposition,但是卻會被意外的下載。
例如像以下的情況。
2020-08-30-17.01.52006r3PQBjw1fav4dsikh6j308c0g5gm1這很可能是由于你的 nginx 少了這一行配置.
include?mime.types;導致默認走了 application/octet-stream,瀏覽器無法識別就下載了文件。
后端返回靜態站點地址
通過靜態站點下載,這里要分為兩種情況,一種為可能該服務自帶靜態目錄,即為同源情況,第二種情況為適用了第三方靜態存儲平臺,例如阿里云、騰訊云之類的進行托管,即非同源(當然也有些平臺直接會返回)。
同源
同源情況下是非常簡單,先上代碼,直接調用一下函數就能輕松實現下載。
import?{downloadDirect}?from?'../js/utils.js'; axios.get('http://localhost:8888/api/downloadUrl').then(res?=>?{if(res.data.code?===?0)?{downloadDirect(res.data.data.url);} })非同源
我們也可以從 MDN 上看到,雖然 download 限制了非同源的情況,但是!!但是!!但是可以使用 blob: URL 和 data: URL ,因此我們只要將文件內容進行下載轉化成 blob 就可以了。
整個過程如下
image-20200830174735143<button?id="oBtnDownload">點擊下載</button><script?type="module">import?{downloadByBlob}?from?'../js/utils.js';function?download(url)?{axios({method:?'get',url,responseType:?'blob'}).then(res?=>?{downloadByBlob(res.data,?url.split('/').pop());})?}oBtnDownload.onclick?=?function(){axios.get('http://localhost:8888/api/downloadUrl').then(res?=>?{if(res.data.code?===?0)?{download(res.data.data.url);}})}</script>現在非同源的也可以愉快地下載啦。
后端返回字符串(base64)
有時候我們也會遇到一些新手后端返回字符串的情況,這種情況很少見,但是來了我們也不慌,順便可以向后端小哥秀一波操作,不管啥數據,咱都能給你下載下來。
ps: 前提是安全無污染的資源 :) ?, 正經文章的招牌閃閃發光。
這種情況下,我需要模擬下后端小哥的騷操作,因此有后端代碼。
994b6f2egy1fgryfevtpvj208c08cmxd核心過程
image-20200830174752476//?node?端 router.get('/api/base64',?async?(ctx)?=>?{const?{?filename?}?=?ctx.query;const?content?=?fs.readFileSync(path.join(__dirname,?'./static/',?filename));const?fStats?=?fs.statSync(path.join(__dirname,?'./static/',?filename));console.log(fStats);ctx.body?=?{code:?0,data:?{base64:?content.toString('base64'),filename,type:?mime.getType(filename)}} }) //?前端 <button?id="oBtnDownload">點擊下載</button> <script?type="module"> import?{base64ToBlob,?downloadByBlob}?from?'../js/utils.js'; function?download({?base64,?filename,?type?})?{const?blob?=?base64ToBlob(blob,?type);downloadByBlob(blob,?filename); } oBtnDownload.onclick?=?function(){axios.get('http://localhost:8888/api/base64?filename=1597375650384.jpg').then(res?=>?{if(res.data.code?===?0)?{download(res.data.data);}}) } </script>思路其實還是利用了我們上面說的 <a> 標簽。但是在這個步驟前,多了一個步驟就是,需要將我們的 base64 字符串轉化為二進制流,這個東西,在我的前一篇文件上傳中也常常提到,畢竟文件就是以二進制流的形式存在。不過也很簡單,js 擁有內置函數 atob。極大地提高了我們轉換的效率。
純前端
上面介紹借助后端來完成文件下載的相關方法,接下來我們來介紹介紹純前端來完成文件下載的一些方法。
方法一: ?blob: URL
image-20200831230800538方法二: data: URL
image-20200831230810963由于 data:URL 會有長度的限制,因此下面的所有例子都會采用 blob 的方式來進行演示。
json/text
下載text和json非常的簡單,可以直接構造一個 Blob。
Blob(blobParts[,?options]) 返回一個新創建的 Blob 對象,其內容由參數中給定的數組串聯組成。 //?html <textarea?name=""?id="text"?cols="30"?rows="10"></textarea> <button?id="textBtn">下載文本</button> <p></p> <textarea?name=""?id="json"?cols="30"?rows="10"?disabled> {"name":?"秋風的筆記" } </textarea> <button?id="jsonBtn">下載JSON</button> //js import?{downloadByContent,?downloadByDataURL}?from?'../js/utils.js'; textBtn.onclick?=?()?=>?{const?value?=?text.value;downloadByContent(value,?'hello.txt',?'text/plain');//?downloadByDataURL(value,?'hello.txt',?'text/plain'); } jsonBtn.onclick?=?()?=>?{const?value?=?json.value;downloadByContent(value,?'hello.json',?'application/json');//?downloadByDataURL(value,?'hello.json',?'application/json'); }效果圖
2020-08-30-17.53.32注釋代碼為 data:URL 的展示部分,由于是第一個例子,因此我講展示代碼,后面都省略了,但是你也可以通過調用 downloadByDataURL 方法,找不到該方法的定義請滑到文章開頭哦~
excel
excel 可以說是我們部分前端打交道很深的一個場景,什么數據中臺,天天需要導出各種報表。以前都是前端請求后端,來獲取一個 excel 文件地址。現在讓我們來展示下純前端是如何實現下載excel。
簡單excel
表格長這個模樣,比較簡陋的形式
image-20200829170347728const?template?=?'<html?xmlns:o="urn:schemas-microsoft-com:office:office"?'+'xmlns:x="urn:schemas-microsoft-com:office:excel"?'+'xmlns="http://www.w3.org/TR/REC-html40">'+'<head>'+'</head>'+'<body><table?border="1"?style="width:60%;?text-align:?center;">{table}</table><\/body>'+'<\/html>';const?context?=?template.replace('{table}',?document.getElementById('excel').innerHTML);downloadByContent(context,?'qiufengblue.xls',?'application/vnd.ms-excel');但是編寫并不復雜,依舊是和我們之前一樣,通過構造出 excel 的格式,轉化成 blob 來進行下載。
最終導出的效果
image-20200829170625763element-ui 導出表格
沒錯,這個就是 element-ui 官方table 的例子。
image-20200829170543891導出效果如下,可以說非常完美。
image-20200829170912128這里我們用到了一個插件 https://github.com/SheetJS/sheetjs
使用起來非常簡單。
<template><el-table?id="ele"?border?:data="tableData"?style="width:?100%"><el-table-column?prop="date"?label="日期"?width="180"></el-table-column><el-table-column?prop="name"?label="姓名"?width="180"></el-table-column><el-table-column?prop="address"?label="地址"></el-table-column></el-table><button?@click="exportExcel">導出excel</button> </template> <script> ... methods:?{exportExcel()?{let?wb?=?XLSX.utils.table_to_book(document.getElementById('ele'));XLSX.writeFile(wb,?'qiufeng.blue.xlsx');} } ... </script>完美表情word
講完了 excel我們再來講講 word 這可是 office 三劍客另外一大利器。這里我們依舊是利用上述的 blob 的方法進行下載。
簡單示例
2020-08-29-20.13.25代碼展示
exportWord.onclick?=?()?=>?{const?template?=?'<html?xmlns:o="urn:schemas-microsoft-com:office:office"?'+'xmlns:x="urn:schemas-microsoft-com:office:word"?'+'xmlns="http://www.w3.org/TR/REC-html40">'+'<head>'+'</head>'+'<body>{table}<\/body>'+'<\/html>';const?context?=?template.replace('{table}',?document.getElementById('word').innerHTML);downloadByContent(context,?'qiufeng.blue.doc',?'application/msword'); }效果展示
image-20200830164208184使用 docx.js插件
如果你想有更高級的用法,可以使用 docx.js這個庫。當然用上述方法也是可以高級定制的。
代碼
<button?type="button"?onclick="generate()">下載word</button><script>async?function?generate()?{const?res?=?await?axios({method:?'get',url:?'http://localhost:8888/static/1597375650384.jpg',responseType:?'blob'})const?doc?=?new?docx.Document();const?image1?=?docx.Media.addImage(doc,?res.data,?300,?400)doc.addSection({properties:?{},children:?[new?docx.Paragraph({children:?[new?docx.TextRun("歡迎關注[秋風的筆記]公眾號").break(),new?docx.TextRun("").break(),new?docx.TextRun("定期發送優質文章").break(),new?docx.TextRun("").break(),new?docx.TextRun("美團點評2020校招-內推").break(),],}),new?docx.Paragraph(image1),],});?docx.Packer.toBlob(doc).then(blob?=>?{console.log(blob);saveAs(blob,?"qiufeng.blue.docx");console.log("Document?created?successfully");});}</script>效果(沒有打廣告...隨便找了張圖,強行不承認系列)
9150e4e5ly1fl8qavz6quj20hs0hsjvl2020-08-30-18.32.09zip下載
前端壓縮還是非常有用的,在一定的場景下,可以節省流量。而這個場景比較使用于,例如前端打包圖片下載、前端打包下載圖標。
一開始我以為我 https://tinypng.com/ 就是用了這個,結果我發現我錯了...仔細一想,因為它壓縮好的圖片是存在后端的,如果使用前端打包的話,反而要去請求所有壓縮的圖片從而來獲取圖片流。如果用后端壓縮話,可以有效節省流量。嗯。。。失敗例子告終。
后來又以為https://www.iconfont.cn/打包下載圖標的時候,使用了這個方案....發現....我又錯了...但是我們分析一下.
image-20200829204540440它官網都是 svg 渲染的圖標,對于 svg 下載的時候,完全可以使用前端打包下載。但是,它還支持 font 以及 jpg 格式,所以為了統一,采用了后端下載,能夠理解。那我們就來實現這個它未完成的功能,當然我們還需要用到一個插件,就是 jszip。
這里我從以上找了兩個 svg 的圖標。
image-20200829204937044實現代碼
download.onclick?=?()?=>?{const?zip?=?new?JSZip();const?svgList?=?[{id:?'demo1',},?{id:?'demo2',}]svgList.map(item?=>?{zip.file(item.id?+?'.svg',?document.getElementById(item.id).outerHTML);})zip.generateAsync({?type:?'blob'}).then(function(content)?{//?下載的文件名var?filename?=?'svg'?+?'.zip';//?創建隱藏的可下載鏈接var?eleLink?=?document.createElement('a');eleLink.download?=?filename;//?下載內容轉變成blob地址eleLink.href?=?URL.createObjectURL(content);//?觸發點擊eleLink.click();//?然后移除});} 2020-08-29-20.52.42查看文件夾目錄,已經將 SVG 打包下載完畢。
image-20200829205329532瀏覽器文件系統(實驗性)
image-20200817234129788在我電腦上都有這么一個瀏覽器,用來學習和調試 chrome 的最新新特性, 如果你的電腦沒有,建議你安裝一個。
玩這個特性需要打開 chrome 的實驗特性 chrome://flags => #native-file-system-api => enable, 因為實驗特性都會伴隨一些安全或者影響原本的渲染的行為,因此我再次強烈建議,下載一個金絲雀版本的 chrome 來進行玩耍。
<textarea?name=""?id="textarea"?cols="30"?rows="10"></textarea> <p><button?id="btn">下載</button></p> <script>btn.onclick?=?async?()?=>?{const?handler?=?await?window.chooseFileSystemEntries({type:?'save-file',accepts:?[{description:?'Text?file',extensions:?['txt'],mimeTypes:?['text/plain'],}],});const?writer?=?await?handler.createWritable();await?writer.write(textarea.value);await?writer.close();} </script>實現起來非常簡單。卻飛一般的感覺。
2020-08-18-00.13.29其他場景
H5文件下載
一般在 h5 下載比較多的是 pdf 或者是 apk 的下載。
Android
在安卓瀏覽器中,瀏覽器直接下載文件。
ios
由于ios的限制,無法進行下載,因此,可以使用復制 url ,來代替下載。
import?{downloadDirect}?from?'../js/utils.js'; const?btn?=?document.querySelector('#download-ios'); if?(/(iPhone|iPad|iPod|iOS)/i.test(navigator.userAgent))?{const?clipboard?=?new?ClipboardJS(btn);clipboard.on('success',?function?()?{alert('已復制鏈接,打開瀏覽器粘貼鏈接下載');});clipboard.on('error',?function?(e)?{alert('系統版本過低,復制鏈接失敗');}); }?else?{btn.onclick?=?()?=>?{downloadDirect(btn.dataset.clipboardText)} }更多
對于 apk 等下載包可以使用這個包(本人暫時沒有試驗,接觸不多,回頭熟悉了再回來補充。)
https://github.com/jawidx/web-launch-app
image-20200830145258473大文件的分片下載
最近在開發媒體流相關的工作的時候,發現在加載 mp4 文件的時候,發現了一個比較有意思的現象,視頻流并不需要將整個 mp4 下載完才進行播放,并且伴隨了很多狀態碼為 206 的請求,乍一看有點像流媒體(HLS等)的韻味。
2020-08-29-21.31.29覺得這個現象非常的有意思,他能夠分片地加載資源,這對于體驗或者是流量的節省都是非常大的幫助。最終發現它帶了一個名為 Range 的頭。我們來看看 MDN 的解釋。
The Range 是一個請求首部,告知服務器返回文件的哪一部分。在一個 Range 首部中,可以一次性請求多個部分,服務器會以 multipart 文件的形式將其返回。如果服務器返回的是范圍響應,需要使用 206 Partial Content 狀態碼。?摘自 MDN
語法
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>Node實現
既然我們知道了它的原理,就來自己實現一下。
router.get('/api/rangeFile',?async(ctx)?=>?{const?{?filename?}?=?ctx.query;const?{?size?}?=?fs.statSync(path.join(__dirname,?'./static/',?filename));const?range?=?ctx.headers['range'];if?(!range)?{ctx.set('Accept-Ranges',?'bytes');ctx.body?=?fs.readFileSync(path.join(__dirname,?'./static/',?filename));return;}const?{?start,?end?}?=?getRange(range);if?(start?>=?size?||?end?>=?size)?{ctx.response.status?=?416;ctx.set('Content-Range',?`bytes?*/${size}`);ctx.body?=?'';return;}ctx.response.status?=?206;ctx.set('Accept-Ranges',?'bytes');ctx.set('Content-Range',?`bytes?${start}-${end???end?:?size?-?1}/${size}`);ctx.body?=?fs.createReadStream(path.join(__dirname,?'./static/',?filename),?{?start,?end?}); })Nginx實現
發現 nginx 不需要寫任何代碼就默認支持了 range 頭,想著我一定知道它到底是支持,還是加入了什么模塊,或者是我默認開啟了什么配置,找了半天沒有找到什么額外的配置。
3630px-Nginx_logo-1正當我準備放棄的時候,靈光一現,去看看源碼吧,說不定會有發現,去查了 nginx 源碼相關的內容,用了慣用的反推方式,才發現原來是max_ranges這個字段。
https://github.com/nginx/nginx/blob/release-1.13.6/src/http/modules/ngx_http_range_filter_module.c#L166
這也怪我一開始文檔閱讀不夠仔細,浪費了大量的時間。
:) 其實我對 nginx 源碼也不熟悉,這里可以用個小技巧,直接在源碼庫 搜索 206 然后 發現了一個宏命令
#define?NGX_HTTP_PARTIAL_CONTENT???????????206然后順藤摸瓜,直接找到這個宏命令NGX_HTTP_PARTIAL_CONTENT用到的地方,這樣一步一步就慢慢能找到我們想要的。
默認 nginx 是自動開啟 range 頭的, 如果不需要配置,則配置 max_range: 0;
Nginx 配置文檔 http://nginx.org/en/docs/http/ngx_http_core_module.html#max_ranges
總結
我們可以來總結一下,其實全文主要講了(xbb)兩個核心的知識,一個是 blob 一個a 標簽,另外還要注意對于大文件,服務器的優化策略,可以通過 Range 來分片加載。
image-20200830181216353參考資料
https://github.com/dolanmiu/docx
https://github.com/SheetJS/sheetjs
https://juejin.im/post/6844903763359039501
1. JavaScript 重溫系列(22篇全)
2. ECMAScript 重溫系列(10篇全)
3. JavaScript設計模式 重溫系列(9篇全)
4.?正則 / 框架 / 算法等 重溫系列(16篇全)
5.?Webpack4 入門(上)||?Webpack4 入門(下)
6.?MobX 入門(上)?||??MobX 入門(下)
7. 80+篇原創系列匯總
回復“加群”與大佬們一起交流學習~
點擊“閱讀原文”查看 100+ 篇原創文章
總結
以上是生活随笔為你收集整理的【Web 技术】818- 一文带你层层解锁「文件下载」的奥秘的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 西医综合记忆手册(太强了,这帮狂人。真败
- 下一篇: 如果期权买方真有这么好,为什么大户和机构