跳到正文

Node 程序怎样构建与交付:从本地链接到独立运行

在开发机上能运行一个 Node 程序,并不代表把入口文件发给别人也能运行。本地链接、生产依赖、模板文件与运行时都可能留在开发仓库里。这里从交付要求区分语法转换、依赖打包和发布包装,再用隔离目录中的固定实验检查产物究竟带齐了什么。

转译、bundle 与交付目录是不同选择

Node 能直接执行代码,为什么还要构建

平时说「构建」,很容易把几种事情混在一起。

操作 解决什么问题 是否把依赖合进一个文件
语法转换 把 TypeScript、JSX 或较新的语法转换成目标能执行的 JavaScript 不一定,tsc 的常规 emit 就会保留模块结构
依赖打包 分析入口的依赖,把需要的模块组合成一个或多个产物 通常会组合,但可以外置部分依赖
发布包装 整理资源、运行时、原生扩展、启动器和安装信息 可以包含 bundle,也可以保留生产依赖目录

三者可以组合,也可以只做其中一部分。压缩、类型检查、生成 source map 又是另外的选项。

Node.js 本身会加载模块。只要语法、模块格式和依赖都符合目标版本的要求,一个使用 ESM 或 CommonJS 的项目完全可以直接执行,不需要先交给 Webpack 或 esbuild。

较新的 Node.js 还支持直接擦除部分 TypeScript 类型,但它有自己的版本和语法限制,也不代替类型检查。例如需要转换的枚举、路径别名等场景,仍需按具体版本确认处理方式。不能简单地把「用了 TypeScript」等同于「必须打成一个 JavaScript 文件」。Node.js TypeScript 文档明确区分了类型擦除和完整工具链支持。

本地链接只解决开发时的查找

假设有两个并排的项目:

workspace/
├─ local-helper/
│  ├─ package.json       # 入口指向已经生成的 dist/index.mjs
│  └─ dist/index.mjs
└─ native-host/
   ├─ package.json       # local-helper 使用本地 link 依赖
   └─ src/index.mjs

只要链接有效、local-helper 的入口存在,Node 就能直接加载它。库自己从 TypeScript 生成 dist/,和应用把这个库再打进 bundle,是两次不同的构建。

困难发生在交付时:把 native-host/src/index.mjs 发给用户,用户机器上并没有旁边那个 local-helper。原来成功的依赖查找就断了。

本地源码通过链接访问相邻包,单独复制入口会缺依赖,打包后可以在独立目录执行

这构成了打包的一个强理由:让交付物不依赖开发机的目录关系。但它仍然有替代方案,例如发布 npm 包、提供包文件,或者整理一份包含生产依赖的发布目录。

把本地包发到 npm,解决的是「依赖从哪里取得」。如果用户通过包管理器安装,它可以继续作为运行时依赖;如果希望用户下载产物后直接启动,仍然要决定是否随包提供依赖、是否 bundle,以及 Node 运行时从哪里来。

哪些场景值得引入构建

我更愿意从交付要求出发选择,而不是给所有 Node 项目套一份构建脚本。

场景 比较合适的起点
开发者自用的小脚本,环境固定 直接运行;只在语法或维护需要时增加转换
能在部署阶段安装依赖的 Node 服务 源码或转译产物,加锁定的生产依赖
发给用户的 CLI、Native Host 优先评估 bundle,减少安装步骤与外部目录依赖
serverless Node 函数 按平台契约整理入口、包体与外置依赖,可能由平台负责打包
npm 工具库 先确定公开入口、模块格式、类型声明与依赖策略,不默认把所有依赖内联
Electron 应用 分进程构建代码,再由桌面应用工具整理运行时与安装包

Node 程序的三种交付方式:源码与生产依赖、JavaScript bundle 与资源、包含运行时的安装包

bundle 的收益也不只体积。较少的文件可以减少分发时的遗漏和文件读取;把特定版本的纯 JavaScript 依赖编入产物,也能减少用户安装环境对结果的影响。CI 可以对这份产物验收,再交付同一份文件。

但收益有边界。CommonJS、动态加载和有副作用的依赖,未必能充分 tree shaking。打包可能增加包装代码,也增加构建配置和调试成本。启动速度是否改善,要在真实项目里测,不能从「变成一个文件」直接推出性能结论。

