快应用开发工具之 asar
asar 是一种将多个文件合并成一个文件的类 tar 风格的归档格式。 Electron 可以无需解压整个文件,即可从其中读取任意文件内容。欲了解更多可参考官方解释。
注意:asar 只做归档,不做压缩。
asar 的使用
asar 的使用非常简单,更详细的用法可以参考文档
- 安装 asar
npm install --engine-strict asar
- 打包
asar pack app app.asar
- 解包
asar extract app.asar app
asar 的作用
-
缓解 Windows 下路径名过长的 问题,略微加快一下 require 的速度。
大多数 Windows 程序不能处理长度超过 260 个字符的文件和文件夹路径。一旦超过此限制,安装脚本便会开始中断,并且无法再使用常规方法删除 node_modules 文件夹。而随着 Node 包变得越来越复杂,依赖层次越来越深,这种情况只会变得更糟。
-
隐藏代码
源码归档在 asar 文档内,不直接暴露源码。
-
减少文件数
类似压缩包,多个文件归档成一个 asar 文件,利于减少基于 electron 开发的应用程序在 Windows 上的安装速度。
asar 原理
Electron 对 Node API 的 fs
模块进行了重写,这样在 Electron 中使用 fs
的方法便可以将 asar 视之为虚拟文件夹,读取 asar 里面的文件就和从真实的文件系统中读取一样。
这里我们看看 fs.readFile 重写过程,源码如下:
const { readFile } = fs;
// 重写 fs.readFile
fs.readFile = function(pathArgument, options, callback) {
const { isAsar, asarPath, filePath } = splitPath(pathArgument);
// 非 asar 路径直接使用原生方法
if (!isAsar) return readFile.apply(this, arguments);
if (typeof options === "function") {
callback = options;
options = { encoding: null };
} else if (typeof options === "string") {
options = { encoding: options };
} else if (options === null || options === undefined) {
options = { encoding: null };
} else if (typeof options !== "object") {
throw new TypeError("Bad arguments");
}
const { encoding } = options;
// 提取 asar 文档内容,缓存中有则直接读取缓存,缓存没有则提取并缓存
const archive = getOrCreateArchive(asarPath);
if (!archive) {
const error = createError(AsarError.INVALID_ARCHIVE, { asarPath });
nextTick(callback, [error]);
return;
}
const info = archive.getFileInfo(filePath);
if (!info) {
const error = createError(AsarError.NOT_FOUND, { asarPath, filePath });
nextTick(callback, [error]);
return;
}
if (info.size === 0) {
nextTick(callback, [null, encoding ? "" : Buffer.alloc(0)]);
return;
}
if (info.unpacked) {
// 将文件复制到临时文件中并返回新路径。
const realPath = archive.copyFileOut(filePath);
return fs.readFile(realPath, options, callback);
}
const buffer = Buffer.alloc(info.size);
const fd = archive.getFd();
if (!(fd >= 0)) {
const error = createError(AsarError.NOT_FOUND, { asarPath, filePath });
nextTick(callback, [error]);
return;
}
logASARAccess(asarPath, filePath, info.offset);
fs.read(fd, buffer, 0, info.size, info.offset, error => {
callback(error, encoding ? buffer.toString(encoding) : buffer);
});
};
- 判断路径是否是 asar 文档,不是则直接调用原生方法读取。
- 提取 asar 文档内容,缓存中有则直接读取缓存,缓存没有则提取并缓存。
- 提取 asar 文档内容后判断是不是 unpacked,是则将文件复制到临时文件中并返回新路径。
- archive 调用的方法都是基于 C++ 实现的
asar 的限制
尽管 Electron 团队已经尽了最大努力使得 asar 包在 Node API 下的应用尽可能的趋向于真实的目录结构,但仍有一些底层 Node API 我们无法保证其正常工作。
档案文件是只读的
档案文件中的内容不可更改,所以 Node APIs 里那些会修改文件的方法在使用 asar 归档文件时都无法正常工作.
工作目录不能设置为档案文件里的目录
尽管 asar 档案是虚拟文件夹,但其实并没有真实的目录架构对应在文件系统里,所以你不可能将 working Directory 设置成 asar 包里的一个文件夹。 将 asar 中的文件夹以 cwd 形式作为参数传入一些 API 中也会报错。
某些 API 需要额外解压档案包
大部分 fs API 可以无需解压即从 asar 档案中读取文件或者文件的信息,但是在处理一些依赖真实文件路径的底层系统方法时,Electron 会将所需文件解压到临时目录下,然后将临时目录下的真实文件路径传给底层系统方法使其正常工作。 对于这类 API,会增加一些开销。
以下是一些需要额外解压的 API:
- child_process.execFile
- child_process.execFileSync
- fs.open
- fs.openSync
- process.dlopen - 用在 require 原生模块时
fs.stat 统计信息不真实
对 asar 档案中的文件取 fs.stat,返回的 Stats 对象不是精确值,因为这些文件不是真实存在于文件系统里。 所以除了文件大小和文件类型以外,你不应该依赖 Stats 对象的值。
执行 asar 档案内的二进制文件
Node 中有一些可以执行程序的 API,如 child_process.exec,child_process.spawn 和 child_process.execFile 等, 但只有 execFile 可以执行 asar 包中的程序。
因为 exec 和 spawn 允许 command 替代 file 作为输入,而 command 是需要在 shell 下执行的. 目前没有 可靠的方法来判断 command 中是否在操作一个 asar 包中的文件,而且即便可以判断,我们依旧无法保证可以在无任何 副作用的情况下替换 command 中的文件路径。
快应用开发工具使用 asar 的背景
-
快应用开发工具中有一个内置插件的 node_modules 有
1w+
的文件数,直接导致安装时间超长,用户体验很糟糕,所以我们使用 asar 的核心需求是减少文件数,提升安装包的安装速度。 -
同时做到 Windows 下 对 node_modules 全覆盖,避免第三方模块的文件删除或重命名后引用错误问题。
node_modules.asar
按照 Node require 加载第三方模块的原理,只读取 xxx/node_modules
下的文件。我们把插件工程的 node_modules 归档成 asar 文档,模块路径变成了 xxx/node_modules.asar
,这就导致代码中的 require 方法找不到模块。因此需要对 node 加载模快进行改造,在原有的 paths
上增加 asar 文档的路径,这样在 node 加载模块时就能找到对应的模块。
node require 流程
实施
exports.enableASARSupport = function(nodeModulesPath) {
const Module = require("module");
const path = require("path");
// 原生 node_modules 路径
let NODE_MODULES_PATH = nodeModulesPath;
if (!NODE_MODULES_PATH) {
NODE_MODULES_PATH = path.join(__dirname, "../node_modules");
}
// node_modules.asar 文档路径
const NODE_MODULES_ASAR_PATH = NODE_MODULES_PATH + ".asar";
// 缓存原生 _resolveLookupPaths 方法
const originalResolveLookupPaths = Module._resolveLookupPaths;
// 重写 _resolveLookupPaths
Module._resolveLookupPaths = function(request, parent) {
// 首先调用原生方法得到加载模块的路径数组
const paths = originalResolveLookupPaths(request, parent);
if (Array.isArray(paths)) {
// 遍历 paths ,如果其中有原生 node_modules 路径,则在这个路径前面插入 node_modules.asar 文档路径
for (let i = 0, len = paths.length; i < len; i++) {
if (paths[i] === NODE_MODULES_PATH) {
paths.splice(i, 0, NODE_MODULES_ASAR_PATH);
break;
}
}
}
return paths;
};
};
- 重写 Module 的 _resolveLookupPaths 方法,这个方法的功能就是获取模块可能存在的目录集合,如下:
/a/b/c/node_modules
/a/b/node_modules
/a/node_modules
/node_modules
-
调用原生方法得到加载模块的路径数组
-
遍历 paths ,如果其中有指定的 node_modules 路径,则在这个路径前面插入 node_modules.asar 文档路径
-
以上修改后,require 函数就能读取到被归档到 node_modules.asar 文档中的模块文件了。