本页目录

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

从可执行文件、工作目录和输入输出开始,建立浏览器之外的 JavaScript 运行模型,理解程序为何启动、等待和退出。

L2 · 能交付约 15 分钟阅读含示例、练习与验收
本页内容

本章目标与阅读方式#

你已经熟悉 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 提供的进程能力;ArrayPromise 等则属于语言运行时的一部分。因此“这段 JavaScript 能在 Vue 页面里运行”不代表它可以原封不动搬到服务器。浏览器代码若访问 localStorage,搬过去首先需要重新决定数据应该保存在哪里,而不是寻找一个同名变量凑上。

现代 Node 也提供 fetchURLAbortController 等 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,完整内容如下。程序只打印信息,不访问文件或网络。

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=undefinedfetch=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,无依赖。它不会启动长期后台任务,约几十毫秒后自行结束。

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 容忍多余尾部字符。

参考答案:完整可运行实现
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 12itemsnode task-count.mjs 0 或传入两个参数都应失败。每次执行后通过 PowerShell 的 $LASTEXITCODE 查看状态。这个版本故意不接受前导零,若产品希望接受,应先修改契约,再修改正则和验收用例。

验收与自测#

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

自测一:两次运行同一个 .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 发布状态。进程字段、退出事件与输出边界见 process 官方文档;定时器与引用行为见 timers 官方文档;宿主差异可对照 浏览器与 Node 的区别

原有课程整理于 2026-09-10;Node / Electron 扩充于 2026-09-11。示例环境与验证范围以正文为准。
原创中文学习手册,阅读结构参考 Vue 文档;非 Vue 官方教材。
下载本章 Markdown

支持中文和英文全文搜索 · ↑ ↓ 选择 · Enter 打开 · Esc 关闭