如果保留生产依赖目录更适合项目,也有成熟工具可用。例如符合 workspace 结构的项目,可以使用 pnpm deploy 生成包含依赖的可搬运目录,并用 --prod 排除开发依赖。它处理的是发布目录,不会把所有模块转换成一个 JS 文件;也不能把它对 workspace 的支持直接推广成任意外部 link: 都已处理。

平台构建、框架构建和应用打包分别负责什么

部署工具可能包含入口打包器,框架也可能提供自己的构建流程。配置时先确定谁负责语法、文件约定、依赖解析和资源整理,再决定由哪个入口发起。

构建步骤 处理内容 需要核对的边界
框架构建 组件编译、自动导入、文件路由、静态资源和目标入口 通用 bundler 不会自动理解全部框架约定
应用打包 依赖入口、平台、模块格式、资源与必要适配 输出需要满足实际宿主的接口与加载方式
部署工具 执行配置的命令,或者收集并上传已有产物 具体工具是否还会改写代码,须按其契约确认

这些职责可以由一个工具串行完成,也可以拆成多个步骤。项目已经需要框架编译或自定义依赖适配时,不应因为部署工具带了 bundler,就假定它能替代所有前置处理。

构建发生在本机还是云端,是另一个选择。CI 可以执行同一份锁定依赖与构建命令;验收应围绕真正交付的文件进行,避免验收后的代码又被第二套默认配置重新解析或改写。

本地链接换成 npm 版本,只改变依赖如何取得。网页、路由、资源和运行时适配仍由各自的构建阶段处理。

文件路由在打包之后怎样保留

「文件即路由」通常是框架的输入约定,不要求线上继续保留全部源码文件。例如 server/api/hello.get.ts 可以在构建时被识别为 GET /api/hello,然后转换成路由表和处理函数。产物变成一个或多个 JavaScript 文件后,请求路径仍然可以按这张表匹配。

Nitro 的路由文档明确说明,文件路由会在构建时编译并拆分代码。这里必须先执行框架的构建流程,再把兼容目标宿主的产物交给部署工具。只用 esbuild 打包一个任意入口,不会自动触发 Nitro 的目录扫描、自动导入与路由生成。

文件路由可以在框架构建时生成路由表;运行时扫描目录则需要保留文件结构

另一种实现会在运行时通过 readdir 扫描处理器目录,或者根据请求路径拼接文件名再加载。此时目录本身确实是运行时输入,合并文件可能破坏约定。可以保留框架规定的输出结构,使用受支持的部署适配器,或在构建期生成显式路由表;不能只改成 ESM 就认为路由已经保留。

目标平台还必须能运行框架生成的入口。Node HTTP 监听入口、fetch 处理函数和静态目录分别对应不同的部署约定;应使用适合目标的框架输出或适配器,不能仅靠打包或改名互换。

ncc 和 esbuild,都能构建 Node 程序

两者的区别,主要在于工具替你处理了多少分发细节。先看各自负责的范围,再用实际产物判断是否适合项目。

ncc:把 Node 程序的常见打包需求封装起来

Vercel ncc 面向 Node.js 模块和依赖的打包,支持 JavaScript、TypeScript,也支持输出 ESM。它在内部使用 Webpack,同时提供资源追踪、外置依赖、source map 等能力,使用者通常不需要自己维护一整份 Webpack 配置。

最简单的命令很短:

ncc build src/index.cjs -o dist

这里选择 .cjs 入口,对应输出也使用 .cjs。具体入口与输出规则仍要结合文件扩展名、包的 type 和 ncc 版本确认。

Umi 的依赖预打包也有使用 ncc 的源码实例。它的 scripts/bundleDeps.ts 提供了调用 ncc(entry, options) 的路径,接收返回的 code 和 assets,再整理到目标位置。

Umi 的部分依赖预打包调用 ncc,ncc 内部调用 Webpack;应用页面构建是另一条链路

准确的说法是:Umi 的部分依赖预打包使用了 ncc。不能进一步推成 Umi 的所有代码或使用 Umi 开发的网页都由 ncc 构建。看到 __webpack_require__ 等样板,只能判断产物带有 Webpack 的运行时代码;是否由 ncc 发起,还要看构建入口。

esbuild:同样适用,配置责任更明确

esbuild 并不局限于前端。它的官方 Node 打包说明直接提供了 Node 场景的用法。

如果项目已经使用 esbuild,或者希望明确掌握入口、模块格式、资源搬运和外置规则,继续用它很合理。对普通纯 JavaScript 依赖,没有必要因为目标是 Node 就另外换成 ncc。

