主题
第 21 章 · 面试题集(Node.js 基础)
Q1:Node.js 和浏览器里的 JavaScript 有什么区别?
考察点:JS 运行时差异。
标准答案:
| 维度 | 浏览器 | Node.js |
|---|---|---|
| 引擎 | V8 / SpiderMonkey 等 | V8 |
| 全局对象 | window / self | global / globalThis |
| DOM / BOM | ✅ 有 | ❌ 没有 |
| 文件系统 | ❌ 没有(只有 File API) | ✅ fs |
| 模块系统 | 原生 ESM | CJS + ESM 共存 |
| 事件循环 | 浏览器规范(task / microtask) | libuv 6 阶段 + microtask |
| 独有 API | document、localStorage、fetch | process、Buffer、require |
通俗理解: 浏览器是 JS 的"剧场"——有舞台(DOM)和观众;Node 是 JS 的"工厂"——有车床(fs)和发货口(http)但没有观众。
加分回答:
- 两者都用 V8,但 Node 直接拿 V8 的 C++ API 封装了一组 OS 能力(libuv)。
- 浏览器有 Web Worker,Node 有 Worker Threads,机制相似但 API 不同。
- Node 18+ 内置了
fetch/Web Streams/Blob,正在向 Web 标准靠拢。
追问:
- Node 的
setTimeout和浏览器的有什么区别?(最小延迟、与 setImmediate 的协作)
Q2:Node.js 是单线程的吗?为什么能高并发?
考察点:异步 I/O 模型 + libuv。
标准答案: 主线程(执行 JS 的那条 V8 线程)是单线程的,但整个 Node 进程是多线程的:
- 网络 I/O 通过操作系统的异步接口(epoll / kqueue / IOCP)完成,不占用线程。
- 文件 I/O / DNS / 部分加密 通过 libuv 线程池(默认 4 个线程)完成。
- 完成后回调被推入事件循环对应阶段,主线程依次执行。
主线程不被阻塞 → 可以同时"挂起"成千上万个 I/O 请求 → 高并发。
通俗理解: 餐厅前台只有 1 个服务员(主线程),但后厨有一队厨师(libuv 线程池)。服务员只负责接单上菜,做菜的事都交给后厨。前台永远不会被卡住,所以能服务很多人。
代码示例:
js
// 模拟 1 万个并发"假"请求
for (let i = 0; i < 10000; i++) {
setTimeout(() => {/* nothing */}, 100);
}
// Node 毫无压力。如果是同步:for 循环里 sleep 100ms × 10000 = 1000 秒加分回答:
- libuv 线程池大小可通过环境变量
UV_THREADPOOL_SIZE调整(默认 4,最大 1024)。 - CPU 密集任务(图像处理、加密)会阻塞主线程,应该放到
worker_threads或子进程。 - Node 的高并发只在 I/O 密集场景成立,CPU 密集场景反而不如多线程语言。
追问:
- 如果某个回调里写死循环会怎样?(事件循环卡死,整个服务无响应)
Q3:Node 的事件循环有几个阶段?分别处理什么?
考察点:核心机制——面试必考。
标准答案: 6 个阶段,按顺序循环:
| 阶段 | 处理内容 |
|---|---|
| timers | 到期的 setTimeout / setInterval 回调 |
| pending callbacks | 部分系统级回调(TCP 错误等) |
| idle, prepare | Node 内部使用 |
| poll | I/O 回调(fs / 网络),无回调时阻塞等待 |
| check | setImmediate 回调 |
| close callbacks | socket.on('close', ...) 等 |
每个阶段切换之间,会清空一次微任务队列:先 process.nextTick,再 Promise .then。
通俗理解: 事件循环像一个"单向环形跑道",分 6 个站点。每到一个站点处理对应的回调;每到下一个站点之前,先把"加急任务"(微任务)清空。
代码示例:
js
console.log("1");
setTimeout(() => console.log("2"), 0);
setImmediate(() => console.log("3"));
process.nextTick(() => console.log("4"));
Promise.resolve().then(() => console.log("5"));
console.log("6");
// 输出:1, 6, 4, 5, 2/3 (后两者顺序不定)加分回答:
process.nextTick优先级比 Promise 还高(在 microtask 队列前面)。- I/O 回调里
setImmediate一定先于setTimeout(fn, 0),因为 poll 结束直接进 check。 - React 的
useEffect在浏览器对应"宏任务后微任务",没有 Node 这种 6 阶段概念。
追问:
setImmediate(fn)和setTimeout(fn, 0)谁先?(主模块不确定;I/O 回调里 setImmediate 必先)
Q4:CommonJS 和 ESM 在 Node 中有什么区别?
考察点:模块系统。
标准答案:
| 维度 | CJS | ESM |
|---|---|---|
| 文件后缀 | .cjs 或 .js(默认) | .mjs 或 package.json "type":"module" |
| 导入 | require() | import |
| 导出 | module.exports / exports.x | export / export default |
| 加载时机 | 运行时同步加载 | 编译时静态分析 |
| 顶层 await | ❌ | ✅ |
__dirname / __filename | ✅ | ❌(用 import.meta.url) |
| Tree Shaking | 难(动态求值) | 易(静态结构) |
| 互相调用 | ESM 可 import CJS;CJS 需 await import() ESM |
通俗理解: CJS 像是"运行到这一行才去取货",ESM 是"出发前就把货单列好"。
代码示例:
js
// CJS
const fs = require("fs");
// ESM
import fs from "node:fs";
import { readFile } from "node:fs/promises";加分回答:
- Node 22+ 实验性允许 CJS 同步 require ESM(要求 ESM 模块没有 top-level await)。
- 现代项目(Vite / Next.js)几乎都全 ESM,加
"type":"module"。 - ESM 的好处是支持 tree shaking → 打包后体积更小。
追问:
- ESM 怎么取
__dirname?(import.meta.url+fileURLToPath)
Q5:process.nextTick、Promise.then、setImmediate、setTimeout(fn, 0) 的执行顺序?
考察点:事件循环细节。
标准答案: 主模块代码中:
process.nextTick—— 微任务,优先级最高Promise.then—— 微任务,但在 nextTick 之后setTimeout(fn, 0)—— timers 阶段,至少 1mssetImmediate—— check 阶段 (3 和 4 顺序不固定)
I/O 回调内部:
process.nextTickPromise.thensetImmediate(一定先)setTimeout
代码示例:
js
import fs from "node:fs";
fs.readFile("./test.txt", () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
});
// 输出:nextTick → promise → immediate → timeout(一定如此)加分回答:
process.nextTick滥用会导致后续阶段饿死(永远进入不了 timer / poll)。- 浏览器没有 nextTick 和 setImmediate;Node 早期为兼容浏览器一起加进来的。
Q6:Node 的核心模块有哪些?分别用来干什么?
考察点:基础认知。
标准答案:
| 模块 | 用途 |
|---|---|
fs | 文件系统:读/写/监听 |
path | 路径处理:拼接、解析、跨平台 |
http / https | 起 HTTP 服务、发请求 |
url | URL 解析 / 拼装 |
events | EventEmitter 发布订阅基类 |
stream | 流式处理大文件 / 网络 |
process | 进程信息、环境变量 |
Buffer | 二进制数据操作 |
os | 操作系统信息(cpu、内存、平台) |
child_process | 启动子进程 |
worker_threads | Worker 线程 |
crypto | 加密、哈希 |
加分回答:
- 推荐用
node:前缀:import fs from "node:fs"显式区分内置模块和 npm 包。 fs/promises是 promise 化版本,比回调风格更现代。
Q7:写一个最小的 Node HTTP 服务器,并用 Express 重写。
考察点:原生 vs 框架的对比。
标准答案:
原生 http:
js
import http from "node:http";
const server = http.createServer((req, res) => {
if (req.method === "GET" && req.url === "/") {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Hello");
} else if (req.method === "POST" && req.url === "/echo") {
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(body);
});
} else {
res.writeHead(404);
res.end("Not Found");
}
});
server.listen(3000);Express 版:
js
import express from "express";
const app = express();
app.use(express.json());
app.get("/", (req, res) => res.send("Hello"));
app.post("/echo", (req, res) => res.json(req.body));
app.listen(3000);通俗理解: 原生 http 像"自己烧瓦砌墙盖房子",Express 像"买了精装房毛坯房"——封装了路由、body 解析、中间件。
加分回答:
- Express 中间件机制是核心:
(req, res, next) => {},调用 next 进入下一个。 - Koa(Express 团队下一代)用
async/await,错误处理更优雅。 - Fastify 性能 ≈ Express 的 2-3 倍,自带 schema 校验。
追问:
- 为什么 Express body 要用中间件解析而不是默认?(兼容历史,body 可能是 form-urlencoded、multipart、json…)
Q8:Node 的 stream 解决了什么问题?有几种类型?
考察点:流式 I/O。
标准答案: stream(流) 解决"大数据无法一次性放入内存"的问题。把数据切成一段段,边读边处理边写,内存占用恒定。
4 种 Stream:
| 类型 | 例子 |
|---|---|
| Readable | fs.createReadStream、http.IncomingMessage |
| Writable | fs.createWriteStream、http.ServerResponse |
| Duplex | TCP socket(既可读又可写) |
| Transform | gzip / 加密(边读边变换边写) |
通俗理解:
- 不用 stream:用桶接水(一次性放内存)。
- 用 stream:用水管接水(流式,恒定内存)。
代码示例:
js
import { createReadStream, createWriteStream } from "node:fs";
import { createGzip } from "node:zlib";
// 一行实现:读 → gzip 压缩 → 写
createReadStream("input.txt")
.pipe(createGzip())
.pipe(createWriteStream("input.txt.gz"));加分回答:
- HTTP 响应本身就是 Writable Stream,所以可以
fs.createReadStream("video.mp4").pipe(res)实现视频流式响应。 - Node 18+ 支持 Web Streams(
ReadableStream/WritableStream),与浏览器统一。 pipeline()比pipe()更安全:自动处理错误和关闭。
追问:
- 背压(backpressure)是什么?(消费者来不及处理,让生产者暂停推送的机制)
Q9(加分题):什么是 Buffer?为什么 Node 需要它?
考察点:二进制处理。
标准答案: Buffer 是 Node 中操作二进制数据的类,本质是固定长度的字节数组。
为什么需要:JS 原生没有处理二进制的能力,但服务器经常要:
- 处理图片 / 音视频
- 网络协议字节解析
- 加密 / 哈希
- 文件 I/O(默认拿到的就是 Buffer)
代码示例:
js
const buf = Buffer.from("你好", "utf8");
console.log(buf); // <Buffer e4 bd a0 e5 a5 bd>
console.log(buf.length); // 6(每个汉字 UTF-8 占 3 字节)
console.log(buf.toString("hex")); // "e4bda0e5a5bd"
console.log(buf.toString("base64"));// "5L2g5aW9"
const buf2 = Buffer.alloc(10); // 10 字节、全 0
const buf3 = Buffer.allocUnsafe(10); // 不清零(更快但有脏数据)加分回答:
- Buffer 在 V8 堆外分配(避免 GC 压力大)。
- ES2017 后浏览器有了
Uint8Array/ArrayBuffer,Buffer 实际是Uint8Array的子类。 - Node 18+ 推荐慢慢迁移到
Uint8Array以与 Web API 兼容。
追问:
Buffer.allocUnsafe为什么不安全?(不清零,可能含其它请求的旧数据,泄漏风险)
Q10(加分题):作为前端工程师,哪些场景必须用到 Node?
考察点:场景化理解。
标准答案:
| 场景 | 例子 |
|---|---|
| 构建工具 | Vite / Webpack / Rollup / esbuild |
| CLI 脚本 | create-next-app / 项目内的 release.js / 自动改 i18n |
| SSR 运行时 | Next.js / Nuxt / Remix |
| BFF 中间层 | 聚合多个微服务接口给前端 |
| Serverless / Edge | Vercel / Netlify / Cloudflare Functions |
| 桌面应用 | Electron / Tauri |
| Mock Server | json-server、msw |
| WebSocket 实时 | 协作编辑、聊天 |
| 测试运行器 | Jest / Vitest / Playwright |
加分回答:
- 现代前端工具链 90% 用 Node 写:
package.json里几乎所有 devDependencies 都是 Node 工具。 - "前端工程师懂 Node" 不等于 "会写后端"——更多是工程化能力。
- 学完 Node,再写一些自己的 CLI(比如自动 commit、自动同步 i18n)会让效率飞跃。
追问:
- BFF 和后端有什么区别?(BFF 不放业务逻辑,只做接口聚合 / 字段裁剪 / 鉴权透传)