快应用开发工具之 asar

快应用开发工具 Jul 15, 2020

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 的背景

  1. 快应用开发工具中有一个内置插件的 node_modules 有 1w+ 的文件数,直接导致安装时间超长,用户体验很糟糕,所以我们使用 asar 的核心需求是减少文件数,提升安装包的安装速度。

  2. 同时做到 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 流程

快应用开发工具之 asar

实施

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 文档中的模块文件了。

vivo developer

快应用引擎、工具开发者、快应用生态拓展达人(vivo)。

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.