Node.js 与 TypeScript
服务端基础——用类型安全的方式写后端
🎯 学习目标
学完本章,你将能够:
- 理解 Node.js 事件循环机制,能解释异步 I/O 的工作原理
- 独立搭建 TypeScript 后端项目,掌握 tsconfig 核心配置项
- 区分 ESM 与 CommonJS 模块系统,能在项目中正确选用
- 使用 dotenv 管理环境变量,并实现类型安全的配置访问
- 理解 Node.js 流的分类,能用流处理大文件读写
你有没有想过
前端的 JavaScript 代码,能不能直接在服务器上运行?
答案是可以——这就是 Node.js 做的事。
但 JavaScript 是动态类型语言,写大型后端项目时容易出错。所以我们还需要 TypeScript 来给代码加上类型保护。
从这章开始,你将从零搭建一个 TypeScript 版的 Node.js 后端项目。这是后面所有后端章节的基础。
Part 1: 运行时回顾
Node.js 的本质与核心机制
Node.js 不仅是"浏览器外的 JavaScript",它还有一套强大的内置模块。开始写后端之前,先搞清楚它和浏览器的区别,以及它的单线程模型如何处理并发请求。理解事件循环和异步模式,是写好 Node.js 代码的基石。
为什么要先学运行时?很多新手跳过基础直接上框架,遇到内存泄漏、事件循环阻塞等线上问题时完全无从下手。花 20 分钟理解底层机制,后面省 20 小时排错时间。
浏览器 vs Node
同样的 JS,不同的运行环境
| 能力 | 浏览器 | Node.js |
|---|---|---|
| DOM 操作 | 有 | 没有 |
| 文件系统 | 没有 | 有(fs 模块) |
| HTTP 服务器 | 没有 | 有(http 模块) |
| 环境变量 | 没有 | 有(process.env) |
| 包管理 | 没有 | 有(npm) |
Node.js 没有 DOM,但多了文件系统、网络、进程管理等服务器能力。它使用和 Chrome 相同的 V8 引擎来执行 JavaScript。
为什么这很重要?你在前端写的 document.querySelector、window.alert 在 Node.js 中全部不存在。反过来,fs.readFile、process.exit 也不能在浏览器中运行。记住这个边界,遇到"xxx is not defined"时就能快速定位。
事件循环机制
单线程如何同时处理上千个请求?
定义:事件循环是 Node.js 的核心调度器——它不断循环检查是否有待处理的回调,有就执行,没有就等待。
白话理解:想象一个前台接待员(主线程),同时接了 100 个电话。每个电话说"帮我查个文件"后挂线等待。接待员把查文件的任务交给后台同事(操作系统),自己继续接下一个电话。后台查完了把结果放桌上(回调队列),接待员空了就按顺序读结果回电。一个接待员搞定 100 个电话。

