Node 程序怎样构建与交付:从本地链接到独立运行
在开发机上能运行一个 Node 程序,并不代表把入口文件发给别人也能运行。本地链接、生产依赖、模板文件与运行时都可能留在开发仓库里。这里从交付要求区分语法转换、依赖打包和发布包装,再用隔离目录中的固定实验检查产物究竟带齐了什么。
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 应用 | 分进程构建代码,再由桌面应用工具整理运行时与安装包 |
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。不能进一步推成 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 的 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 与受限宿主;这与模板、原生扩展和运行时是否随包,是两组不同的检查。