# 从终端认识 Node：第一个程序与进程生命周期

## 本章目标与阅读方式

你已经熟悉 Vue 和 JavaScript，本章不再解释变量、函数或数组。新的起点是：原先由浏览器替你安排的运行环境，现在需要你自己启动、配置和结束。学完以后，你应当能在终端运行一个文件，解释文件路径与工作目录的区别，读取命令参数，并根据退出码判断任务是否成功。后续贯穿的业务是“团队文档与任务助手”：先做读取参数的本地工具，之后才增加网络、数据库与 AI 调用。

第一次学习不要急着创建框架项目。框架启动成功，可能只是说明模板与依赖恰好兼容；亲手运行一个零依赖文件，才能看见程序真正接收了什么输入、拥有什么资源。请把每个实验放在自己新建的练习目录中。这里的程序不会读取工作文档、访问外网或修改已有文件。

截至本教材核对日期 2026 年 9 月 11 日，Node 官方发布页将 Node 24 标为 LTS，Node 22 也仍处于 LTS 周期。新学习环境可以选择 Node 24 的维护版本。本教材实际执行验证使用 Node 22.22.0，因此只依赖该版本已有的稳定功能；看到文档页顶部的版本号时，注意它可能是更新的 Current 版本。本章的命令在 Windows PowerShell 可用，npm 命令在后续章节统一写为 `npm.cmd`，避免终端脚本策略与 JavaScript 本身混淆。

## 从“打开网页”到“启动进程”

浏览器加载网页时，会建立页面环境、解析脚本、提供 DOM 和网络能力。Node 是另一个可以执行 JavaScript 的程序。你在终端输入 `node hello.mjs`，操作系统启动 Node 进程，Node 读取入口模块，执行其中的代码，并在有必要时继续等待异步工作。文件不是进程：同一个文件可以同时被启动两次，得到两个独立进程，也就拥有两份独立内存。

V8 负责执行 JavaScript，但 V8 本身不等于浏览器，也不等于 Node。`document` 属于浏览器提供的页面能力，`process` 属于 Node 提供的进程能力；`Array`、`Promise` 等则属于语言运行时的一部分。因此“这段 JavaScript 能在 Vue 页面里运行”不代表它可以原封不动搬到服务器。浏览器代码若访问 `localStorage`，搬过去首先需要重新决定数据应该保存在哪里，而不是寻找一个同名变量凑上。

现代 Node 也提供 `fetch`、`URL`、`AbortController` 等 Web 风格 API。这种重叠方便共享部分代码，但不意味着运行环境完全一致。浏览器携带的页面来源、Cookie 容器以及跨源访问约束，都不能通过看到相同的函数名就推定存在。共享代码应围绕明确的输入与输出编写，把 DOM、文件系统和进程配置留在各自的边界。

另一个变化是权限。浏览器中的普通页面受到页面环境的限制；以默认方式启动的 Node 程序通常能以当前系统用户的权限访问文件和网络。这不是要求你背一张安全清单，而是解释为什么依赖安装脚本、文件路径和环境变量需要认真理解。执行陌生项目就是让一段程序在自己的账户下工作，不能把它当成只展示文本的网页。

## 终端、当前目录与入口文件

终端是输入命令、查看输出的交互界面，PowerShell 是解释这些命令的 shell，Node 才是解释 `.mjs` 文件的程序。三者各有语法。`node --version` 中的参数交给 Node；`node hello.mjs team-a` 中，入口文件之后的 `team-a` 才是你的业务参数。不要把 JavaScript 代码直接粘到 PowerShell 提示符后面，除非通过 `node -e` 明确要求 Node 执行字符串。

“当前工作目录”是进程启动时继承的定位基准。它和“入口文件所在目录”常常相同，却没有必须相同的关系。从上层目录执行 `node exercises/hello.mjs` 时，工作目录仍是上层目录。`process.cwd()` 返回当前工作目录的绝对路径；`import.meta.url` 表示当前模块自身的文件 URL。读取用户指定的相对路径通常相对于工作目录，读取随模块发布的固定资源通常相对于模块 URL。这个区别能解释大量“本地正常、换一个启动位置就找不到文件”的问题。

