文件下载,搞懂这九种场景就够了

开发 前端
Blob(Binary Large Object)表示二进制类型的大对象。在数据库管理系统中,将二进制数据存储为一个单一个体的集合。Blob 通常是影像、声音或多媒体文件。

 [[415181]]

本文转载自微信公众号「全栈修仙之路」,作者阿宝哥。转载本文请联系全栈修仙之路公众号。

文件上传,搞懂这8种场景就够了 这篇文章发布之后,阿宝哥收到了挺多掘友的留言,感谢掘友们一直以来的鼓励与支持。其中掘友 @我的烟雨不在江南 和 @rainx 在文章底部分别发了以下留言:

既然掘友有要求,连标题也帮阿宝哥想好了,那我们就来整一篇文章,总结一下文件下载的场景。

一般在我们工作中,主要会涉及到 9 种文件下载的场景,每一种场景背后都使用不同的技术,其中也有很多细节需要我们额外注意。今天阿宝哥就来带大家总结一下这 9 种场景,让大家能够轻松地应对各种下载场景。阅读本文后,你将会了解以下的内容:

在浏览器端处理文件的时候,我们经常会用到 Blob 。比如图片本地预览、图片压缩、大文件分块上传及文件下载。在浏览器端文件下载的场景中,比如我们今天要讲到的 a 标签下载、showSaveFilePicker API 下载、Zip 下载 等场景中,都会使用到 Blob ,所以我们有必要在学习具体应用前,先掌握它的相关知识,这样可以帮助我们更好地了解示例代码。

一、基础知识

1.1 了解 Blob

Blob(Binary Large Object)表示二进制类型的大对象。在数据库管理系统中,将二进制数据存储为一个单一个体的集合。Blob 通常是影像、声音或多媒体文件。在 JavaScript 中 Blob 类型的对象表示一个不可变、原始数据的类文件对象。 它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream 用于数据操作。

Blob 对象由一个可选的字符串 type(通常是 MIME 类型)和 blobParts 组成:

在 JavaScript 中你可以通过 Blob 的构造函数来创建 Blob 对象,Blob 构造函数的语法如下:

  1. const aBlob = new Blob(blobParts, options); 

相关的参数说明如下:

  • blobParts:它是一个由 ArrayBuffer,ArrayBufferView,Blob,DOMString 等对象构成的数组。DOMStrings 会被编码为 UTF-8。
  • options:一个可选的对象,包含以下两个属性:
    • type —— 默认值为 "",它代表了将会被放入到 blob 中的数组内容的 MIME 类型。
    • endings —— 默认值为 "transparent",用于指定包含行结束符 \n 的字符串如何被写入。 它是以下两个值中的一个: "native",代表行结束符会被更改为适合宿主操作系统文件系统的换行符,或者 "transparent",代表会保持 blob 中保存的结束符不变。

1.2 了解 Blob URL

Blob URL/Object URL 是一种伪协议,允许 Blob 和 File 对象用作图像、下载二进制数据链接等的 URL 源。在浏览器中,我们使用 URL.createObjectURL 方法来创建 Blob URL,该方法接收一个 Blob 对象,并为其创建一个唯一的 URL,其形式为 blob:/,对应的示例如下:

  1. blob:http://localhost:3000/53acc2b6-f47b-450f-a390-bf0665e04e59 

浏览器内部为每个通过 URL.createObjectURL 生成的 URL 存储了一个 URL → Blob 映射。因此,此类 URL 较短,但可以访问 Blob。生成的 URL 仅在当前文档打开的状态下才有效。它允许引用 、 中的 Blob,但如果你访问的 Blob URL 不再存在,则会从浏览器中收到 404 错误。

上述的 Blob URL 看似很不错,但实际上它也有副作用。 虽然存储了 URL → Blob 的映射,但 Blob 本身仍驻留在内存中,浏览器无法释放它。映射在文档卸载时自动清除,因此 Blob 对象随后被释放。但是,如果应用程序寿命很长,那么 Blob 在短时间内将无法被浏览器释放。因此,如果你创建一个 Blob URL,即使不再需要该 Blob,它也会存在内存中。

针对这个问题,你可以调用 URL.revokeObjectURL(url) 方法,从内部映射中删除引用,从而允许删除 Blob(如果没有其他引用),并释放内存。

现在你已经了解了 Blob 和 Blob URL,如果你还意犹未尽,想深入理解 Blob 的话,可以阅读 你不知道的 Blob 这篇文章。下面我们开始介绍客户端文件下载的场景。

随着 Web 技术的不断发展,浏览器的功能也越来越强大。这些年出现了很多在线 Web 设计工具,比如在线 PS、在线海报设计器或在线自定义表单设计器等。这些 Web 设计器允许用户在完成设计之后,把生成的文件保存到本地,其中有一部分设计器就是利用浏览器提供的 Web API 来实现客户端文件下载。下面阿宝哥先来介绍客户端下载中,最常见的 a 标签下载 方案。

二、a 标签下载

html

  1. <h3>a 标签下载示例</h3> 
  2. <div> 
  3.   <img src="../images/body.png" /> 
  4.   <img src="../images/eyes.png" /> 
  5.   <img src="../images/mouth.png" /> 
  6. </div> 
  7. <img id="mergedPic" src="http://via.placeholder.com/256" /> 
  8. <button onclick="merge()">图片合成</button> 
  9. <button onclick="download()">图片下载</button> 

在以上代码中,我们通过 img 标签引用了以下 3 张素材:

