引言:JavaScript 为什么突然能接触操作系统了
在浏览器中,我们可以编写下面的 JavaScript:
document.querySelector("h1").textContent = "Hello, browser";
同样使用 JavaScript,进入 Node.js 后,我们却会看到另一组能力:
console.log(process.pid);
浏览器中的 document 可以操作网页,Node.js 中的 process 可以读取当前进程的信息。Node.js 还提供 fs、http、net、stream 等模块,使 JavaScript 能够读取文件、监听端口和处理数据流。
问题随之而来:同样是 JavaScript,为什么进入 Node.js 后就能访问文件、网络、进程和操作系统资源?
答案不是“Node.js 扩展了 JavaScript 语法”,也不是“V8 本身提供了文件和网络能力”,而是:
ECMAScript 定义 JavaScript 的语言语义;Node.js 作为 JavaScript 的宿主运行时,向程序提供文件、网络、进程等宿主 API。一次具体操作会根据类型直接由 V8 执行,或按需借助 Node.js 原生组件、libuv、其他原生库与操作系统提供的相应能力完成。
本文以 Node.js 24 为主要参考,目标是建立一张运行时职责地图。我们会讨论事件循环在整体模型中的位置,但暂不展开事件循环阶段、微任务精确顺序和 setImmediate;也只会提到入口文件需要被加载,不讨论 CommonJS 与 ESM 的具体解析规则。
1. JavaScript 为什么需要宿主环境
1.1 ECMAScript 定义的是语言,而不是整个运行环境
日常所说的 JavaScript,首先是一门编程语言。ECMAScript 规范定义了这门语言的语法和运行语义,例如:
- 如何声明变量、创建函数和计算表达式;
- 数字、字符串、对象、数组等值如何表现;
- 作用域、闭包、原型和类如何工作;
- 异常怎样抛出和捕获;
Promise、Map、Set等标准内置对象具有什么行为。
但是,ECMAScript 不会替某个具体程序决定:
- 当前机器上的文件放在哪里;
- 如何打开一个文件描述符;
- 怎样创建 TCP Socket;
- 当前进程的编号是多少;
- 一个 HTTP 请求何时到达;
- 程序应该怎样把字符显示在网页或终端中。
这些能力与具体平台和运行环境有关,不能只由一份语言规范统一决定。ECMAScript 因而保留了由宿主定义的接口和行为,让不同宿主把语言嵌入不同的应用场景。
这里有一个容易忽略的例子:Promise 是 ECMAScript 定义的语言能力,语言规范会规定它的状态和反应函数怎样工作。但是,哪个外部操作最终让一个 Promise 进入 fulfilled(已兑现)状态,通常取决于宿主。Node.js 可以在文件读取完成后兑现一个 Promise,浏览器也可以在某个 Web API 完成后兑现另一个 Promise。
1.2 浏览器和 Node.js 都是宿主环境
浏览器为了运行网页中的 JavaScript,除了 JavaScript 引擎,还提供 DOM、页面事件、渲染、导航等 Web 平台能力。document 不是 ECMAScript 的标准内置对象,而是浏览器宿主提供给 JavaScript 的接口。
Node.js 面向的是另一组问题。它提供文件、网络、进程、操作系统信息等能力,使 JavaScript 可以编写命令行程序、构建工具和长期运行的网络服务。process 和 fs 同样不是 ECMAScript 语言的一部分,而是 Node.js 提供的宿主能力。
| 能力 | ECMAScript | 浏览器宿主 | Node.js 宿主 |
|---|---|---|---|
| 函数、对象、数组、类 | 定义 | 使用 | 使用 |
Promise 的语言语义 |
定义 | 集成到浏览器任务模型 | 集成到 Node.js 调度模型 |
| DOM 与页面渲染 | 不定义 | 提供 | 默认不提供 |
| 文件与进程 API | 不定义 | 通常不直接提供 | 提供 fs、process 等 API |
| TCP、HTTP 服务端能力 | 不定义 | 受 Web 平台安全模型限制 | 提供 net、http 等 API |
| 文件、Socket 等底层资源 | 不负责 | 操作系统管理,浏览器负责封装和协调 | 操作系统管理,Node.js 负责封装和协调 |
因此,更准确的说法不是“浏览器 JavaScript”和“Node.js JavaScript”是两门语言,而是:它们主要遵循相同的 ECMAScript 语言语义,却生活在不同的宿主环境中,因而能够看到不同的全局对象和宿主 API。
2. Node.js 到底是什么
Node.js 官方将自己描述为一个异步、事件驱动的 JavaScript 运行时。这里的关键词是运行时(Runtime):它是一套让 JavaScript 程序真正加载、执行并与外部世界交互的环境。
理解 Node.js 时,最好把几个经常混在一起的概念分开:
| 概念 | 主要回答的问题 | 例子 |
|---|---|---|
| 编程语言 | 程序可以怎样表达计算 | JavaScript |
| 语言规范 | 语法和语义应该遵守什么规则 | ECMAScript |
| JavaScript 引擎 | JavaScript 代码怎样被解析和执行 | V8 |
| JavaScript 运行时 | 代码在什么环境中运行,又能使用哪些宿主能力 | Node.js、浏览器 |
| Web 框架 | 如何更方便地组织路由、中间件和业务代码 | Express、NestJS、Next.js 的服务端部分 |
所以,Node.js 不是一门新的编程语言。我们在 Node.js 中编写的主要仍是 JavaScript,只是程序可以调用 Node.js 提供的宿主 API。
Node.js 也不是 Web 框架。它可以创建 HTTP 服务,但不会像具体框架那样替应用规定路由、控制器、依赖注入或页面组织方式。框架建立在运行时之上,Node.js 则负责让框架和应用拥有可执行的基础环境。
Node.js 也不能简单等同于 V8。V8 可以解析、编译和执行 JavaScript,却不会单独为应用提供完整的 fs、http 或 process API。Node.js 将 V8 嵌入自己的进程,再组合 Node.js 核心代码、原生绑定、libuv、OpenSSL 等组件,形成完整运行时。
从 JavaScript 程序的视角看,Node.js 提供的典型能力包括:
fs:访问文件和目录;http、net、dgram:处理应用层协议、TCP 和 UDP;process:读取参数、环境变量和进程状态,接收部分操作系统信号;stream:用统一抽象组织分段到达或分段写出的数据;- 定时器(timers):安排未来要执行的回调;
worker_threads、child_process:显式引入并行执行或进程隔离。
这些 API 的 JavaScript 表面相似,底层实现路径却不相同。理解 Node.js 运行时,关键不是记住一条万能流水线,而是知道每一层负责什么,以及一次具体操作走向了哪条分支。
3. V8、Node.js、libuv 与操作系统分别负责什么
3.1 四个核心角色
我们可以先用一张职责表建立边界:
| 角色 | 主要职责 | 不应该归给它的职责 |
|---|---|---|
| V8 | 解析、编译和执行 JavaScript;管理 JavaScript 堆与垃圾回收;提供可嵌入的 JavaScript 执行环境 | 不直接定义 Node.js 的 fs、http、process API,也不拥有操作系统文件和 Socket |
| Node.js 核心 | 初始化运行时;组织模块加载;提供宿主 API;把 JavaScript 调用连接到相应的 JavaScript、C++ 或底层组件 | 不是 JavaScript 语言规范,也不是所有底层工作的最终执行者 |
| libuv | 提供事件循环、跨平台 I/O 抽象、Socket 轮询、Worker Pool、线程和进程等基础设施 | 不是 JavaScript 引擎,也不意味着所有异步操作都进入 Worker Pool |
| 操作系统 | 管理进程、线程、文件描述符、文件系统、Socket 和网络协议栈;提供系统调用与 I/O 通知机制 | 不负责理解 Promise、闭包或应用中的 JavaScript 回调 |
Node.js 的原生绑定经常处在 JavaScript API 与 C/C++ 组件之间。它们很重要,但“Bindings”不是所有操作都必须按顺序经过的一条独立业务流水线。有些 Node.js API 大量由 JavaScript 实现,有些进入原生代码,有些最终使用 libuv,有些还会调用其他原生库。
3.2 为什么不能画成一条单向流水线
下面这种画法看起来整齐,却会制造错误理解:
模块加载器 → V8 → Bindings → libuv → 操作系统
它至少掩盖了五件事:
- 模块加载器负责找到和组织代码,不会包围每一次函数调用。
- 普通 JavaScript 计算可以直接由 V8 执行,不需要访问 libuv 或操作系统 I/O。
- 同步 Node.js API 和异步 Node.js API 的等待方式不同。
- 非阻塞 Socket 与异步文件读取通常不走同一条底层路径。
- Worker Threads 和子进程是应用显式选择的并行或隔离机制,不是所有异步 API 的默认终点。
因此,Node.js 更适合使用职责分支模型来理解。
以下五条路径相互独立。普通 JavaScript、同步文件 API 和 Worker Pool 分支沿箭头向右阅读;Socket 分支先沿实线向右看注册与监视,再沿虚线向左看 I/O 通知与回调分派;最后一行的 Worker Threads 与 Child Process 是并列选择。
3.3 分支一:普通 JavaScript 计算
先看一段不涉及宿主资源的代码:
const prices = [12, 20, 35];
const total = prices.reduce((sum, price) => sum + price, 0);
数组、回调函数、数字加法和变量绑定都属于 ECMAScript 语言语义。V8 负责执行这些 JavaScript,并管理过程中产生的 JavaScript 对象。
这个计算不需要读取文件或等待网络,因此没有理由为了形式完整而强行让它经过 Worker Pool。只要它一直计算,当前 JavaScript 线程就会一直被占用。
3.4 分支二:同步 Node.js 文件 API
调用 fs.readFileSync() 时,程序首先调用的是 Node.js 提供的同步文件 API,而不是直接调用操作系统的文件系统接口。Node.js 会沿同步文件路径取得结果,底层可能涉及一次或多次文件系统调用。实现细节会随平台和 Node.js 版本变化,但从运行模型看,可以简化为:
JavaScript 调用
↓
Node.js 文件 API 与原生文件层
↓
一个或多个操作系统文件系统调用
↓
结果返回后,JavaScript 才能继续
libuv 可以作为跨平台文件系统封装参与这条路径,但同步调用不会因为出现了 libuv 就自动变成后台工作。发起调用的 JavaScript 线程必须等到结果返回。
这就是“同步 API 会阻塞 Node.js”的准确含义:它会阻塞当前 JavaScript 线程。在默认单个主 JavaScript 线程处理请求的服务中,这也意味着同一线程上的其他 JavaScript 回调暂时无法执行。
3.5 分支三:非阻塞 Socket I/O
HTTP 服务建立在网络 Socket 之上。对于常见的 TCP Socket,Node.js 和 libuv 通常把 Socket 设置为非阻塞,并让操作系统的 I/O 机制监视它:Unix 类系统可能使用 epoll 或 kqueue,Windows 则使用 IOCP 等机制。
当暂时没有数据可读时,JavaScript 线程不需要停在那里循环询问。操作系统继续管理 Socket、网络协议栈和接收缓冲区;当 Socket 就绪或操作完成时,libuv 得到通知,Node.js 再调度相应回调,回调中的 JavaScript 仍由 V8 执行。
简化后的路径是:
JavaScript 注册网络操作
↓
Node.js / libuv 注册非阻塞 Socket
↓
操作系统等待网络事件
↓
libuv 收到就绪或完成通知
↓
Node.js 调度相应回调
↓
V8 执行回调中的 JavaScript
这里最重要的结论是:网络 Socket I/O 通常不是通过 libuv Worker Pool 等待的。 操作系统本身已经提供了适合非阻塞网络 I/O 的机制,事件循环负责协调通知与回调。
3.6 分支四:libuv Worker Pool
跨平台文件 I/O 的情况不同。为了提供统一的异步文件 API,Node.js 24 中回调式和 Promise 形式的文件系统 API,除明确的例外外,会使用 libuv Worker Pool。以 fs.readFile() 为例,可以把主要过程理解为:
JavaScript 调用 fs.readFile()
↓
Node.js 将文件工作提交给 libuv Worker Pool
↓
Worker 执行阻塞式文件系统工作
↓
工作完成后通知事件循环
↓
Node.js 调度回调,或根据结果兑现 / 拒绝 Promise
↓
V8 执行后续 JavaScript
真实的 fs.readFile() 可能由多次打开、读取和关闭等工作组成,不必把上述简化路径误解成内部只有一个系统调用。它想表达的是:等待文件系统工作的线程与执行最终 JavaScript 回调的线程不是同一个角色。这里的“调度”也不表示 Node.js 取代 V8 执行代码:Node.js 组织完成通知与后续任务,回调或 Promise 后续逻辑中的 JavaScript 仍由 V8 执行。
Node.js 还会把以下部分工作交给 Worker Pool:
dns.lookup()、dns.lookupService()使用的部分操作系统名称解析工作;crypto.pbkdf2()、crypto.scrypt()、部分随机数和密钥生成操作;- 部分 zlib 压缩工作;
- 原生扩展通过 libuv 主动提交的任务。
“部分 DNS”这个限定非常重要。dns.lookup() 使用操作系统名称解析设施,通常进入 libuv Worker Pool;dns.resolve*() 则直接使用 DNS 协议发起网络查询,并不等同于 dns.lookup() 的线程池路径。
Worker Pool 完成工作后,会把完成信息交回发起请求的 Node.js 环境所对应的事件循环。之后需要运行的普通 JavaScript 回调,通常仍由对应的 JavaScript 线程执行,而不是继续占用那个 libuv Worker 线程。
3.7 分支五:Worker Threads 与子进程
如果应用确实需要让 CPU 密集的 JavaScript 与主线程并行,可以显式使用 Worker Threads。每个 Worker 拥有自己的 JavaScript 执行线程和独立的 V8 JavaScript 执行环境(isolate),可以与其他线程并行执行 JavaScript;不同 Worker 还可以传递数据或在受控条件下共享内存。
子进程则由操作系统创建独立进程。它拥有独立的地址空间,可以运行另一个 Node.js 程序,也可以运行其他可执行程序。父子进程可以通过标准输入输出、管道或 IPC 通信。
两者解决的问题并不相同:
| 机制 | 执行边界 | 更适合的方向 | 主要代价 |
|---|---|---|---|
| libuv Worker Pool | Node.js 内部共享的原生工作线程 | 回调或 Promise 形式的多数 fs 操作、dns.lookup() / dns.lookupService()、部分异步 crypto 与 zlib 工作 |
容量有限且被多类任务共享 |
| Worker Threads | 同一进程中的其他 JavaScript 线程与 V8 isolate | CPU 密集 JavaScript、可复用计算池 | 创建、通信和数据传递成本 |
| 子进程 | 独立操作系统进程 | 强隔离、运行其他程序、独立故障边界 | 更高的内存与进程通信成本 |
使用异步 API 不会自动创建 Worker Thread 或子进程。它们必须由应用明确选择、创建和管理。
4. 一段最小 Node.js 程序如何启动、执行和退出
现在来看一段不涉及模块导入的最小程序:
console.log("start", process.pid);
setTimeout(() => {
console.log("finished");
}, 100);
console.log("scheduled");
把它保存为 runtime-demo.js 并交给 Node.js 执行,输出顺序是:
start 12345
scheduled
finished
进程编号会因每次运行而不同。这个小程序背后发生了几个层次的工作。
4.1 操作系统创建进程
当我们运行 node runtime-demo.js 时,Shell 请求操作系统启动 Node.js 可执行文件。操作系统为它创建进程,分配进程编号、虚拟地址空间和其他基础资源,再把控制权交给 Node.js 的启动代码。
所以,Node.js 不是漂浮在操作系统之上的抽象概念。一个正在运行的 Node.js 应用,首先是操作系统中的一个进程。
4.2 Node.js 初始化运行时
Node.js 初始化 V8、事件循环以及自己的运行时状态,并根据命令行参数确定入口文件。真实启动过程还涉及平台初始化、内置模块和其他组件。
重要的是职责关系:操作系统创建进程,Node.js 在进程中建立 JavaScript 运行环境,V8 则负责执行随后进入该环境的 JavaScript。
4.3 入口代码被加载并执行
Node.js 读取入口文件,并按照适用的模块规则把它作为代码加载。CommonJS、ESM、包配置和扩展名怎样影响这一步,将留到专门篇章讨论。
V8 解析并执行顶层代码。第一个 console.log() 执行后,setTimeout() 向 Node.js 注册一个定时器,然后最后一个 console.log() 执行。此时顶层 JavaScript 已经结束,因此 scheduled 会先于 finished 输出。
这里也能再次看见语言与宿主的边界:函数调用、字符串和箭头函数属于 ECMAScript;process、console 和 setTimeout 则由 Node.js 运行时提供或集成。
4.4 顶层代码结束,不等于进程立刻退出
这里由 setTimeout() 注册的定时器默认会维持事件循环,因此 Node.js 继续运行。大约 100 毫秒后,定时器到期,相应回调获得执行机会并输出 finished。
当事件循环不再有需要维持进程的活动资源或请求,也没有其他需要执行的工作时,Node.js 便可以自然退出,操作系统随后回收进程资源。
默认情况下,一个 HTTP 服务不会在入口文件执行完后立刻退出,是因为监听中的服务器仍会维持进程运行。关闭服务器、数据库连接、定时器等资源,正是长期运行服务实现优雅退出时需要处理的问题,不过那会在后续进程与服务生命周期文章中展开。
5. Node.js 为什么适合 I/O 密集任务
5.1 “等待 50 毫秒”和“计算 50 毫秒”完全不同
假设一个接口需要查询数据库,数据库在 50 毫秒后返回结果。这 50 毫秒中的大部分时间,与这次查询相关的后续业务 JavaScript 暂时没有可执行内容:请求已经通过 Socket 发出,数据库进程正在解析和执行 SQL,网络也可能正在传输数据。
如果 Node.js 使用非阻塞 I/O,它就可以在等待期间处理其他连接。当数据库 Socket 有结果可读时,操作系统通知 libuv,Node.js 随后调度这个请求对应的后续逻辑,其中的 JavaScript 仍由 V8 执行。
但是,如果接口在 JavaScript 中连续计算 50 毫秒,情况就不同了。V8 正在当前 JavaScript 线程执行这段计算,同一线程上的其他请求回调、定时器回调和 I/O 回调都只能等待。
Node.js 适合 I/O 密集服务,并不是因为 I/O 不需要时间,而是因为等待外部资源时不必一直占住主 JavaScript 线程。
5.2 异步、并发和并行不是同一件事
这三个概念经常同时出现,但它们回答的是不同问题:
| 概念 | 回答的问题 | Node.js 中的例子 |
|---|---|---|
| 异步(Asynchronous) | 调用方是否必须停在原地等待结果 | 发起文件读取后,通过回调或 Promise 接收结果 |
| 并发(Concurrency) | 多个任务能否在一段时间内共同推进 | 一个进程同时维护许多正在等待网络的请求 |
| 并行(Parallelism) | 多个任务能否在同一时刻真正执行 | Worker Threads 在多个 CPU 核心上同时计算 |
异步接口可以提高并发能力,却不必然意味着业务 JavaScript 正在并行执行。非阻塞 Socket 可以主要依靠操作系统事件通知;文件读取可以使用 Worker Pool;Worker Threads 则可以让 JavaScript 真正并行。这些都是异步或并发系统的一部分,但底层机制不同。
5.3 “单线程”到底指什么
人们常说“Node.js 是单线程的”,这句话只有加上范围才准确。
在一个普通 Node.js 主执行环境中,初始化代码和大多数 JavaScript 回调通常由一个主 JavaScript 线程依次执行。因此,一段长时间不返回的 JavaScript 会挡住同一线程上的其他 JavaScript 工作。
但整个 Node.js 进程并非只有一个线程:V8 和运行时可能拥有内部线程,libuv 有 Worker Pool,应用也可以创建 Worker Threads。Node.js 还可以启动子进程,而子进程甚至已经超出了“同一进程有多少线程”的问题。
所以,更准确的表达是:
Node.js 默认让一个 JavaScript 执行线程通过事件循环协调大量并发工作,同时可以借助操作系统异步 I/O、libuv Worker Pool、Worker Threads 和子进程完成其他工作。
这套模型的优势依赖一个重要前提:每次回到主 JavaScript 线程后,单次业务工作量应当可控。如果某个请求长期占用线程,其他请求就无法获得公平的执行机会。
6. Node.js 不会自动解决哪些问题
6.1 async/await 不会把 CPU 计算搬到后台
给函数加上 async,主要会改变函数的调用契约与异步控制流:调用结果成为 Promise,函数体可以使用 await,执行过程中抛出的异常则会使返回的 Promise 进入 rejected(已拒绝)状态。它不会创建新线程,也不会改变同步 JavaScript 的执行成本。
async function blockForOneSecond() {
const endAt = Date.now() + 1_000;
// 仅用于演示阻塞,不要在真实服务中忙等待。
while (Date.now() < endAt) {
// 持续占用当前 JavaScript 线程
}
return "finished";
}
console.log("before");
blockForOneSecond();
console.log("after");
blockForOneSecond() 在返回 Promise 之前,仍会同步执行循环。只有大约一秒后循环结束,after 才能输出。async 没有让这段计算自动并行。
即使在循环前加入一次 await,也只是把后续计算安排到之后继续;当这段计算真正开始执行时,它仍会占用执行它的 JavaScript 线程。如果只想避免一次长计算持续占住主线程,可以把工作分片,并在片段之间主动让出执行权;这仍然发生在同一个 JavaScript 线程上。若要利用其他 CPU 核心,则需要把计算交给 Worker Threads、子进程或外部计算服务。
6.2 Promise 不决定底层工作在哪里执行
Promise 表示一个结果当前可能尚未可用,并定义如何订阅这个结果,却不规定底层工作一定发生在哪里。Promise 形式的文件读取可能使用 libuv Worker Pool,数据库查询可能主要等待 Socket,一个只包装同步计算的 Promise 仍在当前 JavaScript 线程计算。Worker Thread 也可以用 Promise 封装消息结果,但并行能力来自 Worker,而不是 Promise 本身。
因此,看到 await 或 Promise,只能说明控制流采用了异步接口。要判断是否使用线程池或实现并行,仍要继续追踪被等待的具体操作。
6.3 Worker Pool 也可能被阻塞
Worker Pool 是有限且共享的。如果应用同时提交大量依赖该池的异步 fs、dns.lookup()、pbkdf2()、scrypt() 或 zlib 任务,后来的任务可能需要排队。一个特别缓慢的任务也会长期占用其中一个 Worker。
因此,“已经放进 Worker Pool”不等于“没有性能问题”。事件循环和 Worker Pool 都需要控制单次工作量,只是它们被阻塞后影响的方式不同:事件循环被阻塞会推迟 JavaScript 回调;Worker Pool 饱和则会增加依赖该池的异步任务等待时间。
6.4 同步 API 需要结合执行位置判断
“同步文件 API 会阻塞”不等于“项目中永远不能使用同步 API”。关键要问:它在什么时候运行,会阻塞谁?
- 构建脚本顺序读取少量配置,逻辑简单且没有并发请求需要服务,同步 API 可能完全合理。
- 服务启动时同步读取一次小文件,会延长启动时间,但未必影响稳定运行后的请求延迟。
- 在高频 HTTP 请求路径中递归遍历目录或读取大文件,会直接阻塞处理这些请求的 JavaScript 线程,风险明显更高。
判断 Node.js 性能问题时,不能只看 API 名字,还要看调用频率、数据规模、所在生命周期和延迟目标。
7. 当前博客中的 Node.js 能力
当前博客使用 Next.js Pages Router。它的构建流程与部署后的 standalone 服务都运行在 Node.js 提供的能力之上。我们不在这里展开 Next.js 的渲染模型,只选择几处代码验证前面的职责地图。
7.1 构建期文件读取
src/server/getPost.ts 使用下面的 Node.js 能力定位并读取文章:
const postsDirectory = join(process.cwd(), "src/posts");
const fileContents = fs.readFileSync(fullPath, "utf8");
其中:
process.cwd()读取当前 Node.js 进程的工作目录;fs.readFileSync()通过同步文件路径读取 Markdown;path.join()负责按平台规则组合路径;gray-matter在 JavaScript 中解析 front matter。
生产构建时,首页和文章静态页面会调用这些逻辑。此时使用同步读取让控制流更简单,而且没有线上用户请求正在等待同一个构建进程。不过,sitemap.xml 的服务端请求也会调用 getAllPosts();在那里,同步目录遍历发生在请求路径中,仍应按照实际文章规模和延迟要求评估。开发模式下,框架还可能按请求重新执行页面数据获取逻辑,因此也不能把“静态页面”简单理解成任何环境下都只读取一次文件。
这个例子说明:同步 API 的阻塞属性没有因为框架而消失,只是构建期和请求期对阻塞的容忍度不同。
7.2 Markdown 转换中的 async 不等于并行
src/server/markdownToHtml.ts 暴露了一个异步函数:
export async function markdownToHtml(markdown: VFileCompatible) {
const htmlString = (await remark()
// plugins...
.process(markdown)
).toString();
return htmlString;
}
调用者可以 await markdownToHtml(),但这并不能证明 Markdown 解析、高亮和 HTML 序列化已经转移到其他线程。当前处理链中的 JavaScript 计算仍可能在调用它的 JavaScript 线程执行。
异步函数描述的是调用方式,不是 CPU 调度证明。要判断一段工作会不会阻塞,仍要追踪具体库把工作放在哪里执行。
7.3 留言 API 与 PostgreSQL
src/server/db/index.ts 使用 pg.Pool 连接 PostgreSQL:
const client = await getPool().connect();
const result = await client.query(text, params);
这里至少存在两个独立运行主体:
- Node.js 进程负责接收 HTTP 请求、校验输入、通过数据库连接发送 SQL,并在结果到达后生成响应。
- PostgreSQL 进程负责解析 SQL、生成执行计划、访问数据页并执行查询。
Node.js 等待 PostgreSQL 返回结果时,数据库 Socket 主要由操作系统网络机制管理。await 让当前函数暂停并在结果到达后继续,但不是 await 替 Node.js 执行了 SQL,也不是 JavaScript 线程进入 Worker Pool 等待数据库。
7.4 启动脚本与长期存活的进程
scripts/start-standalone.sh 最终执行:
PORT="$PORT" node server.js
Shell 请求操作系统创建 Node.js 进程,Node.js 加载 Next.js 生成的 server.js。当服务开始监听端口后,活动的服务器 Socket 会让进程继续存活;只有在服务器被关闭、相关资源得到释放,并且没有其他工作需要处理时,进程才会自然退出。
从构建期文件读取、Markdown 转换、HTTP API、数据库 Socket 到进程启动,当前博客已经使用了 Node.js 的多种宿主能力。框架隐藏了一部分细节,却没有改变 V8、Node.js、libuv 和操作系统之间的基本职责边界。
8. 总结:JavaScript 没变,运行它的世界变了
现在可以重新回答开头的问题:同样是 JavaScript,为什么进入 Node.js 后就能访问文件、网络、进程和操作系统资源?
因为 JavaScript 语言只负责表达计算,具体运行时还需要宿主环境。Node.js 把 V8 嵌入自己的进程,提供 fs、http、process、stream 等宿主 API,并根据操作类型与原生组件、libuv 和操作系统协作。文件、Socket、进程和线程最终仍由操作系统管理,Node.js 则把这些能力组织成 JavaScript 可以使用的接口和运行模型。
最后,我们把本文最容易混淆的说法集中纠正一次:
| 常见说法 | 更准确的理解 |
|---|---|
| JavaScript 自己可以读取文件 | ECMAScript 不定义文件 API;Node.js 宿主向 JavaScript 提供了 fs |
| Node.js 就是 V8 | V8 是 JavaScript 引擎,Node.js 还包含运行时核心、宿主 API、libuv 和其他组件 |
| Node.js 是一个 Web 框架 | Node.js 是运行时;Express、NestJS 等框架建立在它之上 |
| Node.js 整个进程只有一个线程 | 默认主 JavaScript 执行通常集中在一个线程,但运行时、Worker Pool 和 Worker Threads 可以包含其他线程 |
| 所有异步 I/O 都进入 Worker Pool | 非阻塞 Socket 通常依靠操作系统 I/O 通知;回调或 Promise 形式的多数 fs、dns.lookup() 和部分异步 crypto 等工作才会使用 Worker Pool |
| 异步就等于并行 | 异步、并发和并行是三个不同维度 |
使用 async/await 就不会阻塞 |
同步 CPU 计算仍会占用执行它的 JavaScript 线程 |
| Node.js 天生适合所有高并发任务 | 它适合单次 JavaScript 工作量可控的 I/O 密集服务;CPU 密集工作仍需专门处理 |
到这里,我们已经知道 Node.js 怎样获得并组织宿主能力。但是,一个真实程序不可能永远只有一个入口文件:当程序由多个文件和依赖组成后,Node.js 怎样找到这些模块、判断它们属于 CommonJS 还是 ESM、执行它们并复用已经加载的结果?
以上问题的答案就是 Node.js 模块机制。
参考资料
- ECMAScript Language Specification:Hosts and Implementations
- Node.js:About Node.js
- Node.js 24 API Documentation
- Node.js:Don't Block the Event Loop (or the Worker Pool)
- Node.js 24:File system
- Node.js 24:DNS
- Node.js 24:Process
- Node.js 24:Timers
- Node.js 24:Net
- Node.js 24:Worker threads
- Node.js v24.x C++ codebase:Isolate
- libuv Design overview
- libuv Thread pool work scheduling
- V8:Getting started with embedding V8
- Next.js 15 Pages Router:getStaticProps
- Next.js 15 Pages Router:getServerSideProps