文件下载
,在我以前曾经发过一篇文件上传的文章(
一文了解文件上传全过程(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

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

为了不不少代码的重复性,由于我抽离出了几个公共函数。(该部分可跳过,名字都比较可读,以后如果遇到不明白则能够在这里寻找)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
,可是却会被意外的下载。
例如像如下的状况。


这极可能是因为你的 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" 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 来进行下载。
最终导出的效果

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
本文分享自微信公众号 - 前端日志(gh_12dcc43e6039)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。