第一次把 Node.js 服务写“死”,我还以为是服务器出了问题。
事情是这样的。
那是一个很普通的内部接口:接收一批数据,做些计算,再把结果返回去。测试环境里一直好好的,到了线上,只要有人提交稍大一点的数据,整个服务就像突然断网了一样——业务接口超时,健康检查超时,连原本几十毫秒就能返回的请求也一起卡住。
我第一反应是数据库慢了。
查了连接池,正常;看了网络,没有丢包;又盯着内存看了半天,也没有泄漏。重启服务以后,一切恢复,但只要再跑一次那个接口,问题就会重新出现。
最后,我们在代码里找到了一个看起来很无辜的同步计算:
const startedAt = Date.now();
while (Date.now() - startedAt < 5_000) {
// 用一段同步计算模拟耗时的数据处理
}
console.log('计算结束');它没有抛异常,也没有把进程搞崩。它只是连续占用了 JavaScript 的执行线程 5 秒钟。
问题恰恰就在这里:这 5 秒里,别的请求不是“处理得慢”,而是根本没有机会开始处理。
那一刻我才真正弄明白,很多人嘴里的“Node.js 是单线程”,并不是说它只能做一件事,也不是说它不能做后端。更准确的说法是:Node.js 很擅长同时等待很多事情,但你不能长时间霸占它执行 JavaScript 的那条主通道。
理解这句话,基本就抓住了 Node.js 的核心。
第一次听到“用 Node.js 写后端”,很多人都会有同一个疑问:JavaScript 不是写网页的吗,为什么换到终端里就能读文件、开端口、接收请求?
答案其实没那么神秘。
JavaScript 是语言,浏览器和 Node.js 是两种不同的运行环境。 浏览器把页面相关的能力交给 JavaScript,Node.js 则把文件、网络、进程和操作系统相关的能力交给 JavaScript。

几个经常被混在一起的名词,可以这样拆开:
.js 或 .mjs,并不存在一套所谓的“Node 语法”。所以执行 node app.js 时,发生的事情并不是“把 JavaScript 翻译成后端语言”。Node.js 只是启动了一个进程,把 app.js 交给 V8 执行,并在代码需要的时候提供运行环境里的能力。
分不清一个东西属于哪一层时,可以问三个问题:它在规定代码怎么写,还是负责把代码跑起来,还是帮助我们组织业务?JavaScript、Node.js 和 Web 框架,分别回答了这三个问题。
我见过不少人第一次在 Node.js 里运行前端代码时,直接收到这样一句报错:
ReferenceError: document is not defined看到这里,很容易误以为 Node.js “不支持某种 JavaScript 语法”。其实 document 从来都不是 JavaScript 语言本身的一部分,它是浏览器提供的页面对象。
JavaScript 能在不同地方运行,是因为真正承载它的程序可以决定向它开放哪些能力。这个承载语言、提供外部能力的环境,通常被称为宿主环境。
浏览器关心页面、点击和浏览器安全边界,所以会提供 document、location 等 Web API。Node.js 关心进程和系统资源,所以会提供 process,以及 node:fs、node:path、node:http 等内置模块。

下面这段代码可以直接交给 Node.js:
import process from 'node:process';
console.log({
nodeVersion: process.version,
platform: process.platform,
processId: process.pid,
hasDocument: typeof document !== 'undefined',
});把它保存为 environment.mjs,执行 node environment.mjs,你会看到当前 Node.js 版本、操作系统平台和进程编号,而 hasDocument 是 false。
不是 JavaScript 少了某条语法,而是当前宿主没有提供网页文档对象。
现代 Node.js 也实现了 fetch、URL、Web Streams 等一批与 Web 平台兼容的 API,所以边界不能简单背成“浏览器能联网,Node.js 能读文件”。真正可靠的判断方式是:这个能力属于 JavaScript 语言本身,还是由宿主提供?当前运行环境里到底有没有它?
同一门语言写前后端当然很方便。纯函数、数据校验规则和 TypeScript 类型,都有机会复用。但“语言相同”绝不等于“代码能原封不动地两边运行”。依赖 node:fs 的服务端代码不该被打进浏览器,依赖 document 的页面代码也不能直接丢给 Node.js。
“这段代码是 JavaScript,所以哪里都能跑”是一个很危险的判断。语法能被引擎理解,只说明第一关通过;宿主 API、模块格式和运行权限也必须同时满足。
说到这里,会出现第二个问题:V8 的工作明明只是执行 JavaScript,它凭什么能让一段代码读取磁盘、监听端口,甚至创建子进程?
答案是 Node.js 在中间搭了一座桥。

