Node.js 练习题(10 题)

这一页给 10 道 Node.js 练习题,覆盖REPL、CommonJS、fs、path、http、process、异步、npm、events、实战这些最核心的知识点。每题都有题目描述、提示、可折叠的参考答案。

使用建议:每段代码都可以复制到本地用 node xxx.js 直接跑——Node 不需要装环境,装好 Node 就行。先自己写,卡住了再看提示,实在写不出再对照答案。

基础(5 题):REPL / 模块 / fs / path / http

第 1 题:REPL 与第一个脚本

题目:写一个 hello.js,打印一句问候语和四个进程信息(版本、平台、PID、当前目录)。说明 REPL 是什么,怎么进入和退出。

提示:process.version 看版本,process.platform 看平台(darwin/linux/win32),process.cwd() 看工作目录。REPL 就是终端敲 node 进入的交互环境,退出用 .exit 或按两次 Ctrl+C。坑:新手常把 process.platform 误写成 process.os。

参考答案
// hello.js — 第一个 Node.js 脚本
console.log('Hello from Node.js');

// 进程信息(常用)
console.log('Node 版本:', process.version);    // v20.x.x
console.log('当前平台:', process.platform);    // darwin / linux / win32
console.log('进程 PID:', process.pid);
console.log('当前目录:', process.cwd());

// 运行方式:在终端执行
//   $ node hello.js

// REPL(交互式命令行):终端直接敲 node 回车,可实时求值
//   $ node
//   > 1 + 1
//   2
//   > process.version
//   'v20.5.0'
//   > .exit    // 或按两次 Ctrl+C 退出

第 2 题:CommonJS 模块

题目:写一个 math.js 导出 add/sub/PI,再写一个 app.js 引入它并调用。说明 module.exports 和 exports 的区别,以及相对路径、内置模块的引入方式。

提示:module.exports 是真正的导出对象,exports 只是对它的引用——不要写 exports = {...}(会断开引用,导出失败)。引入用 require('./math'),相对路径必须带 ./,否则 Node 会去 node_modules 和内置模块找。扩展名 .js 可省略。

参考答案
// === math.js — 提供给别人的模块 ===
function add(a, b) { return a + b; }
function sub(a, b) { return a - b; }
const PI = 3.14159;

// 导出:推荐写法
module.exports = { add, sub, PI };

// 另一种写法(等价):挂到 exports 上
// exports.add = add;
// exports.sub = sub;
// 警告:不要写 exports = { add } —— 这会断开 exports 和 module.exports 的链接

// === app.js — 使用方 ===
const math = require('./math');     // 相对路径必须带 ./ 或 ../
console.log(math.add(2, 3));        // 5
console.log(math.PI);               // 3.14159

// 内置模块不用写路径
const fs = require('fs');
const path = require('path');
const http = require('http');

// 注意:.js 扩展名可省略,require('./math') 自动找 math.js
// 找不到 .js 会找 ./math/index.js(目录的入口)

第 3 题:fs 模块读写文件

题目:写一个脚本读 input.txt 的内容,转大写到 output.txt,再追加一行日志到 log.txt。分别用同步、回调、async/await三种方式实现,并说明各自适用场景。

提示:同步 readFileSync/writeFileSync 简单但会阻塞事件循环(Web 服务器千万勿用,CLI 工具可用);回调用 readFile/writeFile;Promise 版用 require('fs').promises 配合 async/await。判断文件是否存在:fs.exists 已废弃,改用 fs.accessSync(path, fs.constants.R_OK)。

参考答案
const fs = require('fs');
const fsp = require('fs').promises;   // Promise 版本(推荐)

// === 1. 同步(简单但会阻塞——CLI 工具可用,Web 服务器勿用) ===
const text = fs.readFileSync('input.txt', 'utf8');
console.log('读到', text.length, '字符');
fs.writeFileSync('output.txt', '写入的内容');

// === 2. 异步回调(传统写法,容易嵌套) ===
fs.readFile('input.txt', 'utf8', (err, data) => {
  if (err) return console.error(err);
  console.log('异步读到', data.length, '字符');
});

// === 3. 异步 Promise + async/await(推荐) ===
async function main() {
  try {
    const txt = await fsp.readFile('input.txt', 'utf8');
    await fsp.writeFile('upper.txt', txt.toUpperCase());
    await fsp.appendFile('log.txt', '处理完成\n');
    console.log('完成');
  } catch (err) {
    console.error('出错:', err.message);
  }
}
main();

// 判断文件是否存在:别用 fs.exists(已废弃),用 access
fs.accessSync('input.txt', fs.constants.R_OK);   // 不可读会抛错

第 4 题:path 模块

题目:演示 path.join/resolve/dirname/basename/extname/normalize 的用法和区别,并说明 path.sep、path.delimiter 在不同平台的差异。

提示:join 只是拼接片段,resolve 会从左往右解析成绝对路径(遇到 / 开头就重置);dirname/basename/extname 分别取目录、文件名、扩展名。跨平台关键:永远用 path.join 拼路径,不要手写 'a/' + 'b',Windows 上分隔符是反斜杠。

参考答案
const path = require('path');

// 拼接:跨平台自动处理分隔符(/ 或 \)
path.join('a', 'b', 'c.txt');
// Linux/Mac: 'a/b/c.txt'      Windows: 'a\b\c.txt'

// 绝对路径(从当前目录或根解析)
path.resolve('a', 'b');           // /当前工作目录/a/b
path.resolve('/tmp', 'x');        // /tmp/x

// 拆解一个路径
const f = '/home/me/code/app.js';
path.dirname(f);                  // '/home/me/code'
path.basename(f);                 // 'app.js'
path.basename(f, '.js');          // 'app'  (去掉扩展名)
path.extname(f);                  // '.js'

// 规范化(消掉 . 和 ..)
path.normalize('/a/./b/../c');    // '/a/c'

// 两个特殊属性
path.sep;                          // 路径分隔符: '/' 或 '\'
path.delimiter;                    // PATH 分隔符: ':' 或 ';'

// 实用:拿到当前模块的绝对目录(替代 __dirname,ESM 里没有 __dirname)
const dir = __dirname;
const file = path.join(__dirname, 'data.txt');

第 5 题:http 模块建服务器

题目:用内置 http 模块(不装 express)起一个服务器,实现三个路由:首页返回 HTML、/api/time 返回 JSON 时间戳、其他返回 404。监听 3000 端口。

提示:http.createServer((req, res) => {}) 收请求;res.writeHead(statusCode, headers) 设响应头,res.end(body) 结束响应并写体。响应头要在 end 之前设;JSON 要先 JSON.stringify;Content-Type 一定带 charset=utf-8 否则中文乱码。

参考答案
const http = require('http');

const server = http.createServer((req, res) => {
  // req 进来的请求,res 要返回的响应
  console.log(req.method, req.url);          // 'GET' '/'

  // 根据路由分发
  if (req.url === '/' && req.method === 'GET') {
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end('<h1>首页</h1><p>欢迎</p>');
  } else if (req.url === '/api/time') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ now: Date.now() }));
  } else {
    res.writeHead(404, { 'Content-Type': 'text/plain' });
    res.end('404 Not Found');
  }
});

server.listen(3000, () => {
  console.log('服务器已启动: http://localhost:3000');
});

// 运行: node server.js
// 浏览器访问 http://localhost:3000 和 http://localhost:3000/api/time
// 停止: Ctrl + C

进阶(5 题):process / 异步 / npm / events / 实战

第 6 题:process 参数与环境变量

题目:写一个 CLI,从命令行取一个名字参数,打印 Hello, 名字(默认 World);从环境变量取 PORT(默认 3000)和 DATABASE_URL(缺失则报错退出)。说明 process.argv 数组前两项是什么。

提示:process.argv 前 2 项是 node 路径和脚本路径,用户参数从 argv[2] 开始,所以常用 process.argv.slice(2) 取真实参数。坑:process.env 全是字符串,数字要 Number() 转换;默认值也别忘了字符串化。process.exit(1) 非零退出码表示出错。.env 文件用 dotenv 加载。

参考答案
// process.argv:命令行参数数组
// [0] = node 可执行文件路径
// [1] = 当前脚本路径
// [2..] = 用户传进来的参数
const args = process.argv.slice(2);
console.log('用户参数:', args);