当用户点击 图片合成 按钮时,会将合成的图片显示在 img#mergedPic 容器中。在图片成功合成之后,用户可以通过点击 图片下载 按钮把已合成的图片下载到本地。对应的操作流程如下图所示:

由上图可知,整体的操作流程相对简单。接下来,我们来看一下 图片合成 和 图片下载 的实现逻辑。

js

图片合成的功能,阿宝哥是直接使用 Github 上 merge-images 这个第三方库来实现。利用该库提供的 mergeImages(images, [options]) 方法,我们可以轻松地实现图片合成的功能。调用该方法后,会返回一个 Promise 对象,当异步操作完成后,合成的图片会以 Data URLs 的格式返回。

  1. const mergePicEle = document.querySelector("#mergedPic"); 
  2. const images = ["/body.png""/eyes.png""/mouth.png"].map( 
  3.   (path) => "../images" + path 
  4. ); 
  5. let imgDataUrl = null
  6.  
  7. async function merge() { 
  8.   imgDataUrl = await mergeImages(images); 
  9.   mergePicEle.src = imgDataUrl; 

而图片下载的功能是借助 dataUrlToBlob 和 saveFile 这两个函数来实现。它们分别用于实现 Data URLs => Blob 的转换和文件的保存,具体的代码如下所示:

  1. function dataUrlToBlob(base64, mimeType) { 
  2.   let bytes = window.atob(base64.split(",")[1]); 
  3.   let ab = new ArrayBuffer(bytes.length); 
  4.   let ia = new Uint8Array(ab); 
  5.   for (let i = 0; i < bytes.length; i++) { 
  6.     ia[i] = bytes.charCodeAt(i); 
  7.   } 
  8.   return new Blob([ab], { type: mimeType }); 
  9.  
  10. // 保存文件 
  11. function saveFile(blob, filename) { 
  12.   const a = document.createElement("a"); 
  13.   a.download = filename; 
  14.   a.href = URL.createObjectURL(blob); 
  15.   a.click(); 
  16.   URL.revokeObjectURL(a.href) 

因为本文的主题是介绍文件下载,所以我们来重点分析 saveFile 函数。在该函数内部,我们使用了 HTMLAnchorElement.download 属性,该属性值表示下载文件的名称。如果该名称不是操作系统的有效文件名,浏览器将会对其进行调整。此外,该属性的作用是表明链接的资源将被下载,而不是显示在浏览器中。

需要注意的是,download 属性存在兼容性问题,比如 IE 11 及以下的版本不支持该属性,具体如下图所示:

(图片来源:https://caniuse.com/download)

当设置好 a 元素的 download 属性之后,我们会调用 URL.createObjectURL 方法来创建 Object URL,并把返回的 URL 赋值给 a 元素的 href 属性。接着通过调用 a 元素的 click 方法来触发文件的下载操作,最后还会调用一次 URL.revokeObjectURL 方法,从内部映射中删除引用,从而允许删除 Blob(如果没有其他引用),并释放内存。

关于 a 标签下载 的内容就介绍到这,下面我们来介绍如何使用新的 Web API —— showSaveFilePicker 实现文件下载。

a 标签下载示例:a-tag

https://github.com/semlinker/file-download-demos/tree/main/a-tag

三、showSaveFilePicker API 下载

showSaveFilePicker API 是 Window 接口中定义的方法,调用该方法后会显示允许用户选择保存路径的文件选择器。该方法的签名如下所示:

  1. let FileSystemFileHandle = Window.showSaveFilePicker(options); 

showSaveFilePicker 方法支持一个对象类型的可选参数,可包含以下属性:

  • excludeAcceptAllOption:布尔类型,默认值为 false。默认情况下,选择器应包含一个不应用任何文件类型过滤器的选项(由下面的 types 选项启用)。将此选项设置为 true 意味着 types 选项不可用。
  • types:数组类型,表示允许保存的文件类型列表。数组中的每一项是包含以下属性的配置对象:
    • description(可选):用于描述允许保存文件类型类别。
    • accept:是一个对象,该对象的 key 是 MIME 类型,值是文件扩展名列表。

调用 showSaveFilePicker 方法之后,会返回一个 FileSystemFileHandle 对象。有了该对象,你就可以调用该对象上的方法来操作文件。比如调用该对象上的 createWritable 方法之后,就会返回 FileSystemWritableFileStream 对象,就可以把数据写入到文件中。具体的使用方式如下所示:

  1. async function saveFile(blob, filename) { 
  2.   try { 
  3.     const handle = await window.showSaveFilePicker({ 
  4.       suggestedName: filename, 
  5.       types: [ 
  6.         { 
  7.           description: "PNG file"
  8.           accept: { 
  9.             "image/png": [".png"], 
  10.           }, 
  11.         }, 
  12.         { 
  13.           description: "Jpeg file"
  14.           accept: { 
  15.             "image/jpeg": [".jpeg"], 
  16.           }, 
  17.          }, 
  18.       ], 
  19.      }); 
  20.     const writable = await handle.createWritable(); 
  21.     await writable.write(blob); 
  22.     await writable.close(); 
  23.     return handle; 
  24.   } catch (err) { 
  25.      console.error(err.name, err.message); 
  26.   } 
  27.  
  28. function download() { 
  29.   if (!imgDataUrl) { 
  30.     alert("请先合成图片"); 
  31.     return
  32.   } 
  33.   const imgBlob = dataUrlToBlob(imgDataUrl, "image/png"); 
  34.   saveFile(imgBlob, "face.png"); 

当你使用以上更新后的 saveFile 函数,来保存已合成的图片时,会显示以下保存文件选择器:

由上图可知,相比 a 标签下载 的方式,showSaveFilePicker API 允许你选择文件的下载目录、选择文件的保存格式和更改存储的文件名称。看到这里是不是觉得 showSaveFilePicker API 功能挺强大的,不过可惜的是该 API 目前的兼容性还不是很好,具体如下图所示:

(图片来源:https://caniuse.com/?search=showSaveFilePicker)

其实 showSaveFilePicker 是 File System Access API 中定义的方法,除了 showSaveFilePicker 之外,还有 showOpenFilePicker 和 showDirectoryPicker 等方法。如果你想在实际项目中使用这些 API 的话,可以考虑使用 GoogleChromeLabs 开源的 browser-fs-access 这个库,该库可以让你在支持平台上更方便地使用 File System Access API,对于不支持的平台会自动降级使用 和 的方式。

可能大家对 browser-fs-access 这个库会比较陌生,但是如果换成是 FileSaver.js 这个库的话,应该就比较熟悉了。接下来,我们来介绍如何利用 FileSaver.js 这个库实现客户端文件下载。

showSaveFilePicker API 下载示例:save-file-picker

https://github.com/semlinker/file-download-demos/tree/main/save-file-picker

四、FileSaver 下载

FileSaver.js 是在客户端保存文件的解决方案,非常适合在客户端上生成文件的 Web 应用程序。它是 HTML5 版本的 saveAs() FileSaver 实现,支持大多数主流的浏览器,其兼容性如下图所示:

(图片来源:https://github.com/eligrey/FileSaver.js)

在引入 FileSaver.js 这个库之后,我们就可以使用它提供的 saveAs 方法来保存文件。该方法对应的签名如下所示:

  1. FileSaver saveAs( 
  2.  Blob/File/Url,  
  3.  optional DOMString filename,  
  4.  optional Object { autoBom } 

saveAs 方法支持 3 个参数,第 1 个参数表示它支持 Blob/File/Url 三种类型,第 2 个参数表示文件名(可选),而第 3 个参数表示配置对象(可选)。如果你需要 FlieSaver.js 自动提供 Unicode 文本编码提示(参考:字节顺序标记),则需要设置 { autoBom: true}。

了解完 saveAs 方法之后,我们来举 3 个具体的使用示例:

1. 保存文本

  1. let blob = new Blob(["大家好,我是阿宝哥!"], { type: "text/plain;charset=utf-8" }); 
  2. saveAs(blob, "hello.txt"); 

2. 保存线上资源

  1. saveAs("https://httpbin.org/image""image.jpg"); 

如果下载的 URL 地址与当前站点是同域的,则将使用 a[download] 方式下载。否则,会先使用 同步的 HEAD 请求 来判断是否支持 CORS 机制,若支持的话,将进行数据下载并使用 Blob URL 实现文件下载。如果不支持 CORS 机制的话,将会尝试使用 a[download] 方式下载。

标准的 W3C File API Blob 接口并非在所有浏览器中都可用,对于这个问题,你可以考虑使用 Blob.js 来解决兼容性问题。

(图片来源:https://caniuse.com/?search=blob)

3. 保存 canvas 画布内容

  1. let canvas = document.getElementById("my-canvas"); 
  2. canvas.toBlob(function(blob) { 
  3.   saveAs(blob, "abao.png"); 
  4. }); 

需要注意的是 canvas.toBlob() 方法并非在所有浏览器中都可用,对于这个问题,你可以考虑使用 canvas-toBlob.js 来解决兼容性问题。

(图片来源:https://caniuse.com/?search=toBlob)

介绍完 saveAs 方法的使用示例之后,我们来更新前面示例中的 download 方法:

  1. function download() { 
  2.   if (!imgDataUrl) { 
  3.     alert("请先合成图片"); 
  4.     return
  5.   } 
  6.   const imgBlob = dataUrlToBlob(imgDataUrl, "image/png"); 
  7.   saveAs(imgBlob, "face.png"); 

很明显,使用 saveAs 方法之后,下载已合成的图片就很简单了。如果你对 FileSaver.js 的工作原理感兴趣的话,可以阅读 聊一聊 15.5K 的 FileSaver,是如何工作的? 这篇文章。前面介绍的场景都是直接下载单个文件,其实我们也可以在客户端同时下载多个文件,然后把已下载的文件压缩成 Zip 包并下载到本地。

FileSaver 下载示例:file-saver

https://github.com/semlinker/file-download-demos/tree/main/file-saver

五、Zip 下载

在 文件上传,搞懂这8种场景就够了 这篇文章中,阿宝哥介绍了如何利用 JSZip 这个库提供的 API,把待上传目录下的所有文件压缩成 ZIP 文件,然后再把生成的 ZIP 文件上传到服务器。同样,利用 JSZip 这个库,我们可以实现在客户端同时下载多个文件,然后把已下载的文件压缩成 Zip 包,并下载到本地的功能。对应的操作流程如下图所示:

在以上 Gif 图中,阿宝哥演示了把 3 张素材图,打包成 Zip 文件并下载到本地的过程。接下来,我们来介绍如何使用 JSZip 这个库实现以上的功能。

html

  1. <h3>Zip 下载示例</h3> 
  2. <div> 
  3.   <img src="../images/body.png" /> 
  4.   <img src="../images/eyes.png" /> 
  5.   <img src="../images/mouth.png" /> 
  6. </div> 
  7. <button onclick="download()">打包下载</button> 

js

  1. const images = ["body.png""eyes.png""mouth.png"]; 
  2. const imageUrls = images.map((name) => "../images/" + name); 
  3.  
  4. async function download() { 
  5.   let zip = new JSZip(); 
  6.   Promise.all(imageUrls.map(getFileContent)).then((contents) => { 
  7.     contents.forEach((content, i) => { 
  8.       zip.file(images[i], content); 
  9.     }); 
  10.     zip.generateAsync({ type: "blob" }).then(function (blob) { 
  11.       saveAs(blob, "material.zip"); 
  12.     }); 
  13.   }); 
  14.  
  15. // 从指定的url上下载文件内容 
  16. function getFileContent(fileUrl) { 
  17.   return new JSZip.external.Promise(function (resolve, reject) { 
  18.     // 调用jszip-utils库提供的getBinaryContent方法获取文件内容 
  19.     JSZipUtils.getBinaryContent(fileUrl, function (err, data) { 
  20.       if (err) { 
  21.         reject(err); 
  22.       } else { 
  23.         resolve(data); 
  24.       } 
  25.     }); 
  26.   }); 

在以上代码中,当用户点击 打包下载 按钮时,就会调用 download 函数。在该函数内部,会先调用 JSZip 构造函数创建 JSZip 对象,然后使用 Promise.all 函数来确保所有的文件都下载完成后,再调用 file(name, data [,options]) 方法,把已下载的文件添加到前面创建的 JSZip 对象中。最后通过 zip.generateAsync 函数来生成 Zip 文件并使用 FileSaver.js 提供的 saveAs 方法保存 Zip 文件。

Zip 下载示例:Zip

https://github.com/semlinker/file-download-demos/tree/main/jszip

六、附件形式下载

在服务端下载的场景中,附件形式下载是一种比较常见的场景。在该场景下,我们通过设置 Content-Disposition 响应头来指示响应的内容以何种形式展示,是以内联(inline)的形式,还是以附件(attachment)的形式下载并保存到本地。

  1. Content-Disposition: inline 
  2. Content-Disposition: attachment 
  3. Content-Disposition: attachment; filename="mouth.png" 

而在 HTTP 表单的场景下, Content-Disposition 也可以作为 multipart body 中的消息头:

  1. Content-Disposition: form-data 
  2. Content-Disposition: form-data; name="fieldName" 
  3. Content-Disposition: form-data; name="fieldName"; filename="filename.jpg" 

第 1 个参数总是固定不变的 form-data;附加的参数不区分大小写,并且拥有参数值,参数名与参数值用等号(=)连接,参数值用双引号括起来。参数之间用分号(;)分隔。

了解完 Content-Disposition 的作用之后,我们来看一下如何实现以附件形式下载的功能。Koa 是一个简单易用的 Web 框架,它的特点是优雅、简洁、轻量、自由度高。所以我们选择它来搭建文件服务,并使用 @koa/router 中间件来处理路由:

  1. // attachment/file-server.js 
  2. const fs = require("fs"); 
  3. const path = require("path"); 
  4. const Koa = require("koa"); 
  5. const Router = require("@koa/router"); 
  6.  
  7. const app = new Koa(); 
  8. const router = new Router(); 
  9. const PORT = 3000; 
  10. const STATIC_PATH = path.join(__dirname, "./static/"); 
  11.  
  12. // http://localhost:3000/file?filename=mouth.png 
  13. router.get("/file", async (ctx, next) => { 
  14.   const { filename } = ctx.query; 
  15.   const filePath = STATIC_PATH + filename; 
  16.   const fStats = fs.statSync(filePath); 
  17.   ctx.set({ 
  18.     "Content-Type""application/octet-stream"
  19.     "Content-Disposition": `attachment; filename=${filename}`, 
  20.     "Content-Length": fStats.size
  21.   }); 
  22.   ctx.body = fs.createReadStream(filePath); 
  23. }); 
  24.  
  25. // 注册中间件 
  26. app.use(async (ctx, next) => { 
  27.   try { 
  28.     await next(); 
  29.   } catch (error) { 
  30.     // ENOENT(无此文件或目录):通常是由文件操作引起的,这表明在给定的路径上无法找到任何文件或目录 
  31.     ctx.status = error.code === "ENOENT" ? 404 : 500; 
  32.     ctx.body = error.code === "ENOENT" ? "文件不存在" : "服务器开小差"
  33.   } 
  34. }); 
  35. app.use(router.routes()).use(router.allowedMethods()); 
  36.  
  37. app.listen(PORT, () => { 
  38.   console.log(`应用已经启动:http://localhost:${PORT}/`); 
  39. }); 

以上的代码被保存在 attachment 目录下的 file-server.js 文件中,该目录下还有一个 static 子目录用于存放静态资源。目前 static 目录下包含以下 3 个 png 文件。

  1. ├── file-server.js 
  2. └── static 
  3.     ├── body.png 
  4.     ├── eyes.png 
  5.     └── mouth.png 

当你运行 node file-server.js 命令成功启动文件服务器之后,就可以通过正确的 URL 地址来下载 static 目录下的文件。比如在浏览器中打开 http://localhost:3000/file?filename=mouth.png 这个地址,你就会开始下载 mouth.png 文件。而如果指定的文件不存在的话,就会返回文件不存在。

Koa 内核很简洁,扩展功能都是通过中间件来实现。比如常用的路由、CORS、静态资源处理等功能都是通过中间件实现。因此要想掌握 Koa 这个框架,核心是掌握它的中间件机制。若你想深入了解 Koa 的话,可以阅读 如何更好地理解中间件和洋葱模型 这篇文章。

在编写 HTML 网页时,对于一些简单图片,通常会选择将图片内容直接内嵌在网页中,从而减少不必要的网络请求,但是图片数据是二进制数据,该怎么嵌入呢?绝大多数现代浏览器都支持一种名为 Data URLs 的特性,允许使用 Base64 对图片或其他文件的二进制数据进行编码,将其作为文本字符串嵌入网页中。所以文件也可以通过 Base64 的格式进行传输,接下来我们将介绍如何下载 Base64 格式的图片。

附件形式下载示例:attachment

https://github.com/semlinker/file-download-demos/tree/main/attachment

七、base64 格式下载

Base64 是一种基于 64 个可打印字符来表示二进制数据的表示方法。由于 2? = 64 ,所以每 6 个比特为一个单元,对应某个可打印字符。3 个字节有 24 个比特,对应于 4 个 base64 单元,即 3 个字节可由 4 个可打印字符来表示。相应的转换过程如下图所示:

Base64 常用在处理文本数据的场合,表示、传输、存储一些二进制数据,包括 MIME 的电子邮件及 XML 的一些复杂数据。 在 MIME 格式的电子邮件中,base64 可以用来将二进制的字节序列数据编码成 ASCII 字符序列构成的文本。使用时,在传输编码方式中指定 base64。使用的字符包括大小写拉丁字母各 26 个、数字 10 个、加号 + 和斜杠 /,共 64 个字符,等号 = 用来作为后缀用途。

Base64 的相关内容就先介绍到这,如果你想进一步了解 Base64 的话,可以阅读 一文读懂base64编码 这篇文章。下面我们来看一下具体实现代码:

7.1 前端代码

html

在以下 HTML 代码中,我们通过 select 元素来让用户选择要下载的图片。当用户切换不同的图片时,img#imgPreview 元素中显示的图片会随之发生变化。

  1. <h3>base64 下载示例</h3> 
  2. <img id="imgPreview" src="./static/body.png" /> 
  3. <select id="picSelect"
  4.    <option value="body">body.png</option
  5.    <option value="eyes">eyes.png</option
  6.    <option value="mouth">mouth.png</option
  7. </select
  8. <button onclick="download()">下载</button> 

js

  1. const picSelectEle = document.querySelector("#picSelect"); 
  2. const imgPreviewEle = document.querySelector("#imgPreview"); 
  3.  
  4. picSelectEle.addEventListener("change", (event) => { 
  5.   imgPreviewEle.src = "./static/" + picSelectEle.value + ".png"
  6. }); 
  7.  
  8. const request = axios.create({ 
  9.   baseURL: "http://localhost:3000"
  10.   timeout: 60000, 
  11. }); 
  12.  
  13. async function download() { 
  14.   const response = await request.get("/file", { 
  15.     params: { 
  16.       filename: picSelectEle.value + ".png"
  17.     }, 
  18.   }); 
  19.   if (response && response.data && response.data.code === 1) { 
  20.     const fileData = response.data.data; 
  21.     const { name, type, content } = fileData; 
  22.     const imgBlob = base64ToBlob(content, type); 
  23.     saveAs(imgBlob, name); 
  24.   } 

在用户选择好需要下载的图片并点击下载按钮时,就会调用以上代码中的 download 函数。在该函数内部,我们利用 axios 实例的 get 方法发起 HTTP 请求来获取指定的图片。因为返回的是 base64 格式的图片,所以在调用 FileSaver 提供的 saveAs 方法前,我们需要将 base64 字符串转换成 blob 对象,该转换是通过以下的 base64ToBlob 函数来完成,该函数的具体实现如下所示:

  1. function base64ToBlob(base64, mimeType) { 
  2.   let bytes = window.atob(base64); 
  3.   let ab = new ArrayBuffer(bytes.length); 
  4.   let ia = new Uint8Array(ab); 
  5.   for (let i = 0; i < bytes.length; i++) { 
  6.     ia[i] = bytes.charCodeAt(i); 
  7.   } 
  8.   return new Blob([ab], { type: mimeType }); 

7.2 服务端代码

  1. // base64/file-server.js 
  2. const fs = require("fs"); 
  3. const path = require("path"); 
  4. const mime = require("mime"); 
  5. const Koa = require("koa"); 
  6. const cors = require("@koa/cors"); 
  7. const Router = require("@koa/router"); 
  8.  
  9. const app = new Koa(); 
  10. const router = new Router(); 
  11. const PORT = 3000; 
  12. const STATIC_PATH = path.join(__dirname, "./static/"); 
  13.  
  14. router.get("/file", async (ctx, next) => { 
  15.   const { filename } = ctx.query; 
  16.   const filePath = STATIC_PATH + filename; 
  17.   const fileBuffer = fs.readFileSync(filePath); 
  18.   ctx.body = { 
  19.     code: 1, 
  20.     data: { 
  21.       name: filename, 
  22.       type: mime.getType(filename), 
  23.       content: fileBuffer.toString("base64"), 
  24.     }, 
  25.   }; 
  26. }); 
  27.  
  28. // 注册中间件 
  29. app.use(async (ctx, next) => { 
  30.   try { 
  31.     await next(); 
  32.   } catch (error) { 
  33.     ctx.body = { 
  34.       code: 0, 
  35.       msg: "服务器开小差"
  36.     }; 
  37.   } 
  38. }); 
  39. app.use(cors()); 
  40. app.use(router.routes()).use(router.allowedMethods()); 
  41.  
  42. app.listen(PORT, () => { 
  43.   console.log(`应用已经启动:http://localhost:${PORT}/`); 
  44. }); 

在以上代码中,对图片进行 Base64 编码的操作是定义在 /file 路由对应的路由处理器中。当该服务器接收到客户端发起的文件下载请求,比如 GET /file?filename=body.png HTTP/1.1 时,就会从 ctx.query 对象上获取 filename 参数。该参数表示文件的名称,在获取到文件的名称之后,我们就可以拼接出文件的绝对路径,然后通过 Node.js 平台提供的 fs.readFileSync 方法读取文件的内容,该方法会返回一个 Buffer 对象。在成功读取文件的内容之后,我们会继续调用 Buffer 对象的 toString 方法对文件内容进行 Base64 编码,最终所下载的图片将以 Base64 格式返回到客户端。

base64 格式下载示例:base64

https://github.com/semlinker/file-download-demos/tree/main/base64

八、chunked 下载

分块传输编码主要应用于如下场景,即要传输大量的数据,但是在请求在没有被处理完之前响应的长度是无法获得的。例如,当需要用从数据库中查询获得的数据生成一个大的 HTML 表格的时候,或者需要传输大量的图片的时候。

要使用分块传输编码,则需要在响应头配置 Transfer-Encoding 字段,并设置它的值为 chunked 或 gzip, chunked:

  1. Transfer-Encoding: chunked 
  2. Transfer-Encoding: gzip, chunked 

响应头 Transfer-Encoding 字段的值为 chunked,表示数据以一系列分块的形式进行发送。需要注意的是 Transfer-Encoding 和 Content-Length 这两个字段是互斥的,也就是说响应报文中这两个字段不能同时出现。下面我们来看一下分块传输的编码规则:

  • 每个分块包含分块长度和数据块两个部分;
  • 分块长度使用 16 进制数字表示,以 \r\n 结尾;
  • 数据块紧跟在分块长度后面,也使用 \r\n 结尾,但数据不包含 \r\n;
  • 终止块是一个常规的分块,表示块的结束。不同之处在于其长度为 0,即 0\r\n\r\n。
  • 了解完分块传输的编码规则,我们来看如何利用分块传输编码实现文件下载。

8.1 前端代码

html5

  1. <h3>chunked 下载示例</h3> 
  2. <button onclick="download()">下载</button> 

js

  1. const chunkedUrl = "http://localhost:3000/file?filename=file.txt"
  2.  
  3. function download() { 
  4.   return fetch(chunkedUrl) 
  5.     .then(processChunkedResponse) 
  6.     .then(onChunkedResponseComplete) 
  7.     .catch(onChunkedResponseError); 
  8.  
  9. function processChunkedResponse(response) { 
  10.   let text = ""
  11.   let reader = response.body.getReader(); 
  12.   let decoder = new TextDecoder(); 
  13.  
  14.   return readChunk(); 
  15.  
  16.   function readChunk() { 
  17.     return reader.read().then(appendChunks); 
  18.   } 
  19.  
  20.   function appendChunks(result) { 
  21.     let chunk = decoder.decode(result.value || new Uint8Array(), { 
  22.       stream: !result.done, 
  23.     }); 
  24.     console.log("已接收到的数据:", chunk); 
  25.     console.log("本次已成功接收", chunk.length, "bytes"); 
  26.     text += chunk; 
  27.     console.log("目前为止共接收", text.length, "bytes\n"); 
  28.     if (result.done) { 
  29.       return text; 
  30.     } else { 
  31.       return readChunk(); 
  32.     } 
  33.   } 
  34.  
  35. function onChunkedResponseComplete(result) { 
  36.   let blob = new Blob([result], { 
  37.     type: "text/plain;charset=utf-8"
  38.   }); 
  39.   saveAs(blob, "hello.txt"); 
  40.  
  41. function onChunkedResponseError(err) { 
  42.   console.error(err); 

当用户点击 下载 按钮时,就会调用以上代码中的 download 函数。在该函数内部,我们会使用 Fetch API 来执行下载操作。因为服务端的数据是以一系列分块的形式进行发送,所以在浏览器端我们是通过流的形式进行接收。即通过 response.body 获取可读的 ReadableStream,然后用 ReadableStream.getReader() 创建一个读取器,最后调用 reader.read 方法来读取已返回的分块数据。

因为 file.txt 文件的内容是普通文本,且 result.value 的值是 Uint8Array 类型的数据,所以在处理返回的分块数据时,我们使用了 TextDecoder 文本解码器。一个解码器只支持一种特定文本编码,例如 utf-8、iso-8859-2、koi8、cp1261,gbk 等等。

如果收到的分块非 终止块,result.done 的值是 false,则会继续调用 readChunk 方法来读取分块数据。而当接收到 终止块 之后,表示分块数据已传输完成。此时,result.done 属性就会返回 true。从而会自动调用 onChunkedResponseComplete 函数,在该函数内部,我们以解码后的文本作为参数来创建 Blob 对象。之后,继续使用 FileSaver 库提供的 saveAs 方法实现文件下载。

这里我们用 Wireshark 网络包分析工具,抓了个数据包。具体如下图所示:

从图中我们可以清楚地看到在 HTTP chunked response 下面包含了 Data chunk(数据块) 和 End of chunked encoding(终止块)。接下来,我们来看一下服务端的代码。

8.2 服务端代码

  1. const fs = require("fs"); 
  2. const path = require("path"); 
  3. const Koa = require("koa"); 
  4. const cors = require("@koa/cors"); 
  5. const Router = require("@koa/router"); 
  6.  
  7. const app = new Koa(); 
  8. const router = new Router(); 
  9. const PORT = 3000; 
  10.  
  11. router.get("/file", async (ctx, next) => { 
  12.   const { filename } = ctx.query; 
  13.   const filePath = path.join(__dirname, filename); 
  14.   ctx.set({ 
  15.     "Content-Type""text/plain;charset=utf-8"
  16.   }); 
  17.   ctx.body = fs.createReadStream(filePath); 
  18. }); 
  19.  
  20. // 注册中间件 
  21. app.use(async (ctx, next) => { 
  22.   try { 
  23.     await next(); 
  24.   } catch (error) { 
  25.     // ENOENT(无此文件或目录):通常是由文件操作引起的,这表明在给定的路径上无法找到任何文件或目录 
  26.     ctx.status = error.code === "ENOENT" ? 404 : 500; 
  27.     ctx.body = error.code === "ENOENT" ? "文件不存在" : "服务器开小差"
  28.   } 
  29. }); 
  30. app.use(cors()); 
  31. app.use(router.routes()).use(router.allowedMethods()); 
  32.  
  33. app.listen(PORT, () => { 
  34.   console.log(`应用已经启动:http://localhost:${PORT}/`); 
  35. }); 

在 /file 路由处理器中,我们先通过 ctx.query 获得 filename 文件名,接着拼接出该文件的绝对路径,然后通过 Node.js 平台提供的 fs.createReadStream 方法创建可读流。最后把已创建的可读流赋值给 ctx.body 属性,从而向客户端返回图片数据。

现在我们已经知道可以利用分块传输编码(Transfer-Encoding)实现数据的分块传输,那么有没有办法获取指定范围内的文件数据呢?对于这个问题,我们可以利用 HTTP 协议的范围请求。接下来,我们将介绍如何利用 HTTP 范围请求来下载指定范围的数据。

chunked 下载示例:chunked

https://github.com/semlinker/file-download-demos/tree/main/chunked

九、范围下载

HTTP 协议范围请求允许服务器只发送 HTTP 消息的一部分到客户端。范围请求在传送大的媒体文件,或者与文件下载的断点续传功能搭配使用时非常有用。如果在响应中存在 Accept-Ranges 首部(并且它的值不为 “none”),那么表示该服务器支持范围请求。

在一个 Range 首部中,可以一次性请求多个部分,服务器会以 multipart 文件的形式将其返回。如果服务器返回的是范围响应,需要使用 206 Partial Content 状态码。假如所请求的范围不合法,那么服务器会返回 416 Range Not Satisfiable 状态码,表示客户端错误。服务器允许忽略 Range 首部,从而返回整个文件,状态码用 200 。

Range 语法:

  1. Range: <unit>=<range-start>- 
  2. Range: <unit>=<range-start>-<range-end
  3. Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end
  4. Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>, <range-start>-<range-end
  • unit:范围请求所采用的单位,通常是字节(bytes)。
  • :一个整数,表示在特定单位下,范围的起始值。
  • :一个整数,表示在特定单位下,范围的结束值。这个值是可选的,如果不存在,表示此范围一直延伸到文档结束。

了解完 Range 语法之后,我们来看一下实际的使用示例:

  1. # 单一范围 
  2. $ curl http://i.imgur.com/z4d4kWk.jpg -i -H "Range: bytes=0-1023" 
  3. # 多重范围 
  4. $ curl http://www.example.com -i -H "Range: bytes=0-50, 100-150" 

9.1 前端代码

html

  1. <h3>范围下载示例</h3> 
  2. <button onclick="download()">下载</button> 

js

  1. async function download() { 
  2.   try { 
  3.     let rangeContent = await getBinaryContent( 
  4.       "http://localhost:3000/file.txt"
  5.        0, 100, "text" 
  6.     ); 
  7.     const blob = new Blob([rangeContent], { 
  8.       type: "text/plain;charset=utf-8"
  9.     }); 
  10.     saveAs(blob, "hello.txt"); 
  11.   } catch (error) { 
  12.     console.error(error); 
  13.   } 
  14.  
  15. function getBinaryContent(url, start, end, responseType = "arraybuffer") { 
  16.   return new Promise((resolve, reject) => { 
  17.     try { 
  18.       let xhr = new XMLHttpRequest(); 
  19.       xhr.open("GET", url, true); 
  20.       xhr.setRequestHeader("range", `bytes=${start}-${end}`); 
  21.       xhr.responseType = responseType; 
  22.       xhr.onload = function () { 
  23.         resolve(xhr.response); 
  24.       }; 
  25.         xhr.send(); 
  26.     } catch (err) { 
  27.         reject(new Error(err)); 
  28.     } 
  29.   }); 

当用户点击 下载 按钮时,就会调用 download 函数。在该函数内部会通过调用 getBinaryContent 函数来发起范围请求。对应的 HTTP 请求报文如下所示:

  1. GET /file.txt HTTP/1.1 
  2. Host: localhost:3000 
  3. Connection: keep-alive 
  4. User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36 
  5. Accept: */* 
  6. Accept-Encoding: identity 
  7. Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,id;q=0.7 
  8. Range: bytes=0-100 

而当服务器接收到该范围请求之后,会返回对应的 HTTP 响应报文:

  1. HTTP/1.1 206 Partial Content 
  2. Vary: Origin 
  3. Access-Control-Allow-Origin: null 
  4. Accept-Ranges: bytes 
  5. Last-Modified: Fri, 09 Jul 2021 00:17:00 GMT 
  6. Cache-Control: max-age=0 
  7. Content-Type: text/plain; charset=utf-8 
  8. Date: Sat, 10 Jul 2021 02:19:39 GMT 
  9. Connection: keep-alive 
  10. Content-Range: bytes 0-100/2590 
  11. Content-Length: 101 

从以上的 HTTP 响应报文中,我们见到了前面介绍的 206 状态码和 Accept-Ranges 首部。此外,通过 Content-Range 首部,我们就知道了文件的总大小。在成功获取到范围请求的响应体之后,我们就可以使用返回的内容作为参数,调用 Blob 构造函数创建对应的 Blob 对象,进而使用 FileSaver 库提供的 saveAs 方法来下载文件了。

9.2 服务端代码

  1. const Koa = require("koa"); 
  2. const cors = require("@koa/cors"); 
  3. const serve = require("koa-static"); 
  4. const range = require("koa-range"); 
  5.  
  6. const PORT = 3000; 
  7. const app = new Koa(); 
  8.  
  9. // 注册中间件 
  10. app.use(cors()); 
  11. app.use(range); 
  12. app.use(serve(".")); 
  13.  
  14. app.listen(PORT, () => { 
  15.   console.log(`应用已经启动:http://localhost:${PORT}/`); 
  16. }); 

服务端的代码相对比较简单,范围请求是通过 koa-range 中间件来实现的。由于篇幅有限,阿宝哥就不展开介绍了。感兴趣的小伙伴,可以自行阅读该中间件的源码。其实范围请求还可以应用在大文件下载的场景,如果文件服务器支持范围请求的话,客户端在下载大文件的时候,就可以考虑使用大文件分块下载的方案。

范围下载示例:range

https://github.com/semlinker/file-download-demos/tree/main/range

十、大文件分块下载

相信有些小伙伴已经了解大文件上传的解决方案,在上传大文件时,为了提高上传的效率,我们一般会使用 Blob.slice 方法对大文件按照指定的大小进行切割,然后在开启多线程进行分块上传,等所有分块都成功上传后,再通知服务端进行分块合并。

那么对大文件下载来说,我们能否采用类似的思想呢?其实在服务端支持 Range 请求首部的条件下,我们也是可以实现大文件分块下载的功能,具体处理方案如下图所示:

因为在 JavaScript 中如何实现大文件并发下载? 这篇文章中,阿宝哥已经详细介绍了大文件并发下载的方案,所以这里就不展开介绍了。我们只回顾一下大文件并发下载的完整流程:

其实在大文件分块下载的场景中,我们使用了 async-pool 这个库来实现并发控制。该库提供了 ES7 和 ES6 两种不同版本的实现,代码很简洁优雅。如果你想了解 async-pool 是如何实现并发控制的,可以阅读 JavaScript 中如何实现并发控制? 这篇文章。

大文件分块下载示例:big-file

https://github.com/semlinker/file-download-demos/tree/main/big-file

十一、总结

本文阿宝哥详细介绍了文件下载的 9 种场景,希望阅读完本文后,你对 9 种场景背后使用的技术有一定的了解。其实在传输文件的过程中,为了提高传输效率,我们可以使用 gzip、deflate 或 br 等压缩算法对文件进行压缩。由于篇幅有限,阿宝哥就不展开介绍了,如果你感兴趣的话,可以阅读 HTTP 传输大文件的几种方案 这篇文章。

有了文件下载的场景,怎么能缺少文件上传的场景呢?如果你还没阅读过 文件上传,搞懂这 8 种场景就够了 这篇文章,建议你有空的时候,可以一起了解一下。这里再次感谢掘友们一直以来的支持,如果你们还想了解其他方面的内容,欢迎给阿宝哥留言哟。

十二、参考资源

MDN — showSaveFilePicker

MDN — Content-Disposition

The File System Access API: simplifying access to local files

Reading and writing files and directories with the browser-fs-access library

文件上传,搞懂这8种场景就够了

JavaScript 中如何实现大文件并发下载?

 

责任编辑:武晓燕 来源: 全栈修仙之路
相关推荐

2021-07-09 17:17:09

文件场景内核

2020-09-10 09:31:34

Nginx HTTP代理服务器

2020-09-09 12:55:28

Nginx高并发性能

2019-08-13 15:36:57

限流算法令牌桶

2020-11-06 10:01:06

Nginx

2019-08-20 14:40:35

Redis数据库

2021-06-30 00:14:24

JS代码数组

2019-07-31 15:56:57

Jvm虚拟机Content

2021-01-18 11:41:22

SQL数据库编程语言

2021-06-21 09:22:53

按钮设计UI标签

2023-11-01 11:06:18

2023-12-04 16:24:23

2018-09-12 15:16:19

数据中心网络机房

2021-07-14 23:57:26

Vue高级技巧

2020-05-08 11:14:33

Vue开发代码

2017-09-15 16:37:27

2018-07-06 15:25:50

程序员编程python

2022-04-12 08:46:30

for 循环遍历字符串

2019-03-04 08:43:29

LeaderTL职责

2019-08-16 09:41:56

UDP协议TCP
点赞
收藏

51CTO技术栈公众号