图:Node.js 事件循环六阶段与微任务队列
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一个纵向的环形流程图,展示事件循环的六个阶段,从上到下依次为:timers(定时器回调)、pending callbacks(系统级回调)、idle/prepare(内部使用)、poll(I/O 回调,停留最久)、check(setImmediate 回调)、close callbacks(关闭事件),最后用弧形箭头回到顶部形成闭环 - 各阶段用不同颜色的圆角矩形表示:timers 蓝色、poll 绿色(标注"停留最久")、check 橙色,其余阶段用浅灰色 - 在环形循环的左侧,标注两个微任务队列的插入点:在每个阶段结束后、进入下一阶段之前,优先清空 nextTick 队列(红色标注),然后清空微任务队列/Promise(紫色标注)。用虚线箭头从这两个队列指向循环主体,说明它们在阶段切换之间执行 - 关键标注:用箭头标注数据流动方向,在 poll 阶段旁标注"如果没有待处理回调则阻塞等待",在 timers 阶段旁标注"检查是否有到期的 setTimeout/setInterval" - 配色建议:浅蓝/浅灰背景,深色文字,timers 蓝色,poll 绿色,check 橙色,nextTick 队列红色,微任务队列紫色,关键箭头用橙色标注 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色、阴影渐变
事件循环实战
用代码验证:主线程不会卡在 I/O 操作上
// 非阻塞:主线程不会停下来等文件读完
import { readFile } from "node:fs/promises";
console.log("1. 开始");
const filePromise = readFile("big.txt", "utf-8"); // 交给后台
console.log("2. 主线程继续执行");
filePromise.then(data => {
console.log("3. 文件读完了,长度:", data.length);
});
console.log("4. 主线程结束");
输出顺序:1 → 2 → 4 → 3。主线程不会在 readFile 处卡住,这就是非阻塞 I/O。Node 靠这个机制,用一个线程就能服务成千上万的并发连接。
事件循环的致命陷阱:如果你在代码中写了 CPU 密集型操作(比如排序 100 万条数据、做复杂计算),主线程会被占满,所有请求都得排队。Node.js 适合 I/O 密集型,不适合 CPU 密集型。后者需要用 Worker Threads 或拆成微服务。
异步模式演进
从回调地狱到 async/await
Callback → Promise → async/await
// 1. 回调(容易嵌套太深)
readFile("a.txt", (err, data) => {
if (err) throw err;
console.log(data);
});
// 2. Promise(扁平化)
readFile("a.txt", "utf-8")
.then(data => console.log(data))
.catch(err => console.error(err));
// 3. async/await(最推荐)
try {
const data = await readFile("a.txt", "utf-8");
console.log(data);
} catch (err) {
console.error("读文件失败:", err);
}
错误处理三种方式
// try/catch(推荐)
try {
await riskyOperation();
} catch (e) {
console.error(e);
}
// .catch() 链式
fetch(url).catch(e => console.error(e));
// 全局兜底(最后防线)
process.on("unhandledRejection",
(reason) => {
console.error("未捕获:", reason);
}
);
// 并发读取三个文件——比串行快三倍
const [a, b, c] = await Promise.all([
readFile("a.txt", "utf-8"),
readFile("b.txt", "utf-8"),
readFile("c.txt", "utf-8"),
]);
// Promise.all:全部成功才成功,一个失败就失败
// Promise.allSettled:等全部完成,不管成败
// Promise.race:谁先完成用谁的结果
为什么推荐 async/await?它的代码读起来像同步代码,思维负担最小。而且 try/catch 错误处理和同步代码完全一致,不会遗漏。在现代 Node.js 项目中,async/await 已经是事实标准。
核心内置模块
用 node: 前缀导入,这是推荐写法
import fs from "node:fs"; // 文件系统
import path from "node:path"; // 路径处理
import http from "node:http"; // HTTP 服务器
import { EventEmitter } from "node:events"; // 事件
import crypto from "node:crypto"; // 加密
import os from "node:os"; // 操作系统信息
// 使用 node: 前缀的异步 API
import { readFile } from "node:fs/promises";
const content = await readFile("package.json", "utf-8");
console.log(JSON.parse(content).name);
Node.js 有四个核心特点:单线程事件循环、非阻塞异步 API、npm 生态(世界最大包仓库)、V8 引擎。它特别适合 I/O 密集型应用。
为什么用 node: 前缀?它明确告诉 Node.js 这是内置模块,不是 npm 包。这样 Node 跳过 node_modules 查找,启动更快,也避免了你本地有个同名包导致冲突。
Part 2: 搭建项目
从零创建一个 TS 后端项目
先把项目骨架搭起来。你需要初始化 npm 项目、安装 TypeScript 相关依赖、配置 tsconfig。我们还会对比几种常用的开发工具,帮你选到最趁手的方案。项目结构清晰了,后面的开发才能事半功倍。
为什么要花时间搭骨架?很多教程直接给你一个 app.listen(3000),跳过了项目配置。但真实项目中,tsconfig 配错一行就编译失败,目录结构混乱就找不到文件。花 10 分钟搭好骨架,后面开发效率翻倍。
初始化 TS 项目
五步创建一个可运行的 TS 后端项目
$ mkdir my-api && cd my-api
$ npm init -y
# 安装 TypeScript 开发依赖
$ npm install -D typescript tsx @types/node
# 安装 Express 及其类型
$ npm install express
$ npm install -D @types/express
# 初始化 tsconfig
$ npx tsc --init
其中 tsx 是一个零配置的 TypeScript 执行器,让你直接运行 .ts 文件,不需要先编译。
为什么是 -D(开发依赖)?typescript、tsx、@types/node 这些只在开发和构建时使用,生产环境运行的是编译后的 JS,不需要它们。区分依赖类型能让生产环境的 node_modules 体积减半。
推荐目录结构
清晰的分层是项目可维护的前提
src/
├── index.ts // 入口
├── routes/ // 路由
├── middleware/ // 中间件
├── services/ // 业务逻辑
├── models/ // 数据模型
└── types/ // 类型定义
这就像一栋楼的楼层:入口是门厅,路由是走廊,中间件是安检,业务逻辑是办公室。每层各司其职。
为什么一开始就分文件夹?刚开始只有 5 行代码,全放一个文件没问题。但到第 3 周,你的代码会变成 500 行——如果全挤在一个文件里,找一个函数要滚动半天。从第一天就分层,随着项目增长你的代码依然清晰可读。
入口文件
五行代码启动一个 Web 服务器
// src/index.ts
import express from "express";
const app = express();
app.use(express.json());
app.get("/health", (_req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
const PORT = Number(process.env.PORT) || 3000;
app.listen(PORT, () => {
console.log(`服务器运行在 http://localhost:${PORT}`);
});
$ npx tsx src/index.ts
服务器运行在 http://localhost:3000
$ curl http://localhost:3000/health
{"status":"ok","timestamp":"2025-01-01T00:00:00.000Z"}
为什么用 Express?原生 node:http 需要手动解析路由、解析请求体、处理 CORS,一个简单接口要写 30 行。Express 把这些都封装好了,让你专注写业务逻辑。它是 Node.js 最成熟、生态最丰富的 Web 框架。
npm 脚本配置
把常用命令写成脚本,一键执行
// package.json
{
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "tsc --noEmit"
}
}
| 命令 | 作用 |
|---|---|
npm run dev | 开发模式,文件改动自动重启 |
npm run build | 编译 TS 到 JS(输出到 dist/) |
npm start | 运行编译后的代码(生产环境) |
npm run lint | 只做类型检查,不输出文件 |
为什么要写 npm scripts?团队成员不需要记住每个命令的具体参数,npm run dev 一个命令搞定。而且 CI/CD 流水线可以复用同样的脚本,开发环境和部署环境保持一致。
开发工具对比
tsx vs ts-node vs nodemon——选哪个?
| 工具 | 原理 | 速度 | 热重载 | 适合场景 |
|---|---|---|---|---|
tsx | esbuild 即时编译 | 极快 | 支持(watch 模式) | 日常开发(推荐) |
ts-node | TSC 编译后执行 | 较慢 | 不支持 | 需要严格类型检查时 |
nodemon | 监听文件变化重启 | 中等 | 支持 | 配合 ts-node 使用 |
# 推荐组合:tsx watch(文件改动自动重启)
$ npx tsx watch src/index.ts
# 或者在 package.json 中:
"scripts": { "dev": "tsx watch src/index.ts" }
// .vscode/launch.json —— VS Code 调试配置
{
"version": "0.2.0",
"configurations": [{
"name": "调试 TS 服务",
"type": "node",
"request": "launch",
"runtimeExecutable": "npx",
"runtimeArgs": ["tsx"],
"args": ["src/index.ts"],
"console": "integratedTerminal",
"skipFiles": ["/**"]
}]
}
配置好 launch.json 后,在 VS Code 中按 F5 就能打断点调试 TypeScript 代码,和在浏览器中调试前端一样方便。
Part 3: tsconfig 配置
让 TypeScript 编译器听你的
TypeScript 的编译行为全靠 tsconfig.json 控制。后端项目和前端的配置有显著区别:前端打包用 webpack/vite,后端直接用 tsc 编译。几个关键配置项搞错了,整个项目跑不起来。
tsconfig 要点
后端推荐配置——Node.js 18+ 用 ES2022
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
为什么用 NodeNext?它是 Node.js 团队推荐的模块解析策略,能正确处理 ESM 和 CommonJS 混合的情况。用 Node16 或 NodeNext 是官方文档明确推荐的做法。
package.json 中设置 "type": "module" 来启用 ESM。如果项目用 CommonJS(require),把 module 改为 "CommonJS"。
☕ 学习断点
Node.js 基础已掌握,准备进入项目实战
前半部分我们理解了 Node.js 运行时、项目搭建和 tsconfig 配置——这是后端开发的基石。
建议:休息 5 分钟,确认你的项目能正常启动。
💡 后半部分将学习模块系统、环境变量和流式处理——这些是构建真实后端服务的必备知识。
Part 4: 模块系统
ESM 与 CommonJS——两种导入导出方式
Node.js 历史上使用 CommonJS(require),后来 JavaScript 标准化了 ESM(import)。新项目推荐用 ESM,但你在老项目和第三方库里会经常看到 CommonJS。搞懂两者区别,遇到报错才不慌。
为什么还在讲 CommonJS?npm 上超过一半的包还在用 CommonJS。你 npm install 一个库,打开源码看到的可能就是 module.exports。不认识它,排错时就会一头雾水。
ESM vs CommonJS
语法对照表——看清差异
ESM(推荐)
// 导出
export const name = "app";
export default function run() {}
// 导入
import fs from "node:fs";
import { readFile } from "node:fs/promises";
// 文件扩展名必须写
import { config } from "./config.js";
CommonJS
// 导出
module.exports = { name: "app" };
exports.run = function() {};
// 导入
const fs = require("node:fs");
const { readFile } = require("node:fs/promises");
// 不需要写扩展名
const { config } = require("./config");
关键区别:ESM 是静态分析(编译时确定依赖),CommonJS 是动态加载(运行时才 require)。ESM 支持 tree-shaking,CommonJS 不支持。新项目推荐用 ESM。
ESM 实践要点
从 CommonJS 迁移到 ESM 要注意的坑
// package.json —— 启用 ESM 的开关
{
"type": "module"
}
// CommonJS 中可用的 __dirname 和 __filename,在 ESM 中不存在
// 解决方案:
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// 现在可以正常拼接路径了
const dbPath = join(__dirname, "data", "db.json");
tsconfig 的 moduleResolution:用 "NodeNext" 时,TypeScript 强制要求 ESM 导入带 .js 扩展名(即使源文件是 .ts)。这很反直觉但是正确的。
什么时候用 CommonJS?当你维护的老项目大量使用 require(),或者某个第三方库只提供 CJS 版本且没有 ESM 兼容层时。其他情况都建议用 ESM。
Part 5: 环境变量
敏感信息的"保险箱"
数据库密码、JWT 密钥这些敏感信息,绝不能硬编码在代码里。一旦推到 GitHub,全世界都能看到。环境变量就是解决这个问题的标准方案——配置和代码分离,不同环境用不同的值。
为什么不能硬编码?你把 const password = "abc123" 推到 GitHub,自动化爬虫 10 分钟内就能发现。即便立刻删除,Git 历史中仍然存在。环境变量让同一份代码在开发、测试、生产环境使用不同的配置,且敏感信息永远不出现在代码仓库中。
dotenv 入门
一行代码加载 .env 文件到 process.env
$ npm install dotenv
# .env 文件(不要提交到 Git!)
PORT=3000
NODE_ENV=development
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
JWT_SECRET=your-secret-key
// 在入口文件最顶部加载
import "dotenv/config"; // 自动加载 .env 到 process.env
const port = process.env.PORT; // string | undefined
务必把 .env 加入 .gitignore!在 .gitignore 中添加一行 .env。同时创建一个 .env.example 文件列出所有需要的环境变量名(不含真实值),让团队成员知道需要配置什么。
类型安全配置
用函数封装,启动时就能发现配置缺失
// src/config.ts
function requireEnv(key: string): string {
const value = process.env[key];
if (!value) throw new Error(`环境变量 ${key} 未设置`);
return value;
}
export const config = {
port: Number(process.env.PORT) || 3000,
nodeEnv: process.env.NODE_ENV ?? "development",
databaseUrl: requireEnv("DATABASE_URL"),
jwtSecret: requireEnv("JWT_SECRET"),
isDev: process.env.NODE_ENV !== "production",
} as const;
// src/types/env.d.ts —— 让 process.env 有类型提示
declare namespace NodeJS {
interface ProcessEnv {
PORT?: string;
NODE_ENV?: "development" | "production" | "test";
DATABASE_URL: string;
JWT_SECRET: string;
}
}
为什么要用 requireEnv 函数?直接写 process.env.DATABASE_URL 的类型是 string | undefined,你随时可能传入 undefined。用 requireEnv 封装后,返回类型一定是 string,TypeScript 编译器帮你把空值风险挡在门外。
使用配置
import config 之后,每个字段都有类型提示
import { config } from "./config";
console.log(config.port); // number —— 有类型提示
console.log(config.databaseUrl); // string —— 启动时已验证
console.log(config.isDev); // boolean
// 如果 .env 中缺少 DATABASE_URL,启动时会直接报错:
// Error: 环境变量 DATABASE_URL 未设置
这种"启动时校验"的模式叫 fail fast(快速失败)。宁可启动时崩溃,也不要运行到一半才发现配置缺失。生产环境中,配置错误导致的中断远比启动失败难排查 100 倍。
Part 6: 流式处理
用 Streams 处理大文件和数据
读取一个 2GB 的日志文件,用 readFile 会把整个文件加载到内存,服务器直接崩溃。Stream 的思路是"边读边处理",每次只加载一小块数据到内存。Node.js 的流是事件循环的最佳搭档。
为什么 Stream 是 Node.js 的精髓?前端开发几乎不用考虑内存——浏览器页面关了内存就释放了。但后端服务 7x24 运行,一次日志分析、一次大文件上传就可能耗尽内存。Stream 让你用恒定的小内存处理任意大小的数据,这是后端工程师的必备技能。
四种流
Readable、Writable、Transform、Duplex
| 类型 | 方向 | 典型场景 |
|---|---|---|
| Readable | 只读 | 读取文件、HTTP 请求体 |
| Writable | 只写 | 写入文件、HTTP 响应 |
| Transform | 读 → 转换 → 写 | 压缩、加密、格式转换 |
| Duplex | 独立读写 | TCP socket、双向通信 |
import { createReadStream, createWriteStream } from "node:fs";
import { createGzip } from "node:zlib";
import { pipeline } from "node:stream/promises";
// 用 pipeline 把大文件压缩——内存占用极低
await pipeline(
createReadStream("access.log"), // 读取
createGzip(), // 压缩(Transform)
createWriteStream("access.log.gz") // 写入
);
console.log("压缩完成!内存占用始终很低");
为什么用 pipeline 而不是 .pipe()?stream.pipe() 不会自动处理错误——如果中间某一步崩溃,内存泄漏就发生了。pipeline 会自动清理资源,出错时正确关闭所有流。它是 Node.js 官方推荐的方式。
为什么用流
readFile vs createReadStream——差距有多大?
readFile(一次性加载)
import { readFile } from "node:fs/promises";
// 读取 2GB 文件
// 内存瞬间飙升到 2GB+
// 服务器可能直接 OOM 崩溃
const data = await readFile("big.log", "utf-8");
console.log(data.length);
1GB 日志文件 → 占用 ~1GB 内存。10 个并发请求 → 10GB 内存。服务器直接崩溃。
createReadStream(流式)
import { createReadStream } from "node:fs";
// 同样是 2GB 文件
// 内存占用始终只有几 MB
// 因为每次只读一小块
for await (const chunk of createReadStream(
"big.log", "utf-8"
)) {
// 每次处理 64KB
process.stdout.write(chunk);
}
流式处理 → 内存恒定 ~64KB。100 个并发请求也只需 ~6MB 内存。

图:readFile 与 Stream 内存占用对比
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一张分组柱状图,横轴为文件大小(10MB、100MB、500MB、2GB),纵轴为内存占用。每个文件大小下有两根柱子:红色柱子代表 readFile,绿色柱子代表 Stream - readFile 的柱子高度随文件大小线性增长(10MB → 10MB 内存、100MB → 100MB 内存、500MB → 500MB 内存、2GB → 2GB 内存),并在柱子顶部标注具体数值 - Stream 的柱子在所有文件大小下都保持在极低的固定高度(约 64KB),几乎贴着横轴,柱子顶部标注"~64KB(恒定)" - 在图表上方或右侧,用一个简洁的文字标注总结:readFile"内存 = 文件大小"呈线性增长,Stream"内存恒定 ~64KB"不随文件增大而变化 - 关键标注:用虚线连接 readFile 柱子顶部,画出线性增长趋势线;用一条绿色水平虚线标注 Stream 的恒定 64KB 水平线 - 配色建议:浅蓝/浅灰背景,深色文字,readFile 柱子用红色/珊瑚色,Stream 柱子用绿色,趋势线用虚线橙色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色、阴影渐变
逐行处理文件
用 readline 逐行分析日志——最实用的流场景
定义:readline 模块把 Readable 流按换行符拆成一行一行的数据,每读一行触发一次事件。
白话理解:就像看书——你不需要把整本书复印一份再读,翻开一页读一页就行。readline 就是帮你"翻页"的工具。
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
// 统计日志中 404 错误的次数
let errorCount = 0;
let totalLines = 0;
const rl = createInterface({
input: createReadStream("access.log", "utf-8"),
crlfDelay: Infinity, // 正确处理不同操作系统的换行符
});
for await (const line of rl) {
totalLines++;
if (line.includes(" 404 ")) {
errorCount++;
console.log(`[404] 第 ${totalLines} 行: ${line.slice(0, 80)}`);
}
}
console.log(`\n总计 ${totalLines} 行,${errorCount} 个 404 错误`);
真实场景:分析 Nginx 访问日志、从 CSV 文件导入数据、读取大批量用户记录。这些都是后端日常工作,而 readline + createReadStream 是最省内存的方案。
Transform 流
数据在流动的过程中被"加工"
定义:Transform 流是一种双工流——它接收数据(Readable 端),加工处理后输出新数据(Writable 端)。
白话理解:想象一条流水线上的加工站——原材料进来,经过切割、打磨、上漆,成品出去。Transform 流就是这个加工站,数据经过它就变了样。
import { Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import { createReadStream, createWriteStream } from "node:fs";
// 自定义 Transform:把每行日志转成 JSON 对象
const logToJson = new Transform({
objectMode: true, // 处理对象而不是 Buffer
transform(line, _encoding, callback) {
try {
// 假设日志格式: [时间] 级别 消息
const match = line.toString().match(
/\[(.+?)\] (\w+) (.+)/
);
if (match) {
const [, time, level, message] = match;
callback(null, JSON.stringify({ time, level, message }) + "\n");
} else {
callback(); // 跳过格式不匹配的行
}
} catch (err) {
callback(err);
}
},
});
await pipeline(
createReadStream("app.log"),
logToJson,
createWriteStream("app.jsonl") // 输出 JSON Lines 格式
);
console.log("日志转换完成!");
Transform 流的威力:你可以把多个 Transform 流串联起来,像搭积木一样组合数据管道。比如:读取 → 解析 CSV → 过滤无效行 → 格式化 → 写入数据库。每一步独立、可测试、可复用。
背压机制
生产者和消费者的速度平衡
定义:背压(Backpressure)是指当数据生产速度 > 消费速度时,系统自动告知生产者"慢一点"的反馈机制。
白话理解:水龙头(Readable)放水太快,下水道(Writable)排不过来,水就会溢出。背压就像一个智能阀门——下水道忙不过来时,自动关小水龙头,等下水道排完了再开大。

图:Stream 背压机制——管道中的数据流与反馈信号
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一个从左到右的数据管道示意图,包含三个核心节点——Readable(蓝色圆角矩形,标注"生产数据")、Transform(橙色圆角矩形,标注"加工数据")、Writable(绿色圆角矩形,标注"消费数据"),节点之间用粗实线箭头连接,表示数据流动方向(从左到右) - 在每个节点下方各画一个小的缓冲区示意条:Readable 的缓冲区标注"半满(正常)"用浅蓝色填充一半、Transform 的缓冲区标注"满"用浅红色几乎填满、Writable 的缓冲区标注"空"用浅灰色几乎不填充 - 在管道下方,用一条红色虚线箭头从 Writable 反向指向 Transform,再从 Transform 反向指向 Readable,标注"背压信号(从慢到快回传)",说明当 Writable 消费不过来时,信号逐级回传让上游暂停 - 关键标注:在 Writable 节点旁标注"处理慢(如磁盘写入、网络请求)",在 Readable 节点旁标注"收到信号后暂停读取";在数据流箭头上方标注"数据块(chunk)",在背压信号箭头下方标注"false = 暂停" - 配色建议:浅蓝/浅灰背景,深色文字,Readable 蓝色、Transform 橙色、Writable 绿色,背压信号红色虚线,缓冲区用对应节点的浅色填充 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色、阴影渐变
// pipeline 自动处理背压——你不需要手动管理
// 这也是为什么推荐用 pipeline 而不是 .pipe()
await pipeline(
createReadStream("huge-file.bin"), // 读得快
createGzip(), // 压缩慢
createWriteStream("huge-file.bin.gz") // 写入也慢
// pipeline 会自动暂停读取,等压缩和写入跟上
);
// 手动处理背压(了解原理)
const readable = createReadStream("big.txt");
const writable = createWriteStream("out.txt");
readable.on("data", (chunk) => {
const canContinue = writable.write(chunk);
if (!canContinue) {
readable.pause(); // 写不过来,暂停读取
writable.once("drain", () => {
readable.resume(); // 排完了,继续读取
});
}
});
好消息:用 pipeline 或 .pipe() 时,Node.js 自动帮你处理背压。了解原理是为了排错——如果流的处理速度突然变慢,可能就是背压在起作用。
项目排错
问题一:npx tsx 提示"命令未找到"
原因:没有安装 tsx,或者没有在项目目录下运行。
修复:执行 npm install -D tsx,确认当前目录有 node_modules。
问题二:import 报错"Cannot use import statement"
原因:package.json 中缺少 "type": "module",或 tsconfig 的 module 设置不对。
修复:在 package.json 中添加 "type": "module",确认 tsconfig 的 module 为 NodeNext。
问题三:process.env.XXX 总是 undefined
原因:忘记在入口文件顶部加载 dotenv/config,或 .env 文件不在项目根目录。
修复:确保 import "dotenv/config" 在所有其他 import 之前,且 .env 文件和 package.json 同级。
问题四:tsc 编译报错"Cannot find module './xxx'"
原因:ESM 模式下,导入本地文件必须带 .js 扩展名(即使源文件是 .ts)。
修复:把 import { x } from "./config" 改为 import { x } from "./config.js"。
Stream 排错
流相关的常见报错和解决方案
问题五:stream 报错"stream.push() after EOF"
原因:在 Transform 流中,callback() 和 this.push() 的调用顺序不对,或者调用了多次 callback。
修复:确保每个 transform 方法只调用一次 callback,并且数据通过 callback(null, data) 传递而不是 this.push() + callback() 混用。
问题六:for await...of 报错"is not async iterable"
原因:Node.js 12 以下版本不支持流的异步迭代,或者使用的是回调式 API 而不是流。
修复:确保 Node.js 版本 >= 16(推荐 18+),并且 createReadStream 返回的是 Readable 流。
问题七:pipeline 报错"Premature close"
原因:Writable 流提前关闭了(比如磁盘满了、文件被其他进程占用),但 Readable 还在发送数据。
修复:给 pipeline 加上 try/catch 处理错误,检查目标文件是否有写入权限,确保没有其他进程占用文件。
try {
await pipeline(src, dest);
console.log("完成");
} catch (err) {
console.error("流处理出错:", err.message);
// 清理临时文件等
}
练习一
时间戳接口——最简单的 API
任务:创建一个 TS 项目,写一个接口返回当前时间戳。用 curl 测试它。
// 提示:new Date().toISOString() 返回时间戳
// 你的代码:
参考答案
import express from "express";
const app = express();
app.get("/time", (_req, res) => {
res.json({ time: new Date().toISOString() });
});
app.listen(3000, () => console.log("运行在 3000"));
验证方式:curl http://localhost:3000/time,应该看到类似 {"time":"2025-06-15T10:30:00.000Z"} 的响应。
练习二
环境变量配置——敏感信息的正确管理
任务:创建 .env 文件,配置 APP_NAME 变量。写一个接口 GET /info,返回 APP_NAME 和 PORT。
// 提示:用 requireEnv 函数确保 APP_NAME 必填
// 你的代码:
参考答案
// .env
// APP_NAME=MyApp
// PORT=3000
import "dotenv/config";
import express from "express";
function requireEnv(key: string): string {
const value = process.env[key];
if (!value) throw new Error(`环境变量 ${key} 未设置`);
return value;
}
const config = {
appName: requireEnv("APP_NAME"),
port: Number(process.env.PORT) || 3000,
};
const app = express();
app.get("/info", (_req, res) => {
res.json({ app: config.appName, port: config.port });
});
app.listen(config.port);
验证方式:故意删掉 .env 中的 APP_NAME,再次启动应该看到 Error: 环境变量 APP_NAME 未设置。
练习三
多路由服务——综合练习
任务:创建一个有 4 个路由的 Express 服务,模拟一个简单的用户管理 API。
GET /users—— 返回用户列表(模拟数据)GET /users/:id—— 返回指定用户POST /users—— 创建新用户(从请求体获取数据)DELETE /users/:id—— 删除指定用户
// 提示:用数组存储用户数据,不需要数据库
// 提示:app.use(express.json()) 解析请求体
// 你的代码:
参考答案
import express, { Request, Response } from "express";
interface User {
id: number;
name: string;
email: string;
}
const users: User[] = [
{ id: 1, name: "张三", email: "zhang@test.com" },
{ id: 2, name: "李四", email: "li@test.com" },
];
const app = express();
app.use(express.json());
// 获取所有用户
app.get("/users", (_req: Request, res: Response) => {
res.json({ data: users, total: users.length });
});
// 获取单个用户
app.get("/users/:id", (req: Request, res: Response) => {
const id = Number(req.params.id);
const user = users.find(u => u.id === id);
if (!user) {
res.status(404).json({ error: "用户不存在" });
return;
}
res.json(user);
});
// 创建用户
app.post("/users", (req: Request, res: Response) => {
const { name, email } = req.body;
if (!name || !email) {
res.status(400).json({ error: "name 和 email 必填" });
return;
}
const newUser: User = {
id: users.length + 1,
name,
email,
};
users.push(newUser);
res.status(201).json(newUser);
});
// 删除用户
app.delete("/users/:id", (req: Request, res: Response) => {
const id = Number(req.params.id);
const index = users.findIndex(u => u.id === id);
if (index === -1) {
res.status(404).json({ error: "用户不存在" });
return;
}
users.splice(index, 1);
res.json({ message: "删除成功" });
});
app.listen(3000, () => console.log("运行在 3000"));
验证方式:用 curl 或浏览器测试每个路由:curl http://localhost:3000/users、curl -X POST -H "Content-Type: application/json" -d '{"name":"王五","email":"wang@test.com"}' http://localhost:3000/users。
练习四
流式文件处理——实战 Stream
任务:用 Stream 读取一个大文件(如日志文件),统计每个 IP 地址出现的次数,输出前 10 个最活跃的 IP。
- 用
createReadStream+readline逐行读取 - 用
Map统计 IP 出现次数 - 处理完成后排序输出 Top 10
// 提示:日志格式示例:
// 192.168.1.1 - - [01/Jan/2025:10:00:01] "GET / HTTP/1.1" 200
// 用正则提取 IP:/^(\d+\.\d+\.\d+\.\d+)/
// 你的代码:
参考答案
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
async function analyzeLog(filePath: string) {
const ipCounts = new Map<string, number>();
let totalLines = 0;
const rl = createInterface({
input: createReadStream(filePath, "utf-8"),
crlfDelay: Infinity,
});
for await (const line of rl) {
totalLines++;
const match = line.match(/^(\d+\.\d+\.\d+\.\d+)/);
if (match) {
const ip = match[1];
ipCounts.set(ip, (ipCounts.get(ip) ?? 0) + 1);
}
}
// 按次数降序排序,取前 10
const top10 = [...ipCounts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
console.log(`\n总行数: ${totalLines}`);
console.log("Top 10 IP:");
top10.forEach(([ip, count], i) => {
console.log(` ${i + 1}. ${ip} — ${count} 次`);
});
}
analyzeLog("access.log");
验证方式:先创建一个测试日志文件(每行一个 IP),运行脚本检查输出是否正确。试试用 100MB 以上的文件测试,观察内存占用始终很低。
为什么不用 readFile?这个练习的真正意义不在算法,而在体验 Stream 的优势。如果用 readFile 读取一个 2GB 日志文件,内存直接爆掉。而 Stream 方案的内存占用和文件大小无关——处理 2MB 和 2GB 用一样多的内存。
📝 要点总结
本章核心知识点
事件循环
Node.js 通过事件循环实现非阻塞 I/O,理解宏任务/微任务顺序是写出高性能代码的基础
项目搭建
npm init + tsconfig + 目录结构 = 可维护的 TypeScript 后端项目起点
模块系统
ESM 是未来趋势,掌握 import/export 语法和 Node.js 的 ESM 配置要点
环境变量
用 dotenv 管理配置,用 TypeScript 类型约束确保运行时不会遗漏关键配置
流式处理
Stream 让大文件处理不再爆内存,pipe() 是连接流的最佳方式
下一章
项目骨架搭好了。下一章学习构建 Web 服务器——路由、中间件、错误处理,把骨架变成一个真正能用的 API。