// 简单解析(生产环境用 commander / yargs 模块)
const name = args[0] || 'World';
console.log('Hello, ' + name);

// process.env:环境变量(读出来都是字符串!)
const port = process.env.PORT || '3000';     // 注意默认值也要字符串
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
  console.error('错误: 缺少 DATABASE_URL');
  process.exit(1);                            // 非零退出码表示出错
}

// 运行示例:
//   $ node cli.js Alice
//   用户参数: [ 'Alice' ]
//   Hello, Alice
//
//   $ PORT=8080 DATABASE_URL=xxx node server.js
//   (然后代码里 process.env.PORT === '8080')

// .env 文件:用 dotenv 包加载 require('dotenv').config()

第 7 题:异步:回调 / Promise / async-await

题目:顺序读取 a.txt 和 b.txt 后拼接打印。分别用回调、Promise then 链、async/await三种方式写。再用 Promise.all 改造成并发版本。

提示:回调嵌套两层就开始乱(回调地狱);Promise 把嵌套扁平化成 then 链;async/await 让异步代码长得像同步,错误用 try/catch。性能关键:两个独立任务顺序 await 是串行的(慢),用 Promise.all([a, b]) 才是并发(快)。

参考答案
const fs = require('fs');
const fsp = require('fs').promises;

// === 1. 回调(传统,容易回调地狱) ===
fs.readFile('a.txt', 'utf8', (err, dataA) => {
  if (err) return console.error(err);
  fs.readFile('b.txt', 'utf8', (err, dataB) => {
    if (err) return console.error(err);
    console.log(dataA + dataB);     // 嵌套两层,再多就乱
  });
});

// === 2. Promise(then 链,扁平化了) ===
fsp.readFile('a.txt', 'utf8')
  .then(dataA => fsp.readFile('b.txt', 'utf8').then(dataB => dataA + dataB))
  .then(combined => console.log(combined))
  .catch(err => console.error(err));

// === 3. async/await(推荐:像同步代码一样写异步) ===
async function readBoth() {
  try {
    const dataA = await fsp.readFile('a.txt', 'utf8');
    const dataB = await fsp.readFile('b.txt', 'utf8');
    console.log(dataA + dataB);
  } catch (err) {
    console.error('出错:', err.message);
  }
}
readBoth();

// 性能优化:多个独立任务用 Promise.all 并发,不要顺序 await
async function parallel() {
  const [a, b] = await Promise.all([
    fsp.readFile('a.txt', 'utf8'),     // 两个文件同时读
    fsp.readFile('b.txt', 'utf8')
  ]);
  console.log(a, b);
}

第 8 题:npm 包管理

题目:从零开始:初始化项目、装运行时依赖 express、装开发依赖 nodemon、写 scripts 让 npm run dev 启动 nodemon。说明 dependencies vs devDependencies 的区别,以及 ^ 和 ~ 版本前缀的含义。

提示:npm init -y 生成 package.json;npm i 包名 装运行依赖,-D 装开发依赖(测试、构建工具等不会上线的东西);-g 全局装命令行工具。版本前缀:^1.2.3 允许 1.x.x(不破坏主版本),~1.2.3 只允许 1.2.x。特殊脚本:start 和 test 可省略 run,其他必须 npm run xxx。

参考答案
# === 1. 初始化项目(在当前目录生成 package.json) ===
npm init -y                    # -y 表示全部用默认值

# === 2. 安装依赖 ===
npm install express            # 写到 dependencies(运行时依赖)
npm i express                  # 简写
npm install --save-dev nodemon # 写到 devDependencies(只开发时用)
npm i -D nodemon               # 简写
npm install -g pm2             # 全局安装(命令行工具)

# === 3. 卸载 / 更新 ===
npm uninstall express          # 卸载
npm update                     # 按语义化版本范围更新

# === package.json 关键字段 ===
{
  "name": "my-app",
  "version": "1.0.0",
  "type": "commonjs",            // 或 "module"(用 ESM)
  "main": "index.js",            // 包的入口
  "scripts": {                   // 自定义命令:npm run xxx
    "start": "node index.js",
    "dev": "nodemon index.js",
    "test": "jest"
  },
  "dependencies": {
    "express": "^4.18.0"          // ^ 允许 4.x.x 自动升级
  },
  "devDependencies": {
    "nodemon": "^3.0.0"
  }
}

