一文帶你層層解鎖「文件下載」的奧祕

你們好我是秋風,今天帶來的主題是關於 文件下載 ,在我以前曾經發過一篇文件上傳的文章( 一文了解文件上傳全過程(1.8w字深度解析,進階必備 ),反響還不錯,時隔多日,因爲最近有研究一些媒體相關的工做,所以打算對下載作一個整理, 所以他的兄弟篇誕生了,帶你領略文件下載的奧祕。本文會花費你較長的時間閱讀,建議先收藏/點贊,而後查看你感興趣的部分,平時也能夠充當當作字典的效果來查詢。

:) 不整不知道,一整,竟然整出這麼多狀況,我只是想簡單地作個頁面仔。html

前言

一圖覽全文,能夠先看看大綱適不適合本身,若是你喜歡則繼續往下閱讀。前端

一文了解文件下載

這一節呢,主要介紹一些前置知識,對一些基礎知識的介紹,若是你以爲你是這個。⬇️⬇️⬇️,你能夠跳過前言。node

和榮耀王者說你嘛呢?_榮耀_王者表情

前端的文件下載主要是經過 <a> ,再加上 download屬性,有了它們讓咱們的下載變得簡單。ios

download此屬性指示瀏覽器下載 URL 而不是導航到它,所以將提示用戶將其保存爲本地文件。若是屬性有一個值,那麼此值將在下載保存過程當中做爲預填充的文件名(若是用戶須要,仍然能夠更改文件名)。此屬性對容許的值沒有限制,可是 /\ 會被轉換爲下劃線。大多數文件系統限制了文件名中的標點符號,故此,瀏覽器將相應地調整建議的文件名。( 摘自 https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/a)nginx

注意:git

  • 此屬性僅適用於同源 URL。
  • 儘管 HTTP URL 須要位於同一源中,可是可使用 blob: URL 和 data: URL ,以方便用戶下載使用 JavaScript 生成的內容(例如使用在線繪圖 Web 應用程序建立的照片)。

所以下載 url 主要有三種方式。(本文大部分以 blob 的方式進行演示)github

image-20200830153314861

兼容性web

能夠看到它的兼容性也很是的可觀(https://www.caniuse.com/#search=download)chrome

image-20200817232216749

爲了不不少代碼的重複性,由於我抽離出了幾個公共函數。(該部分可跳過,名字都比較可讀,以後如果遇到不明白則能夠在這裏尋找)element-ui

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.52
006r3PQBjw1fav4dsikh6j308c0g5gm1

這極可能是因爲你的 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 = {
        code0,
        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-20200829170347728
const 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-20200829170625763

element-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, 300400)
            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>

效果(沒有打廣告...隨便找了張圖,強行不認可系列)

9150e4e5ly1fl8qavz6quj20hs0hsjvl
2020-08-30-18.32.09

zip下載

前端壓縮仍是很是有用的,在必定的場景下,能夠節省流量。而這個場景比較使用於,例如前端打包圖片下載、前端打包下載圖標。

一開始我覺得我 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

本文分享自微信公衆號 - 前端日誌(gh_12dcc43e6039)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索