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 事件循环六阶段与微任务队列

事件循环实战

用代码验证:主线程不会卡在 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——选哪个?

工具原理速度热重载适合场景
tsxesbuild 即时编译极快支持(watch 模式)日常开发(推荐)
ts-nodeTSC 编译后执行较慢不支持需要严格类型检查时
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 是官方文档明确推荐的做法。

使用 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 内存占用对比

逐行处理文件

用 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 背压机制——管道中的数据流与反馈信号
// 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。

1 / X