写点什么

8 千长文解决前端资源下载全场景问题

作者:梁龙先森
  • 2021 年 12 月 13 日
  • 本文字数:8330 字

    阅读完需:约 27 分钟

8千长文解决前端资源下载全场景问题

前端资源下载作为前端常用的功能之一,我们的工具库该如何保证它的健壮性,以及通用性呢?以及面对不同下载的场景,比如:文件资源超过了 Blob 对象所能支撑的最大 size, 或者超过了浏览器所能支撑的最大 RAM,该如何进行资源下载呢?可能你会说教给服务端处理,这完全没毛病,那万一刚好又没有自己的服务资源呢?那又该怎么做呢?本文将给你介绍下面这些知识。

一、文件大小 Blob 对象最大 Size

1. download

耳熟能详的资源文件下载,最先能想到的可能就是 a 标签了,借助 html5 的新增特性 download 。然后可能行云流水的写下如下通过代码:

/** * @params url 下载资源路径 * @params name 文件名 */function saveAs(url,name){  var a = document.createElement('a');  a.download = name;  a.rel = 'noopener';  a.href = url;  a.click()}
复制代码

同源环境下,该代码能够正常的下载资源并且文件名称按照 name 的配置正常显示。但当下载的资源是跨域资源,文件名就不能按照 name 正常显示了。


了解下 download 的知识点了:

  1. download属性指示浏览器下载 URL 而不是导航到它,因此将提示用户将其保存为本地文件。

  2. 如果属性有一个值,那么此值将在下载保存过程中作为预填充的文件名。

  3. 此属性对允许的值没有限制,但是 / 和 \ 会被转换为下划线。

  4. 大多数文件系统限制了文件名中的标点符号,故此,浏览器将相应地调整建议的文件名。

  5. 此属性仅适用于同源 URL。

2. Blob

Blob 对象,已经不陌生,复习下它的知识点。

  1. 它表示一个不可变、原始数据的类文件对象。它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream 来用于数据操作。

  2. File接口基于Blob,继承了blob的功能并将其扩展使其支持用户系统上的文件。

  3. blob对象转Blob,请使用Blob()构造函数;创建子集 blob,用slice()方法,大文件切片就靠它了。


了解到这里,又可以洋洋洒洒的写下代码了:

