主题
第 21 章 · Node.js 基础(前端工程师视角)
一句话开篇:本章不教你做后端,只教你"作为前端工程师,必须懂的那部分 Node.js"——它是如何运行 JS 的、它的事件循环长什么样、它和浏览器有什么区别、以及你日常用到的 npm / 构建工具 / SSR / CLI 背后到底是怎么跑起来的。
0. 生活类比(先建立直觉)
把 JavaScript 想成一种"语言",那么:
- 浏览器 是这门语言的"剧场":观众(用户)来这里看戏,剧场提供舞台(DOM)、灯光(CSS)、音响(Web API:
window、fetch、localStorage)。 - Node.js 是这门语言的"工厂":没有舞台和观众,但有车床(fs)、传送带(stream)、对外发货口(http)、配电房(process)。
JS 演员(你的代码)穿同一身衣服,但在剧场和工厂能干的事完全不同:
- 在剧场能跟观众打招呼(操作 DOM),但不能去仓库提货(不能读文件)
- 在工厂能开车床、装配产品(读文件、起服务器),但没有舞台没有观众(没有 DOM、没有 window)
一句话:Node.js 让 JavaScript 离开浏览器、获得操作系统能力的运行时(Runtime)。
1. Node.js 是什么
1.1 定义
Node.js = 基于 Chrome V8 引擎的 JavaScript 运行时,让 JS 能跑在服务器/操作系统上。
关键组件:
┌──────────────────────────────────────┐
│ 你的 JS 代码 │
└──────────────────────────────────────┘
│
┌─────────────┴────────────┬───────────┐
│ V8(执行 JS) │ Node API │
│ ECMAScript 标准 │ fs/http/ │
│ 解释 + JIT 编译 │ path/... │
└──────────────────────────┴───────────┘
│
┌──────────────────────────────────────┐
│ libuv(事件循环 + 线程池) │
│ Timer / I/O / 网络 / Worker │
└──────────────────────────────────────┘
│
┌──────────────────────────────────────┐
│ 操作系统(Linux/macOS/Win) │
└──────────────────────────────────────┘1.2 谁用 Node.js?
- Vercel / Netlify / Cloudflare——SSR、Edge Functions
- VS Code / Cursor / Electron 应用——本质是 Node + Chromium
- 几乎所有前端构建工具:Webpack / Vite / Rollup / esbuild / SWC / Babel
- CLI 工具:
npm/pnpm/eslint/prettier/vue-cli/create-react-app
2. Node.js vs 浏览器 JS(必懂区别)
2.1 核心差异表
| 维度 | 浏览器 | Node.js |
|---|---|---|
| 全局对象 | window / self | global / globalThis |
| 顶层 this | window | {}(CJS 模块)/ undefined(ESM) |
| DOM / BOM | ✅ 有 | ❌ 没有 |
| 文件系统 | ❌ 没有 | ✅ fs |
| 网络 | fetch / XMLHttpRequest | http / https / fetch(18+) |
| 模块系统 | <script type="module"> 原生 ESM | CJS(require)+ ESM(import)共存 |
| 事件循环 | 浏览器规范(task / microtask) | libuv 6 阶段循环 |
| 常用 API | DOM API、Web API | process / Buffer / os / path |
| 包管理 | <script src> / CDN | npm / pnpm / yarn |
2.2 一段代码看差异
js
// ===== 浏览器 =====
console.log(window.innerWidth); // 1920
document.getElementById("root"); // <div id="root">
localStorage.setItem("k", "v");
// ===== Node.js =====
console.log(process.platform); // "linux" / "darwin" / "win32"
console.log(process.cwd()); // "/Users/me/project"
require("fs").readFileSync("./a.txt", "utf8");
// document.getElementById("root"); // ❌ ReferenceError: document is not defined3. 核心模块速览
Node.js 内置了一套"标准库",叫核心模块。引用时不用安装、不用相对路径,直接
require("xxx")或import "node:xxx"(推荐加node:前缀,跟用户包区分)。
3.1 必学 8 个
| 模块 | 干啥的 | 一句话 |
|---|---|---|
fs | 文件系统 | 读/写/监听文件、创建目录 |
path | 路径处理 | 拼路径、提取文件名、跨平台分隔符 |
http / https | 网络服务 | 起 HTTP 服务、发起 HTTP 请求 |
url | URL 解析 | 解析、拼接 URL |
events | 事件触发器 | EventEmitter 是 Node 的发布订阅基类 |
stream | 流 | 大文件、视频、网络的流式处理 |
process | 进程信息 | 环境变量、命令行参数、工作目录 |
Buffer | 二进制 | 处理图片、加密、网络协议字节 |
3.2 fs:读写文件
js
import { readFile, writeFile } from "node:fs/promises";
// 读文件(异步 + Promise)
const text = await readFile("./README.md", "utf8");
// 写文件
await writeFile("./output.txt", "hello node\n");⚠️ 不要在 Web 服务里用
readFileSync——它是同步的,会阻塞整个事件循环。
3.3 path:路径处理
js
import path from "node:path";
path.join("/foo", "bar", "baz"); // "/foo/bar/baz"
path.resolve("./a", "b"); // "/cwd/a/b"(绝对路径)
path.basename("/a/b/c.txt"); // "c.txt"
path.extname("c.txt"); // ".txt"
path.dirname("/a/b/c.txt"); // "/a/b"
// __dirname / __filename 在 ESM 里没有,要这样取:
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);3.4 http:最小服务器
js
import http from "node:http";
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Hello from Node!");
});
server.listen(3000, () => console.log("http://localhost:3000"));3.5 events:EventEmitter
Node 几乎所有"会触发回调"的东西都是 EventEmitter 的子类——
http.Server、stream、process都是。
js
import { EventEmitter } from "node:events";
const bus = new EventEmitter();
bus.on("user:login", (user) => console.log("欢迎", user.name));
bus.emit("user:login", { name: "Alice" });3.6 stream:流
类比:文件就像一桶水。
- 不用 stream:把整桶水倒进内存(
readFile),桶大了内存爆。- 用 stream:用水管一段一段流(
createReadStream),内存恒定。
js
import { createReadStream, createWriteStream } from "node:fs";
createReadStream("big-video.mp4")
.pipe(createWriteStream("copy.mp4"));3.7 process:进程信息
js
process.argv; // 命令行参数 ["node","script.js","--flag"]
process.env.NODE_ENV; // 环境变量
process.cwd(); // 当前工作目录
process.platform; // "linux" | "darwin" | "win32"
process.version; // "v20.10.0"
process.exit(0); // 退出(0 成功,非 0 失败)
process.on("SIGINT", () => console.log("收到 Ctrl+C"));3.8 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"4. 模块系统(CJS vs ESM)
Node 历史上用 CommonJS(CJS),浏览器用 ESM。后来 Node 也支持 ESM 了。两者写法、加载机制都不同,是面试高频考点。
4.1 对比表
| 维度 | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| 文件后缀 | .js(默认) / .cjs | .mjs 或 package.json 中 "type": "module" |
| 导出 | module.exports = {} / exports.x = ... | export const x = ... / export default ... |
| 导入 | const x = require("x") | import x from "x" |
| 加载时机 | 运行时同步加载 | 编译时静态分析 |
顶层 await | ❌ 不支持 | ✅ 支持 |
__dirname / __filename | ✅ 有 | ❌ 用 import.meta.url |
| Tree Shaking | 难 | 易 |
互相 import | ESM 可以 import CJS(默认导出);CJS require ESM 需要 await import() |
4.2 写法示例
js
// === CJS ===
// math.cjs
function add(a, b) { return a + b; }
module.exports = { add };
// app.cjs
const { add } = require("./math.cjs");
console.log(add(1, 2));js
// === ESM ===
// math.mjs
export function add(a, b) { return a + b; }
// app.mjs
import { add } from "./math.mjs";
console.log(add(1, 2));💡 现代项目(Vite / Next.js / Nuxt)都建议在
package.json加"type": "module"全用 ESM。
5. 事件循环(Event Loop)—— 必考!
浏览器和 Node 的事件循环机制不同,注意区分。
5.1 Node 事件循环 6 个阶段
┌────────────────────────────┐
│ timers │ setTimeout / setInterval 回调
└────────────────────────────┘
↓
┌────────────────────────────┐
│ pending callbacks │ 系统操作回调(如 TCP 错误)
└────────────────────────────┘
↓
┌────────────────────────────┐
│ idle, prepare │ Node 内部使用
└────────────────────────────┘
↓
┌────────────────────────────┐
│ poll │ ⭐ 最重要:处理 I/O 回调(fs/网络)
└────────────────────────────┘
↓
┌────────────────────────────┐
│ check │ setImmediate 回调
└────────────────────────────┘
↓
┌────────────────────────────┐
│ close callbacks │ socket.on('close',...) 等
└────────────────────────────┘
↓
(回到 timers)在每个阶段切换之间,Node 会清空一次微任务队列(
process.nextTick→Promise.then)。
5.2 6 个阶段详解
| 阶段 | 处理什么 | 典型 API |
|---|---|---|
| timers | 到期的定时器回调 | setTimeout / setInterval |
| pending callbacks | 部分系统级回调(TCP 错误等) | 内部 |
| idle, prepare | 内部空闲准备 | — |
| poll | 处理 I/O 回调;如无回调则停在这里等 | fs.readFile / net |
| check | 执行 setImmediate 回调 | setImmediate |
| close callbacks | 关闭事件 | socket.on('close') |
5.3 浏览器 vs Node 事件循环差异
| 维度 | 浏览器 | Node |
|---|---|---|
| 任务队列 | task / microtask | 6 阶段 + microtask |
| 微任务时机 | 每个 task 后清空 | 每个阶段之间清空 |
process.nextTick | ❌ 没有 | ✅ 有,优先级最高 |
setImmediate | ❌ 没有 | ✅ 有 |
5.4 经典面试题
js
setTimeout(() => console.log("setTimeout"), 0);
setImmediate(() => console.log("setImmediate"));
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
console.log("sync");
// 输出顺序(Node 中):
// sync
// nextTick ← 微任务最先(且 nextTick 比 promise 还早)
// promise
// setTimeout 和 setImmediate 顺序不固定(取决于事件循环启动时机)⚠️
setTimeout(fn, 0)和setImmediate(fn)在主模块代码里顺序不确定,但放在 I/O 回调里setImmediate一定先(因为 poll 阶段刚结束直接进 check)。
6. 异步 I/O 与 libuv
6.1 为什么 Node 单线程也能高并发
类比:餐厅前台只有 1 个服务员(主线程),但后厨有一队厨师(线程池)。
服务员只负责"接单 + 上菜",做菜的事都交给后厨。前台永远不会被卡住。
┌──────────────────────────────┐
│ 主线程(V8 + Event Loop) │ ← 单线程
└──────────────────────────────┘
▲ │
│ │ 派发耗时任务
完成回调 │ ▼
┌──────────────────────────────┐
│ libuv 线程池(默认 4 线程) │
│ fs / DNS / 部分加密 │
└──────────────────────────────┘
┌──────────────────────────────┐
│ 操作系统异步 I/O │
│ 网络(epoll/kqueue/IOCP) │
└──────────────────────────────┘结论:Node 不是真正的"单线程"——主线程单线程,但 I/O 通过 libuv 线程池或操作系统异步通知完成。
6.2 阻塞 vs 非阻塞
js
// ❌ 阻塞:服务被卡住,其它请求全部等待
import { readFileSync } from "node:fs";
const data = readFileSync("./big.json");
// ✅ 非阻塞:主线程立即返回,文件读完触发回调
import { readFile } from "node:fs/promises";
const data = await readFile("./big.json");7. Express 简介(最常见的 HTTP 框架)
原生
http写多了很麻烦,Express 是 Node 生态最经典的 Web 框架,提供路由、中间件、模板引擎。
7.1 最小 Express
js
import express from "express";
const app = express();
app.use(express.json()); // 解析 JSON body
app.get("/", (req, res) => res.send("Hello Express"));
app.get("/user/:id", (req, res) => {
res.json({ id: req.params.id, name: "Alice" });
});
app.post("/login", (req, res) => {
const { username } = req.body;
res.json({ ok: true, user: username });
});
app.listen(3000);7.2 中间件(核心概念)
js
// 中间件 = (req, res, next) => {}
// 类比:流水线上的工人,每人加工一道工序后传给下一个
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
next(); // ⚠️ 必须调用 next,否则请求会卡住
});
// 鉴权中间件
function auth(req, res, next) {
if (!req.headers.authorization) {
return res.status(401).json({ error: "Unauthorized" });
}
next();
}
app.get("/admin", auth, (req, res) => res.send("Admin only"));7.3 替代品
| 框架 | 特点 |
|---|---|
| Express | 经典、简单、生态最大 |
| Koa | Express 原班人马打造,async/await 友好 |
| Fastify | 性能极致,TypeScript 一流 |
| Hono | 边缘 Runtime(Cloudflare/Deno)首选 |
| NestJS | 企业级、TypeScript、模块化(仿 Spring/Angular) |
8. 常用 npm 包入门
8.1 dotenv:读取 .env 环境变量
bash
npm i dotenvbash
# .env
PORT=3000
DATABASE_URL=postgres://localhost/mydb
API_KEY=sk-xxxxxjs
import "dotenv/config"; // ESM 写法
console.log(process.env.PORT);8.2 nodemon:开发时自动重启
bash
npm i -D nodemonjson
{
"scripts": { "dev": "nodemon server.js" }
}文件变化时自动重启 Node 进程,开发体验提升 10 倍。Node 20+ 内置
--watch也能做到:node --watch server.js。
8.3 cross-env:跨平台设置环境变量
bash
# Windows: set NODE_ENV=production
# Linux/Mac: NODE_ENV=production
# cross-env: 一份脚本两边都能跑
npm i -D cross-envjson
{
"scripts": {
"build": "cross-env NODE_ENV=production webpack"
}
}9. 「哪些场景下需要 Node.js」清单(前端工程师视角)
| 场景 | 用途 | 例子 |
|---|---|---|
| CLI 工具 | 命令行脚本(构建/初始化/迁移) | vite、create-next-app、自家的部署脚本 |
| 构建工具 | 把源码转换成浏览器可执行的产物 | Vite、Webpack、Rollup、esbuild |
| SSR 服务 | 服务端渲染 React/Vue | Next.js / Nuxt 的运行时 |
| BFF(Backend for Frontend) | 给前端聚合多个后端接口的中间层 | Node 起个 Express,调多个微服务 |
| Serverless / Edge Functions | 函数即服务、CDN 边缘计算 | Vercel Functions、Cloudflare Workers |
| 桌面应用 | Electron / Tauri | VS Code、Cursor、Discord |
| Mock Server | 本地模拟后端接口 | json-server、msw |
| 代码生成 / 模板 | 自动化脚本 | 自动生成路由表、自动改 i18n 文件 |
| WebSocket / 实时服务 | 聊天、协作 | Socket.io |
总结:前端工程师写的 Node 代码 90% 不是"业务后端",而是工程化、SSR、BFF 这些。
10. 实战案例
详见 examples/ 目录:
fs-demo.js—— 文件读写、目录遍历http-server.js—— 原生 http 起最小服务器express-demo.js—— Express 路由 + 中间件stream-demo.js—— 用流复制大文件event-loop-node.js—— 事件循环 6 阶段输出顺序
11. ⚠️ 易踩的坑
坑 1:在 Web 服务里用同步 API
js
// ❌ 整个进程卡住,所有请求都被排队
const data = fs.readFileSync("./big.json");
// ✅ 异步
const data = await fs.promises.readFile("./big.json");坑 2:ESM 里没有 __dirname / __filename
js
// ❌ ReferenceError
console.log(__dirname);
// ✅
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));坑 3:CJS 和 ESM 互相 require / import
js
// CJS 里 require 一个 ESM 包 → ❌ 报错 "ERR_REQUIRE_ESM"
const chalk = require("chalk"); // chalk 5+ 是纯 ESM
// ✅ 改成动态 import
const chalk = await import("chalk");坑 4:process.env.X 永远是字符串
js
process.env.DEBUG = true;
console.log(typeof process.env.DEBUG); // "string",是 "true" 不是 true
if (process.env.DEBUG) { /* 永远进 */ } // ⚠️ "false" 也是 truthy坑 5:Promise 错误没 catch 会让进程崩溃(Node 15+ 默认)
js
// ❌ unhandledRejection → 进程退出
fetch("https://broken-url");
// ✅ 一定要 catch
fetch("https://broken-url").catch(console.error);
// 兜底
process.on("unhandledRejection", (err) => console.error("未处理的 reject", err));坑 6:把巨大的 JSON / 文件全部读入内存
js
// ❌ 1GB 文件直接 OOM
const buf = await fs.readFile("./huge.csv");
// ✅ 用 stream 边读边处理
import { createReadStream } from "node:fs";
import readline from "node:readline";
const rl = readline.createInterface({ input: createReadStream("./huge.csv") });
for await (const line of rl) {
// 处理一行
}坑 7:require 路径相对当前文件,但 fs.readFile 路径相对 process.cwd()
js
// 当前文件在 src/utils/a.js,运行命令在项目根
const cfg = require("./config.json"); // ✅ src/utils/config.json
const txt = fs.readFileSync("./config.json"); // ❌ 实际找的是项目根/config.json想固定相对当前文件,用
path.join(__dirname, "config.json")。
12. 一句话总结
Node.js = V8 + libuv + 一组 OS API,让 JS 离开浏览器获得操作系统能力;前端工程师 90% 的 Node 用途是构建工具、CLI、SSR、BFF——理解事件循环 6 阶段、模块系统差异、异步 I/O 模型,就足以应对绝大多数场景和面试。
13. 延伸阅读
- Node.js 官方文档:https://nodejs.org/docs/latest/api/
- libuv 设计文档:https://docs.libuv.org/en/v1.x/design.html
- 朴灵《深入浅出 Node.js》(中文经典书)
- 阮一峰《Node.js 教程》:https://wangdoc.com/nodejs/
- Node.js 事件循环官方解释:https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick