FileSaver.js
本地保存文件的场景还是比较常见的,比如网页快照(配合 html2canvas)、生成 pdf 等,相关解决方法可以参考文章 文件导出。大部分解决方案中都会用到 FileSaver.js
这个库,今天主要是探究下这个库的实现原理
基本使用
安装
yarn add file-saver
yarn add @types/file-saver --save-dev
import { saveAs } from "file-saver";
// saveAs(Blob/File/Url, optional DOMString filename, optional Object { autoBom })
const blob = new Blob(["Hello, world!"], { type: "text/plain; charset=utf-8" });
FileSaver.saveAs(blob, "demo.txt");
源码架构
FileSaver.js
├─ src
│ └─ FileSaver.js
├─ CHANGELOG.md
├─ LICENSE.md
├─ README.md
├─ bower.json
├─ package-lock.json
└─ package.json
项目结构比较简单,主要逻辑都在 src/FileSaver.js
文件里。这里打包的逻辑也很精简
"scripts": {
"build:development": "babel -o dist/FileSaver.js --plugins @babel/plugin-transform-modules-umd src/FileSaver.js",
"build:production": "babel -o dist/FileSaver.min.js -s --plugins @babel/plugin-transform-modules-umd --presets minify src/FileSaver.js",
"build": "npm run build:development && npm run build:production",
"prepublishOnly": "npm run build"
}
npm run build:production
发生了啥?
babel -o dist/FileSaver.min.js -s
。借助 babel 编译源文件(默认为根目录下的 src),-o
指定输出文件位置和名称,-s
会在编译输出时生成与源代码的映射关系(map 文件),以方便调试和追踪问题--plugins @babel/plugin-transform-modules-umd src/FileSaver.js
。借助@babel/plugin-transform-modules-umd
这个 babel 插件将源代码转换为 UMD 格式,便于在多种运行环境中使用--presets minify src/FileSaver.js
。压缩源文件
UMD 是一种通用的模块定义规范,它兼容 CommonJS、AMD 和全局变量(Global Variable)三种模块化方案。使用 UMD 格式可以使你的代码在不同的环境中都能够正常工作,无论是在 Node.js、浏览器还是其他 JavaScript 运行环境中
一些工具函数
需要先熟悉下一些工具函数的实现,后面会经常看到
下载文件:
function download(url, name, opts) {
var xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.responseType = "blob";
xhr.onload = function () {
saveAs(xhr.response, name, opts);
};
xhr.onerror = function () {
console.error("could not download file");
};
xhr.send();
}
检测是否支持跨域:
function corsEnabled(url) {
var xhr = new XMLHttpRequest();
// use sync to avoid popup blocker
xhr.open("HEAD", url, false);
try {
xhr.send();
} catch (e) {}
return xhr.status >= 200 && xhr.status <= 299;
}
saveAs
源文件总共才 200 行不到。主要就是探究 saveAs
这个函数的实现。源码中使用了以下几种方案,顺序代表了使用的优先级:
- 不支持 web worker 或者其他环境。如果一定需要在 worker 中使用,可以将文件数据传回主线程,然后在主线程使用
var _global =
typeof window === "object" && window.window === window
? window
: typeof self === "object" && self.self === self
? self
: typeof global === "object" && global.global === global
? global
: this;
const notAvailableGlobal = typeof window !== "object" || window !== _global;
- 优先使用 HTML5 的
download
属性(注意 macOS webview 环境并不支持 download 属性)
// var isMacOSWebView = _global.navigator && /Macintosh/.test(navigator.userAgent) && /AppleWebKit/.test(navigator.userAgent) && !/Safari/.test(navigator.userAgent)
// 判断支不支持download属性,可以通过以下方法判断
// "download" in HTMLAnchorElement.prototype && !isMacOSWebView
function saveAs(blob, name, opts) {
var URL = _global.URL || _global.webkitURL;
// Namespace is used to prevent conflict w/ Chrome Poper Blocker extension (Issue #561)
var a = document.createElementNS("http://www.w3.org/1999/xhtml", "a");
name = name || blob.name || "download";
a.download = name;
a.rel = "noopener"; // tabnabbing
if (typeof blob === "string") {
// 1.文件链接的方式
a.href = blob;
// download 属性只有在同源的情况下会触发文件下载
if (a.origin !== location.origin) {
// 判断是否支持跨域,支持的话通过xhr对象直接下载文件取到blob数据,再执行 saveAs 保存到本地
corsEnabled(a.href)
? download(blob, name, opts)
: // 不支持跨域,那就只能触发a标签点击打开新窗口的方式触发下载
// 注意:这种方式受限于服务端下发文件的类型,如果是浏览器可预览的文件,依然会尝试预览而不是下载
// 需要设置正确的响应头,可以参考 https://docs.zhouweibin.top/docs/explorer/%E6%96%87%E4%BB%B6%E4%B8%8B%E8%BD%BD
click(a, (a.target = "_blank"));
} else {
click(a);
}
} else {
// 2.处理 blob 数据
// 创建 objectUrl
a.href = URL.createObjectURL(blob);
setTimeout(function () {
// 释放 objectUrl
URL.revokeObjectURL(a.href);
}, 4e4); // 40s
setTimeout(function () {
click(a);
}, 0);
}
}
- IE 浏览器不支持
download
属性,可以使用msSaveOrOpenBlob
保存 blob 数据。但其实现在都不用考虑这个,基本上大家都放弃 IE 了~
// if(navigator.msSaveOrOpenBlob)
function saveAs(blob, name, opts) {
name = name || blob.name || "download";
// 1.文件链接的方式大同小异
if (typeof blob === "string") {
if (corsEnabled(blob)) {
download(blob, name, opts);
} else {
var a = document.createElement("a");
a.href = blob;
a.target = "_blank";
setTimeout(function () {
click(a);
});
}
} else {
// 2.处理blob数据
navigator.msSaveOrOpenBlob(bom(blob, opts), name);
}
}
- 如果上述方案都不支持,那就会选择这个兜底方案。主要就是直接给
location
赋值blobUrl
,但是部分浏览器需要借助FileReader
读取 blob 数据,然后再将读取的dataUrl
(base64) 分配给location.href
实现文件下载
function saveAs(blob, name, opts, popup) {
// Open a popup immediately do go around popup blocker
// Mostly only available on user interaction and the fileReader is async so...
popup = popup || open("", "_blank");
if (popup) {
popup.document.title = popup.document.body.innerText = "downloading...";
}
// 1.文件链接处理大同小异
if (typeof blob === "string") return download(blob, name, opts);
// 2.处理 blob 数据
var force = blob.type === "application/octet-stream";
var isSafari = /constructor/i.test(_global.HTMLElement) || _global.safari;
var isChromeIOS = /CriOS\/[\d]+/.test(navigator.userAgent);
// 2.1 Safari/ChromeIOS/MacOSWebView 不允许直接下载 Blob 数据
if (
(isChromeIOS || (force && isSafari) || isMacOSWebView) &&
typeof FileReader !== "undefined"
) {
var reader = new FileReader();
reader.onloadend = function () {
var url = reader.result;
url = isChromeIOS
? url
: // 将数据包装成附件的形式,才能正确触发下载(ios浏览器的要求)
url.replace(/^data:[^;]*;/, "data:attachment/file;");
if (popup) popup.location.href = url;
else location = url;
popup = null;
};
reader.readAsDataURL(blob);
} else {
// 2.2 将 blob url 赋值给 location,大部分浏览器都会直接下载文件
var URL = _global.URL || _global.webkitURL;
var url = URL.createObjectURL(blob);
if (popup) popup.location = url;
else location.href = url;
popup = null;
setTimeout(function () {
URL.revokeObjectURL(url);
}, 4e4); // 40s
}
}