function download (url, name) {  var xhr = new XMLHttpRequest()  xhr.open('GET', url)  xhr.responseType = 'blob'  xhr.onload = function () {    var url = URL.createObjectURL(xhr.response)    saveAs(url, name)    setTimeout(function () { URL.revokeObjectURL(url) }, 4E4) // 40s  }  xhr.onerror = function () {    console.error('could not download file')  }  xhr.send()}
复制代码

同样的,该代码下载不了不允许 cors 的资源,因此需要在前面校验下该资源是否支持跨域。

function corsEnabled (url) {  var xhr = new XMLHttpRequest()  xhr.open('HEAD', url, false)  try {    xhr.send()  } catch (e) {}  return xhr.status >= 200 && xhr.status <= 299}
复制代码

注意了,这里请求是否允许 cors 是同步的。

corsEnabled (url)?download(url,name):saveAs(url,name)
复制代码

3. msSaveOrOpenBlob

针对 IE10 浏览器,它提供了 msSaveBlobmsSaveOrOpenBlob 两方法允许用户在客户端上保存文件,就像从 Internet 下载文件,这也是为啥此类文件能保存到下载文件夹。这两方法存在一些区别:

  1. msSaveBlob只提供一个保存按钮

  2. msSaveOrOpenBlob提供保存和打开按钮

直接上代码:

/** * @params blob :Blob对象 * @params name :文件名 */function saveAs(blob,name,opts){  if('msSaveOrOpenBlob' in navigator){    navigator.msSaveOrOpenBlob(bom(blob, opts), name)  }}
function bom (blob, opts) { if (typeof opts === 'undefined') opts = { autoBom: false } else if (typeof opts !== 'object') { opts = { autoBom: !opts } } // 为UTF-8 XML和text/*类型(包括HTML)预先准备BOM表 // 浏览器会自动将UTF-16 U+FEFF转换为EF BB BF if (opts.autoBom && /^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(blob.type)) { return new Blob([String.fromCharCode(0xFEFF), blob], { type: blob.type }) } return blob}
复制代码

4. FileReader

  1. FileReader 对象允许 Web 应用程序异步读取存储在用户计算机上的文件(或原始数据缓冲区)的内容,使用 FileBlob 对象指定要读取的文件或数据。

  2. File 对象来自 input 元素选择文件返回的 FileList 对象,也可以是拖拽生成的 DataTransfer 对象,或者是 Canvas 上执行 mozGetAsFile() 方法返回的结果.

  3. FileReader 仅用于以安全的方式从用户(远程)系统读取文件内容 它不能用于从文件系统中按路径名简单地读取文件.

function saveAs(){   var isChromeIOS = /CriOS\/[\d]+/.test(navigator.userAgent);   var reader = new FileReader()   reader.onloadend = function () {      var url = reader.result      url = isChromeIOS ? url : url.replace(/^data:[^;]*;/, 'data:attachment/file;')      window.location.href = url    }   reader.readAsDataURL(blob)}
复制代码


到此为止,已经写了 4 种下载的方式,那这 4 种方式存在的情况下如何排优先级呢?

  1. 'download' in HTMLAnchorElement.prototype

  2. 'msSaveOrOpenBlob' in navigator

  3. FileReader

二、文件大小超过 Blob 最大 Size

上面这些实现最大资源文件下载的大小依靠浏览器 Blob 对象的 Max Size,在 Chrome 上是 2GB,Firefox 是 800MiB,其他浏览器略有差异。那么当你去下载一个 10G 文件对时候,浏览器就崩溃了!那为啥会崩溃呢?通常发起一个 URL 请求到服务端,浏览器会根据返回的响应头 Content-Type 字段来区分返回的资源,如果返回的是 application/octet-stream,显示数据是字节流,通常浏览器会按照下载类型来处理该请求,将它转发给下载管理器,由它执行 IO 操作去存储资源。这种情况下,浏览器基本不存在崩溃的情况。但是,如果将文件通过 js 直接读取到内存,一方面 Blob 存在大小限制,资源文件不能大于Blob Max Size,另一方面,大对象读到堆空间,可能撑爆空间,导致页面崩溃。文件很大,超过 Blob 的最大限制 2GB,读到客户端存储或内存中,已然是不可取,那还有什么其他方式呢?


答案是:流,创建一个直接到文件系统的可写流


说起流,下载远端资源,如果文件大,服务端考虑到内存的消耗,通常也是边读文件边返回流给前端。那前端可以这么保存流吗?

link = document.createElement('a')link.href = URL.createObjectURL(stream) // DOES NOT WORKlink.download = 'filename'link.click() // Save
复制代码


答案是:不能,不能通过 stream 去创建 Object URL

2.1 Content-Disposition

在常规的 HTTP 应答中,Content-Disposition 响应头指示回复的内容该以何种形式展示,是以内联的形式(即网页或者页面的一部分),还是以附件的形式下载并保存到本地。

  • 语法:作为消息主体中的消息头在 HTTP 场景中,第一个参数或者是 inline(默认值,表示回复中的消息体会以页面的一部分或者整个页面的形式展示),或者是 attachment(意味着消息体应该被下载到本地;大多数浏览器会呈现一个“保存为”的对话框,将 filename 的值预填为下载后的文件名,假如它存在的话)

Content-Disposition: inlineContent-Disposition: attachmentContent-Disposition: attachment; filename="filename.jpg"
复制代码

2.2 方案概述

所以,解决方案只有一个:发送带有Content-Disposition标头的流告诉浏览器保存文件。 如果下载保存的资源来自自有服务器,可以让服务端处理。那万一我们没有服务器或者内容不在服务器上呢?模拟服务器,通过创建一个可以拦截请求并使用responseWith()并充当服务器的service worker

2.3 自有服务器资源

让服务器响应请求去读流写流,配置响应头 Content-Type/Content-Disposition 等,不多说。

2.4 无服务器或客户端资源

这个方案是:模拟服务器,创建一个可以拦截请求并使用 responseWith() 并充当服务器的 service worker。但是 Service Worker 只允许在安全的上下文中使用,并且需要付出一些努力。大多数情况下,都在主线程中工作,而 Service Worker 在空闲前仅存活 < 5 分钟。


  1. 因此,可以在中间创建了个中间人,将 service worker 安装托管在 github 静态页面上的安全上下文中。如果你的页面不安全,则采用自 iframe(在安全上下文中)或新弹出窗口。

  2. 使用 postMessage 将流(或 DataChannel)传输到 Service Worker

  3. 然后创建一个下载链接,然后我们打开它。


如果“可传输的”可读流没有传递给 Service Worker,那么 mitm 还将尝试通过每 x 秒 ping 服务工作者来保持服务工作者活着,以防止它闲置。以上便是整体的大体实践,下面看看如何实现。

2.4.1 中间人 mith.html

github 是 https 的站点,认为它的上下文是安全的。我们本质是创建 service worker 来冒充服务器拦截请求,但考虑没有服务器或者下载的是客户端资源,所以采用免费的 github。mith.html文件作为 web 页面通过 iframe 加载的站点,它的功能主要有两个:

  1. 注册管理 service worker,防重启。

  2. 作为 web 页面和 service worker 消息通信的中间人,加工处理 web 页面消息以及MessageChannelservice worker

1. github 建立托管文件
// service worker文件,充当服务器,用来拦截请求,制造假的响应,让浏览器去下载资源sw.js// web页面通过iframe加载的github静态资源文件,由它注册service workermitm.html
复制代码
2. mith.html 监听 web 页面发送的消息
// 消息队列let messages = [] // iframe监听到消息,塞到消息队列window.onmessage = evt => messages.push(evt)
复制代码
3. mitm.html 注册 service worker
// 注册的service worker实例let sw = nulllet scope = ''
// 注册service workerfunction registerWorker() { return navigator.serviceWorker.getRegistration('./').then(swReg => { return swReg || navigator.serviceWorker.register('sw.js', { scope: './' }) }).then(swReg => { const swRegTmp = swReg.installing || swReg.waiting scope = swReg.scope // 精简代码 return (sw = swReg.active) })}
if (navigator.serviceWorker) { registerWorker()}
复制代码
4. mitm.html 处理队列消息,并发送 service worker
// 队列内消息的处理,发生在sw注册成功之后registerWorker().then(()=>{   window.onmessage = onMessage   // 依次执行消息,并post到sw   messages.forEach(window.onmessage)})
// 队列消息处理逻辑function onMessage(event){ let { data, ports, origin } = event // 所以所有下载链接都需要加前缀以避免任何其他冲突 data.origin = origin // 重定向到发起 http 请求的页面 data.referrer = data.referrer || document.referrer || origin if (typeof data.filename === 'string') { data.filename = data.filename.replace(/\//g, ':') } if (!data.pathname) { data.pathname = Math.random().toString().slice(-6) + '/' + data.filename } // 删除所有前导斜杠 data.pathname = data.pathname.replace(/^\/+/g, '') // 删除协议 let org = origin.replace(/(^\w+:|^)\/\//, '') // 将绝对路径名设置为下载 url。 data.url = new URL(`${scope + org}/${data.pathname}`).toString() // 将页面传递进来的messageChannel.port2传递给service worker,方便service worker与页面进行通信 const transferable = [ ports[0] ] // 我们本文默认通过可传输流传递数据,所以keepAlive,实际上没有必要,但为了扩展,还是保留了 if (!data.transferringReadable){ keepAlive() } return sw.postMessage(data, transferable)}
复制代码
5. mith.html 防止 sw 重启

service worker 完成后,可以关闭 mitm,但最好别,这会停止 sw

let keepAlive = () => {  keepAlive = () => {}  var ping = location.href.substr(0, location.href.lastIndexOf('/')) + '/ping'  var interval = setInterval(() => {    if (sw) {      sw.postMessage('ping')    } else {      fetch(ping).then(res => res.text(!res.ok && clearInterval(interval)))    }  }, 10000)}
复制代码

2.4.2 “服务器”service worker

service worker 首要任务自然是拦截请求,并伪造请求头,同时返回数据给浏览器,这里的数据是流。

1. service worker 监听生命周期和数据
const map = new Map()
self.addEventListener('install', () => { self.skipWaiting()})
self.addEventListener('activate', event => { event.waitUntil(self.clients.claim())})
// 每次下载都应该只被调用一次,每个事件都有一个数据通过,数据将通过管道传输self.onmessage = event => { // 发送心跳,响应心跳,保证sw在没有传输数据下仍然活跃 if (event.data === 'ping') { return }
const data = event.data const downloadUrl = data.url || self.registration.scope + Math.random() + '/' + (typeof data === 'string' ? data : data.filename) const port = event.ports[0] const metadata = new Array(3) // [stream, data, port] metadata[1] = data metadata[2] = port port.onmessage = evt => { port.onmessage = null metadata[0] = evt.data.readableStream } // 存储数据 map.set(downloadUrl, metadata) // 往web页面发送消息,这个port是由web交由mith.html中间人传递进来 port.postMessage({ download: downloadUrl })}
复制代码
2. 拦截请求,伪造响应
self.onfetch = event => {  const url = event.request.url    const hijacke = map.get(url)  if (!hijacke) return null
const [ stream, data, port ] = hijacke map.delete(url)
// 只复制length和disposition const responseHeaders = new Headers({ 'Content-Type': 'application/octet-stream; charset=utf-8', // 为了安全起见,链接可以在iframe中打开,但 octet-stream 应该停止它 'Content-Security-Policy': "default-src 'none'", 'X-Content-Security-Policy': "default-src 'none'", 'X-WebKit-CSP': "default-src 'none'", 'X-XSS-Protection': '1; mode=block' })
let headers = new Headers(data.headers || {}) if (headers.has('Content-Length')) { responseHeaders.set('Content-Length', headers.get('Content-Length')) }
if (headers.has('Content-Disposition')) { responseHeaders.set('Content-Disposition', headers.get('Content-Disposition')) }
// data, data.filename and size should not be used anymore if (data.size) { responseHeaders.set('Content-Length', data.size) }
let fileName = typeof data === 'string' ? data : data.filename if (fileName) { // 使文件名与 RFC5987 兼容 fileName = encodeURIComponent(fileName).replace(/['()]/g, escape).replace(/\*/g, '%2A') responseHeaders.set('Content-Disposition', "attachment; filename*=UTF-8''" + fileName) }
event.respondWith(new Response(stream, { headers: responseHeaders }))
port.postMessage({ debug: 'Download started' })}
复制代码

2.4.3 页面流数据保存者

有了中间人传递数据,也有 service worker 拦截请求,伪造响应头和返回数据。那 web 页面就剩下创建对象写数据。

1. 构造 streamSaver 对象
const streamSaver = {   createWriteStream,   WritableStream: global.WritableStream,   mitm: 'https://***/mitm.html'}
复制代码
2. 创建隐藏的 iframe 添加到 dom
function makeIframe (src) {    if (!src) throw new Error('meh')    const iframe = document.createElement('iframe')    iframe.hidden = true    iframe.src = src    iframe.loaded = false    iframe.name = 'iframe'    iframe.isIframe = true    iframe.postMessage = (...args) => iframe.contentWindow.postMessage(...args)    iframe.addEventListener('load', () => {      iframe.loaded = true    }, { once: true })    document.body.appendChild(iframe)    return iframe}
复制代码
3. 写流函数
// 中间人let mitmTransporter = null
function loadTransporter() { if (!mitmTransporter) { mitmTransporter = makeIframe(streamSaver.mitm); }}
function createWriteStream(filename, options, size) { let opts = { size: null, pathname: null, writableStrategy: undefined, readableStrategy: undefined, };
let bytesWritten = 0; // by StreamSaver.js (not the service worker) let downloadUrl = null; let channel = null; let ts = null;
// 格式化参数,相关代码省略 ... // 加载中间人 loadTransporter(); // 创建消息通道 channel = new MessageChannel(); // 自定义响应参数 const response = { transferringReadable: true, pathname: opts.pathname || Math.random().toString().slice(-6) + '/' + filename, headers: { 'Content-Type': 'application/octet-stream; charset=utf-8', 'Content-Disposition': "attachment; filename*=UTF-8''" + filename, }, }; // 传递响应参数 给中间人进行格式化,并传递给service worker // channel.port2传递给service worker方便与channel.port1相互通信 const args = [response, '*', [channel.port2]];
const transformer = undefined; // 默认支持传输流,不支持浏览器可以使用pollfill进行处理 // transformer只有在非安全环境下需要转化,这里只为了展示流程,不展开 ts = new streamSaver.TransformStream(transformer, opts.writableStrategy, opts.readableStrategy); const readableStream = ts.readable; // 发送读取到流 channel.port1.postMessage({ readableStream }, [readableStream]); // 监听service worker返回的消息 channel.port1.onmessage = evt => { // download定义返回到可下载数据 if (evt.data.download) { // 默认使用iframe加载 makeIframe(evt.data.download); } }; // 中间人加载完成,推送数据(iframe加载完成) if (mitmTransporter.loaded) { mitmTransporter.postMessage(...args); } else { mitmTransporter.addEventListener( 'load', () => { mitmTransporter.postMessage(...args); }, { once: true } ); }
return new streamSaver.WritableStream( { write(chunk) { // 只能写Unit8Array的数据 if (!(chunk instanceof Uint8Array)) { throw new TypeError('Can only write Uint8Arrays'); } // 将获取到chunk数据,推送给service worker channel.port1.postMessage(chunk); bytesWritten += chunk.length;
if (downloadUrl) { location.href = downloadUrl; downloadUrl = null; } }, close() { channel.port1.postMessage('end'); }, abort() { // 执行一些资源的置为空值 }, }, opts.writableStrategy );}
复制代码
4. 检测传输流是否可用

我们这里流程默认是浏览器支持传输流,实际上浏览器只有到 chrome 73 才支持,这里扩展下如何优雅检测。

// 检测函数const test = fn => { try { fn() } catch (e) {} }
test(() => { const { readable } = new TransformStream(); const mc = new MessageChannel(); mc.port1.postMessage(readable, [readable]); mc.port1.close(); mc.port2.close(); supportsTransferable = true; // 冻结TransformStream对象,只能以原生使用 Object.defineProperty(streamSaver, 'TransformStream', { configurable: false, writable: false, value: TransformStream, });});
复制代码

2.4.4 用法

该模块主要是为了拓展下视野,知道前端原来还可以这么玩。最后你对这块感兴趣,可以详细去读 StreamSaver.js。


附用法:

const fileStream = streamSaver.createWriteStream('filename.txt', {   size: uInt8.byteLength,    writableStrategy: undefined,    readableStrategy: undefined  })
new Response('streamsaver真棒!').body .pipeTo(fileStream) .then(success, error)
复制代码

总结

这篇文章,基本已经涵盖了所有场景下的资源下载,希望对你有用。

用户头像

梁龙先森

关注

无情的写作机器 2018.03.17 加入

vite原理/微前端/性能监控方案...,正在来的路上...

评论

发布
暂无评论
8千长文解决前端资源下载那些事