# 运行脚本:start 和 test 是特殊的,可省 run
#   npm start   等价于  npm run start
#   npm test
# 其他必须带 run:
#   npm run dev

第 9 题:events 模块(EventEmitter)

题目:创建一个 EventEmitter 实例,监听 greet 事件(可多次触发)和 boot 事件(只触发一次)。再写一个继承 EventEmitter 的 Counter 类,每次 inc() 都广播 change 事件带上当前值。

提示:on(event, fn) 监听,once 只触发一次,emit(event, ...args) 触发并传参。Node 内部大量用这个模式(stream、http 都是 EventEmitter)。继承时记得 super()。注意:EventEmitter 是同步触发的——emit 会立即把所有监听器按顺序跑完。

参考答案
const EventEmitter = require('events');

// 直接实例化一个发射器(用作事件总线)
const bus = new EventEmitter();

// 监听(可多次触发)
bus.on('greet', (name) => {
  console.log('你好,' + name);
});

// 只监听一次
bus.once('boot', () => console.log('系统启动(只会触发一次)'));

// 触发
bus.emit('greet', '张三');       // 你好,张三
bus.emit('greet', '李四');       // 你好,李四
bus.emit('boot');                // 系统启动
bus.emit('boot');                // (没反应,once 已自动解绑)

// 经典模式:自定义类继承 EventEmitter
class Counter extends EventEmitter {
  constructor() {
    super();
    this.n = 0;
  }
  inc() {
    this.n++;
    this.emit('change', this.n);   // 每次自增都广播事件
  }
}

const c = new Counter();
c.on('change', v => console.log('当前值:', v));
c.inc();   // 当前值: 1
c.inc();   // 当前值: 2

第 10 题:实战:命令行读取目录并统计文件数

题目:写一个 count-files.js,接受一个目录参数(默认 .),递归统计该目录下的文件总数和子目录数,打印结果。目录不存在要报错退出。

提示:用 fs.readdirSync(dir, { withFileTypes: true }) 拿 Dirent 对象,可以直接调 .isDirectory()/.isFile()(比 fs.statSync 快很多);递归累加子目录的统计;从 process.argv[2] 取参数。坑:别用 fs.existsSync 判断后再读——有 TOCTOU 竞态(判断和读之间文件可能变化),不过对 CLI 工具够用了。

参考答案
// count-files.js — 递归统计目录里的文件和子目录数量
// 用法: node count-files.js ./src
const fs = require('fs');
const path = require('path');

function countFiles(dir) {
  let files = 0;
  let dirs = 0;
  // withFileTypes:返回 Dirent 而不是字符串,可直接调 isDirectory
  const entries = fs.readdirSync(dir, { withFileTypes: true });
  for (const entry of entries) {
    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      dirs++;
      const sub = countFiles(full);    // 递归
      files += sub.files;
      dirs += sub.dirs;
    } else if (entry.isFile()) {
      files++;
    }
    // 符号链接、设备文件等会跳过
  }
  return { files, dirs };
}

// 从命令行取参数:argv[2] 是用户传的第一个参数
const target = process.argv[2] || '.';
const abs = path.resolve(target);

if (!fs.existsSync(abs)) {
  console.error('错误: 目录不存在 ->', abs);
  process.exit(1);
}

const result = countFiles(abs);
console.log('目录:', abs);
console.log('文件总数:', result.files);
console.log('子目录数:', result.dirs);

// 运行示例:
//   $ node count-files.js ./src
//   目录: /Users/me/project/src
//   文件总数: 42
//   子目录数: 8

练习建议

Node.js 的核心是「JavaScript 跑在服务端」——浏览器里的 JS 能做的它都能做,加上完整的文件、网络、进程能力。掌握模块系统(CommonJS/ESM)、异步(回调→Promise→async/await)、fs/path/http 三大内置模块、process 接口,就能写绝大多数后端小工具。

做完这 10 题,建议扩展第 10 题:加 --ext .js 参数只统计某种后缀;加 --exclude node_modules 排除目录;输出成 JSON 文件——这就是一个真正可用的命令行工具了。

← 上一篇 Node.js 实战项目

返回 Node.js 教程目录

✈️💬