`.mjs` 明确告诉 Node 这是 ECMAScript 模块，不需要先创建 `package.json`。我们这样选择是为了让第一章只承担运行环境知识。下一章会解释 `.js`、`.cjs` 与包配置之间的关系。路径包含空格时应在终端中加引号，那是 shell 的参数分组规则，与 JavaScript 字符串规则不是一回事。

## 第一组 API：观察程序的外壳

`process.argv` 是字符串数组，通常前两项分别是 Node 可执行文件路径和入口文件路径，后面才是业务输入。因此使用 `process.argv.slice(2)` 获取用户参数。这个 API 不替你识别布尔值、数字、选项名或默认值；若输入 `03`，拿到的仍是字符串 `03`。参数是否允许为空、是否重复、是否能写成负数，属于你的工具契约。

`process.env` 提供环境变量对象。常规读取结果是字符串或者 `undefined`，不会自动把 `false` 变成布尔值。`process.version` 返回当前 Node 版本字符串，`process.versions` 包含 V8、libuv 等组件版本，适合记录诊断上下文。`process.cwd()` 无参数，正常返回字符串；若工作目录被外部移除等异常情况发生，也可能抛出系统错误，不能把它理解为永远不会失败的语言常量。

`console.log()` 将常规信息写向标准输出，`console.error()` 写向标准错误。它们不是业务数据存储，也不能被当成可靠的事务日志。两个输出流合并显示时，不应仅根据视觉顺序推导跨流的精确先后。工具若以后需要通过管道输出 JSON，应把机器可读结果放在标准输出，把诊断文字放在标准错误，避免调用方解析失败。

## 实验一：观察同一段代码的运行环境

环境为 Node 22.22.0 或更新版本，无第三方依赖。新建 `hello-node.mjs`，完整内容如下。程序只打印信息，不访问文件或网络。

```js hello-node.mjs
import process from 'node:process';

// 入口文件后面的内容才是传给业务程序的参数。
const [team = 'demo-team'] = process.argv.slice(2);
console.log(`team=${team}`);
console.log(`node=${process.version}`);
console.log(`cwd=${process.cwd()}`);
console.log(`module=${import.meta.url}`);
console.log(`document=${typeof document}`);
console.log(`fetch=${typeof fetch}`);
```

在该文件所在目录执行：

```powershell
node --version
node hello-node.mjs alpha
node hello-node.mjs
```

第一次业务运行应输出 `team=alpha`，第二次输出 `team=demo-team`。版本、目录和文件 URL 随机器变化，不能照抄为固定断言。两次都应看到 `document=undefined` 和 `fetch=function`。这里使用 `typeof document`，因为它可以检查未声明的名称；直接读取 `document.title` 则会在 Node 中抛出引用错误。

逐段看代码：导入 `node:process` 明确声明依赖的宿主能力；解构中的默认值只在没有传该参数时生效，传入空字符串仍然是空字符串；后面的输出分别对应业务输入、运行版本、工作环境和宿主差异。现在回到上一级目录，用包含子目录的相对路径再执行一次。你会观察到 `cwd` 改变，而 `module` 仍指向同一个文件。这个实验验证的不是路径拼接技巧，而是两个定位基准确实不同。

在团队助手中，用户输入的“要导入哪个目录”属于显式参数，程序随包发布的模板则属于模块资源。先决定每个路径来自哪一种来源，再选择相应的 API，会比到处调用路径拼接更可靠。绝对路径方便诊断，但若日志要上传共享，也应考虑其中可能含有个人用户名。

## 程序执行完了，为什么还不退出

顶层代码到达末尾，只表示当前同步执行告一段落。Node 是否退出，还取决于是否存在需要继续维持运行的活动资源，例如监听中的服务器、尚未触发的定时器或进行中的某些异步操作。可以把进程看成一间工作室：写在入口中的安排已经读完，不代表所有预约工作都结束了。

Promise 对象本身不是让进程保持存活的资源。创建一个永远不兑现的普通 Promise，并不等于预约了操作系统事件。顶层 `await` 又涉及模块执行状态，不能用它去替代资源管理。建立这样的区分以后，就不会把“有一个 pending Promise”与“进程一定还在等待”混为一谈。

