如何用打包工具把一个复杂支持插件化的灵矶(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

相关推荐
Mintopia43 分钟前
像素的进化史诗:计算机图形学与屏幕的千年之恋
前端·javascript·计算机图形学
Mintopia1 小时前
Three.js 中三角形到四边形的顶点变换:一场几何的华丽变身
前端·javascript·three.js
归于尽1 小时前
async/await 从入门到精通,解锁异步编程的优雅密码
前端·javascript
晓13131 小时前
JavaScript加强篇——第六章 定时器(延时函数)与JS执行机制
开发语言·javascript·ecmascript
yanlele2 小时前
【实践篇】【01】我用做了一个插件, 点击复制, 获取当前文章为 Markdown 文档
前端·javascript·浏览器
LeeAt2 小时前
手把手教你构建自己的MCP服务器并把它连接到你的Cursor
javascript·cursor·mcp
前端风云志2 小时前
TypeScript枚举类型应用:前后端状态码映射的最简方案
javascript
望获linux3 小时前
【实时Linux实战系列】多核同步与锁相(Clock Sync)技术
linux·前端·javascript·chrome·操作系统·嵌入式软件·软件
小飞悟3 小时前
JavaScript 中的“伪私有”与“真私有”:你以为的私有变量真的安全吗?
javascript