这座桥可以拆成三层看。
表达式怎样求值、函数怎样调用、对象怎样分配和回收,主要是引擎的工作。Chrome 和 Node.js 都在使用 V8,但这并不意味着它们拥有相同的能力。发动机相同,不代表装出来的是同一辆车。
node:fs 负责文件,node:http 负责 HTTP 客户端和服务器,node:process 负责当前进程。使用 node: 前缀还有一个好处:一眼就能看出这是内置模块,而不是项目里碰巧同名的第三方包。
不同操作系统处理网络、文件和进程的方式并不一样。libuv 给 Node.js 提供了统一的事件循环与跨平台抽象,再由操作系统或工作线程完成适合它们的工作。
这里有个特别容易被说错的地方:并不是所有异步任务都会被扔进同一个线程池。 网络 I/O 通常依赖操作系统的事件通知;文件系统、部分 DNS 和加密任务可能使用 libuv 工作线程池;还有一些能力会由其他原生组件完成。
所以,“Node.js 只有一个线程”这句话只能帮助入门,不能拿来解释真实系统。更准确的版本是:默认情况下,我们写下的 JavaScript 回调主要在一个事件循环线程上执行;与此同时,Node.js 进程内部仍然可能有工作线程、引擎辅助线程和操作系统参与。
服务器的大部分时间其实都在等:等数据库返回结果,等磁盘读完文件,等另一个服务响应,等客户端继续发送数据。
如果每来一个请求,就安排一条线程站在原地等结果,线程数量、内存占用和调度成本都会跟着并发一起上涨。Node.js 选择了另一种方式:发起 I/O,登记好“完成以后要做什么”,然后先去处理别的事情。

用 node:fs 的 Promise API,就能看到这个过程:
import { readFile } from 'node:fs/promises';
const fileTask = readFile(new URL(import.meta.url), 'utf8');
console.log('读取任务已经发出,先执行这行代码');
const source = await fileTask;
console.log(`读取完成,共 ${source.length} 个字符`);readFile() 返回 Promise 后,JavaScript 不需要站在原地做无意义的等待。await 暂停的是当前这段后续逻辑,而不是让 CPU 一直空转等文件。
但千万别顺手得出另一个错误结论:异步 I/O 很高效,所以 Node.js 会自动把所有工作并行化。
不会。
文章开头的同步循环就是最直接的反例。只要一段同步计算一直占着 JavaScript 执行线程,其他回调就只能排队。事件循环不是魔法,它只能调度“有机会被调度”的任务。
这也是 Node.js 最重要的工程习惯:每次回调里的同步工作要尽量短。 大量计算可以拆分,或者放进 Worker Threads、子进程和独立计算服务,但这些都需要我们主动设计,运行时不会自动替我们完成。
我判断一个人有没有分清 Node.js 和 Web 框架,通常只看一个问题:不用 Express,你还能不能启动一个 HTTP 服务?
答案当然是能。
import { createServer } from 'node:http';
const server = createServer((request, response) => {
if (request.method === 'GET' && request.url === '/health') {
response.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
});
response.end(JSON.stringify
把代码保存为 server.mjs,执行 node server.mjs,再打开终端输出的地址,就能得到一段 JSON。
整个过程没有路由框架。createServer() 来自 Node.js 内置的 node:http。Express、Fastify 和 NestJS 做的,是在这些底层能力上继续提供路由、中间件、依赖组织等开发约定,而不是赋予 Node.js “做后端的资格”。
Node.js 也不只用来做网站后端。命令行工具、自动化脚本、构建工具、开发服务器和桌面应用的支撑进程,都可能使用它。真正应该问的不是“这个项目有没有网页”,而是“它需要怎样的运行环境,主要时间花在等待 I/O,还是持续计算”。

API 服务、BFF、网关、实时连接和流式处理,通常都能发挥 Node.js 的优势。如果每个请求都要长时间进行数值计算、图像编码或巨量压缩,就必须认真规划工作线程、进程或专用计算服务。
这不是一句简单的“能做”或“不能做”。Node.js 可以利用多个 CPU 核心,也能调用原生扩展;其他语言同样有成熟的异步框架。真正影响选型的,往往是团队经验、生态成熟度、延迟目标,以及系统里最重的任务究竟会在哪里执行。
如果前面的概念还是有点抽象,先不要继续背“运行时、宿主、事件循环”这些词。花一分钟亲手运行一个文件,很多关系会立刻变得具体。

新建一个空目录,在里面创建 hello.js,写入 console.log('你好,Node.js');。这仍然是一行普通 JavaScript,文件里不需要声明“这是后端代码”。
打开终端并进入该目录,先执行 node --version。看到版本号说明 node 可执行程序已经可用;再执行 node hello.js,就是明确要求 Node.js 运行这个文件。
看到“你好,Node.js”以后,再执行 node -e "console.log(process.platform)"。-e 会直接运行一小段代码,process.platform 则证明 JavaScript 已经拿到了 Node.js 宿主提供的进程信息。
现在再回头看开头的问题,真相就很清楚了。
Node.js 没有崩,数据库也没有停。真正发生的是:一段同步计算占住了执行 JavaScript 的线程,连健康检查回调都只能排队,所以外部看起来像“整个服务全灭”。
从这个问题里,可以留下四个比定义更有用的判断:
很多 Node.js 事故,表面上看是“服务突然变慢”,根源却是我们没有分清等待和计算。
会写 JavaScript,只代表你能把代码交给 Node.js;理解它怎样等待、怎样调度、又会被什么堵住,才代表你真正开始会用 Node.js。
不过,“不要堵住主线程”还只是一条经验。要把它变成可以排查问题的工程判断,我们还得继续拆开 Node.js 进程:谁在执行 JavaScript,谁替它等待 I/O,任务在哪些队列里停留,结果又怎样回到代码中。运行时内部的这套分工,才是后面所有异步、HTTP 和扩展问题共同的地基。