乐趣区

关于前端:一文带你层层解锁文件下载的奥秘

大家好我是秋风,明天带来的主题是对于 文件下载,在我之前已经发过一篇文件上传的文章(一文理解文件上传全过程(1.8w 字深度解析,进阶必备 200+ 点赞),反应还不错,时隔多日,因为最近有钻研一些媒体相干的工作,因而打算对下载做一个整顿,因而他的兄弟篇诞生了,带你领略文件下载的神秘。本文会破费你较长的工夫浏览,倡议先珍藏 / 点赞,而后查看你感兴趣的局部,平时也能够充当当做字典的成果来查问。

:) 不整不晓得,一整,竟然整出这么多状况,我只是想简略地做个页面仔。

前言

一图览全文,能够先看看纲要适不适宜本人,如果你喜爱则持续往下浏览。

这一节呢,次要介绍一些前置常识,对一些基础知识的介绍,如果你感觉你是这个。⬇️⬇️⬇️,你能够跳过前言。

前端的文件下载次要是通过 <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 的形式进行演示)

兼容性

能够看到它的兼容性也十分的可观(https://www.caniuse.com/#search=download)

为了防止很多代码的重复性,因为我抽离出了几个公共函数。(该局部可跳过,名字都比拟可读,之后若是遇到不明确则能够在这里寻找)

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,然而却会被意外的下载。

例如像以下的状况。

这很可能是因为你的 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 就能够了。

整个过程如下

<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: 前提是平安无污染的资源 :) , 正经文章的招牌闪闪发光。

这种状况下,我须要模仿下后端小哥的骚操作,因而有后端代码。

外围过程

// 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

办法二: data: URL

因为 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');
}

效果图

正文代码为 data:URL 的展现局部,因为是第一个例子,因而我讲展现代码,前面都省略了,然而你也能够通过调用 downloadByDataURL 办法,找不到该办法的定义请滑到文章结尾哦~

excel

excel 能够说是咱们局部前端打交道很深的一个场景,什么数据中台,天天须要导出各种报表。以前都是前端申请后端,来获取一个 excel 文件地址。当初让咱们来展现下纯前端是如何实现下载 excel。

简略 excel

表格长这个模样,比拟简陋的模式

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">{table}</table><\/body>'
            +'<\/html>';
    const context = template.replace('{table}', document.getElementById('excel').innerHTML);
    downloadByContent(context, 'qiufengblue.xls', 'application/vnd.ms-excel');

然而编写并不简单,仍旧是和咱们之前一样,通过结构出 excel 的格局,转化成 blob 来进行下载。

最终导出的成果

element-ui 导出表格

没错,这个就是 element-ui 官网table 的例子。

导出成果如下,能够说十分完满。

这里咱们用到了一个插件 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 的办法进行下载。

简略示例

代码展现

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');
}

成果展现

应用 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>

成果(没有打广告 … 轻易找了张图,强行不抵赖系列)

zip 下载

前端压缩还是十分有用的,在肯定的场景下,能够节俭流量。而这个场景比拟应用于,例如前端打包图片下载、前端打包下载图标。

一开始我认为我 https://tinypng.com/ 就是用了这个,后果我发现我错了 … 认真一想,因为它压缩好的图片是存在后端的,如果应用前端打包的话,反而要去申请所有压缩的图片从而来获取图片流。如果用后端压缩话,能够无效节俭流量。嗯。。。失败例子告终。

起初又认为 https://www.iconfont.cn/ 打包 …,应用了这个计划 …. 发现 …. 我又错了 … 然而咱们剖析一下.

它官网都是 svg 渲染的图标,对于 svg 下载的时候,齐全能够应用前端打包下载。然而,它还反对 font 以及 jpg 格局,所以为了对立,采纳了后端下载,可能了解。那咱们就来实现这个它未实现的性能,当然咱们还须要用到一个插件,就是 jszip。

这里我从以上找了两个 svg 的图标。

实现代码

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();
            // 而后移除
        });
    }

查看文件夹目录,曾经将 SVG 打包下载结束。

浏览器文件系统(实验性)

在我电脑上都有这么一个浏览器,用来学习和调试 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>

实现起来非常简单。却飞个别的感觉。

其余场景

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

大文件的分片下载

最近在开发媒体流相干的工作的时候,发现在加载 mp4 文件的时候,发现了一个比拟有意思的景象,视频流并不需要将整个 mp4 下载完才进行播放,并且随同了很多状态码为 206 的申请,乍一看有点像流媒体 (HLS 等) 的韵味。

感觉这个景象十分的有意思,他可能分片地加载资源,这对于体验或者是流量的节俭都是十分大的帮忙。最终发现它带了一个名为 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 头,想着我肯定晓得它到底是反对,还是退出了什么模块,或者是我默认开启了什么配置,找了半天没有找到什么额定的配置。

正当我筹备放弃的时候,灵光一现,去看看源码吧,说不定会有发现,去查了 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 来分片加载。

参考资料

https://github.com/dolanmiu/docx

https://github.com/SheetJS/sheetjs

https://juejin.im/post/6844903763359039501

最初

如果我的文章有帮忙到你,心愿你也能帮忙我,欢送关注我的微信公众号 秋风的笔记 ,回复 好友 二次,可加微信并且退出交换群, 秋风的笔记 将始终陪伴你的左右。

退出移动版