Node.js 基础复习 - 速通版 01
- 2026-10-11 05:19:32
Node.js 是基于 V8 引擎 + libuv 库 的 JavaScript 运行时(不是一门语言、也不是框架),让 JS 能脱离浏览器在服务端 / 本地执行。
Node.js 的 JS 运行时: V8 引擎(解析、编译、执行 JS 代码) + libuv(事件循环、IO、跨平台) + Node 内置 API(fs、http、path 等)
浏览器里的 JS 运行时: V8 引擎 + 浏览器宿主 API(DOM、BOM、fetch、定时器)
运行时 = 引擎 + 宿主提供的所有能力
fs / path / http / net / crypto / stream |
浏览器里的 JS 没有 DOM/BOM 就跑不起来; Node 里的 JS 反过来——没有 DOM/BOM,但有文件、网络、操作系统能力。 同语言,不同宿主环境。

① 单线程(指 JS 主线程)
用户的 JS 代码在一个主线程里跑。不是整个 Node 进程只有一个线程—— libuv 底层有线程池处理文件 I/O、DNS 解析、压缩等耗时操作,完成后通过事件循环回调主线程。
② 事件驱动(EventEmitter)
核心模型是发布订阅:EventEmitter 是 http.Server、Stream、net.Socket 等几乎所有 I/O 模块的基类。
import { EventEmitter } from 'node:events';const bus = new EventEmitter();// on: 持续监听;// once: 只监听一次;// off: 取消监听const onOrder = order => console.log('收到订单', order.id);bus.on('order', onOrder);bus.once('ready', () => console.log('系统就绪(只打一次)'));bus.emit('order', { id: 42 }); // 触发 onOrderbus.emit('ready'); // 打印一次bus.emit('ready'); // 不会再打印bus.off('order', onOrder); // 取消监听bus.emit('order', { id: 99 }); // 无输出
const emitter = new EventEmitter();emitter.on('error', (err) => console.error('捕获到错误:', err.message));emitter.emit('error', new Error('数据库连不上')); // 不会崩
③ 非阻塞 I/O (Non-blocking Input/Output)
发起 fs.readFile、http.request 时不等待结果,主线程继续往下走;I/O 完成后回调被推入事件队列。这就是 Node 能在单线程下扛住高并发的根本原因。
注意,"单线程"不等于"不能用多核"——主线程只有一个,但可以通过 cluster 开多进程、 worker_threads 开多线程
const fs = require('fs');console.log('1. 开始读取文件');// 异步读取(JS层非阻塞)fs.readFile('./test.txt', 'utf-8', (err, data) => {if (err) throw err;console.log('3. 文件读取完成:', data);});console.log('2. 我不用等文件读完,直接执行这一行');
事件循环 是一个 不断从各队列取回调(函数)执行 的循环。
按固定顺序经过 6 个阶段:
1. timers (看哪些 setTimeout / setInterval 到点了)
2. pending callbacks (上一轮遗留的 I/O 错误回调)
// 上一轮循环被延迟到这一轮的系统级 I/O 错误回调// 它处理的是" 推迟过来的错误 "// 连一个没人监听的 TCP 端口(比如 `127.0.0.1:9999`),// 操作系统返回 `ECONNREFUSED` 错误。// 某些系统会把这个错误报告推迟到下一轮循环的 pending callbacks 阶段。import net from 'node:net';const client = net.createConnection({ port: 9999, host: '127.0.0.1' });client.on('error', (err) => console.log(err.code)); // ECONNREFUSED
3. idle / prepare (Node 内部用,不用管)
4. poll (I/O 回调)
5. check (处理 setImmediate 的回调)
6. close callbacks ( socket.on('close') 这类关闭事件 )
代码示例:
// loop.cjsconst fs = require("node:fs");console.log("1. 同步代码开始");// ========== timers 阶段 ==========setTimeout(() => {console.log("2. setTimeout(timers 阶段)");}, 0);// ========== 微任务队列 ==========process.nextTick(() => {console.log("3. nextTick(微任务,优先级最高)");});Promise.resolve().then(() => {console.log("4. Promise.then(微任务)");});// ========== poll 阶段(I/O 回调)==========fs.readFile("./package.json", () => {console.log("5. fs.readFile 回调(poll 阶段)");// 在 I/O 回调里再注册任务,观察后续阶段process.nextTick(() => {console.log("6. I/O 里的 nextTick");});Promise.resolve().then(() => {console.log("7. I/O 里的 Promise");});setTimeout(() => {console.log("8. I/O 里的 setTimeout(下一轮 timers)");}, 0);setImmediate(() => {console.log("9. I/O 里的 setImmediate(本轮 check)");});});// ========== check 阶段 ==========setImmediate(() => {console.log("10. 顶层 setImmediate(check 阶段)");});console.log("11. 同步代码结束");