关注点 ncc esbuild
常见定位 封装过的 Node 模块打包工具 通用构建器,可显式选择 Node 平台
底层构建器 Webpack esbuild 自身
配置方式 少量 CLI 选项或 JavaScript API CLI 或 JavaScript API,适合清楚定义构建边界
运行时资源 带资源追踪,但受版本、文件类型和代码形态限制 fs 运行时读文件等场景通常需要显式整理资源
外部依赖 支持 --external 等选项 支持 external、packages: 'external'
选型时怎样确认 实际构建并检查所有输出文件 实际构建并检查所有输出文件

这两个工具都不会自动把 Node 可执行文件装进 JS bundle。工具选择也不等于已经解决原生扩展、系统程序和安装器的问题。

把体积当作产物的约束

以 ESA Pages 搜索应用为例,2026-10-29 读取它已有的压缩构建文件,函数 JS 为 3,321,770 字节。这是文件本身的大小,不是网络压缩结果,也不代表 SDK 或 node_modules 单独占了这么多。口径见函数体积原始记录。

这个量级说明体积检查应放到构建末尾。可以借用前端的分析方法:审查依赖图,清理无用分支,确认可裁剪入口,开启适当压缩,再对照行为和字节数。CommonJS、副作用和动态加载也可能限制 tree shaking,不能承诺打包后一定足够小。

超限时,不能把不支持的模块简单标成 external 来骗过构建。外置把加载责任交给宿主;宿主不提供它,产物就从构建失败变成运行失败。保留资源、缩减依赖与使用成熟宿主能力,需要一起验收。gzip、Brotli 和 CDN 压缩改善传输,不自动替代平台代码大小检查。当前只建立体积基线,尚未测量优化收益。

打包后,哪些东西仍然要交付

把 JS 依赖打进 bundle,只覆盖了交付物的一部分。

内置模块和 npm 依赖,外置后的责任不同

node:crypto 外置后,完整 Node.js 会提供实现;some-library 外置后,运行时通常还要能找到对应的包。二者都叫 external,交付要求却不同。

esbuild 的 packages: 'external' 可以把包依赖留给运行时。这适合希望保留 Node 原生依赖加载的项目,但此时仍然需要生产 node_modules 或宿主明确提供的依赖。不能为了消除构建错误,把所有模块都 external,然后只交付一个入口文件。

动态插件同样需要设计边界。require(variable)、根据目录扫描加载的插件、运行时才知道名称的模块,可能无法靠静态分析枚举。如果功能依赖开放的插件目录,就应保留明确的插件发现和安装机制。

资源文件不会因为生成了 JS 而消失

例如这个路径明确指向程序旁边的文件:

fs.readFileSync(path.join(__dirname, 'message.txt'), 'utf8');

esbuild 打包成功后,目标目录里仍然要有 message.txt,或者由应用采用另一种已经验证的资源加载方式。模板、WASM、worker 子入口以及外部可执行程序,也都需要检查。

ncc 提供资源追踪,但也要验收。我在 ncc 0.45.0 下测了两份内容相同、都按 CommonJS 执行的文件:

源文件扩展名 ncc 的资源输出 运行结果
.js 自动输出 message.txt 成功
.cjs 没有输出该文件 ENOENT;手动复制后成功

对应版本源码中的资源 loader 匹配式包含 .js、.mjs、.ts、.tsx、.node,没有包含 .cjs。这能解释当前夹具的差异,同时也说明:支持 CommonJS 代码打包,和覆盖所有扩展名的资源追踪,是不同的能力。

相同资源读取代码在 ncc 0.45.0 下,js 自动搬运资源,cjs 缺少资源,补齐后恢复运行

使用 ncc 的 JavaScript API 时,返回的 assets 也属于产物,必须写到交付目录。不能只保存 code。

原生扩展、运行时和桌面安装包

像图像处理等依赖可能包含 .node 扩展及配套二进制。它们需要匹配目标操作系统、CPU 架构,以及相应的 ABI 或 Node-API 支持范围。把 JavaScript 外壳合并起来,不会消除这些要求。

Native Host 还需要自己的安装、启动和浏览器注册信息。如果目标用户没有 Node,就需要随交付物提供受支持的运行时,或者另外评估可执行文件方案。Node 的 Single Executable Applications属于另一层能力,有独立的版本和资源加载限制;普通 esbuild/ncc bundle 本身不会变成 .exe。

