如何用打包工具把一个复杂支持插件化的灵矶(node.js)打包成独立可执行程序(exe)

灵矶是一个支持插件化、模块动态加载、运行时行为高度灵活的 Node.js 应用平台。在开发环境下,灵矶依赖于 Node 的模块解析机制进行插件动态加载;但是作为以易用为目标的灵矶,我们希望它能作为一个独立可执行程序部署和运行。

本文将介绍我们是如何使用 @yao-pkg/pkg 工具将灵矶打包为单文件可执行程序的,并详细讲解我们如何绕过 pkg 的限制,实现灵活而稳定的模块加载机制。

可以从这里获取我们已经构建的产物,github.com/tachybase/t... ,我们已经用于交付给客户的系统中,但是泛用性、启动效率等目前还在优化中,使用方式和官方文档中使用 @tachybase/engine 的方式一致,我们也在将来提供图形化启动的方式。


为什么选择 @yao-pkg/pkg

pkg 可以将 Node.js 项目打包成单一的可执行文件,便于在生产环境部署,不需要依赖 node_modules 或 Node.js 本身。 @yao-pkg/pkg 本身是 fork 自 vercel/pkg,但是 node 官方支持打包之后就不再维护了,但是 node 使用的 sea 对于灵矶不适配,所以我们选用了 @yao-pkg/pkg

但它也有一些限制:

  • 所有动态 require 的路径必须能在打包阶段静态解析;
  • require.resolve() 的路径必须在 snapshot 中;
  • 动态路径加载、插件目录、外部依赖都不被自动包含。

这些限制与灵矶的"插件化架构"天然冲突,因此我们对模块加载器进行了定制。


灵矶在使用 @yao-pkg/pkg 打包时的模块加载器定制

问题背景

灵矶支持在运行时动态加载第三方模块,尤其是形如 @tachybase/plugin-xxx 的插件模块。在打包为可执行程序后,require.resolve() 无法在 snapshot 外查找路径,这会导致插件加载失败。

定制目标

我们定制了 Node.js 内部模块加载器 Module._load,实现目标包括:

  1. 允许打包后的应用仍然可以动态加载部分模块
  2. 支持白名单机制和特定命名空间模块(如 @tachybase/)的加载
  3. 对可能的加载失败提供容错和回退机制,保持原有行为一致
  4. 避免影响 Node.js 原有的相对路径/绝对路径加载行为

实现方案

ts 复制代码
// 重写 Node.js 默认模块加载器
Module._load = function (request: string, parent: NodeModule | null, isMain: boolean) {
  // 拦截白名单模块或以 @tachybase/ 开头的模块
  if (whitelists.has(request) || request.startsWith('@tachybase/')) {
    try {
      // 尝试从指定路径中解析模块路径
      const resolvedFromApp = require.resolve(request, { paths: lookingPaths });
      // 使用解析后的路径加载模块
      return originalLoad(resolvedFromApp, parent, isMain);
    } catch (err) {
      // 如果模块未找到,回退使用默认加载方式
      if (err.code === 'MODULE_NOT_FOUND') {
        return originalLoad(request, parent, isMain);
      }
    }
  }

  // 其他模块保持默认行为
  return originalLoad(request, parent, isMain);
};

获取插件的 package.json 路径

在插件系统中,我们常常需要访问插件的 package.json 来获取元信息,比如版本号、配置字段等。但在打包后,直接拼接路径的方式并不可靠。因此我们封装了一个兼容 pkg 的版本路径解析逻辑:

ts 复制代码
export function getDepPkgPath(packageName: string, cwd?: string) {
  const basePaths = cwd ? [cwd] : [process.env.NODE_MODULES_PATH || process.cwd()];

  try {
    return require.resolve(`${packageName}/package.json`, { paths: basePaths });
  } catch {
    const mainFile = require.resolve(packageName, { paths: basePaths });

    const packageDir = mainFile.slice(
      0,
      mainFile.indexOf(packageName.replace('/', path.sep)) + packageName.length
    );
    const result = path.join(packageDir, 'package.json');

    if (!fs.existsSync(result)) {
      return path.join(findPackageJson(mainFile), 'package.json');
    }

    return result;
  }
}