CJS 是同步加载(打电话)—— `require()` 返回时模块体已经跑完,然后才进事件循环,所以 nextTick 按规矩先于 Promise。
ESM 是异步加载(点外卖) —— 整个模块体 本身就在一个 Promise 回调里执行。模块体跑完后,Node 先把 Promise 队列清干净(注册的 `Promise.then` 搭便车先跑了),nextTick 要等正式进事件循环才轮到。
注意:
在每两个阶段之间,Node 会先清空所有微任务队列(nextTick + Promise),才进入下一阶段。
对比:
浏览器的事件循环就比较简单了:
一个宏任务(script 整体、setTimeout、I/O 回调、UI 事件……)→
清空所有微任务(Promise.then 等) → 渲染 → 下一个宏任务
| 宏任务组织方式 | ||
| 微任务什么时候清 | 每个阶段之间 | 每个宏任务之后 |
| process.nextTick | ||
| setImmediate | ||
| I/O 回调位置 | poll 阶段(队列) | |
| 渲染步骤 | ||
| 常见宏任务源 |
console.log('start');setTimeout(() => console.log('timeout'), 0);Promise.resolve().then(() => console.log('promise'));process.nextTick(() => console.log('nextTick'));console.log('end');// 输出顺序:// start → end → nextTick → promise → timeout
process.nextTick | |
Promise.then / queueMicrotask | |
process.nextTick 递归调用会 卡住事件循环(独占队列),后面的 I/O 和 timer 永远没机会执行。能用 Promise.then 或 queueMicrotask 就别用 nextTick。
// ❌ 反例:nextTick 递归会把事件循环卡死let i = 0;function loop() {process.nextTick(loop); // 每次都往 nextTick 队列塞任务if (++i < 16) console.log(i);}loop();// 下面这行永远不会执行!setTimeout(() => console.log('我永远轮不到'), 0);fs.readFile('a.txt', () => {}); // I/O 回调也永远进不来
`process.nextTick` 不属于事件循环 6 大阶段里的任何队列,它是独立的nextTick 队列;
每一个阶段执行完,准备进入下一阶段前,优先一次性清空 nextTick 队列,再清空 Promise 微任务队列,之后才进入事件循环下一个阶段。
nextTick 它被设计成 "让代码在当前操作完成后、控制权回到事件循环之前的异步执行",
通常用来把 同步回调 延后成 异步,避免 意外 同步执行:
【经典场景】构造函数里触发事件 :
const EventEmitter = require('events');const emitter = new EventEmitter();class MyEmitter extends EventEmitter {constructor() {super();// 如果直接同步emit,外部还没来得及注册监听this.emit('start');}}const e = new MyEmitter();e.on('start', () => {console.log('收到start事件');})
❌ 问题:`new MyEmitter()` 实例化的时候,`emit('start')` 同步立刻执行,但`e.on`还没注册,事件收不到。
✅ 用 nextTick 延后 emit:
class MyEmitter extends EventEmitter {constructor() {super();process.nextTick(() => {this.emit('start');})}}const e = new MyEmitter();e.on('start', () => {console.log('收到start事件');})
setTimeout(cb, 0) | |||
setImmediate(cb) | |||
process.nextTick(cb) |
在 I/O 回调内部:setImmediate 永远先于 setTimeout(0) 执行(因为 I/O 回调在 poll 阶段,跑完立刻进 check)。 在模块顶层:两者顺序不确定(受进程启动耗时影响),不要依赖。 ESM 里顺序又不同:Node 官方文档明确指出,.mjs 文件顶层执行顺序和 CommonJS 有差异(esm模块加载本身是异步的)
// ✅ 场景 A:写在 I/O 回调里 —— setImmediate 一定先跑import fs from 'node:fs';fs.readFile('./package.json', () => {setTimeout(() => console.log('timeout'), 0);setImmediate(() => console.log('immediate'));});// 固定输出:immediate → timeout// 原因:I/O 回调在 poll 阶段,跑完直接进 check 阶段执行 setImmediate;// 下一轮循环才回到 timers 阶段执行 setTimeout。// ❌ 场景 B:写在模块顶层 —— 顺序不稳定setTimeout(() => console.log('timeout'), 0);setImmediate(() => console.log('immediate'));// 哪个先取决于进程启动本身花了多久(可能 >1ms)