定时器默认具有保持事件循环活动的引用。`setTimeout(callback, delay)` 返回一个 Timeout 对象，`delay` 是至少等待的时间阈值，不是准点执行保证。回调还需要等到 JavaScript 线程可以执行它。`clearTimeout(timeout)` 取消尚未执行的定时任务。对象的 `unref()` 则表示：如果只剩这个定时器，不必为了它保留进程。它不是取消，若其他资源让进程仍活着，回调仍有机会发生。

正常结束时，退出码默认是零。把 `process.exitCode` 设为非零，可以表达最终失败，同时允许已安排的收尾继续进行。`process.exit(code)` 会要求进程立即退出，可能截断尚未完成的输出或异步清理，不适合当作普通错误处理的默认操作。以后写 HTTP 服务时，还要先停止接收新请求，再等待已有请求结束，而不是只设置退出码。

## 实验二：观察生命周期与退出码

保存为 `lifecycle.mjs`，无依赖。它不会启动长期后台任务，约几十毫秒后自行结束。

```js lifecycle.mjs
import process from 'node:process';

console.log('1: 顶层开始');
process.once('beforeExit', (code) => {
  // 此处只观察，不再创建新的异步任务。
  console.log(`4: beforeExit=${code}`);
});
process.once('exit', (code) => {
  // exit 监听器只能做同步观察，不能等待异步收尾。
  console.log(`5: exit=${code}`);
});
setTimeout(() => console.log('3: 定时器完成'), 20);
console.log('2: 顶层结束');
```

```powershell
node lifecycle.mjs
$LASTEXITCODE
```

预期按编号打印五行，随后 shell 显示退出码 `0`。前两行连着出现，因为安排一个定时器不会暂停当前函数。第三行出现后，进程已没有需要等待的活动工作，于是出现后两行。二十毫秒只是设置的等待阈值，机器繁忙时实际间隔可能更长，验收不应要求精确到毫秒。

`beforeExit` 在自然耗尽工作时触发，监听器如果再次安排异步任务，可能使进程继续运行并在以后再次触发该事件。`exit` 则是已经进入退出阶段的同步通知，里面安排定时器不会让进程重新活过来。它们适合帮助你观察边界，不应被包装成“无论怎样崩溃都会执行的保存钩子”。强制终止、系统掉电等情况显然无法依靠 JavaScript 收尾。

## 从实验走向可用的命令行工具

一个可用工具至少区分三种结果：输入格式不符合约定、业务执行失败、业务成功。前端表单可以即时提示输入错误，但服务端或命令行不能假定调用方一定经过那个表单。验证应该放在程序边界，验证通过后再调用业务函数。这样以后把同一个业务函数接到 HTTP 路由时，就能复用领域规则，而不必把终端输出搬过去。

读取环境变量时先检查原始字符串，再转换类型。`Number('')` 会得到零，`parseInt('12items')` 会得到十二，这些都是合法的语言行为，却可能违背配置的要求。默认值也应只在明确允许缺省时使用，不能用一个宽泛的“假值则默认”吞掉零和空字符串之间的差别。本章的练习采用十进制正整数字符串，原因是它提供了清楚、可验证的输入边界。

调试时先确认实际运行的是哪个 Node 与哪个文件，再讨论程序逻辑。在 PowerShell 中可以通过 `Get-Command node` 查看命令位置；打印 `process.execPath` 能看到当前进程所用的可执行文件。编辑器中选中的解释器、终端 PATH 中的解释器与另一台机器的解释器可能不同。版本信息与启动命令比一句“我这里可以运行”更有交流价值。

## 再观察一次输入输出边界

终端中显示出来的文字并不一定来自同一种渠道。标准输入、标准输出和标准错误是进程启动时获得的三个通信入口。它们可以连接终端，也可以连接文件或另一个进程。前端组件的 `emit` 通常面向父组件，命令行工具的标准输出则可能直接成为另一个程序的输入，因此输出格式也是接口。开发初期先随意打印调试信息，后来却把同一输出交给 JSON 解析器，是一种很常见的集成失败。