Electron 也是类似的分层结构:JavaScript 可以先构建,随后还要把应用与 Electron 运行时整理成分发包。Electron 的应用包装文档说明了目录和 ASAR 两种形式。ASAR 把文件归档起来,不能代替语法转换、依赖打包或 tree shaking。

Electron 的主进程与渲染进程还要分开看:主进程运行在 Node 环境,渲染进程执行网页,沙箱 preload 有进一步的能力限制,整个项目不能套同一份 Node 配置。具体边界见进程模型。原生扩展则要核对 Electron 的兼容要求;必要时用官方推荐的 @electron/rebuild 处理。

减少文件,也要保留维护信息

产物还应包含必要的许可证和声明。source map 怎样分发、日志如何定位源码、依赖漏洞修复后如何重新构建,也需要有明确做法。打进 bundle 的依赖更新后,要重新生成并发布产物,用户目录里已经不存在一个可以单独更新的同名 npm 包。

最小化时也不要把运行时凭据编译进去。构建期静态替换和运行时读取环境变量,是两种配置方式,和是否使用 bundle 没有必然绑定关系。

离开开发仓库,才能检查真实交付边界

产物应在怎样的环境里验收

最容易产生错误信心的测试,是在开发仓库里运行刚打好的文件。它可能继续向上找到仓库的 node_modules,或者通过链接访问相邻项目。

这组最小实验专门检查交付后能否独立运行。输入、链接包和交付目录全部创建在开发仓库之外;每个产物从自己的目录启动,清除子进程的 NODE_PATH、NODE_OPTIONS。外置依赖的场景会明确复制生产依赖,其余独立 bundle 场景不提供 node_modules。

构建之后移到独立交付目录,从真实入口启动,再验证资源与依赖,避免借用开发目录

验证环境是 Windows x64、Node.js 26.12.0、esbuild 0.28.2、ncc 0.45.0。这些结果证明的是固定夹具的行为;示例中的 Node.js 22.22 语法目标,需要再通过最低版本 CI 验证,不能拿 Node.js 26 的执行结果代替。

交付方式或条件 实际结果
源码能访问本地链接包 Node 直接执行成功
只复制源码入口 找不到本地依赖,MODULE_NOT_FOUND
esbuild,Node 平台,CJS bundle 无生产 node_modules 也可执行
ncc,CJS bundle 无生产 node_modules 也可执行
esbuild,browser,再外置内置模块 能执行,但选错了包的实现
esbuild,CJS 入口打成 ESM 内置模块的动态 require 执行失败
普通 CJS 包内联进 ESM 正常执行,说明 CJS 打包本身受支持
ESM 产物显式使用 createRequire 在完整 Node 中正常执行
固定 crypto 引用转为原生 import 正常执行,产物不依赖宿主 require
原生 ESM 入口内联普通 CJS 包 内置模块 import 与普通 CJS 包可共同工作
原生 ESM 入口,包依赖外置并交付 Node 正常加载 CommonJS 包
esbuild 产物漏掉模板 读取失败;复制模板后成功
ncc 的 .js / .cjs 资源读取夹具 资源输出不同;缺失文件需补齐

测试脚本共断言了 19 个场景,预期失败也计入其中。新增加载场景同时记录 external 的实际加载类型。完整结果见19 个场景的原始记录。

这组极小夹具在当前记录中生成的 esbuild CJS bundle 是 916 字节,ncc CJS bundle 是 2127 字节,均未开启压缩和 source map。这个数字只适合检查夹具产物,不适合比较两种工具的真实项目表现,路径注释等细节也会影响字节数。原始入口虽然更小,却缺少依赖,不能当成完整交付物参与体积比较。

完整源码工作区弹窗提供实验入口与依赖。复制 `copy/` 的全部文件到独立目录后,执行 `pnpm install --frozen-lockfile` 和 `pnpm test`,会生成自己的验证记录。这里展示源码,浏览器不会执行 Node 子进程实验。

真实项目还应覆盖 CLI 参数或服务启动、关键业务路径、运行时资源、最低 Node 版本,以及承诺支持的系统和架构。如果程序依赖数据库或平台 API,模块加载成功也只是其中一层验收。

模块加载需要单独核对

依赖已经交付,也可能留下宿主无法执行的加载方式。ESM 与 CommonJS 加载篇继续区分内联 CJS、external、createRequire 与受限宿主;这与模板、原生扩展和运行时是否随包,是两组不同的检查。

构建工具、依赖与资源的交付实验:完整源码
平台、模块格式与资源的最小实验正在加载代码工作区...