Skip to content

第 21 章 · Node.js 基础(前端工程师视角)

一句话开篇:本章不教你做后端,只教你"作为前端工程师,必须懂的那部分 Node.js"——它是如何运行 JS 的、它的事件循环长什么样、它和浏览器有什么区别、以及你日常用到的 npm / 构建工具 / SSR / CLI 背后到底是怎么跑起来的。


0. 生活类比(先建立直觉)

把 JavaScript 想成一种"语言",那么:

  • 浏览器 是这门语言的"剧场":观众(用户)来这里看戏,剧场提供舞台(DOM)、灯光(CSS)、音响(Web API:windowfetchlocalStorage)。
  • 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 / selfglobal / globalThis
顶层 thiswindow{}(CJS 模块)/ undefined(ESM)
DOM / BOM✅ 有❌ 没有
文件系统❌ 没有fs
网络fetch / XMLHttpRequesthttp / https / fetch(18+)
模块系统<script type="module"> 原生 ESMCJS(require)+ ESM(import)共存
事件循环浏览器规范(task / microtask)libuv 6 阶段循环
常用 APIDOM API、Web APIprocess / Buffer / os / path
包管理<script src> / CDNnpm / 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 defined

3. 核心模块速览

Node.js 内置了一套"标准库",叫核心模块。引用时不用安装、不用相对路径,直接 require("xxx")import "node:xxx"(推荐加 node: 前缀,跟用户包区分)。

3.1 必学 8 个

模块干啥的一句话
fs文件系统读/写/监听文件、创建目录
path路径处理拼路径、提取文件名、跨平台分隔符
http / https网络服务起 HTTP 服务、发起 HTTP 请求
urlURL 解析解析、拼接 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.Serverstreamprocess 都是。

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.mjspackage.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
互相 importESM 可以 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.nextTickPromise.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 / microtask6 阶段 + 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经典、简单、生态最大
KoaExpress 原班人马打造,async/await 友好
Fastify性能极致,TypeScript 一流
Hono边缘 Runtime(Cloudflare/Deno)首选
NestJS企业级、TypeScript、模块化(仿 Spring/Angular)

8. 常用 npm 包入门

8.1 dotenv:读取 .env 环境变量

bash
npm i dotenv
bash
# .env
PORT=3000
DATABASE_URL=postgres://localhost/mydb
API_KEY=sk-xxxxx
js
import "dotenv/config"; // ESM 写法
console.log(process.env.PORT);

8.2 nodemon:开发时自动重启

bash
npm i -D nodemon
json
{
  "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-env
json
{
  "scripts": {
    "build": "cross-env NODE_ENV=production webpack"
  }
}

9. 「哪些场景下需要 Node.js」清单(前端工程师视角)

场景用途例子
CLI 工具命令行脚本(构建/初始化/迁移)vitecreate-next-app、自家的部署脚本
构建工具把源码转换成浏览器可执行的产物Vite、Webpack、Rollup、esbuild
SSR 服务服务端渲染 React/VueNext.js / Nuxt 的运行时
BFF(Backend for Frontend)给前端聚合多个后端接口的中间层Node 起个 Express,调多个微服务
Serverless / Edge Functions函数即服务、CDN 边缘计算Vercel Functions、Cloudflare Workers
桌面应用Electron / TauriVS Code、Cursor、Discord
Mock Server本地模拟后端接口json-servermsw
代码生成 / 模板自动化脚本自动生成路由表、自动改 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. 延伸阅读