当输出连接终端或管道时，具体写入行为还受到平台和目标类型影响。不要用“我看到了最后一行”推断所有文件都已经保存，也不要用立即退出来保证日志完整。对真正重要的文件写入应等待文件 API 的完成结果；对长期服务日志应使用明确的采集与刷新策略。第一章先记住：控制台显示是观察工具，业务成功需要由业务操作自己的完成条件来证明。

Node 不带入口文件直接启动时，会进入交互式求值环境，适合检查表达式和简单 API。可是交互环境中的模块语义、历史变量与输入方式，不一定和 `.mjs` 文件一致。遇到模块加载或生命周期问题，应复制到一个新文件再运行，让复现条件独立、可重放。把“我在控制台试过”升级为“这是文件、版本、命令与结果”，是从前端页面调试走向服务端问题定位的重要一步。

最后，退出码表达的是进程对外报告的结果，并不能自动证明工作正确。忘记处理一个失败分支，程序仍可能以零退出；捕获了错误却只打印文字，也可能让上层自动化误判成功。因此错误路径必须与正常路径一样接受验证。练习中的非零退出码，就是未来构建脚本、后台作业和持续集成能够停止后续步骤的依据。

## 练习：实现有明确契约的任务计数工具

为团队助手编写 `task-count.mjs`。它只能接收零个或一个参数；无参数时使用三，单个参数必须是十进制正整数且不超过一百。成功打印 `准备处理 N 项任务`，失败只向标准错误打印提示并以退出码一结束。提示：先验证参数个数和原始文本，再使用 `Number`，不要用 `parseInt` 容忍多余尾部字符。

<details><summary>参考答案：完整可运行实现</summary>

```js task-count.mjs
import process from 'node:process';

const args = process.argv.slice(2);
const raw = args[0] ?? '3';
// 格式与范围分开验证，让错误边界可以独立解释。
const formatOK = /^[1-9]\d*$/.test(raw);
const count = formatOK ? Number(raw) : NaN;
if (args.length > 1 || !formatOK || !Number.isSafeInteger(count) || count > 100) {
  console.error('参数错误：请提供 1 到 100 的十进制正整数');
  process.exitCode = 1;
} else {
  console.log(`准备处理 ${count} 项任务`);
}
```

执行 `node task-count.mjs` 得到三项；执行 `node task-count.mjs 12` 得到十二项；执行 `node task-count.mjs 12items`、`node task-count.mjs 0` 或传入两个参数都应失败。每次执行后通过 PowerShell 的 `$LASTEXITCODE` 查看状态。这个版本故意不接受前导零，若产品希望接受，应先修改契约，再修改正则和验收用例。

</details>

## 验收与自测

验收时不要只观察成功路径。请在两个不同的工作目录启动第一个程序，说明两个路径输出为何变化；运行生命周期实验，说明为什么顶层结束后还有输出；运行计数工具的缺省、合法、非法和多参数四类输入，并验证退出码。做到这些，才说明你理解了程序边界，而不是只是复制成功。

自测一：两次运行同一个 `.mjs` 文件会共享变量吗？答案：默认不会，它们是两个独立进程；要共享状态必须使用文件、数据库、IPC 等显式机制。自测二：`setTimeout(fn, 0)` 是否意味着立即调用 `fn`？答案：不是，它安排后续执行，还受到最小延时处理和事件循环忙碌程度影响。自测三：程序出错时为什么通常设置 `exitCode`，而不是直接 `exit()`？答案：让失败状态可见，同时保留正常收尾与输出完成的机会。

## 本章实际验证范围

Node 22.22.0 下实际执行 hello-node、lifecycle、task-count。计数工具验证了缺省、12、12items、0 和两个参数，成功退出码为零，非法输入为一。生命周期顺序与文中一致。

## 官方参考

本章实验以本机 Node 22.22.0 为验证基线；版本选择参考 [Node 发布状态](https://nodejs.org/en/about/previous-releases)。进程字段、退出事件与输出边界见 [process 官方文档](https://nodejs.org/docs/latest-v24.x/api/process.html)；定时器与引用行为见 [timers 官方文档](https://nodejs.org/docs/latest-v24.x/api/timers.html)；宿主差异可对照 [浏览器与 Node 的区别](https://nodejs.org/en/learn/getting-started/differences-between-nodejs-and-the-browser)。
