Node.js 实战项目
学了 12 篇基础,是时候把它们串起来做点真东西了。这一章给三个完整、可运行的项目——HTTP 静态服务器、REST API、CLI 文件处理,分别覆盖 Node.js 三大主力场景:Web 服务、接口 API、命令行工具。三个项目都零依赖(只用内置模块),复制到本地 node xxx.js 就能跑。每个项目含完整代码、运行结果和要点讲解。建议你亲手跑一遍,再改改需求,这才是真正"上手"。
实战 1:HTTP 静态文件服务器(http + fs + MIME)
需求:不用任何框架,用内置 http + fs 写一个静态文件服务器——能把指定目录下的 HTML/CSS/JS/图片按正确 Content-Type 返回,访问根路径自动找 index.html。支持 node server.js ./public 8080 传参。
涉及知识:http.createServer、fs.readFile 回调、path.join/resolve、URL 解析、MIME 映射、路径遍历防护、process.argv。
// static-server.js — 极简静态文件服务器
// 运行:node static-server.js ./public 8080
const http = require("http");
const fs = require("fs");
const path = require("path");
const ROOT = process.argv[2] || ".";
const PORT = parseInt(process.argv[3] || "8080", 10);
// 扩展名 → MIME 类型映射
const MIME = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "application/javascript",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
};
const server = http.createServer((req, res) => {
// 1. 取路径;decodeURIComponent 处理 URL 里的中文/空格
let urlPath = decodeURIComponent(req.url.split("?")[0]);
if (urlPath === "/") urlPath = "/index.html";
// 2. path.join 拼绝对路径;检查仍在 ROOT 内,防路径遍历攻击(../)
const rootAbs = path.resolve(ROOT);
const filePath = path.join(rootAbs, urlPath);
if (!filePath.startsWith(rootAbs)) {
res.writeHead(403);
return res.end("Forbidden");
}
// 3. 读文件;按扩展名定 Content-Type
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404);
return res.end(`404 Not Found: ${urlPath}`);
}
const mime = MIME[path.extname(filePath)] || "application/octet-stream";
res.writeHead(200, { "Content-Type": mime });
res.end(data);
});
});
server.listen(PORT, () => {
console.log(`静态服务器已启动: http://localhost:${PORT}`);
console.log("按 Ctrl+C 退出");
});
// 输出:
// 静态服务器已启动: http://localhost:8080
// 按 Ctrl+C 退出
// 浏览器访问 http://localhost:8080/ 即可看到 ./public/index.html要点讲解:
createServer:回调收到req(请求对象)和res(响应对象);req.url是路径加查询串,用split("?")[0]取纯路径。- MIME 映射:浏览器靠
Content-Type决定怎么渲染——HTML 要带charset=utf-8,JS 是application/javascript。漏了会让 CSS 不生效、JS 不执行。 - 路径遍历防护:用户可能请求
/../../etc/passwd读系统文件。path.resolve(ROOT)取绝对根,检查拼出来的filePath是否仍以根开头——这是静态服务器的必修安全题。 - 回调异步:
fs.readFile是非阻塞的,读完在回调里res.end(data)——Node 的所有 IO 都是这个模式,事件循环不被磁盘等待卡住。 process.argv:命令行参数数组,[0]是 node 路径,[1]是脚本路径,用户传的参数从[2]开始。- 进阶方向:加流式响应(
fs.createReadStream().pipe(res))处理大文件、加 gzip 压缩、加 ETag 缓存——就是生产级 static 服务。
实战 2:REST API(纯 http + JSON + 路由)
需求:用纯 http 模块(不用 Express)写一个商品 REST API——GET /products 列全部、GET /products?id=3 取单条、POST /products 新增。所有响应都是 JSON,正确设置状态码。
涉及知识:new URL() 解析、res.writeHead 设状态码头、JSON.stringify、请求 body 流式读取(用 Promise 包装)、HTTP 状态码。
// api.js — 零依赖 REST API:用纯 http 模块写商品增删查
// 运行:node api.js
// 测试:curl http://localhost:3000/products
const http = require("http");
// 内存数据(真实项目接数据库)
const products = [
{ id: 1, name: "苹果", price: 5.0 },
{ id: 2, name: "香蕉", price: 3.0 },
{ id: 3, name: "橙子", price: 4.0 },
];
// 统一 JSON 响应;JSON.stringify 第三个参数控制缩进
function sendJSON(res, data, code = 200) {
res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(data));
}
// 把请求 body 读成字符串(Promise 包装,配合 async/await)
function readBody(req) {
return new Promise(resolve => {
let data = "";
req.on("data", chunk => data += chunk);
req.on("end", () => resolve(data));
});
}
const server = http.createServer(async (req, res) => {
// URL 对象:方便解析 pathname 和 searchParams
const url = new URL(req.url, `http://${req.headers.host}`);
const { method } = req;
if (url.pathname === "/products") {
if (method === "GET") {
// 取单条 ?id=3
const id = url.searchParams.get("id");
if (id) {
const p = products.find(x => x.id === parseInt(id, 10));
return p ? sendJSON(res, p)
: sendJSON(res, { error: `未找到 id=${id}` }, 404);
}
return sendJSON(res, products);
}
if (method === "POST") {
const body = JSON.parse(await readBody(req));
body.id = products.length + 1;
products.push(body);
return sendJSON(res, body, 201);
}
}
sendJSON(res, { error: "路由不存在" }, 404);
});
server.listen(3000, () => console.log("API 已启动: http://localhost:3000"));
// 测试(另开终端):
// $ curl http://localhost:3000/products
// [{"id":1,"name":"苹果","price":5},{"id":2,"name":"香蕉","price":3},{"id":3,"name":"橙子","price":4}]
// $ curl 'http://localhost:3000/products?id=2'
// {"id":2,"name":"香蕉","price":3}
// $ curl -X POST http://localhost:3000/products \
// -H 'Content-Type: application/json' -d '{"name":"葡萄","price":8}'
// {"id":4,"name":"葡萄","price":8}要点讲解:
new URL(req.url, base):Node 内置 URL 解析器;url.pathname取路由,url.searchParams.get("id")取查询参数——比手动 split 干净。第二参数base必须给(虽然是相对路径)。- 读 body:Node 的请求是流——必须监听
"data"收 chunks、"end"拼完整。用new Promise包成可await的形式,代码更线性。 asynchandler:createServer(async (req, res) => ...)让处理函数支持await——读 body、查数据库都能写得像同步代码。注意未捕获的 reject 要.catch兜底。- 状态码:
res.writeHead(200/201/404/500, ...)必须在res.end()之前设;201 Created 用于 POST 新建,404 用于不存在——REST 客户端靠它判断成败。 - 对比 Express:Express 把"路由分发、参数解析、body 解析、错误处理"全包装好了,5 行能搞定这里的 50 行。理解了原理再用框架,才不会"离了框架不会写"。
实战 3:CLI 文件处理(fs + path + 命令行参数)
需求:写一个命令行工具,递归扫描目录下所有 .txt 文件,统计每个文件和总计的行数、词数、字符数。支持 node wordcount.js ./docs 传参。
涉及知识:fs.promises 异步 API、path.join/basename、递归遍历目录、process.argv、async/await、fs.readdir({ withFileTypes: true })。
// wordcount.js — 递归扫描目录下的 .txt 文件,统计行/词/字符数
// 运行:node wordcount.js ./docs
const fs = require("fs").promises; // 用 promise 版,可 async/await
const path = require("path");
// 递归遍历目录,返回所有文件的完整路径
async function walk(dir) {
let results = [];
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
results = results.concat(await walk(full)); // 目录:递归
} else {
results.push(full); // 文件:收集
}
}
return results;
}
async function main() {
// 1. 从命令行参数取目录
const root = process.argv[2] || ".";
const files = (await walk(root)).filter(f => f.endsWith(".txt"));
console.log(`扫描到 ${files.length} 个 .txt 文件`);
// 2. 逐个统计;累加总计
let totalLines = 0, totalWords = 0, totalChars = 0;
for (const file of files) {
const content = await fs.readFile(file, "utf-8");
const lines = content.split("\n").length; // 按换行符数行数
const words = content.trim().split(/\s+/).filter(Boolean).length; // 按空白分词
const chars = content.length;
totalLines += lines;
totalWords += words;
totalChars += chars;
console.log(` ${path.basename(file)}: ${lines} 行, ${words} 词, ${chars} 字符`);
}
// 3. 输出汇总
console.log(`合计: ${totalLines} 行, ${totalWords} 词, ${totalChars} 字符`);
}
main().catch(err => {
console.error("出错:", err.message);
process.exit(1);
});
// 运行:node wordcount.js ./docs
// 输出:
// 扫描到 3 个 .txt 文件
// readme.txt: 42 行, 318 词, 2047 字符
// notes.txt: 15 行, 96 词, 612 字符
// todo.txt: 8 行, 52 词, 311 字符
// 合计: 65 行, 466 词, 2970 字符要点讲解:
fs.promises:Node 10+ 提供 promise 版 fs,require("fs").promises——配合async/await写异步文件代码,告别回调地狱。readdir({ withFileTypes: true }):返回Dirent对象,直接entry.isDirectory()判断类型,省去每个文件fs.stat一次的开销。- 递归遍历:
walk(dir)是经典模式——目录就递归,文件就收集;返回所有文件路径。这是所有"目录扫描"工具的雏形。 - 统计技巧:行数 =
split("\n").length;词数 = 按空白分词后过滤空串;filter(Boolean)去掉空字符串(文件末尾换行会产生)。 path.basename:取文件名(去掉目录),输出更简洁;对应的path.dirname取目录、path.extname取扩展名。- 进阶方向:用
commander库加--help、加--ext js,ts多扩展名过滤、并行处理(Promise.all)、输出 CSV 报告。
小结
三个项目分别对应 Node.js 三大主力场景:Web 服务(静态文件)、API 服务(JSON 接口)、CLI 工具(自动化脚本)。它们都用到了内置 http/fs/path——这是 Node 的"地基",所有框架(Express/Koa/Fastify)都建在上面。建议至少把其中一个改造成自己的版本(给静态服务器加缓存、给 API 接 SQLite、给 CLI 加 Markdown 支持和 HTML 输出),做过一遍比读十遍更扎实。
恭喜!到这里 Node.js 系列就真正完结了。接下来挑一个方向深入——Web 框架(Express / Koa / NestJS)、数据库集成(Prisma / TypeORM)、实时通信(Socket.io / WebSocket)、或前端工具链(Vite / Webpack 内部都是 Node)——边做边学,进步最快。
← 上一篇 Node.js 调试
返回 Node.js 教程目录