该函数优先使用 require.resolve 定位 package.json,如果失败则退回主模块路径向上查找。这样可以兼容开发环境与打包后路径结构不同的情况。


多语言资源加载的动态路径处理

灵矶支持国际化语言包,但语言资源文件通常分布在 lib/localesrc/locale 等目录下,路径结构在不同环境下可能不一致,因此我们也对其加载逻辑进行了封装:

ts 复制代码
export const getResource = (packageName: string, lang: string, isPlugin = true) => {
  const resources = [];
  const prefixes = [isPlugin ? 'dist' : 'lib'];

  if (process.env.APP_ENV !== 'production') {
    try {
      require.resolve('@tachybase/client/src');
      if (packageName === '@tachybase/module-web') {
        packageName = '@tachybase/client';
      }
    } catch {
      // fallback to default path
    }
    prefixes.unshift('src');
  }

  for (const prefix of prefixes) {
    try {
      const file = `${packageName}/${prefix}/locale/${lang}`;
      const resolved = require.resolve(file, {
        paths: [process.cwd(), process.env.NODE_MODULES_PATH].filter(Boolean),
      });
      if (process.env.APP_ENV !== 'production') {
        delete require.cache[resolved];
      }
      const resource = require(resolved);
      resources.push(resource);
    } catch {
      // fallback
    }
    if (resources.length) break;
  }

  if (resources.length === 0 && lang.includes('-')) {
    return getResource(packageName, lang.replace('-', '_'));
  }

  return arr2obj(resources);
};

这段逻辑优雅地支持了:

  • srclib 双模式加载(用于开发 vs 生产);
  • 多语言回退(如 zh-CNzh_CN);
  • 可插拔模块语言包路径一致性;
  • 开发环境下热更新(清除 require 缓存)。

总结

灵矶是一套高度插件化、模块化的系统,传统的打包方式在处理其动态特性时会遇到障碍。通过定制 Node 加载器、重写 require.resolve 的行为、构建路径容错机制以及语言资源管理策略,我们成功将灵矶打包成了一个可独立部署运行的单文件程序。

这让灵矶既保留了开发时的灵活性,又具备了部署时的极致简洁性。如果你也在构建一个可扩展的 Node 平台,希望部署更轻量,不妨尝试类似的方式打包你的系统。


如果你对灵矶的打包方式感兴趣,或者希望进一步了解插件化架构与部署优化,欢迎留言交流 🙌 📚 更多信息请访问:

官网 👉 tachybase.org

GitHub 👉 tachybase/tachybase

相关推荐
水冗水孚几秒前
express使用node-schedule实现定时任务,比如定时清理文件夹中的文件写入日志功能
javascript·node.js·express
天平16 分钟前
使用https-proxy-agent下载墙外资源
前端·javascript
sirius星夜1 小时前
鸿蒙开发实践:深入使用 AppGallery Connect 提升应用开发效率
javascript
sirius星夜2 小时前
鸿蒙功效:"AbilitySlice"的远程启动和参数传递
javascript
彬师傅2 小时前
JSAPITHREE-自定义瓦片服务加载
前端·javascript
梦语花2 小时前
深入探讨前端本地存储方案:Dexie.js 与其他存储方式的对比
javascript
练习前端两年半2 小时前
JavaScript当中的数据结构与算法
javascript
独立开发者Pony2 小时前
关于我用 Ai 完成了一套系统 99% 代码这件事
前端·javascript·github
sirius星夜2 小时前
HarmonyOS SDK 应用服务模块:通过 AccountManager 快速获取当前设备上的用户账户信息
javascript