NodeJs教程
第一阶段:Node.js 基础与核心概念 (约6节)
好的,欢迎来到 Node.js 学习的第一阶段!我们将从最基础的概念开始,逐步深入。
第 1 节:Node.js 简介与环境搭建
1.1 什么是 Node.js?为什么选择 Node.js?
什么是 Node.js?
Node.js 是一个开源的、跨平台的 JavaScript 运行时环境。简单来说,它允许你在浏览器之外运行 JavaScript 代码。
-
运行时环境 (Runtime Environment): 就像 Java 有 JVM (Java Virtual Machine) 一样,Node.js 提供了一个环境,让 JavaScript 代码可以在服务器、桌面应用、命令行工具等地方执行。
-
基于 Chrome V8 引擎: Node.js 的核心是 Google Chrome 浏览器使用的 V8 JavaScript 引擎。V8 引擎负责将 JavaScript 代码编译成高效的机器码,从而实现极高的执行速度。
-
事件驱动 (Event-driven): Node.js 采用事件驱动模型,这意味着它通过监听和响应事件来执行代码,而不是等待任务完成。
-
非阻塞 I/O (Non-blocking I/O): 这是 Node.js 的一个关键特性。当执行一个耗时的输入/输出 (I/O) 操作(如文件读写、网络请求、数据库查询)时,Node.js 不会等待该操作完成,而是立即继续执行后续代码。当 I/O 操作完成后,它会通过回调函数或 Promise 通知 Node.js,然后由事件循环处理。
-
单线程 (Single-threaded): Node.js 的 JavaScript 执行是单线程的。这简化了并发模型,避免了多线程编程中常见的复杂问题(如死锁、竞态条件)。但它通过非阻塞 I/O 和事件循环来高效处理并发请求,而不是为每个请求创建新线程。
为什么选择 Node.js?
-
高性能与高并发:
-
非阻塞 I/O 和事件循环: 这是 Node.js 最大的优势。它能够以极低的资源消耗处理大量并发连接,非常适合 I/O 密集型应用(如实时聊天、API 服务)。
-
V8 引擎: 编译执行 JavaScript,速度快。
-
-
统一语言栈 (Full-stack JavaScript):
-
前端和后端都使用 JavaScript,这意味着开发者可以复用代码、共享知识,提高开发效率。
-
团队协作更顺畅,减少了语言切换带来的心智负担。
-
-
庞大的生态系统 (NPM - Node Package Manager):
-
NPM 是世界上最大的开源库生态系统,拥有数百万个可重用的模块。
-
这意味着你可以轻松找到并集成各种功能,从数据库驱动到 Web 框架,大大加速开发进程。
-
-
快速开发周期:
-
JavaScript 的灵活性和 NPM 的丰富模块使得 Node.js 项目的开发速度非常快。
-
热重载、模块化等特性也提升了开发体验。
-
-
活跃的社区支持:
- Node.js 拥有一个庞大且活跃的开发者社区,可以轻松找到文档、教程和问题解决方案。
1.2 Node.js 的应用场景
Node.js 因其独特的特性,在许多领域都有广泛应用:
-
Web 服务器和 API 服务:
-
构建高性能的 RESTful API 和微服务。
-
处理大量并发请求,如电商后端、社交媒体 API。
-
-
实时应用:
-
聊天应用、在线游戏、协作工具(如 Google Docs)。
-
利用 WebSocket 实现服务器与客户端的双向通信。
-
-
数据流应用:
-
处理文件上传、视频流、日志处理等。
-
Node.js 的流 (Stream) 机制非常高效。
-
-
命令行工具 (CLI Tools):
- 许多流行的前端构建工具(如 Webpack, Gulp, Grunt)和包管理器(如 npm, yarn)都是用 Node.js 编写的。
-
服务器端渲染 (SSR):
- 与 React、Vue 等前端框架结合,实现服务器端渲染,提高首屏加载速度和 SEO。
-
物联网 (IoT):
- 轻量级、事件驱动的特性使其适合在资源受限的设备上运行。
-
桌面应用:
- 使用 Electron 框架,可以用 Node.js 和 Web 技术构建跨平台的桌面应用(如 VS Code, Slack)。
1.3 安装 Node.js (LTS 版本)
推荐安装 LTS (Long Term Support) 版本,因为它更稳定,适合生产环境。
安装步骤:
-
访问官方网站: 前往 Node.js 官方网站:https://nodejs.org/
-
下载安装包: 在首页你会看到两个下载选项:LTS (长期支持) 和 Current (最新特性)。点击 LTS 版本的下载按钮,它会自动识别你的操作系统并提供相应的安装包(.msi for Windows, .pkg for macOS, .tar.xz for Linux)。
-
运行安装程序:
-
Windows/macOS: 双击下载的安装包,按照安装向导的提示一步步操作即可。通常,一路点击“Next”并接受默认设置即可。安装程序会自动配置环境变量。
-
Linux: 可以通过包管理器(如
aptfor Debian/Ubuntu,yumfor CentOS/RHEL)或使用nvm(Node Version Manager) 进行安装。推荐使用nvm,因为它允许你在同一台机器上轻松切换不同版本的 Node.js。-
使用 nvm 安装 (推荐,尤其对于开发者):
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash # 安装完成后,重启终端或执行 source ~/.bashrc (或 ~/.zshrc) nvm install --lts # 安装最新的LTS版本 nvm use --lts # 使用LTS版本 nvm alias default lts # 设置默认使用LTS版本
-
-
-
验证安装: 打开你的终端或命令提示符,输入以下命令:
node -v npm -v如果安装成功,它们会分别显示 Node.js 和 第 15 节:构建 RESTful API
RESTful API 设计原则。
使用 Express.js 和 Mongoose 实现 RESTful API 的 CRUD 接口。
数据验证与错误处理。
第 16 节:用户认证与授权 (JWT)
会话 (Session) 和令牌 (Token) 认证机制对比。
JWT (JSON Web Token) 简介与工作原理。
使用 jsonwebtoken 库实现注册、登录和保护路由。npm (Node Package Manager,随 Node.js 一起安装) 的版本号。
1.4 REPL (Read-Eval-Print Loop) 交互式环境使用
REPL 是 Node.js 提供的一个交互式命令行环境,非常适合快速测试 JavaScript 代码片段、调试或学习 Node.js API。
如何进入 REPL:
在终端或命令提示符中输入 node 并按回车键:
node
你会看到一个 > 提示符,表示你已进入 REPL 环境。
基本使用:
你可以直接输入 JavaScript 代码并按回车执行:
> console.log("Hello, REPL!");
Hello, REPL!
undefined
> 1 + 1
2
> let name = "Node.js";
undefined
> name
'Node.js'
> function greet(n) { return `Hi, ${n}!`; }
undefined
> greet("World")
'Hi, World!'
REPL 特殊命令:
-
.help: 显示所有 REPL 命令。 -
.exit: 退出 REPL 环境。你也可以按Ctrl + C两次。 -
.save <filename>: 将当前 REPL 会话中输入的所有代码保存到文件中。 -
.load <filename>: 加载并执行指定文件中的 JavaScript 代码。 -
.clear: 清除当前上下文(但不会清除历史记录)。 -
Tab键:自动补全。 -
上/下箭头键: 浏览历史命令。
1.5 运行第一个 Node.js 文件
现在,让我们来运行一个简单的 Node.js 文件。
-
创建文件: 在你选择的目录下创建一个新文件,命名为
hello.js。 -
编写代码: 在
hello.js文件中输入以下内容:// hello.js console.log("Hello from Node.js!"); const os = require('os'); // 引入Node.js内置的os模块 console.log(`Your operating system is: ${os.platform()}`); console.log(`Node.js version: ${process.version}`); // 使用process全局对象 -
打开终端/命令提示符: 导航到你保存
hello.js文件的目录。- 例如,如果文件在
C:\Users\YourUser\my-node-app,则在终端中输入cd C:\Users\YourUser\my-node-app。
- 例如,如果文件在
-
运行文件: 在终端中输入以下命令:
node hello.js你将看到类似以下的输出:
Hello from Node.js! Your operating system is: win32 (或 darwin/linux) Node.js version: v20.11.1 (或你安装的版本)恭喜你,你已经成功运行了你的第一个 Node.js 文件!
1.6 process 全局对象简介
在上面的 hello.js 例子中,我们使用了 process.version。process 是 Node.js 提供的一个全局对象,它提供了关于当前 Node.js 进程的信息和控制能力。你不需要 require 它,因为它总是可用的。
process 对象的一些常用属性和方法:
-
process.argv: 一个数组,包含启动 Node.js 进程时传入的命令行参数。-
第一个元素是
node命令的路径。 -
第二个元素是当前执行的 JavaScript 文件的路径。
-
后续元素是你在命令行中传入的其他参数。
-
示例: 如果你运行
node app.js arg1 arg2,那么process.argv将是['/path/to/node', '/path/to/app.js', 'arg1', 'arg2']。
-
-
process.env: 一个对象,包含用户环境的所有环境变量。你可以通过它访问操作系统级别的环境变量。- 示例:
console.log(process.env.PATH);
- 示例:
-
process.cwd(): 返回 Node.js 进程的当前工作目录。 -
process.exit([code]): 终止当前 Node.js 进程。code是可选的退出码,默认为0(表示成功)。非零值通常表示错误。 -
process.version: Node.js 的版本字符串(例如v20.11.1)。 -
process.platform: 运行 Node.js 的操作系统平台(例如win32,darwin,linux)。 -
process.uptime(): 返回 Node.js 进程已运行的秒数。 -
process.memoryUsage(): 返回 Node.js 进程的内存使用情况(以字节为单位)。 -
process.nextTick(callback): 将回调函数添加到“微任务队列”中,在当前事件循环迭代的末尾,但在任何 I/O 操作之前执行。这将在后续的事件循环章节中详细讲解。
示例:使用 process.argv
创建一个 args.js 文件:
// args.js
console.log("命令行参数:", process.argv);
// 访问自定义参数 (跳过前两个默认参数)
const customArgs = process.argv.slice(2);
if (customArgs.length > 0) {
console.log("你传入的自定义参数是:", customArgs.join(', '));
} else {
console.log("没有传入自定义参数。");
}
在终端运行:
node args.js hello world 123
输出:
命令行参数: [
'/usr/local/bin/node', // Node.js 可执行文件路径
'/path/to/your/args.js', // 当前执行文件路径
'hello',
'world',
'123'
]
你传入的自定义参数是: hello, world, 123
通过本节的学习,你已经对 Node.js 有了初步的认识,并掌握了基本的环境搭建和文件运行方法。接下来,我们将深入探讨 Node.js 的核心运行机制:V8 引擎与事件循环。
好的,我们来深入探讨 Node.js 的核心运行机制:V8 引擎与事件循环 (Event Loop)。理解这两者是理解 Node.js 高性能和异步特性的关键。
2.1 Node.js 如何运行 JavaScript?V8 引擎的作用
Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行时。要理解 Node.js 如何运行 JavaScript,首先要了解 V8 引擎。
V8 引擎是什么?
-
JavaScript 引擎: V8 是 Google 开发的开源高性能 JavaScript 和 WebAssembly 引擎。
-
作用: 它的主要任务是将 JavaScript 代码编译成机器码,然后由计算机直接执行。
-
即时编译 (JIT - Just-In-Time Compilation): V8 不仅仅是解释器,它还包含一个即时编译器。这意味着它在运行时将 JavaScript 代码编译成高效的机器码,而不是逐行解释执行,从而大大提高了 JavaScript 的执行速度。
-
内存管理: V8 还负责内存分配和垃圾回收。
V8 引擎在 Node.js 中的作用:
Node.js 的核心就是 V8 引擎。
-
执行 JavaScript 代码: V8 引擎是 Node.js 执行 JavaScript 代码的“心脏”。当你运行一个 Node.js 应用程序时,V8 负责解析、编译和执行你的 JavaScript 代码。
-
高性能: V8 的高性能特性使得 Node.js 能够快速处理大量的请求和复杂的业务逻辑。
-
跨平台: V8 引擎本身是跨平台的,这使得 Node.js 可以在 Windows、macOS、Linux 等多种操作系统上运行。
-
提供核心对象: V8 提供了 JavaScript 的基本对象(如
Object,Array,Function等)和运行时环境。
总结: 可以把 Node.js 理解为一个“容器”,它将 V8 引擎嵌入其中,并在此基础上添加了许多 C++ 编写的模块(如文件系统、网络、加密等),这些模块通过 V8 提供的接口暴露给 JavaScript,使得 JavaScript 能够进行服务器端操作。
2.2 理解事件驱动、非阻塞 I/O 模型
这是 Node.js 区别于许多传统服务器端语言(如 PHP、Ruby on Rails、Java Servlet)的关键特性。
单线程 (Single-threaded):
-
Node.js 的 JavaScript 执行是单线程的。这意味着在任何给定时刻,JavaScript 代码只在一个线程上运行。
-
优点: 简化了并发模型,避免了多线程编程中常见的死锁、竞态条件等复杂问题。
-
挑战: 如果有长时间运行的同步计算任务(CPU 密集型),它会阻塞主线程,导致整个应用程序停滞。
事件驱动 (Event-driven):
-
Node.js 应用程序的核心是事件循环。
-
当一个操作完成时(例如,文件读取完成,网络请求收到响应),它会触发一个“事件”。
-
应用程序会“监听”这些事件,并在事件发生时执行相应的“回调函数”。
-
这种模型使得 Node.js 能够高效地处理大量并发连接,因为它不是为每个连接创建一个新线程,而是通过事件和回调来管理它们。
非阻塞 I/O (Non-blocking I/O):
-
I/O (Input/Output): 指的是输入/输出操作,例如读取文件、写入数据库、发送网络请求等。这些操作通常比 CPU 计算慢得多。
-
阻塞 I/O (Blocking I/O): 在传统的阻塞 I/O 模型中,当一个 I/O 操作开始时,程序会暂停执行,直到该 I/O 操作完成并返回结果,然后才能继续执行后续代码。这意味着在等待 I/O 的过程中,CPU 处于空闲状态,无法处理其他任务。
-
非阻塞 I/O (Non-blocking I/O): Node.js 采用非阻塞 I/O。当发起一个 I/O 操作时,Node.js 会立即将该操作交给底层系统(通常是操作系统内核或线程池)去处理,然后立即返回,继续执行后续的 JavaScript 代码,而不会等待 I/O 操作完成。
-
如何知道 I/O 完成了? 当底层系统完成 I/O 操作后,它会通知 Node.js,并将相应的回调函数放入事件队列。Node.js 的事件循环会在主线程空闲时,从队列中取出这些回调函数并执行。
总结:
Node.js 的单线程、事件驱动、非阻塞 I/O 模型使得它非常适合构建高性能、高并发的网络应用(如 Web 服务器、API 网关),因为它能够高效地处理大量的并发连接,而不会因为等待 I/O 操作而阻塞。
2.3 事件循环机制深入解析 (Phases, Microtasks vs Macrotasks)
事件循环 (Event Loop) 是 Node.js 实现非阻塞 I/O 的核心机制。它是一个持续运行的循环,负责检查调用栈、事件队列,并决定何时执行哪些回调函数。
核心组件:
-
调用栈 (Call Stack): 存放正在执行的同步 JavaScript 代码。当函数被调用时,它被推入栈中;当函数执行完毕时,它被弹出。
-
Node.js APIs (C++ APIs): Node.js 提供的异步操作接口,如
fs.readFile(),http.get(),setTimeout()等。当调用这些 API 时,它们会将耗时操作交给底层系统处理,并注册一个回调函数。 -
事件队列 (Event Queue / Callback Queue / Macrotask Queue): 当异步操作完成时,其对应的回调函数会被放入这个队列中等待执行。
-
微任务队列 (Microtask Queue): 优先级更高的队列,用于存放
process.nextTick()和 Promise 的.then(),.catch(),.finally()回调。
事件循环的执行流程 (Phases):
Node.js 的事件循环分为几个阶段,每个阶段都有自己的特定任务和队列。当事件循环进入某个阶段时,它会执行该阶段的所有回调,直到队列为空或达到执行上限,然后进入下一个阶段。
-
timers(定时器阶段):-
执行
setTimeout()和setInterval()的回调。 -
这些回调的执行时间取决于它们设定的延迟时间,但并不保证精确。
-
-
pending callbacks(待定回调阶段):- 执行一些系统操作的回调,例如 TCP 错误。
-
idle, prepare(空闲/准备阶段):- Node.js 内部使用,不直接与用户代码相关。
-
poll(轮询阶段):-
核心阶段。
-
检查 I/O 事件: 大多数 I/O 回调(如文件读取、网络请求、数据库查询)都在此阶段执行。
-
检查定时器: 如果
timers队列为空,并且有新的定时器到期,事件循环可能会在此阶段停留,等待新的 I/O 事件或定时器到期。 -
执行
setImmediate: 如果poll队列为空,并且setImmediate队列中有回调,事件循环会跳到check阶段执行setImmediate回调。
-
-
check(检查阶段):- 执行
setImmediate()的回调。
- 执行
-
close callbacks(关闭回调阶段):- 执行一些关闭事件的回调,例如
socket.on('close')。
- 执行一些关闭事件的回调,例如
每次事件循环迭代的顺序:
当调用栈清空后,事件循环会按照上述阶段的顺序进行迭代。在每个阶段之间,以及在每个阶段执行完其所有(或部分)回调之后,事件循环都会检查并清空微任务队列。
微任务 (Microtasks) vs. 宏任务 (Macrotasks):
这是理解事件循环中回调执行顺序的关键。
-
宏任务 (Macrotasks):
-
包括:
setTimeout(),setInterval(),setImmediate(), I/O 操作的回调(如fs.readFile的回调)、requestAnimationFrame(浏览器环境)。 -
执行特点: 事件循环的每个阶段都会处理其对应的宏任务队列。在处理完一个阶段的宏任务后,会检查并清空微任务队列,然后才进入下一个阶段。
-
-
微任务 (Microtasks):
-
包括:
process.nextTick(), Promise 的.then(),.catch(),.finally()回调。 -
执行特点: 具有更高的优先级。在当前调用栈清空后,以及事件循环的每个阶段之间,都会优先清空所有微任务队列中的回调,然后再处理下一个宏任务或进入下一个事件循环阶段。
-
优先级总结:
-
当前正在执行的同步代码 (Call Stack)
-
process.nextTick()(最高优先级的微任务) -
其他微任务 (Promise 回调)
-
事件循环的各个阶段 (宏任务,按顺序执行)
示例:
console.log('Start'); // 同步代码
setTimeout(() => {
console.log('setTimeout 1'); // 宏任务 (timers 阶段)
Promise.resolve().then(() => {
console.log('Promise inside setTimeout'); // 微任务
});
}, 0);
setImmediate(() => {
console.log('setImmediate 1'); // 宏任务 (check 阶段)
});
Promise.resolve().then(() => {
console.log('Promise 1'); // 微任务
});
process.nextTick(() => {
console.log('process.nextTick 1'); // 微任务 (最高优先级)
});
console.log('End'); // 同步代码
可能的输出顺序 (取决于系统和 Node.js 版本,但通常是这样):
Start
End
process.nextTick 1
Promise 1
setTimeout 1
Promise inside setTimeout
setImmediate 1
解释:
-
Start和End是同步代码,立即执行。 -
process.nextTick 1是最高优先级的微任务,在当前同步代码执行完毕后立即执行。 -
Promise 1是另一个微任务,在process.nextTick之后执行。 -
此时,所有微任务都已清空。事件循环进入
timers阶段,执行setTimeout 1。 -
setTimeout 1内部又创建了一个 Promise 微任务Promise inside setTimeout,它会在setTimeout 1执行完毕后立即执行(因为微任务优先级高)。 -
所有微任务再次清空。事件循环进入
poll阶段(如果poll队列为空),然后进入check阶段,执行setImmediate 1。
2.4 setTimeout(), setInterval(), setImmediate(), process.nextTick() 的区别
这些函数都用于调度异步代码的执行,但它们在事件循环中的执行时机和优先级有所不同。
-
process.nextTick(callback):-
执行时机: 在当前执行栈清空后,立即执行,且在事件循环的任何阶段开始之前。它比所有其他微任务(包括 Promise 回调)和宏任务都优先执行。
-
用途: 用于在当前操作完成后,但又不想进入事件循环的下一个阶段时,执行一些代码。例如,在处理完一个请求后,立即发送响应,或者在异步操作中确保回调在同步代码之后但在任何 I/O 之前执行。
-
注意: 连续调用
process.nextTick会导致事件循环无法进入下一个阶段,可能导致 I/O 饥饿。
-
-
setTimeout(callback, delay):-
执行时机: 在
delay毫秒后,将callback放入定时器队列。当事件循环进入timers阶段时,如果delay时间已到,就会执行该回调。 -
精度:
delay只是一个最小延迟时间,不保证精确。实际执行时间会受事件循环中其他任务的影响。例如,setTimeout(fn, 0)并不意味着立即执行,它仍然需要等待当前同步代码执行完毕,并等待微任务队列清空,然后才能进入timers阶段。 -
用途: 延迟执行代码,例如动画、定时任务等。
-
-
setInterval(callback, delay):-
执行时机: 类似于
setTimeout,但它会重复地将callback放入定时器队列,每隔delay毫秒执行一次。 -
精度: 同样不保证精确,可能会有“漂移”现象,即实际间隔时间会比
delay长。 -
用途: 周期性执行任务,例如轮询数据。
-
-
setImmediate(callback):-
执行时机: 将
callback放入检查队列。当事件循环进入check阶段时,会执行该回调。 -
与
setTimeout(fn, 0)的区别:-
在 I/O 回调内部:
setImmediate总是比setTimeout(fn, 0)先执行。因为 I/O 回调在poll阶段执行,poll阶段之后是check阶段,然后才是timers阶段。 -
在顶层模块代码中: 执行顺序不确定,取决于系统性能和事件循环的准备情况。有时
setTimeout(fn, 0)先执行,有时setImmediate先执行。
-
-
用途: 用于在当前
poll阶段(I/O 阶段)结束后,但在进入下一个事件循环迭代之前,立即执行一些代码。常用于将一些计算密集型任务分解,避免阻塞 I/O。
-
总结表格:
好的,我们来深入探讨 Node.js 中一个至关重要的概念:异步编程。理解异步编程是掌握 Node.js 的核心。
3.1 为什么需要异步编程?
Node.js 的一个核心设计理念是其单线程、非阻塞 I/O 模型。
-
单线程 (Single-threaded): 意味着 Node.js 进程只有一个主线程来执行 JavaScript 代码。这与多线程语言(如 Java、Python)不同,后者可以同时运行多个代码块。
-
阻塞 I/O (Blocking I/O): 如果一个操作(例如读取一个大文件、进行网络请求、查询数据库)需要很长时间才能完成,并且在等待期间主线程不能做任何其他事情,那么这个操作就是“阻塞”的。
-
非阻塞 I/O (Non-blocking I/O): 当一个耗时操作开始时,Node.js 不会等待它完成。它会立即将这个操作交给底层系统(例如操作系统内核)去处理,然后主线程继续执行后续的 JavaScript 代码。当耗时操作完成后,操作系统会通知 Node.js,然后 Node.js 会将相应的回调函数放入事件队列,等待主线程空闲时执行。
为什么需要异步编程?
想象一下,如果 Node.js 采用阻塞 I/O:
-
当一个用户请求到达服务器,服务器需要从数据库获取数据。
-
如果这个数据库查询需要 500 毫秒。
-
在这 500 毫秒内,Node.js 的主线程会完全停滞,无法处理其他任何用户的请求,也无法执行任何其他代码。
-
这会导致服务器的吞吐量极低,用户体验极差。
异步编程的解决方案:
通过异步编程,当 Node.js 遇到一个耗时操作(如文件读写、网络请求、数据库查询)时,它会:
-
发起操作: 将操作交给底层系统。
-
注册回调: 提供一个回调函数,告诉系统操作完成后应该做什么。
-
继续执行: 主线程立即继续执行后续的 JavaScript 代码,不等待当前操作完成。
-
事件循环: 当耗时操作完成时,其回调函数会被放入事件队列。Node.js 的事件循环会不断检查事件队列,当主线程空闲时,就会取出并执行这些回调函数。
这种模型使得 Node.js 能够以极高的效率处理大量并发连接,因为它从不等待 I/O 操作,而是利用空闲时间处理其他请求。
3.2 回调函数 (Callbacks) 及 "回调地狱" 问题
回调函数 (Callbacks):
回调函数是异步编程最基本、最原始的实现方式。它是一个函数,作为参数传递给另一个函数,并在那个函数完成其任务后被调用。
示例:
// 模拟一个异步操作:读取文件
function readFileAsync(filePath, callback) {
console.log(`开始读取文件: ${filePath}`);
setTimeout(() => { // 模拟文件读取耗时
const content = `这是文件 ${filePath} 的内容。`;
console.log(`文件读取完成: ${filePath}`);
callback(null, content); // 成功时调用回调,第一个参数为null表示无错误
}, 1000);
}
console.log("程序开始执行...");
readFileAsync("file1.txt", (error, data) => {
if (error) {
console.error("读取文件失败:", error);
return;
}
console.log("成功读取到数据:", data);
console.log("所有操作完成。");
});
console.log("后续代码继续执行,不等待文件读取...");
"回调地狱" (Callback Hell / Pyramid of Doom):
当多个异步操作需要按顺序执行,并且后一个操作依赖于前一个操作的结果时,使用回调函数会导致代码层层嵌套,形成一个难以阅读、维护和调试的结构,形似金字塔。
示例:
// 模拟异步操作:
function step1(callback) {
setTimeout(() => {
console.log("Step 1 完成");
callback(null, "数据A");
}, 500);
}
function step2(dataA, callback) {
setTimeout(() => {
console.log(`Step 2 完成,使用了 ${dataA}`);
callback(null, "数据B");
}, 700);
}
function step3(dataB, callback) {
setTimeout(() => {
console.log(`Step 3 完成,使用了 ${dataB}`);
callback(null, "最终结果");
}, 300);
}
console.log("--- 回调地狱示例 ---");
step1((err1, result1) => {
if (err1) { console.error(err1); return; }
step2(result1, (err2, result2) => {
if (err2) { console.error(err2); return; }
step3(result2, (err3, result3) => {
if (err3) { console.error(err3); return; }
console.log("所有步骤完成,最终结果:", result3);
});
});
});
console.log("主程序继续执行...");
回调地狱的问题:
-
可读性差: 随着嵌套层级的增加,代码变得越来越难以理解。
-
维护困难: 任何逻辑修改都可能影响多层嵌套。
-
错误处理复杂: 每个回调都需要单独处理错误,容易遗漏。
-
控制流混乱: 难以判断代码的执行顺序。
3.3 Promise (Promises) 基础
Promise 是 ES6 (ECMAScript 2015) 引入的一种异步编程解决方案,旨在解决回调地狱问题,提供更优雅、可预测的异步操作管理方式。
Promise 的概念:
Promise 是一个代表了异步操作最终完成(或失败)的对象,以及它所产生的值。它是一个未来值的占位符。
Promise 的三种状态:
-
pending(待定): 初始状态,既没有成功也没有失败。 -
fulfilled(已成功 / resolved): 异步操作成功完成,Promise 拥有一个结果值。 -
rejected(已失败): 异步操作失败,Promise 拥有一个拒绝原因(通常是一个 Error 对象)。
Promise 的特点:
-
一旦 Promise 的状态从
pending变为fulfilled或rejected,它就不可逆转,状态不会再改变。 -
一个 Promise 只能成功或失败一次。
创建 Promise
使用 new Promise() 构造函数来创建一个 Promise 实例。它接收一个 executor 函数作为参数,这个 executor 函数会立即执行,并接收两个参数:resolve 和 reject。
-
resolve(value): 当异步操作成功时调用,将 Promise 的状态从pending变为fulfilled,并将value作为结果值。 -
reject(reason): 当异步操作失败时调用,将 Promise 的状态从pending变为rejected,并将reason作为拒绝原因。
示例:
function delay(ms) {
return new Promise((resolve, reject) => {
if (ms < 0) {
reject(new Error("延迟时间不能为负数!")); // 异步操作失败
return;
}
setTimeout(() => {
resolve(`延迟了 ${ms} 毫秒`); // 异步操作成功
}, ms);
});
}
console.log("--- Promise 创建示例 ---");
delay(2000)
.then(message => {
console.log(message); // 输出:延迟了 2000 毫秒
})
.catch(error => {
console.error("发生错误:", error.message);
});
delay(-500) // 模拟一个错误情况
.then(message => {
console.log(message);
})
.catch(error => {
console.error("发生错误:", error.message); // 输出:发生错误: 延迟时间不能为负数!
});
链式调用 (.then())
Promise 的核心优势在于其链式调用能力,它解决了回调地狱问题。
-
.then(onFulfilled, onRejected):-
onFulfilled: 当 Promise 成功时调用的回调函数。 -
onRejected: 当 Promise 失败时调用的回调函数(可选)。 -
关键:
.then()方法总是返回一个新的 Promise。这使得你可以将多个异步操作串联起来。
-
链式调用的原理:
-
onFulfilled或onRejected回调函数可以返回一个值,这个值会作为下一个.then()的成功结果。 -
onFulfilled或onRejected回调函数也可以返回一个新的 Promise。在这种情况下,下一个.then()会等待这个新的 Promise 解决,并以其结果作为自己的结果。
示例:解决回调地狱问题
// 假设 step1, step2, step3 现在都返回 Promise
function step1Promise() {
return new Promise(resolve => {
setTimeout(() => {
console.log("Step 1 完成 (Promise)");
resolve("数据A");
}, 500);
});
}
function step2Promise(dataA) {
return new Promise(resolve => {
setTimeout(() => {
console.log(`Step 2 完成 (Promise),使用了 ${dataA}`);
resolve("数据B");
}, 700);
});
}
function step3Promise(dataB) {
return new Promise(resolve => {
setTimeout(() => {
console.log(`Step 3 完成 (Promise),使用了 ${dataB}`);
resolve("最终结果");
}, 300);
});
}
console.log("\n--- Promise 链式调用示例 ---");
step1Promise()
.then(result1 => {
return step2Promise(result1); // 返回一个新的 Promise
})
.then(result2 => {
return step3Promise(result2); // 返回一个新的 Promise
})
.then(finalResult => {
console.log("所有步骤完成,最终结果 (Promise):", finalResult);
})
.catch(error => { // 统一处理链中任何环节的错误
console.error("Promise 链中发生错误:", error);
});
console.log("主程序继续执行...");
通过链式调用,代码变得扁平化,可读性大大提高。
错误处理 (.catch())
-
.catch(onRejected):-
是
.then(null, onRejected)的语法糖。 -
它专门用于捕获 Promise 链中任何环节抛出的错误(即任何一个 Promise 被
rejected)。 -
一个
.catch()可以捕获其之前所有.then()中发生的错误。
-
示例:
function mightFail(shouldFail) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldFail) {
reject(new Error("操作失败了!"));
} else {
resolve("操作成功!");
}
}, 500);
});
}
console.log("\n--- Promise 错误处理示例 ---");
mightFail(false) // 成功的情况
.then(result => {
console.log("成功结果:", result);
})
.catch(error => {
console.error("捕获到错误:", error.message);
});
mightFail(true) // 失败的情况
.then(result => {
console.log("成功结果:", result); // 这行不会执行
})
.catch(error => {
console.error("捕获到错误:", error.message); // 输出:捕获到错误: 操作失败了!
});
// 链式调用中的错误
mightFail(false)
.then(result => {
console.log("第一步成功:", result);
throw new Error("第二步故意抛出错误!"); // 在 then 中抛出错误,会被下一个 catch 捕获
})
.then(nextResult => {
console.log("第二步成功:", nextResult); // 这行不会执行
})
.catch(error => {
console.error("链式调用中捕获到错误:", error.message); // 输出:链式调用中捕获到错误: 第二步故意抛出错误!
});
最终处理 (.finally())
-
.finally(onFinally):-
无论 Promise 最终是
fulfilled还是rejected,onFinally回调函数都会被执行。 -
它不接收任何参数,因为它不知道 Promise 是成功还是失败。
-
主要用于执行一些清理工作,例如关闭加载指示器、释放资源等。
-
.finally()也会返回一个 Promise,允许你继续链式调用。
-
示例:
function doSomethingAsync(succeed) {
console.log("开始执行异步操作...");
return new Promise((resolve, reject) => {
setTimeout(() => {
if (succeed) {
resolve("操作成功完成!");
} else {
reject(new Error("操作失败了!"));
}
}, 1000);
});
}
console.log("\n--- Promise .finally() 示例 ---");
doSomethingAsync(true)
.then(result => {
console.log("结果:", result);
})
.catch(error => {
console.error("错误:", error.message);
})
.finally(() => {
console.log("无论成功或失败,都会执行清理工作。");
});
doSomethingAsync(false)
.then(result => {
console.log("结果:", result);
})
.catch(error => {
console.error("错误:", error.message);
})
.finally(() => {
console.log("无论成功或失败,都会执行清理工作。");
});
总结:
-
异步编程是 Node.js 高性能的关键,它通过非阻塞 I/O 避免了主线程的阻塞。
-
回调函数是异步编程的基础,但多层嵌套会导致回调地狱,降低代码可读性和可维护性。
-
Promise 提供了更结构化、更易于管理异步操作的方式,通过链式调用 (
.then()) 解决了回调地狱,并通过.catch()提供了统一的错误处理机制,.finally()则用于执行最终的清理操作。
Promise 是现代 JavaScript 异步编程的基石,为后续更高级的 async/await 语法奠定了基础。
好的,我们来深入探讨现代 JavaScript 异步编程的利器:Async/Await。
在 async/await 出现之前,我们主要使用回调函数(容易导致“回调地狱”)和 Promise 链来处理异步操作。虽然 Promise 解决了回调地狱的问题,但当 Promise 链变得很长时,代码的可读性仍然会受到影响。async/await 正是为了解决这个问题而诞生的。
4.1 async/await 语法糖:如何简化异步代码
async/await 是 ECMAScript 2017 (ES8) 引入的新特性,它实际上是基于 Promise 的语法糖。它的核心目标是让你能够以一种同步的、更直观的方式来编写和阅读异步代码,而不会阻塞主线程。
核心概念:
-
async关键字:-
用于修饰一个函数,使其成为一个异步函数。
-
一个
async函数总是返回一个 Promise。-
如果
async函数内部返回一个非 Promise 的值,这个值会被自动包装成一个已解决(resolved)的 Promise。 -
如果
async函数内部抛出一个错误,这个错误会被自动包装成一个已拒绝(rejected)的 Promise。
-
-
await关键字只能在async函数内部使用。
-
-
await关键字:-
只能在
async函数内部使用。 -
它会暂停
async函数的执行,直到它等待的 Promise 解决 (resolved) 或 拒绝 (rejected)。 -
如果 Promise 解决了,
await会返回 Promise 解决的值。 -
如果 Promise 拒绝了,
await会抛出一个错误(这个错误可以用try...catch捕获)。 -
重要:
await只是暂停了当前async函数的执行,它不会阻塞 JavaScript 引擎的主线程。这意味着其他代码(例如事件循环中的其他任务)仍然可以继续执行。
-
简化异步代码的示例对比
让我们通过一个模拟网络请求的例子来对比 Promise 链和 async/await。
场景: 模拟从服务器获取用户数据,然后根据用户ID获取该用户的帖子列表。
1. 使用 Promise 链:
function fetchUserData(userId) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`Fetched user ${userId}`);
resolve({ id: userId, name: `User ${userId}` });
}, 1000);
});
}
function fetchUserPosts(userId) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`Fetched posts for user ${userId}`);
resolve([`Post A by ${userId}`, `Post B by ${userId}`]);
}, 800);
});
}
console.log("--- Promise Chain Example ---");
fetchUserData(123)
.then(user => {
console.log("User data:", user);
return fetchUserPosts(user.id); // 返回一个新的 Promise
})
.then(posts => {
console.log("User posts:", posts);
console.log("All data fetched successfully!");
})
.catch(error => {
console.error("Error during Promise chain:", error);
});
2. 使用 async/await:
// 假设 fetchUserData 和 fetchUserPosts 函数与上面相同,它们都返回 Promise
async function getAllUserData(userId) {
console.log("--- Async/Await Example ---");
try {
// await 会暂停这里,直到 fetchUserData(userId) 这个 Promise 解决
const user = await fetchUserData(userId);
console.log("User data:", user);
// await 会暂停这里,直到 fetchUserPosts(user.id) 这个 Promise 解决
const posts = await fetchUserPosts(user.id);
console.log("User posts:", posts);
console.log("All data fetched successfully!");
} catch (error) {
console.error("Error during Async/Await:", error);
}
}
getAllUserData(456);
对比分析:
-
可读性:
async/await的代码看起来就像同步代码一样,从上到下顺序执行,非常直观。而 Promise 链则需要通过.then()方法进行链式调用,虽然解决了回调地狱,但仍然有一定的心智负担。 -
流程控制: 在
async/await中,你可以使用普通的if/else、for循环等同步控制流语句来处理异步操作的结果,这在 Promise 链中通常需要更复杂的嵌套或额外的逻辑。 -
错误处理:
async/await可以使用标准的try...catch语句来捕获异步操作中的错误,这比 Promise 的.catch()方法更符合我们处理同步错误的习惯。
4.2 try...catch 处理异步错误
在 async/await 中,错误处理变得非常简单和直观。当 await 等待的 Promise 被拒绝(rejected)时,它会像同步代码抛出错误一样,将错误“抛出”。这意味着你可以使用传统的 try...catch 语句来捕获这些异步错误。
示例:模拟一个失败的请求
function simulateFailedRequest() {
return new Promise((resolve, reject) => {
setTimeout(() => {
const success = Math.random() > 0.5; // 50% 几率失败
if (success) {
console.log("Request successful!");
resolve("Data from server");
} else {
console.error("Request failed!");
reject(new Error("Network error or server issue."));
}
}, 1000);
});
}
async function fetchDataWithErrorHandling() {
console.log("\n--- Error Handling Example ---");
try {
const result = await simulateFailedRequest();
console.log("Received data:", result);
} catch (error) {
// 如果 simulateFailedRequest 内部的 Promise 被 reject,
// 错误就会在这里被捕获
console.error("Caught an error:", error.message);
} finally {
console.log("Error handling complete.");
}
}
fetchDataWithErrorHandling();
fetchDataWithErrorHandling(); // 再次调用,看不同结果
在这个例子中,如果 simulateFailedRequest() 返回的 Promise 被拒绝,await 就会抛出那个错误,然后 try...catch 块中的 catch 部分就会捕获到它,并执行相应的错误处理逻辑。这使得异步错误的管理与同步错误一样简单。
4.3 与 Promise 的关系
理解 async/await 与 Promise 的关系至关重要:
-
async/await是基于 Promise 的:-
async函数的返回值总是一个 Promise。 -
await关键字只能等待一个 Promise。如果你await一个非 Promise 的值,它会被立即解析。 -
这意味着
async/await并没有取代 Promise,而是提供了一种更优雅、更易读的方式来使用和管理 Promise。
-
-
可以混合使用:
-
你可以在
async函数内部使用Promise.all()、Promise.race()等 Promise 静态方法来处理并行异步操作。 -
你也可以在非
async函数中使用.then()和.catch()来处理async函数返回的 Promise。
-
示例:async/await 与 Promise.all 结合
当你有多个不相互依赖的异步操作需要并行执行时,Promise.all 是一个很好的选择。结合 async/await,代码会更加简洁。
function fetchUsers() {
return new Promise(resolve => setTimeout(() => {
console.log("Fetched users.");
resolve(['Alice', 'Bob']);
}, 1500));
}
function fetchProducts() {
return new Promise(resolve => setTimeout(() => {
console.log("Fetched products.");
resolve(['Laptop', 'Mouse']);
}, 1000));
}
async function fetchAllDataConcurrently() {
console.log("\n--- Concurrency with Promise.all & Async/Await ---");
try {
// Promise.all 会并行执行这两个 Promise,并等待它们都解决
// await 会暂停这里,直到 Promise.all 返回的 Promise 解决
const [users, products] = await Promise.all([
fetchUsers(),
fetchProducts()
]);
console.log("All concurrent data fetched:");
console.log("Users:", users);
console.log("Products:", products);
} catch (error) {
console.error("Error fetching data concurrently:", error);
}
}
fetchAllDataConcurrently();
在这个例子中,fetchUsers() 和 fetchProducts() 会同时开始执行,而不是一个接一个。await Promise.all([...]) 会等待这两个 Promise 都完成后,才继续执行后面的代码。这极大地提高了效率。
总结
-
async/await是 Promise 的语法糖,它让异步代码看起来和写起来都更像同步代码。 -
async函数返回 Promise,并且允许在函数内部使用await。 -
await暂停async函数的执行直到 Promise 解决或拒绝,并返回其结果或抛出错误。 -
try...catch是处理async/await异步错误的标准方式。 -
async/await与 Promise 并非互斥,而是相辅相成,可以结合使用Promise.all()等方法来优化并行操作。
掌握 async/await 是现代 Node.js 和前端开发中处理异步操作的关键技能,它能显著提升代码的可读性和可维护性。
好的,我们继续深入 Node.js 的世界,这次我们来聊聊它的核心特性之一:模块化开发。
在任何大型项目中,将代码分割成独立、可复用、易于管理的小块(即模块)都是至关重要的。Node.js 从诞生之初就内置了模块系统,使得开发者能够轻松地组织和共享代码。
Node.js 主要支持两种模块系统:
-
CommonJS 模块系统:Node.js 早期和默认的模块系统。
-
ES Modules (ESM):ECMAScript 官方标准,逐渐成为主流。
5.1 CommonJS 模块系统
CommonJS 是 Node.js 默认的模块系统,它采用同步加载的方式。
核心概念:
-
require: 用于导入(加载)模块。 -
module.exports: 用于导出模块的公共接口。 -
exports: 一个指向module.exports的快捷方式,用于导出多个成员。
模块的导出规则
每个 Node.js 文件都被视为一个独立的模块。在一个模块内部,有两个重要的变量:
-
module对象: 代表当前模块本身。它有一个exports属性,即module.exports。 -
exports对象: 这是一个指向module.exports的引用。
1. module.exports (推荐和常用)
这是模块真正导出的对象。当你需要导出一个单一的值(一个函数、一个对象、一个类、一个原始值)时,直接赋值给 module.exports。
- 特点: 赋值给
module.exports会覆盖掉之前的所有导出。require()返回的就是module.exports的值。
示例 1:导出一个函数
myFunction.js
// my_function.js
function greet(name) {
return `Hello, ${name}!`;
}
module.exports = greet; // 导出 greet 函数本身
示例 2:导出一个对象(包含多个成员)
myModule.js
// my_module.js
const PI = 3.14159;
function add(a, b) {
return a + b;
}
const subtract = (a, b) => a - b;
module.exports = { // 导出一个包含 PI, add, subtract 的对象
PI,
add,
subtract
};
2. exports (作为 module.exports 的快捷方式)
exports 对象是 module.exports 的一个引用。你可以通过给 exports 添加属性来导出多个成员。
- 特点: 只能通过添加属性的方式导出,不能直接赋值给
exports。如果你直接赋值exports = { ... },会切断exports和module.exports之间的引用关系,导致require()仍然返回原始的module.exports(通常是空对象),从而无法正确导出。
示例 3:通过 exports 导出多个成员
anotherModule.js
// another_module.js
exports.name = "Node.js"; // 添加属性到 exports 对象
exports.version = "v18.x";
exports.sayHello = function() {
console.log("Hello from another module!");
};
重要提示:exports 与 module.exports 的区别
-
module.exports是真正的导出对象。 -
exports只是module.exports的一个引用。 -
如果你想导出一个单一的值(函数、类、原始值),请使用
module.exports = ...。 -
如果你想导出多个命名成员,可以使用
exports.member = ...或module.exports = { member1, member2 }。 -
永远不要直接对
exports进行赋值操作,例如exports = { a: 1 },这会破坏引用,导致导出失败。
模块的导入规则
使用 require() 函数来导入模块。
-
语法:
const moduleName = require('modulePath'); -
返回值:
require()返回的是被导入模块的module.exports对象。 -
缓存: 模块在第一次被
require()时会被加载和执行。之后再次require()同一个模块时,会直接从缓存中返回,不会重复加载。
示例:导入上面定义的模块
app.js
// app.js
const greet = require('./my_function'); // 导入函数
console.log(greet('Alice')); // Output: Hello, Alice!
const myModule = require('./my_module'); // 导入对象
console.log(myModule.PI); // Output: 3.14159
console.log(myModule.add(5, 3)); // Output: 8
const anotherModule = require('./another_module'); // 导入通过 exports 添加属性的对象
console.log(anotherModule.name); // Output: Node.js
anotherModule.sayHello(); // Output: Hello from another module!
路径解析机制
require() 函数在解析模块路径时有一套规则:
-
核心模块 (Core Modules):
-
如果路径是 Node.js 内置模块的名称(如
fs,http,path),Node.js 会直接加载这些内置模块。 -
示例:
require('fs'),require('path')
-
-
相对路径模块 (Relative Path Modules):
-
如果路径以
./(当前目录) 或../(上级目录) 或/(根目录) 开头,Node.js 会将其视为文件路径。 -
它会尝试按顺序查找:
-
精确匹配的文件名(例如
require('./my-module.js')) -
添加
.js扩展名(例如require('./my-module')会尝试my-module.js) -
添加
.json扩展名(例如require('./data')会尝试data.json) -
添加
.node扩展名(编译后的C++插件) -
如果路径是一个目录,它会尝试查找该目录下的
package.json文件中的main字段指定的入口文件。 -
如果
main字段不存在或无效,它会尝试查找index.js文件。
-
-
示例:
require('./utils/helper'),require('../config')
-
-
第三方模块 (Node Modules):
-
如果路径既不是核心模块也不是相对路径,Node.js 会认为这是一个第三方模块。
-
它会从当前文件所在的目录开始,向上级目录逐级查找名为
node_modules的文件夹。 -
一旦找到
node_modules文件夹,它会尝试在该文件夹内查找对应的模块。 -
查找规则与相对路径模块类似:先找精确匹配的文件,然后尝试
.js,.json,.node扩展名,最后尝试目录下的package.json的main字段或index.js。 -
示例:
require('express'),require('lodash')
-
5.2 ES Modules (ESM) 简介及在Node.js中的使用
ES Modules 是 ECMAScript 2015 (ES6) 引入的官方模块标准,旨在统一浏览器和 Node.js 的模块化方案。它采用静态加载的方式,这意味着模块的导入和导出在代码执行前就已经确定。
核心语法:
-
export: 用于导出模块的成员。 -
import: 用于导入模块的成员。
导出规则 (export)
ESM 提供了两种主要的导出方式:命名导出 (Named Exports) 和默认导出 (Default Export)。
1. 命名导出 (Named Exports)
可以导出多个命名成员,导入时需要使用相同的名称。
-
直接导出:
// math.mjs export const PI = 3.14159; export function add(a, b) { return a + b; } export class Calculator { /* ... */ } -
列表导出:
// utils.mjs const greet = (name) => `Hello, ${name}!`; const farewell = (name) => `Goodbye, ${name}!`; export { greet, farewell }; // 导出多个已声明的变量/函数 -
重命名导出:
// constants.mjs const MY_CONSTANT = 100; export { MY_CONSTANT as ConstantValue }; // 导出时重命名
2. 默认导出 (Default Export)
每个模块只能有一个默认导出。导入时可以为它指定任意名称。
-
语法:
// my_default_module.mjs const myDefaultFunction = () => console.log("This is the default export."); export default myDefaultFunction; // 导出一个默认函数 // 或者直接导出匿名函数/类/值 // export default class MyClass { /* ... */ } // export default 42;
导入规则 (import)
1. 导入命名导出:
-
语法:
import { member1, member2 } from 'modulePath'; -
重命名导入:
import { member1 as newName } from 'modulePath'; -
导入所有命名导出 (作为命名空间对象):
import * as moduleAlias from 'modulePath';// app.mjs import { PI, add } from './math.mjs'; // 导入命名成员 console.log(PI); console.log(add(2, 3)); import { greet as sayHi } from './utils.mjs'; // 导入并重命名 sayHi('Bob'); import * as myMath from './math.mjs'; // 导入所有命名成员到 myMath 对象 console.log(myMath.PI);
2. 导入默认导出:
-
语法:
import defaultMember from 'modulePath';// app.mjs import myFunc from './my_default_module.mjs'; // 导入默认导出,可以任意命名 myFunc();
3. 混合导入 (默认导出和命名导出同时导入):
-
语法:
import defaultMember, { namedMember1, namedMember2 } from 'modulePath';// mixed_exports.mjs export default function defaultFunc() { console.log("Default!"); } export const namedVar = "Named!"; // app.mjs import defaultFunc, { namedVar } from './mixed_exports.mjs'; defaultFunc(); console.log(namedVar);
4. 仅为副作用导入 (Side-effect Import):
-
执行模块中的代码,但不导入任何绑定。常用于 polyfill 或全局配置。
-
语法:
import 'modulePath';// polyfill.mjs // 假设这里有一些全局的兼容性代码 Array.prototype.myCustomMethod = function() { /* ... */ }; // app.mjs import './polyfill.mjs'; // 仅仅执行 polyfill.mjs 中的代码
ES Modules 在 Node.js 中的使用
Node.js 对 ESM 的支持经历了几个阶段,现在已经非常成熟。主要有两种方式来告诉 Node.js 一个文件是 ESM:
1. 使用 .mjs 文件扩展名
-
这是最直接和推荐的方式。Node.js 会自动将
.mjs文件识别为 ES Modules。 -
示例:
-
my_module.mjs(使用export) -
app.mjs(使用import)
-
2. 在 package.json 中设置 "type": "module"
-
在项目的
package.json文件中添加"type": "module"字段。 -
这样,该包内的所有
.js文件都将被 Node.js 视为 ES Modules。 -
如果需要在此模式下使用 CommonJS 模块,可以使用
.cjs扩展名。 -
示例:
// package.json { "name": "my-esm-app", "version": "1.0.0", "type": "module", // 告诉 Node.js 这是一个 ESM 包 "main": "app.js" }-
app.js(现在可以使用import和export) -
my_commonjs_util.cjs(如果需要,仍然可以使用require和module.exports)
-
3. 默认行为 ("type": "commonjs")
-
如果
package.json中没有type字段,或者设置为"type": "commonjs",则.js文件默认被视为 CommonJS 模块。 -
在这种情况下,如果你想使用 ESM,必须使用
.mjs扩展名。
ESM 与 CommonJS 的互操作性
-
在 ESM 中导入 CommonJS 模块:
-
你可以使用
import语句导入 CommonJS 模块。Node.js 会将其视为一个默认导出,即module.exports的值。 -
// commonjs_lib.js (CommonJS) module.exports = { foo: 'bar', baz: () => 'qux' }; // esm_app.mjs (ESM) import commonjsLib from './commonjs_lib.js'; console.log(commonjsLib.foo); // bar console.log(commonjsLib.baz()); // qux -
注意: CommonJS 模块没有命名导出,所以
import { foo } from './commonjs_lib.js'是无效的。 -
如果你需要动态导入 CommonJS 模块,可以使用
import()表达式(返回 Promise)。
-
-
在 CommonJS 中导入 ESM 模块:
-
直接
require()ESM 模块是不支持的。 因为 ESM 是异步加载的,而require()是同步的。 -
如果你需要在 CommonJS 模块中加载 ESM 模块,可以使用动态
import()表达式。 -
// esm_lib.mjs (ESM) export const esmVar = "Hello from ESM!"; // commonjs_app.js (CommonJS) async function loadEsm() { const esmModule = await import('./esm_lib.mjs'); console.log(esmModule.esmVar); // Hello from ESM! } loadEsm(); -
或者,Node.js 提供了一个实验性的
createRequire函数来在 ESM 中模拟 CommonJS 的require,但反过来在 CommonJS 中requireESM 仍然是限制。
-
ESM 的优势
-
静态分析: 模块的依赖关系在编译时就能确定,有利于工具进行优化(如 Tree Shaking,移除未使用的代码)。
-
异步加载: 更好地支持异步加载,尤其是在浏览器环境中。
-
标准统一: 统一了浏览器和 Node.js 的模块化方案,减少了学习成本和代码迁移的障碍。
-
严格模式: ESM 模块默认在严格模式下运行。
总结对比
| 特性 | CommonJS | ES Modules (ESM) |
| :----------- | :------------------------------------- | :--------------------------------------------- |
| 导入/导出 | require(), module.exports, exports | import, export |
| 加载方式 | 同步加载 | 异步加载 (但 Node.js 环境下表现为同步) |
| 解析时机 | 运行时动态解析 | 编译时静态解析 (利于 Tree Shaking) |
| 导出类型 | 默认导出 (通过 module.exports) | 命名导出 (export { a, b }), 默认导出 (export default) |
| this | 模块文件中的 this 指向 module.exports | 模块文件中的 this 为 undefined |
| __dirname, __filename | 可用 | 不可用 (需用 import.meta.url 模拟) |
| 文件扩展名 | .js (默认) | .mjs 或 package.json 中 "type": "module" |
| 循环依赖 | 容易出现问题 (返回部分加载的模块) | 更好地处理 (返回已加载的部分,未加载的为 undefined) |
现在,你对 Node.js 的两种主要模块系统有了全面的了解。在新的 Node.js 项目中,推荐优先使用 ES Modules,因为它代表了 JavaScript 模块化的未来方向,并提供了更好的静态分析能力。但在维护旧项目或与某些库交互时,CommonJS 仍然是不可或缺的。
好的,我们来深入了解 Node.js 生态系统中不可或缺的工具——NPM (Node Package Manager)。
6. NPM (Node Package Manager) 包管理
NPM 是 Node.js 的默认包管理器,它由两部分组成:
-
命令行工具 (CLI):用于与 NPM 注册表交互,执行安装、卸载、更新等操作。
-
NPM 注册表 (Registry):一个巨大的在线数据库,包含了数百万个开源的 Node.js 包(模块)。
NPM 的作用:
-
管理项目依赖: 轻松安装、更新和删除项目所需的第三方库。
-
代码共享与复用: 开发者可以将自己的代码打包成模块发布到 NPM 注册表,供其他人使用。
-
自动化工作流: 通过
package.json中的scripts字段,可以定义和运行各种项目任务。
6.1 package.json 文件详解
package.json 是每个 Node.js 项目的“身份证”或“清单文件”。它是一个 JSON 格式的文件,位于项目的根目录,记录了项目的元数据、依赖信息和可执行脚本等。
核心字段:
-
name: 项目的名称。必须是小写字母,不能有空格,可以包含连字符或下划线。 -
version: 项目的版本号。遵循语义化版本 (SemVer) 规范。 -
description: 项目的简短描述。 -
main: 项目的入口文件。当其他模块require()或import你的包时,默认会加载这个文件。 -
scripts: 一个对象,定义了可以在命令行中运行的脚本命令。这是 NPM 自动化工作流的核心。-
示例:
"scripts": { "start": "node app.js", // 运行 app.js "test": "jest", // 运行测试 "dev": "nodemon server.js", // 开发模式下运行服务器 "build": "webpack --config webpack.config.js" // 构建项目 } -
运行方式:
npm run <script-name>(例如npm run dev)。对于start,test,stop,restart等少数几个特殊脚本,可以直接使用npm <script-name>(例如npm start,npm test)。
-
-
dependencies: 生产环境依赖。这些是项目在运行时所必需的第三方包。当你的项目部署到生产环境时,这些包也会被安装。-
示例:
"dependencies": { "express": "^4.17.1", "mongoose": "~5.10.0", "lodash": "4.17.21" } -
版本前缀:
-
^(caret/插入符): 推荐。表示兼容性更新。例如^4.17.1意味着安装4.x.x系列的最新版本,但不会安装5.0.0或更高版本(即不升级主版本号)。 -
~(tilde/波浪号): 表示次要版本兼容。例如~5.10.0意味着安装5.10.x系列的最新版本,但不会安装5.11.0或更高版本(即不升级次版本号)。 -
无前缀 (精确匹配): 例如
4.17.21。表示只安装这个精确的版本。 -
*或latest: 安装最新版本(不推荐,可能导致不兼容)。
-
-
-
devDependencies: 开发环境依赖。这些包只在开发、测试或构建过程中需要,在生产环境中不需要。例如测试框架、打包工具、代码检查工具等。-
示例:
"devDependencies": { "jest": "^27.0.6", "webpack": "^5.50.0", "eslint": "^7.32.0" }
-
-
author: 作者信息。 -
license: 项目的开源许可证。 -
repository: 项目的代码仓库地址。
package-lock.json 文件:
当运行 npm install 时,除了 node_modules 文件夹,还会生成一个 package-lock.json 文件。
-
作用: 它记录了项目安装时所有依赖包的精确版本号,包括直接依赖和间接依赖(依赖的依赖)。
-
重要性: 确保团队成员在不同机器上或在不同时间点执行
npm install时,都能安装到完全相同的依赖版本,从而保证构建的可复现性。这个文件应该被提交到版本控制系统(如 Git)。
6.2 常用 NPM 命令
-
npm init:-
作用: 在当前目录初始化一个新的 Node.js 项目,引导你创建一个
package.json文件。 -
用法:
-
npm init: 交互式地填写项目信息。 -
npm init -y或npm init --yes: 快速生成一个默认的package.json文件,所有信息都使用默认值。
-
-
-
npm install:-
作用: 安装项目依赖。
-
用法:
-
npm install: 在项目根目录运行,会根据package.json和package-lock.json文件安装所有dependencies和devDependencies中列出的包到node_modules文件夹。 -
npm install <package-name>: 安装指定的包到node_modules文件夹。- 从 NPM 5.0 开始,默认会将包添加到
dependencies中。
- 从 NPM 5.0 开始,默认会将包添加到
-
npm install <package-name> --save或npm install <package-name> -S: 明确将包添加到dependencies中(旧版本 NPM 的默认行为)。 -
npm install <package-name> --save-dev或npm install <package-name> -D: 将包添加到devDependencies中。 -
npm install <package-name> --global或npm install <package-name> -g: 全局安装包(通常用于命令行工具)。
-
-
-
npm uninstall:-
作用: 卸载项目依赖。
-
用法:
-
npm uninstall <package-name>: 从node_modules文件夹中移除指定的包。- 默认也会从
package.json中移除对应的依赖记录。
- 默认也会从
-
npm uninstall <package-name> --save或npm uninstall <package-name> -S: 明确从dependencies中移除。 -
npm uninstall <package-name> --save-dev或npm uninstall <package-name> -D: 明确从devDependencies中移除。 -
npm uninstall <package-name> -g: 卸载全局安装的包。
-
-
-
npm update:-
作用: 更新项目依赖。
-
用法:
-
npm update: 更新package.json中所有依赖包到其允许的最新版本(根据版本语义化规则和^,~等前缀)。 -
npm update <package-name>: 更新指定的包到其允许的最新版本。
-
-
注意:
npm update不会更新到主版本号有变化的版本(即不会从^4.x.x更新到5.x.x),除非你手动修改package.json中的版本号。
-
6.3 版本语义化 (SemVer)
语义化版本(Semantic Versioning,简称 SemVer)是一种版本号命名规范,旨在通过版本号本身传达软件更新的类型和兼容性。版本号格式为:MAJOR.MINOR.PATCH。
-
MAJOR(主版本号):当你做了不兼容的 API 修改时,增加主版本号。这意味着使用旧版本代码的应用程序可能无法直接兼容新版本,需要修改代码。- 例如:从
1.x.x到2.0.0。
- 例如:从
-
MINOR(次版本号):当你做了向下兼容的功能性新增时,增加次版本号。旧代码仍然可以正常工作,但可以利用新功能。- 例如:从
1.0.x到1.1.0。
- 例如:从
-
PATCH(修订版本号):当你做了向下兼容的 Bug 修复时,增加修订版本号。- 例如:从
1.0.0到1.0.1。
- 例如:从
预发布版本和构建元数据:
-
预发布版本: 可以通过在版本号后添加连字符和标识符来表示,例如
1.0.0-alpha,1.0.0-beta.1,1.0.0-rc.2。 -
构建元数据: 可以通过在版本号后添加加号和标识符来表示,例如
1.0.0+20130313144700。
SemVer 的重要性:
-
可预测性: 开发者可以根据版本号判断更新是否会引入破坏性变更。
-
稳定性: 帮助项目维护者和使用者更好地管理依赖,避免因不兼容更新导致的问题。
-
自动化: 使得包管理器(如 NPM)能够根据规则自动更新依赖。
6.4 全局安装与本地安装的区别
NPM 包可以安装在两个不同的位置:
-
本地安装 (Local Installation):
-
位置: 包会被安装到当前项目目录下的
node_modules文件夹中。 -
目的: 用于项目特定的依赖。每个项目都有自己独立的
node_modules文件夹,即使不同项目依赖同一个包的不同版本,也不会冲突。 -
如何安装:
npm install <package-name>(默认行为)。 -
如何使用:
-
在代码中通过
require()或import导入。 -
如果包提供了命令行工具(例如
webpack,jest),可以通过package.json中的scripts字段来运行,或者使用npx命令来执行本地安装的二进制文件(例如npx webpack)。
-
-
-
全局安装 (Global Installation):
-
位置: 包会被安装到系统全局的
node_modules文件夹中(具体路径取决于你的操作系统和 Node.js 安装方式)。 -
目的: 主要用于提供命令行工具 (CLI tools),这些工具可以在系统的任何位置直接运行,而不需要进入特定的项目目录。
-
如何安装:
npm install -g <package-name>。 -
如何使用: 直接在命令行中输入包提供的命令(例如
npm,vue,create-react-app)。 -
常见全局包:
nodemon,webpack-cli,create-react-app,vue-cli等。
-
何时选择全局安装,何时选择本地安装?
-
本地安装 (推荐):
-
所有项目依赖(如
express,react,lodash等)。 -
项目构建工具(如
webpack,babel),即使它们有 CLI 接口,也建议本地安装,并通过package.json的scripts或npx运行。这样可以确保项目使用的工具版本与团队其他成员一致,并且不会污染全局环境。
-
-
全局安装:
- 纯粹的命令行工具,不属于任何特定项目,你希望在任何地方都能直接运行它们(例如
npm本身,npx,或者一些系统级的工具)。
- 纯粹的命令行工具,不属于任何特定项目,你希望在任何地方都能直接运行它们(例如
总结:
NPM 是 Node.js 开发的基石,它极大地简化了依赖管理和项目构建。理解 package.json、常用命令、语义化版本以及全局/本地安装的区别,是成为一名高效 Node.js 开发者的关键。
第二阶段:Node.js 核心模块与文件操作 (约4节)
好的,我们来学习 Node.js 中两个非常核心且常用的内置模块:path 和 fs。它们分别用于处理文件路径和进行文件系统操作。
第 7 节:核心模块:path 和 fs (文件系统)
Node.js 提供了许多内置的核心模块,你无需安装,只需使用 require() 即可引入。
7.1 path 模块:路径拼接、解析、规范化等
path 模块提供了用于处理文件和目录路径的实用工具。它在处理不同操作系统(Windows 使用 \,Unix/Linux/macOS 使用 /)的路径时尤其有用,因为它会根据当前操作系统自动调整。
引入方式:
const path = require('path');
常用方法:
-
path.join([...paths]):-
作用: 将所有给定的
path片段连接在一起,并规范化结果路径。它会处理多余的斜杠、点号等,并根据操作系统使用正确的路径分隔符。 -
优点: 跨平台兼容性好,避免手动拼接路径可能导致的错误。
-
示例:
console.log(path.join('/foo', 'bar', 'baz/asdf', 'quux', '..')); // 输出 (Linux/macOS): /foo/bar/baz/asdf // 输出 (Windows): \foo\bar\baz\asdf console.log(path.join(__dirname, 'data', 'users.json')); // 假设当前文件在 /project/src,则输出:/project/src/data/users.json注意:
__dirname是 Node.js 提供的一个全局变量,表示当前文件所在的目录的绝对路径。
-
-
path.resolve([...paths]):-
作用: 将一系列路径或路径片段解析为绝对路径。它从右到左处理路径片段,直到构造出绝对路径。
-
与
path.join的区别:-
join只是简单拼接并规范化,不保证是绝对路径。 -
resolve总是返回一个绝对路径。如果解析过程中没有遇到根路径(如/或C:\),它会使用当前工作目录作为基础路径。
-
-
示例:
console.log(path.resolve('/foo/bar', './baz')); // 输出: /foo/bar/baz console.log(path.resolve('/foo/bar', '/tmp/file/')); // 输出: /tmp/file/ (因为 /tmp/file/ 是一个根路径,前面的被忽略) console.log(path.resolve('foo', 'bar', 'baz')); // 假设当前工作目录是 /home/user/project // 输出: /home/user/project/foo/bar/baz console.log(path.resolve('/a', 'b', '../c')); // 输出: /a/c
-
-
path.basename(path[, ext]):-
作用: 返回路径的最后一部分(文件名或目录名)。
-
ext参数: 可选,如果提供,则会从返回的名称中移除指定的文件扩展名。 -
示例:
console.log(path.basename('/foo/bar/baz/asdf.html')); // asdf.html console.log(path.basename('/foo/bar/baz/asdf.html', '.html')); // asdf console.log(path.basename('/foo/bar/baz/')); // baz console.log(path.basename('index.js')); // index.js
-
-
path.dirname(path):-
作用: 返回路径的目录名(即路径的最后一部分之前的所有内容)。
-
示例:
console.log(path.dirname('/foo/bar/baz/asdf/quux.html')); // /foo/bar/baz/asdf console.log(path.dirname('/foo/bar/baz/')); // /foo/bar console.log(path.dirname('index.js')); // . (当前目录)
-
-
path.extname(path):-
作用: 返回路径的扩展名(包括点
.)。如果没有扩展名,则返回空字符串。 -
示例:
console.log(path.extname('index.html')); // .html console.log(path.extname('index.js')); // .js console.log(path.extname('index.coffee.md')); // .md console.log(path.extname('index.')); // . console.log(path.extname('index')); // ''
-
-
path.parse(path):-
作用: 返回一个对象,其属性表示路径的各个组成部分。
-
返回对象属性:
-
root: 根目录(如/或C:\) -
dir: 目录路径 -
base: 文件名(包括扩展名) -
ext: 扩展名 -
name: 文件名(不包括扩展名)
-
-
示例:
const parsedPath = path.parse('/home/user/dir/file.txt'); console.log(parsedPath); /* { root: '/', dir: '/home/user/dir', base: 'file.txt', ext: '.txt', name: 'file' } */
-
-
path.format(pathObject):-
作用: 从一个路径对象返回一个路径字符串。与
path.parse()相反。 -
示例:
const pathObject = { root: '/', dir: '/home/user/dir', base: 'file.txt', ext: '.txt', name: 'file' }; console.log(path.format(pathObject)); // /home/user/dir/file.txt
-
-
path.sep:-
作用: 提供平台特定的路径片段分隔符。
-
示例:
console.log(path.sep); // 在 Linux/macOS 上是 '/',在 Windows 上是 '\'
-
7.2 fs 模块:同步 (Synchronous) 文件操作
fs (File System) 模块提供了与文件系统交互的 API。它提供了同步和异步两种操作方式。本节我们先关注同步操作。
引入方式:
const fs = require('fs');
常用同步方法:
-
fs.readFileSync(path[, options]):-
作用: 同步地读取文件的全部内容。
-
path: 文件路径。 -
options: 可选,可以是一个字符串(表示编码,如'utf8')或一个对象。 -
返回值: 如果指定了编码,则返回字符串;否则返回
Buffer对象。 -
示例:
// 创建一个测试文件 test.txt // echo "Hello Node.js FS!" > test.txt try { const data = fs.readFileSync('test.txt', 'utf8'); console.log('同步读取文件内容:', data); } catch (err) { console.error('同步读取文件失败:', err.message); }
-
-
fs.writeFileSync(path, data[, options]):-
作用: 同步地将数据写入文件。如果文件不存在,则创建文件;如果文件已存在,则覆盖其内容。
-
path: 文件路径。 -
data: 要写入的数据,可以是字符串或Buffer。 -
options: 可选,可以是一个字符串(表示编码)或一个对象。 -
示例:
try { fs.writeFileSync('output.txt', '这是通过同步方式写入的内容。\n第二行内容。', 'utf8'); console.log('文件 output.txt 同步写入成功!'); } catch (err) { console.error('同步写入文件失败:', err.message); }
-
-
fs.appendFileSync(path, data[, options]):-
作用: 同步地将数据追加到文件末尾。如果文件不存在,则创建文件。
-
参数: 与
writeFileSync类似。 -
示例:
try { fs.appendFileSync('output.txt', '\n这是追加的新内容。', 'utf8'); console.log('文件 output.txt 同步追加成功!'); } catch (err) { console.error('同步追加文件失败:', err.message); }
-
-
fs.mkdirSync(path[, options]):-
作用: 同步地创建目录。
-
options.recursive: 布尔值,默认为false。如果设置为true,则可以创建嵌套目录(即父目录不存在时也会一并创建)。 -
示例:
try { fs.mkdirSync('my_new_dir'); console.log('目录 my_new_dir 同步创建成功!'); fs.mkdirSync('nested/dir/structure', { recursive: true }); console.log('嵌套目录 nested/dir/structure 同步创建成功!'); } catch (err) { console.error('同步创建目录失败:', err.message); }
-
-
fs.rmSync(path[, options]): (Node.js 14.14.0+ 引入,推荐替代unlinkSync和rmdirSync)-
作用: 同步地删除文件或目录。
-
options.recursive: 布尔值,默认为false。如果设置为true,则可以递归删除非空目录。 -
options.force: 布尔值,默认为false。如果设置为true,则忽略不存在的路径和权限错误。 -
示例:
// 先创建一些文件和目录用于删除 fs.writeFileSync('file_to_delete.txt', 'delete me'); fs.mkdirSync('dir_to_delete/subdir', { recursive: true }); fs.writeFileSync('dir_to_delete/subdir/file.txt', 'delete me too'); try { fs.rmSync('file_to_delete.txt'); console.log('文件 file_to_delete.txt 同步删除成功!'); fs.rmSync('dir_to_delete', { recursive: true, force: true }); console.log('目录 dir_to_delete 及其内容同步删除成功!'); } catch (err) { console.error('同步删除失败:', err.message); }
-
-
fs.existsSync(path):-
作用: 同步地检查指定路径的文件或目录是否存在。
-
返回值: 布尔值。
-
注意: 尽管是同步方法,但由于其简单性且不涉及长时间 I/O,在某些简单场景下(如启动时检查配置),偶尔使用是可以接受的。但在频繁的运行时检查中,仍推荐异步方法。
-
示例:
if (fs.existsSync('test.txt')) { console.log('文件 test.txt 存在。'); } else { console.log('文件 test.txt 不存在。'); }
-
重要提示:生产环境应避免同步 I/O!
这是 Node.js 异步编程中一个非常非常重要的原则。
为什么?
-
阻塞事件循环: Node.js 的核心是单线程的事件循环。当执行一个同步 I/O 操作时(例如
fs.readFileSync),Node.js 的主线程会完全停滞,直到该 I/O 操作完成。 -
性能瓶颈: 在服务器环境中,这意味着当一个用户请求触发了同步 I/O 操作时,所有其他用户的请求都必须等待,直到这个 I/O 操作完成。这会导致服务器的吞吐量急剧下降,响应时间变长,用户体验极差。在高并发场景下,这几乎是灾难性的。
-
无法处理并发: 同步 I/O 违背了 Node.js 非阻塞 I/O 的核心优势,使得它无法高效地处理大量并发连接。
何时可以使用同步 I/O?
-
启动脚本: 在应用程序启动时,执行一些初始化配置读取、目录创建等操作,因为此时还没有用户请求,阻塞主线程的影响可以忽略。
-
简单的命令行工具: 如果你的 Node.js 脚本是一个简单的、一次性执行的命令行工具,且不涉及高并发,那么使用同步 I/O 可能会让代码更简洁。
-
学习和测试: 在学习和测试阶段,同步方法可以帮助你快速验证功能,但请务必理解其局限性。
最佳实践:
在生产环境和任何需要处理并发请求的场景中,始终优先使用 fs 模块的异步版本(例如 fs.readFile, fs.writeFile 等),它们通常以回调函数或 Promise 的形式提供。我们将在后续章节中详细介绍这些异步方法。
通过本节,你已经掌握了 path 模块来处理路径,以及 fs 模块的同步文件操作。请牢记同步 I/O 的局限性,并为后续学习异步 I/O 打下基础。
好的,我们继续深入 Node.js 的文件系统操作。本节将重点介绍 fs 模块的异步操作,以及处理大文件时非常重要的文件流 (Streams) 概念。
第 8 节:fs (文件系统) 模块:异步操作与流
8.1 异步文件操作 (readFile, writeFile, appendFile 等)
在第 7 节中,我们强调了在生产环境中应避免使用同步 I/O 操作,因为它会阻塞 Node.js 的事件循环。现在,我们将学习如何使用 fs 模块的异步方法,它们是 Node.js 处理 I/O 的推荐方式。
异步 fs 方法通常有两种形式:
-
回调函数 (Callback-based): 这是 Node.js 早期和传统的方式,将一个回调函数作为最后一个参数传入,当操作完成时,回调函数会被调用,通常第一个参数是错误对象
err,第二个参数是结果数据。 -
Promise-based (使用
fs.promises): Node.js 10 引入了fs.promisesAPI,它提供了所有异步fs方法的 Promise 版本,使得可以使用async/await语法来编写更清晰、更易于维护的异步代码。这是现代 Node.js 开发中推荐的方式。
引入方式:
const fs = require('fs'); // 用于回调函数版本
const fsPromises = require('fs/promises'); // 用于 Promise 版本 (推荐)
常用异步方法示例:
-
fs.readFile(path[, options], callback)/fsPromises.readFile(path[, options]):-
作用: 异步地读取文件的全部内容。
-
示例 (回调函数):
fs.readFile('test.txt', 'utf8', (err, data) => { if (err) { console.error('异步读取文件失败 (回调):', err.message); return; } console.log('异步读取文件内容 (回调):', data); }); -
示例 (Promise / async/await - 推荐):
async function readMyFile() { try { const data = await fsPromises.readFile('test.txt', 'utf8'); console.log('异步读取文件内容 (Promise):', data); } catch (err) { console.error('异步读取文件失败 (Promise):', err.message); } } readMyFile();
-
-
fs.writeFile(path, data[, options], callback)/fsPromises.writeFile(path, data[, options]):-
作用: 异步地将数据写入文件。如果文件不存在,则创建文件;如果文件已存在,则覆盖其内容。
-
示例 (回调函数):
fs.writeFile('output_async.txt', '这是异步写入的内容。', 'utf8', (err) => { if (err) { console.error('异步写入文件失败 (回调):', err.message); return; } console.log('文件 output_async.txt 异步写入成功 (回调)!'); }); -
示例 (Promise / async/await - 推荐):
async function writeMyFile() { try { await fsPromises.writeFile('output_async_promise.txt', '这是异步写入的 Promise 内容。', 'utf8'); console.log('文件 output_async_promise.txt 异步写入成功 (Promise)!'); } catch (err) { console.error('异步写入文件失败 (Promise):', err.message); } } writeMyFile();
-
-
fs.appendFile(path, data[, options], callback)/fsPromises.appendFile(path, data[, options]):-
作用: 异步地将数据追加到文件末尾。
-
示例 (Promise / async/await):
async function appendMyFile() { try { await fsPromises.appendFile('output_async_promise.txt', '\n这是异步追加的新内容。', 'utf8'); console.log('文件 output_async_promise.txt 异步追加成功 (Promise)!'); } catch (err) { console.error('异步追加文件失败 (Promise):', err.message); } } appendMyFile();
-
-
fs.mkdir(path[, options], callback)/fsPromises.mkdir(path[, options]):-
作用: 异步地创建目录。
-
示例 (Promise / async/await):
async function createMyDir() { try { await fsPromises.mkdir('my_async_dir', { recursive: true }); console.log('目录 my_async_dir 异步创建成功!'); } catch (err) { console.error('异步创建目录失败:', err.message); } } createMyDir();
-
-
fs.rm(path[, options], callback)/fsPromises.rm(path[, options]):-
作用: 异步地删除文件或目录。
-
示例 (Promise / async/await):
async function removeMyStuff() { // 先创建一些用于删除的文件/目录 await fsPromises.writeFile('file_to_delete_async.txt', 'delete me'); await fsPromises.mkdir('dir_to_delete_async/subdir', { recursive: true }); try { await fsPromises.rm('file_to_delete_async.txt'); console.log('文件 file_to_delete_async.txt 异步删除成功!'); await fsPromises.rm('dir_to_delete_async', { recursive: true, force: true }); console.log('目录 dir_to_delete_async 及其内容异步删除成功!'); } catch (err) { console.error('异步删除失败:', err.message); } } removeMyStuff();
-
-
fs.access(path[, mode], callback)/fsPromises.access(path[, mode]):-
作用: 异步地检查用户对文件或目录的权限。这是检查文件或目录是否存在以及是否有权限访问的推荐方式,因为它不会引发竞争条件(race condition)。
-
mode: 可选,用于指定要检查的权限(fs.constants.F_OK存在,R_OK可读,W_OK可写,X_OK可执行)。 -
示例 (Promise / async/await):
async function checkFileAccess() { try { await fsPromises.access('test.txt', fs.constants.F_OK); // 检查文件是否存在 console.log('文件 test.txt 存在且可访问。'); } catch (err) { console.error('文件 test.txt 不存在或无法访问:', err.message); } } checkFileAccess();
-
8.2 文件流 (Streams) 概念:ReadableStream, WritableStream
尽管异步 readFile 和 writeFile 解决了阻塞问题,但它们仍然有一个潜在的缺点:它们会将整个文件内容加载到内存中。对于小文件来说这没问题,但对于大文件(几百 MB 甚至 GB),这会导致:
-
内存溢出 (Out of Memory): 应用程序可能会耗尽内存而崩溃。
-
性能下降: 即使内存足够,加载和处理整个大文件也会消耗大量时间和资源。
文件流 (Streams) 就是为了解决这个问题而生的。
什么是流?
流是 Node.js 中处理数据的一种抽象接口。它允许你以分块 (chunks) 的方式处理数据,而不是一次性将所有数据加载到内存中。你可以把流想象成一根水管:
-
数据源 (Source): 水管的一端是数据源(例如文件、网络请求)。
-
数据目的地 (Destination): 另一端是数据目的地(例如另一个文件、网络响应)。
-
数据流动: 数据像水一样,一小块一小块地从源头流向目的地。
流的优势:
-
内存效率: 只在内存中保留当前处理的数据块,大大减少内存占用。
-
时间效率: 数据一旦可用就可以立即处理,无需等待整个文件加载完成。
-
可组合性: 流可以像管道一样连接起来,形成复杂的数据处理链。
Node.js 中有四种基本类型的流:
-
Readable Stream(可读流):-
用于从数据源读取数据。
-
例如:
fs.createReadStream()(读取文件), HTTP 请求的req对象。 -
事件:
-
data: 当有数据块可用时触发。 -
end: 当没有更多数据可读时触发。 -
error: 当读取过程中发生错误时触发。 -
close: 当底层资源关闭时触发。
-
-
-
Writable Stream(可写流):-
用于向数据目的地写入数据。
-
例如:
fs.createWriteStream()(写入文件), HTTP 响应的res对象。 -
事件:
-
drain: 当写入缓冲区清空,可以继续写入更多数据时触发(用于流量控制/背压)。 -
finish: 当所有数据都已写入底层系统时触发。 -
error: 当写入过程中发生错误时触发。 -
close: 当底层资源关闭时触发。
-
-
-
Duplex Stream(双工流):-
既是可读的又是可写的。
-
例如:TCP sockets。
-
-
Transform Stream(转换流):-
一种特殊的双工流,它可以在数据读入和写出之间修改或转换数据。
-
例如:压缩流 (zlib)。
-
8.3 使用流进行大文件读写 (createReadStream, createWriteStream)
fs 模块提供了创建文件可读流和可写流的方法。
-
fs.createReadStream(path[, options]):-
作用: 创建一个可读文件流。
-
options:-
encoding: 编码方式 (如'utf8')。 -
highWaterMark: 内部缓冲区的大小(字节),默认为 64KB。当缓冲区达到此限制时,流会暂停读取。 -
start,end: 指定读取文件的起始和结束字节位置。
-
-
示例:读取大文件
const readStream = fs.createReadStream('large_file.txt', { encoding: 'utf8', highWaterMark: 16 * 1024 }); // 16KB 缓冲区 let chunkCount = 0; readStream.on('data', (chunk) => { chunkCount++; console.log(`接收到第 ${chunkCount} 个数据块,大小: ${chunk.length} 字节`); // console.log('数据块内容 (部分):', chunk.substring(0, 50) + '...'); // 打印部分内容 // 在这里处理每个数据块 }); readStream.on('end', () => { console.log('文件读取完毕!总共接收到', chunkCount, '个数据块。'); }); readStream.on('error', (err) => { console.error('读取文件时发生错误:', err.message); }); // 为了测试,先创建一个大文件 (例如 1MB) // const dummyData = 'a'.repeat(1024 * 1024); // 1MB 的 'a' // fs.writeFileSync('large_file.txt', dummyData);
-
-
fs.createWriteStream(path[, options]):-
作用: 创建一个可写文件流。
-
options:-
encoding: 编码方式。 -
flags: 文件打开模式,默认为'w'(写入,覆盖)。'a'表示追加。 -
mode: 文件权限。
-
-
示例:写入大文件
const writeStream = fs.createWriteStream('output_large_file.txt', { encoding: 'utf8' }); // 写入一些数据块 for (let i = 0; i < 10000; i++) { const canWrite = writeStream.write(`这是第 ${i} 行数据。\n`); // 如果缓冲区已满,canWrite 为 false,需要等待 'drain' 事件 if (!canWrite) { console.log('写入缓冲区已满,暂停写入...'); writeStream.once('drain', () => { console.log('缓冲区已清空,继续写入...'); // 实际应用中,这里会恢复循环或数据源 }); } } // 所有数据写入完毕后,关闭流 writeStream.end('所有数据写入完成。'); // 写入最后一块数据并关闭流 writeStream.on('finish', () => { console.log('文件写入完毕!'); }); writeStream.on('error', (err) => { console.error('写入文件时发生错误:', err.message); });
-
8.4 管道 (pipe) 操作
pipe() 方法是 Node.js 流中最强大和常用的功能之一。它允许你将一个可读流的输出直接连接到另一个可写流的输入,从而实现高效的数据传输。
核心思想:
readableStream.pipe(writableStream)
当 readableStream 有数据时,它会自动将数据推送到 writableStream。更重要的是,pipe() 会自动处理背压 (backpressure)。这意味着如果 writableStream 写入速度跟不上 readableStream 的读取速度,pipe() 会自动暂停 readableStream 的读取,直到 writableStream 准备好接收更多数据。这防止了内存溢出。
示例:使用 pipe 复制大文件
这是 pipe 最经典的用例,也是最能体现其优势的场景。
const sourceFilePath = 'large_file.txt'; // 假设这个文件存在且较大
const destinationFilePath = 'copied_large_file.txt';
// 确保源文件存在,否则创建它
if (!fs.existsSync(sourceFilePath)) {
console.log(`创建测试文件: ${sourceFilePath}`);
fs.writeFileSync(sourceFilePath, 'This is a large file content.\n'.repeat(100000)); // 约 2.8MB
}
console.log(`开始复制文件从 ${sourceFilePath} 到 ${destinationFilePath}...`);
const readStream = fs.createReadStream(sourceFilePath);
const writeStream = fs.createWriteStream(destinationFilePath);
// 使用 pipe 连接读写流
readStream.pipe(writeStream);
// 监听完成事件
writeStream.on('finish', () => {
console.log('文件复制成功!');
});
// 监听错误事件
readStream.on('error', (err) => {
console.error('读取文件时发生错误:', err.message);
});
writeStream.on('error', (err) => {
console.error('写入文件时发生错误:', err.message);
});
pipe 的优点:
-
简洁性: 一行代码即可实现复杂的数据传输和流量控制。
-
自动化背压: 无需手动管理
data、drain、pause、resume事件,pipe会自动处理。 -
错误传播: 默认情况下,如果可读流发生错误,它会传播到可写流,并触发可写流的
error事件。 -
可链式调用: 你可以将多个流通过
pipe连接起来,形成一个数据处理管道。// 示例:读取文件 -> 压缩 -> 写入新文件 // const zlib = require('zlib'); // readStream.pipe(zlib.createGzip()).pipe(writeStream);
通过本节的学习,你已经掌握了 Node.js 中 fs 模块的异步操作,以及如何利用强大的文件流和 pipe 方法来高效、内存友好地处理大文件。在实际开发中,异步操作和流是构建高性能 Node.js 应用不可或缺的工具。
好的,我们继续 Node.js 的核心模块学习。本节将深入探讨事件驱动编程的核心——events 模块,以及如何使用 http 模块构建最简单的 Web 服务器。
第 9 节:核心模块:events 与 EventEmitter
9.1 事件驱动编程范式
Node.js 的核心是事件驱动 (Event-driven) 和非阻塞 I/O (Non-blocking I/O)。这意味着 Node.js 不会等待一个操作完成,而是注册一个“监听器”或“回调函数”,然后继续执行其他代码。当操作完成时,它会“触发”一个事件,然后之前注册的监听器就会被调用。
这种范式非常适合处理高并发的 I/O 密集型任务,因为它避免了传统多线程模型中常见的线程创建和上下文切换开销。
EventEmitter 是 Node.js 中所有事件驱动的核心。许多 Node.js 内置模块(如 fs.createReadStream、http.Server、net.Socket 等)都继承自 EventEmitter,或者内部使用了它。
9.2 EventEmitter 类:注册事件 (on/addListener), 触发事件 (emit), 移除事件 (removeListener)
要使用 EventEmitter,首先需要引入 events 模块:
const EventEmitter = require('events');
你可以创建一个 EventEmitter 的实例,或者让你的自定义类继承它。
1. 注册事件 (on/addListener)
-
emitter.on(eventName, listener): 注册一个事件监听器。当eventName事件被触发时,listener函数会被调用。 -
emitter.addListener(eventName, listener): 与on方法功能完全相同,on是更常用的别名。
示例:
const myEmitter = new EventEmitter();
// 注册一个名为 'greet' 的事件监听器
myEmitter.on('greet', (name) => {
console.log(`Hello, ${name}!`);
});
// 注册另一个名为 'greet' 的事件监听器
myEmitter.on('greet', (name) => {
console.log(`Nice to meet you, ${name}.`);
});
// 注册一个名为 'data' 的事件监听器
myEmitter.on('data', (payload) => {
console.log('接收到数据:', payload);
});
2. 触发事件 (emit)
emitter.emit(eventName[, ...args]): 触发一个事件。所有注册到eventName的监听器都会按照注册的顺序同步调用。...args会作为参数传递给监听器函数。
示例:
// 触发 'greet' 事件,并传递 'Alice' 作为参数
myEmitter.emit('greet', 'Alice');
// 输出:
// Hello, Alice!
// Nice to meet you, Alice.
// 触发 'data' 事件,并传递一个对象作为参数
myEmitter.emit('data', { id: 1, value: 'some value' });
// 输出:
// 接收到数据: { id: 1, value: 'some value' }
3. 移除事件 (removeListener/off)
-
emitter.removeListener(eventName, listener): 移除指定eventName的指定listener函数。 -
emitter.off(eventName, listener): 与removeListener功能完全相同,是其别名。 -
emitter.removeAllListeners([eventName]): 移除所有指定eventName的监听器。如果eventName未指定,则移除所有事件的所有监听器。
注意: 要成功移除监听器,你必须传递同一个函数引用。匿名函数无法被移除。
示例:
const myEmitter2 = new EventEmitter();
function callbackA() {
console.log('Callback A called!');
}
function callbackB() {
console.log('Callback B called!');
}
myEmitter2.on('testEvent', callbackA);
myEmitter2.on('testEvent', callbackB);
myEmitter2.on('testEvent', () => console.log('Anonymous callback called!')); // 匿名函数
console.log('\n--- 第一次触发 ---');
myEmitter2.emit('testEvent');
// 输出:
// Callback A called!
// Callback B called!
// Anonymous callback called!
// 移除 callbackA
myEmitter2.removeListener('testEvent', callbackA);
console.log('\n--- 移除 Callback A 后触发 ---');
myEmitter2.emit('testEvent');
// 输出:
// Callback B called!
// Anonymous callback called!
// 移除所有 'testEvent' 的监听器
myEmitter2.removeAllListeners('testEvent');
console.log('\n--- 移除所有监听器后触发 ---');
myEmitter2.emit('testEvent'); // 不会输出任何东西
9.3 一次性事件 (once)
emitter.once(eventName, listener): 注册一个只会被触发一次的事件监听器。当eventName第一次被触发后,该监听器会自动移除。
示例:
const myEmitter3 = new EventEmitter();
myEmitter3.once('setup', () => {
console.log('应用程序初始化设置完成!');
});
myEmitter3.on('log', (message) => {
console.log('日志:', message);
});
myEmitter3.emit('setup'); // 第一次触发 'setup'
myEmitter3.emit('log', '用户登录');
myEmitter3.emit('setup'); // 第二次触发 'setup',但监听器已被移除,不会再执行
myEmitter3.emit('log', '数据更新');
// 输出:
// 应用程序初始化设置完成!
// 日志: 用户登录
// 日志: 数据更新
9.4 错误事件处理
error 事件是一个特殊的事件。当 EventEmitter 实例发出 error 事件时,如果没有注册任何监听器来处理它,Node.js 进程会崩溃并退出,并打印堆栈跟踪。
这是非常重要的! 在生产环境中,你几乎总是需要为 error 事件注册一个监听器,以防止应用程序意外崩溃。
示例:
const myEmitter4 = new EventEmitter();
// 错误处理示例 1: 没有监听器
// myEmitter4.emit('error', new Error('Something went wrong!'));
// ^^^ 如果运行上面这行,程序会崩溃
// 错误处理示例 2: 注册监听器
myEmitter4.on('error', (err) => {
console.error('捕获到错误事件:', err.message);
// 在这里可以进行错误日志记录、优雅关闭资源等操作
// process.exit(1); // 也可以选择退出进程,但通常会先尝试恢复或记录
});
myEmitter4.emit('error', new Error('文件读取失败!'));
// 输出:
// 捕获到错误事件: 文件读取失败!
console.log('程序继续运行...'); // 程序不会崩溃
第 10 节:核心模块:http (构建基础 Web 服务器)
http 模块是 Node.js 内置的,用于创建 HTTP 服务器和客户端。它是构建 Web 应用的基础,像 Express.js 这样的流行框架也是基于 http 模块构建的。
10.1 使用 http 模块创建最简单的 Web 服务器
引入方式:
const http = require('http');
创建服务器:
http.createServer() 方法返回一个 http.Server 实例。它接受一个回调函数作为参数,这个回调函数会在每次接收到 HTTP 请求时被调用。
回调函数有两个参数:
-
req(Request): 一个http.IncomingMessage对象,包含了客户端请求的所有信息。 -
res(Response): 一个http.ServerResponse对象,用于向客户端发送响应。
const http = require('http');
// 创建一个 HTTP 服务器
const server = http.createServer((req, res) => {
// 每当有请求到来时,这个回调函数就会执行
console.log(`收到请求: ${req.method} ${req.url}`);
// 设置响应头 (Content-Type: text/plain 表示纯文本)
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
// 设置 HTTP 状态码 (200 OK)
res.statusCode = 200;
// 发送响应体数据
res.write('Hello, Node.js Web Server!\n');
res.write('这是我的第一个 HTTP 响应。');
// 结束响应,必须调用 res.end(),否则客户端会一直等待
res.end();
});
// 监听端口和主机名
const PORT = 3000;
const HOST = '127.0.0.1'; // 或 'localhost'
server.listen(PORT, HOST, () => {
console.log(`服务器运行在 http://${HOST}:${PORT}/`);
console.log('请在浏览器中访问此地址,或使用 curl 命令测试。');
console.log('例如: curl http://localhost:3000/');
});
// 监听服务器错误
server.on('error', (err) => {
console.error('服务器发生错误:', err.message);
});
如何运行和测试:
-
将上述代码保存为
server.js。 -
在终端中运行:
node server.js -
打开浏览器,访问
http://localhost:3000/。 -
或者在另一个终端中使用
curl命令:curl http://localhost:3000/
10.2 理解请求 (req) 和响应 (res) 对象
req (Request) 对象:
req 对象是 http.IncomingMessage 的实例,它提供了关于客户端请求的详细信息。
-
req.url: 客户端请求的 URL 路径和查询字符串(例如/users?id=123)。 -
req.method: HTTP 请求方法(例如'GET','POST','PUT','DELETE')。 -
req.headers: 一个对象,包含所有请求头(例如{'user-agent': 'Mozilla/5.0', 'accept': '*/*'})。 -
req.rawHeaders: 原始的请求头数组。 -
req.httpVersion: HTTP 协议版本(例如'1.1')。 -
req.socket: 底层的net.Socket对象,可以获取客户端 IP 地址等信息。 -
请求体 (Request Body): 对于
POST或PUT请求,请求体数据是作为流 (Stream) 接收的。你需要监听data和end事件来收集数据。我们将在后续章节详细讲解。
res (Response) 对象:
res 对象是 http.ServerResponse 的实例,它用于构建并发送响应给客户端。
-
res.statusCode: 设置 HTTP 响应状态码(默认为200)。 -
res.statusMessage: 设置 HTTP 响应状态消息(例如OK,Not Found)。通常与statusCode自动匹配。 -
res.setHeader(name, value): 设置一个响应头。可以多次调用设置多个头。 -
res.writeHead(statusCode[, statusMessage][, headers]): 一次性设置状态码、状态消息和多个响应头。调用此方法后,就不能再使用res.statusCode或res.setHeader了。 -
res.write(chunk[, encoding][, callback]): 向响应体写入数据块。可以多次调用。 -
res.end([data][, encoding][, callback]): 结束响应。必须调用此方法,否则客户端会一直等待,请求不会完成。如果提供了data,它会作为最后一块数据写入并结束响应。
10.3 处理不同的 HTTP 方法 (GET, POST) 和路径
在 http.createServer 的回调函数中,你可以根据 req.method 和 req.url 来路由请求并发送不同的响应。
const http = require('http');
const url = require('url'); // Node.js 内置模块,用于解析 URL
const server = http.createServer((req, res) => {
const parsedUrl = url.parse(req.url, true); // true 表示解析查询字符串
const path = parsedUrl.pathname;
const query = parsedUrl.query; // 查询参数对象
console.log(`请求方法: ${req.method}, 路径: ${path}, 查询参数:`, query);
res.setHeader('Content-Type', 'text/html; charset=utf-8'); // 响应 HTML 内容
if (req.method === 'GET') {
if (path === '/') {
res.statusCode = 200;
res.end('<h1>欢迎来到首页!</h1><p>尝试访问 /about 或 /api/users</p>');
} else if (path === '/about') {
res.statusCode = 200;
res.end('<h1>关于我们</h1><p>这是一个简单的 Node.js Web 服务器。</p>');
} else if (path === '/api/users') {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json'); // 响应 JSON 内容
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
];
res.end(JSON.stringify(users));
} else if (path === '/greet') {
const name = query.name || '访客';
res.statusCode = 200;
res.end(`<h1>你好, ${name}!</h1>`);
} else {
// 404 Not Found
res.statusCode = 404;
res.end('<h1>404 Not Found</h1><p>您访问的页面不存在。</p>');
}
} else if (req.method === 'POST') {
if (path === '/submit') {
let body = '';
req.on('data', (chunk) => {
body += chunk.toString(); // 收集请求体数据
});
req.on('end', () => {
console.log('接收到 POST 请求体:', body);
res.statusCode = 200;
res.end(`<h1>POST 请求成功!</h1><p>你发送的数据是: ${body}</p>`);
});
req.on('error', (err) => {
console.error('POST 请求数据接收错误:', err.message);
res.statusCode = 500;
res.end('<h1>500 Internal Server Error</h1>');
});
} else {
res.statusCode = 404;
res.end('<h1>404 Not Found</h1><p>POST 请求的路径不存在。</p>');
}
} else {
// 处理其他 HTTP 方法
res.statusCode = 405; // Method Not Allowed
res.setHeader('Allow', 'GET, POST');
res.end('<h1>405 Method Not Allowed</h1><p>只支持 GET 和 POST 方法。</p>');
}
});
const PORT = 3000;
server.listen(PORT, () => {
console.log(`服务器运行在 http://localhost:${PORT}/`);
console.log('测试路径:');
console.log(' GET /');
console.log(' GET /about');
console.log(' GET /api/users');
console.log(' GET /greet?name=John');
console.log(' POST /submit (使用 curl -X POST -d "mydata=test" http://localhost:3000/submit)');
});
测试 POST 请求:
在终端中运行服务器后,打开另一个终端,使用 curl 命令:
curl -X POST -d "username=testuser&password=123" http://localhost:3000/submit
总结:
通过本节,你已经掌握了 Node.js 事件驱动编程的核心 EventEmitter,以及如何使用 http 模块从零开始构建一个简单的 Web 服务器,理解了请求和响应对象的基本操作。虽然这个服务器功能有限,但它是所有 Node.js Web 框架的基础。在实际开发中,你通常会使用 Express.js、Koa.js 等框架来简化 Web 应用的开发。
第三阶段:Node.js Web 开发:Express.js (约6节)
好的,我们继续深入 Node.js 的 Web 开发,进入 Express.js 的世界。
第 11 节:Express.js 入门
11.1 为什么选择 Express.js?它的作用。
在第 10 节中,我们使用 Node.js 内置的 http 模块创建了一个最简单的 Web 服务器。你可能已经发现,即使是处理几个不同的 URL 路径和 HTTP 方法,代码也变得有些复杂和冗长。这正是像 Express.js 这样的 Web 框架出现的原因。
Express.js 是什么?
Express.js 是一个基于 Node.js 平台的快速、开放、极简的 Web 框架。它提供了一系列强大的功能,用于构建 Web 应用程序和 API。
为什么选择 Express.js?
-
简化路由:
http模块需要手动解析req.url和req.method来决定如何响应。Express.js 提供了简洁的 API (app.get(),app.post(),app.put(),app.delete()) 来定义不同 URL 路径和 HTTP 方法的处理器。 -
中间件 (Middleware) 机制: 这是 Express.js 最强大的特性之一。中间件函数可以访问请求对象 (
req)、响应对象 (res) 和应用程序的请求-响应循环中的next中间件函数。它们可以执行各种任务,如日志记录、身份验证、解析请求体、处理会话等。这使得代码模块化、可复用性高。 -
模板引擎集成: Express.js 方便地与各种模板引擎(如 Pug/Jade, EJS, Handlebars)集成,用于渲染动态 HTML 页面。
-
错误处理: 提供统一的错误处理机制,使得捕获和响应应用程序错误更加容易。
-
社区和生态系统: 作为 Node.js 最流行的 Web 框架,Express.js 拥有庞大而活跃的社区,以及丰富的第三方模块(npm 包),可以轻松扩展功能。
-
性能: Express.js 本身非常轻量和快速,因为它只提供了 Web 应用所需的核心功能,其他功能通过中间件按需添加。
总结: Express.js 的作用是提供一个结构化、高效且易于使用的框架,来简化 Node.js Web 应用程序和 API 的开发,让你能够专注于业务逻辑而不是底层 HTTP 细节。
11.2 安装 Express.js
Express.js 是一个第三方模块,需要通过 npm 进行安装。
-
创建一个新的项目目录并初始化 npm:
mkdir my-express-app cd my-express-app npm init -y # -y 会跳过所有提问,直接生成默认的 package.json -
安装 Express.js:
npm install express这会将 Express.js 安装到
node_modules目录,并将其添加到package.json的dependencies中。
11.3 创建第一个 Express.js 应用:基本路由设置
创建一个名为 app.js 的文件:
// app.js
// 1. 引入 Express 模块
const express = require('express');
// 2. 创建 Express 应用实例
const app = express();
// 3. 定义端口号
const PORT = 3000;
// 4. 设置基本路由
// app.get() 用于处理 GET 请求
// 第一个参数是路径,第二个参数是处理该请求的回调函数 (req, res)
app.get('/', (req, res) => {
// res.send() 方法可以发送各种类型的响应:字符串、对象、数组等
// 它会自动设置 Content-Type 和 Content-Length,并结束响应
res.send('Hello from Express.js! This is the homepage.');
});
// 另一个 GET 路由
app.get('/about', (req, res) => {
res.send('<h1>About Us</h1><p>This is a simple Express.js application.</p>');
});
// 5. 启动服务器并监听指定端口
app.listen(PORT, () => {
console.log(`Express 服务器运行在 http://localhost:${PORT}`);
console.log('请在浏览器中访问此地址,或尝试访问 /about');
});
运行和测试:
-
在终端中进入
my-express-app目录。 -
运行:
node app.js -
打开浏览器访问:
-
http://localhost:3000/ -
http://localhost:3000/about
-
11.4 中间件 (Middleware) 概念与使用 (app.use())
中间件概念:
中间件函数是 Express.js 应用程序中处理请求的核心。它们是函数,可以访问请求对象 (req)、响应对象 (res),以及应用程序请求-响应循环中的下一个中间件函数 (next)。
中间件可以执行以下任务:
-
执行任何代码。
-
对请求 (
req) 和响应 (res) 对象进行更改。 -
结束请求-响应循环(即发送响应)。
-
调用堆栈中的下一个中间件函数。
next() 函数:
如果当前中间件函数没有结束请求-响应循环(例如,没有调用 res.send() 或 res.end()),它必须调用 next() 函数,将控制权传递给下一个中间件函数。否则,请求将停滞不前,客户端将不会收到响应。
使用 app.use():
app.use() 方法用于挂载中间件函数。
-
app.use(middlewareFunction): 没有任何路径参数,表示该中间件会应用于所有请求。 -
app.use('/path', middlewareFunction): 指定路径参数,表示该中间件只应用于以/path开头的请求。
示例:日志中间件
// app.js (在现有代码基础上修改)
const express = require('express');
const app = express();
const PORT = 3000;
// 1. 定义一个简单的日志中间件
// 这个中间件会在每个请求到达时打印请求信息
app.use((req, res, next) => {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] ${req.method} ${req.url}`);
next(); // 必须调用 next(),否则请求会在这里停止
});
// 2. 定义另一个中间件,只对 /api 路径生效
app.use('/api', (req, res, next) => {
console.log('这是一个针对 /api 路径的中间件。');
next();
});
// 3. 设置基本路由 (与之前相同)
app.get('/', (req, res) => {
res.send('Hello from Express.js! This is the homepage.');
});
app.get('/about', (req, res) => {
res.send('<h1>About Us</h1><p>This is a simple Express.js application.</p>');
});
app.get('/api/data', (req, res) => {
res.json({ message: 'Data from API endpoint', timestamp: new Date() });
});
// 4. 启动服务器
app.listen(PORT, () => {
console.log(`Express 服务器运行在 http://localhost:${PORT}`);
});
测试中间件:
-
访问
http://localhost:3000/:你会看到控制台输出[时间戳] GET /。 -
访问
http://localhost:3000/about:你会看到控制台输出[时间戳] GET /about。 -
访问
http://localhost:3000/api/data:你会看到控制台输出[时间戳] GET /api/data和这是一个针对 /api 路径的中间件。。
中间件的顺序很重要:
中间件是按照它们被 app.use() 或路由方法(如 app.get())定义的顺序执行的。如果一个中间件在处理请求后没有调用 next(),那么后续的中间件和路由处理函数将不会被执行。
第 12 节:Express.js 路由与请求处理
12.1 路由参数 (Route Params) 和查询字符串 (Query Strings)
1. 路由参数 (Route Parameters)
路由参数用于捕获 URL 中特定位置的值。它们在路径中使用冒号 : 来定义。
-
定义:
/users/:id -
访问:
req.params.id
示例:
// app.js (在现有代码基础上添加)
// 获取单个用户信息的路由
app.get('/users/:userId', (req, res) => {
const userId = req.params.userId; // 从路由参数中获取 userId
res.send(`你请求的用户 ID 是: ${userId}`);
});
// 获取特定产品评论的路由
app.get('/products/:productId/reviews/:reviewId', (req, res) => {
const productId = req.params.productId;
const reviewId = req.params.reviewId;
res.send(`你请求的产品 ID 是 ${productId} 的评论 ID 是 ${reviewId}`);
});
测试:
-
访问
http://localhost:3000/users/123 -
访问
http://localhost:3000/products/abc/reviews/456
2. 查询字符串 (Query Strings)
查询字符串是 URL 中 ? 后面跟着的 key=value 对,用于传递可选参数、过滤条件、排序方式等。
-
定义:
/search?q=nodejs&category=web -
访问:
req.query对象
示例:
// app.js (在现有代码基础上添加)
// 搜索商品的路由
app.get('/search', (req, res) => {
const searchTerm = req.query.q; // 获取查询参数 'q'
const category = req.query.category; // 获取查询参数 'category'
if (searchTerm) {
res.send(`你正在搜索: "${searchTerm}" ${category ? '在分类 ' + category : ''}`);
} else {
res.send('请提供搜索关键词,例如: /search?q=express');
}
});
测试:
-
访问
http://localhost:3000/search?q=express -
访问
http://localhost:3000/search?q=javascript&category=backend
12.2 处理 POST 请求:body-parser 或 Express 内置中间件
当客户端发送 POST、PUT 或 PATCH 请求时,数据通常包含在请求体 (Request Body) 中。原始的 req 对象是一个可读流,直接处理请求体非常繁琐。Express.js 提供了中间件来自动解析不同格式的请求体。
现代 Express.js (4.16.0+ 版本) 已经内置了 body-parser 的核心功能。 你不再需要单独安装 body-parser 模块。
使用 Express 内置中间件:
-
express.json(): 用于解析Content-Type: application/json格式的请求体。 -
express.urlencoded({ extended: true }): 用于解析Content-Type: application/x-www-form-urlencoded格式的请求体(通常是 HTML 表单提交的数据)。extended: true允许解析更复杂的嵌套对象和数组。
示例:
// app.js (在现有代码基础上修改)
const express = require('express');
const app = express();
const PORT = 3000;
// ... (之前的日志中间件等)
// 启用 Express 内置的 JSON 解析中间件
app.use(express.json());
// 启用 Express 内置的 URL-encoded 解析中间件
app.use(express.urlencoded({ extended: true }));
// 处理 POST 请求的路由
app.post('/submit-form', (req, res) => {
// 解析后的请求体数据会存储在 req.body 中
const formData = req.body;
console.log('接收到 POST 请求体:', formData);
res.send(`
<h1>表单提交成功!</h1>
<p>你提交的数据是: ${JSON.stringify(formData)}</p>
<p>用户名: ${formData.username || '未提供'}</p>
<p>邮箱: ${formData.email || '未提供'}</p>
`);
});
// ... (其他路由和服务器启动代码)
测试 POST 请求:
由于浏览器直接访问通常是 GET 请求,你需要使用工具来测试 POST 请求,例如:
-
Postman / Insomnia (图形化工具)
-
curl 命令 (命令行工具)
使用 curl 测试 JSON 数据:
curl -X POST -H "Content-Type: application/json" -d '{"username": "testuser", "email": "test@example.com"}' http://localhost:3000/submit-form
使用 curl 测试 URL-encoded 数据:
curl -X POST -H "Content-Type: application/x-www-form-urlencoded" -d "username=anotheruser&email=another@example.com" http://localhost:3000/submit-form
12.3 HTTP 请求方法(GET, POST, PUT, DELETE)
Express.js 为常见的 HTTP 请求方法提供了对应的路由方法,使得处理不同类型的请求变得非常直观。
-
app.get(path, handler): 处理GET请求,用于获取资源。 -
app.post(path, handler): 处理POST请求,用于创建新资源或提交数据。 -
app.put(path, handler): 处理PUT请求,用于更新(完全替换)现有资源。 -
app.delete(path, handler): 处理DELETE请求,用于删除资源。 -
app.patch(path, handler): 处理PATCH请求,用于部分更新现有资源。 -
app.all(path, handler): 匹配所有 HTTP 方法。
示例:模拟 RESTful API
// app.js (在现有代码基础上添加)
// 假设我们有一个简单的用户数据存储
let users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
];
let nextUserId = 3;
// GET /api/users - 获取所有用户
app.get('/api/users', (req, res) => {
res.json(users);
});
// GET /api/users/:id - 获取单个用户
app.get('/api/users/:id', (req, res) => {
const id = parseInt(req.params.id);
const user = users.find(u => u.id === id);
if (user) {
res.json(user);
} else {
res.status(404).json({ message: '用户未找到' });
}
});
// POST /api/users - 创建新用户
app.post('/api/users', (req, res) => {
const newUser = {
id: nextUserId++,
name: req.body.name,
email: req.body.email
};
if (!newUser.name || !newUser.email) {
return res.status(400).json({ message: '姓名和邮箱是必填项' });
}
users.push(newUser);
res.status(201).json(newUser); // 201 Created
});
// PUT /api/users/:id - 更新(替换)用户
app.put('/api/users/:id', (req, res) => {
const id = parseInt(req.params.id);
const userIndex = users.findIndex(u => u.id === id);
if (userIndex !== -1) {
const updatedUser = {
id: id,
name: req.body.name,
email: req.body.email
};
if (!updatedUser.name || !updatedUser.email) {
return res.status(400).json({ message: '姓名和邮箱是必填项' });
}
users[userIndex] = updatedUser;
res.json(updatedUser);
} else {
res.status(404).json({ message: '用户未找到' });
}
});
// DELETE /api/users/:id - 删除用户
app.delete('/api/users/:id', (req, res) => {
const id = parseInt(req.params.id);
const initialLength = users.length;
users = users.filter(u => u.id !== id);
if (users.length < initialLength) {
res.status(204).send(); // 204 No Content (成功删除但无返回内容)
} else {
res.status(404).json({ message: '用户未找到' });
}
});
测试这些 API (使用 curl 或 Postman):
-
GET http://localhost:3000/api/users -
GET http://localhost:3000/api/users/1 -
POST http://localhost:3000/api/users(Body:{"name": "Charlie", "email": "charlie@example.com"}) -
PUT http://localhost:3000/api/users/1(Body:{"name": "Alice Smith", "email": "alice.s@example.com"}) -
DELETE http://localhost:3000/api/users/2
通过本节,你已经掌握了 Express.js 的基本用法,包括如何设置路由、理解和使用中间件,以及如何处理不同类型的请求(包括路由参数、查询字符串和 POST 请求体)。这些是构建任何 Express.js Web 应用的基础。
好的,我们继续深入 Express.js 和数据库集成。
第 13 节:Express.js 中间件深入
中间件是 Express.js 应用程序的核心,它提供了一种强大且灵活的方式来处理请求和响应。
13.1 应用级中间件、路由级中间件、错误处理中间件
Express.js 中的中间件可以根据其作用范围和功能分为几种类型:
-
应用级中间件 (Application-level Middleware):
-
使用
app.use()或app.METHOD()(如app.get(),app.post()) 绑定到app对象实例。 -
app.use([path], callback): 如果没有指定path,则应用于所有请求。如果指定了path,则应用于以该path开头的所有请求。 -
app.METHOD(path, callback): 绑定到特定的 HTTP 方法和路径。这些实际上也是中间件,但它们通常是请求处理链的终点。 -
示例:
const express = require('express'); const app = express(); // 应用级中间件:对所有请求生效的日志记录器 app.use((req, res, next) => { console.log(`[应用级中间件] ${req.method} ${req.url} at ${new Date().toISOString()}`); next(); }); // 应用级中间件:只对 /api 路径生效 app.use('/api', (req, res, next) => { console.log('[应用级中间件] 进入 /api 路由。'); next(); }); app.get('/', (req, res) => { res.send('首页'); }); app.get('/api/data', (req, res) => { res.json({ message: 'API 数据' }); });
-
-
路由级中间件 (Router-level Middleware):
-
使用
express.Router()实例来定义。它与应用级中间件的工作方式相同,但它被绑定到express.Router()的实例而不是app实例。这有助于模块化路由。 -
示例:
const express = require('express'); const app = express(); const router = express.Router(); // 创建一个路由实例 // 路由级中间件:只对通过此路由处理的请求生效 router.use((req, res, next) => { console.log(`[路由级中间件] 请求到用户路由: ${req.method} ${req.url}`); next(); }); router.get('/', (req, res) => { res.send('用户列表'); }); router.get('/:id', (req, res) => { res.send(`用户 ID: ${req.params.id}`); }); // 将路由挂载到应用程序的 /users 路径 app.use('/users', router); // ... 其他应用级中间件和路由
-
-
错误处理中间件 (Error-handling Middleware):
-
与其他中间件不同,错误处理中间件函数有四个参数:
(err, req, res, next)。 -
它们必须定义在所有其他路由和中间件之后。
-
当任何路由或中间件中发生错误(例如,通过
next(err)传递错误)时,Express 会跳过所有常规中间件,直接将控制权交给错误处理中间件。 -
示例:
const express = require('express'); const app = express(); app.get('/error-test', (req, res, next) => { // 模拟一个错误 const error = new Error('这是一个测试错误!'); error.statusCode = 500; // 可以自定义错误属性 next(error); // 将错误传递给下一个错误处理中间件 }); // 404 处理中间件 (放在所有路由之后,错误处理之前) app.use((req, res, next) => { res.status(404).send('<h1>404 Not Found</h1>'); }); // 错误处理中间件 (必须有四个参数,且放在所有路由和常规中间件之后) app.use((err, req, res, next) => { console.error('捕获到错误:', err.stack); // 打印错误堆栈 const statusCode = err.statusCode || 500; res.status(statusCode).send(` <h1>${statusCode} - 服务器错误</h1> <p>${err.message}</p> <pre>${process.env.NODE_ENV === 'development' ? err.stack : ''}</pre> `); }); // ... 服务器启动代码
-
13.2 自定义中间件的编写
编写自定义中间件非常简单,只需遵循 (req, res, next) 的函数签名。
示例:身份验证中间件
// authMiddleware.js
function authenticate(req, res, next) {
const apiKey = req.headers['x-api-key']; // 假设 API Key 在请求头中
if (apiKey === 'MY_SECRET_API_KEY') {
// 认证成功,可以在 req 对象上添加用户信息
req.user = { id: 1, name: 'Authenticated User' };
next(); // 继续处理请求
} else {
// 认证失败
res.status(401).send('未授权: 无效的 API Key');
}
}
module.exports = authenticate;
// app.js
const express = require('express');
const app = express();
const authenticate = require('./authMiddleware'); // 引入自定义中间件
app.use(express.json()); // 用于解析请求体
// 应用到所有 /secure 路径的请求
app.use('/secure', authenticate);
app.get('/secure/data', (req, res) => {
// 只有通过 authenticate 中间件的请求才能到达这里
res.json({ message: `欢迎, ${req.user.name}! 这是受保护的数据。` });
});
app.get('/public/data', (req, res) => {
res.send('这是公开数据,无需认证。');
});
// ... 服务器启动代码
测试:
-
GET http://localhost:3000/public/data(成功) -
GET http://localhost:3000/secure/data(401 未授权) -
GET http://localhost:3000/secure/data(添加请求头X-API-Key: MY_SECRET_API_KEY,成功)
13.3 常用的第三方中间件介绍
Express.js 的强大之处在于其庞大的中间件生态系统。
-
morgan(HTTP 请求日志):-
作用: 记录 HTTP 请求的详细信息,如请求方法、URL、状态码、响应时间等。
-
安装:
npm install morgan -
使用:
const morgan = require('morgan'); // ... app.use(morgan('dev')); // 'dev' 是一个预定义的格式,还有 'tiny', 'short', 'common', 'combined' // 或者自定义格式: app.use(morgan(':method :url :status :res[content-length] - :response-time ms'));运行后,每次请求都会在控制台打印日志。
-
-
cors(跨域资源共享):-
作用: 启用 CORS (Cross-Origin Resource Sharing),允许或限制来自不同源的 Web 应用程序访问你的服务器资源。
-
安装:
npm install cors -
使用:
const cors = require('cors'); // ... app.use(cors()); // 允许所有来源的跨域请求 (开发环境常用) // 或者配置特定来源 // app.use(cors({ // origin: 'http://example.com', // 只允许来自 example.com 的请求 // methods: ['GET', 'POST'], // allowedHeaders: ['Content-Type', 'Authorization'] // }));
-
-
helmet(安全):-
作用: 通过设置各种 HTTP 头来帮助保护 Express 应用程序免受一些常见的 Web 漏洞攻击。
-
安装:
npm install helmet -
使用:
const helmet = require('helmet'); // ... app.use(helmet()); // 启用所有默认的 Helmet 中间件 // 你也可以选择性地启用或禁用某些模块 // app.use(helmet.contentSecurityPolicy()); // app.use(helmet.xssFilter());
-
-
express-session(会话管理):-
作用: 提供会话管理功能,允许你在用户请求之间存储用户特定的数据。
-
安装:
npm install express-session -
使用: (需要一个 secret 字符串来签名会话 ID cookie)
const session = require('express-session'); // ... app.use(session({ secret: 'your_secret_key', // 必须提供一个 secret resave: false, // 强制会话保存,即使它在请求期间没有被修改 saveUninitialized: true, // 强制未初始化的会话保存到存储 cookie: { secure: false } // 在生产环境中应设置为 true (HTTPS) })); app.get('/set-session', (req, res) => { req.session.views = (req.session.views || 0) + 1; res.send(`你访问了此页面 ${req.session.views} 次`); });
-
第 14 节:数据库集成(MongoDB & Mongoose)
14.1 NoSQL 数据库简介:MongoDB
在关系型数据库(如 MySQL, PostgreSQL)中,数据以表格形式存储,并遵循严格的预定义模式(Schema)。而 NoSQL (Not only SQL) 数据库则提供了更灵活的数据存储方式。
MongoDB 是一种流行的 文档型 (Document-oriented) NoSQL 数据库。
-
数据存储: MongoDB 将数据存储为 BSON (Binary JSON) 文档,这与 JavaScript 中的 JSON 对象非常相似。
-
无模式 (Schema-less) 或灵活模式: 同一个集合 (Collection) 中的文档可以有不同的字段和结构,这为开发提供了极大的灵活性,尤其是在数据结构不确定或经常变化时。
-
可伸缩性: 易于水平扩展 (sharding)。
-
高性能: 适用于大数据量和高并发场景。
-
与 Node.js 的契合度: 由于 MongoDB 使用 JSON 格式存储数据,与 JavaScript 对象天然兼容,使得 Node.js 开发者可以非常自然地操作数据。
14.2 安装 MongoDB
安装 MongoDB 有多种方式:
-
官方安装包: 访问 MongoDB 官网下载并按照指南安装适合你操作系统的版本。
-
Docker: 对于开发环境,使用 Docker 是一个非常方便的选择。
docker pull mongo docker run --name my-mongo -p 27017:27017 -d mongo -
云服务: 在生产环境中,通常会使用 MongoDB Atlas (MongoDB 官方提供的云数据库服务) 或其他云提供商的 MongoDB 服务。
安装完成后,请确保 MongoDB 服务正在运行。 默认情况下,MongoDB 运行在 localhost:27017。
14.3 使用 Mongoose ODM (Object Data Modeling) 连接 MongoDB
直接使用 MongoDB 的原生驱动程序可能会比较繁琐。Mongoose 是一个流行的 Node.js ODM (Object Data Modeling) 库,它在 MongoDB 驱动程序之上提供了一个更高级别的抽象,使得与 MongoDB 的交互更加简单、结构化,并提供了模式验证等功能。
安装 Mongoose:
npm install mongoose
连接 MongoDB:
// db.js (或者直接在 app.js 中)
const mongoose = require('mongoose');
const connectDB = async () => {
try {
const conn = await mongoose.connect('mongodb://localhost:27017/mydatabase', {
// 这些选项在 Mongoose 6.0+ 中已是默认,可以省略
// useNewUrlParser: true,
// useUnifiedTopology: true,
// useCreateIndex: true, // 已废弃
// useFindAndModify: false // 已废弃
});
console.log(`MongoDB 连接成功: ${conn.connection.host}`);
} catch (err) {
console.error(`MongoDB 连接失败: ${err.message}`);
process.exit(1); // 退出进程
}
};
module.exports = connectDB;
// app.js (在 Express 应用启动前调用)
const express = require('express');
const connectDB = require('./db'); // 引入数据库连接函数
const app = express();
app.use(express.json()); // 用于解析 JSON 请求体
// 连接数据库
connectDB();
// ... Express 路由和中间件
14.4 Mongoose Schema 和 Model 的定义
1. Schema (模式):
Schema 定义了 MongoDB 文档的结构、数据类型、验证规则和默认值。它不是数据库中的实际表结构,而是 Mongoose 在应用层面对数据的一种约束和描述。
2. Model (模型):
Model 是 Schema 的编译版本。它是一个构造函数,用于创建文档实例,并提供了与数据库交互的方法(如 find(), save(), update() 等)。
示例:定义一个用户 Schema 和 Model
// models/User.js
const mongoose = require('mongoose');
// 定义用户 Schema
const UserSchema = new mongoose.Schema({
name: {
type: String,
required: [true, '用户名是必填项'], // 必填,并提供错误消息
trim: true, // 自动去除字符串两端的空白
minlength: [3, '用户名至少需要3个字符']
},
email: {
type: String,
required: [true, '邮箱是必填项'],
unique: true, // 邮箱必须唯一
lowercase: true, // 存储前转换为小写
match: [/.+@.+\..+/, '请输入有效的邮箱地址'] // 正则表达式验证
},
age: {
type: Number,
min: [0, '年龄不能为负数'],
max: [120, '年龄不能超过120']
},
createdAt: {
type: Date,
default: Date.now // 默认值为当前时间
}
});
// 创建并导出 User Model
// 'User' 是集合的名称 (Mongoose 会自动将其复数化为 'users')
module.exports = mongoose.model('User', UserSchema);
14.5 基本 CRUD (创建、读取、更新、删除) 操作
现在,我们将这些 Mongoose 操作集成到 Express.js 路由中,构建一个简单的 RESTful API。
// app.js (完整示例)
const express = require('express');
const connectDB = require('./db'); // 引入数据库连接函数
const User = require('./models/User'); // 引入 User 模型
const app = express();
const PORT = 3000;
// 连接数据库
connectDB();
// 中间件
app.use(express.json()); // 用于解析 JSON 请求体
app.use(express.urlencoded({ extended: true })); // 用于解析 URL-encoded 请求体
// --- 用户 API 路由 ---
// GET /api/users - 获取所有用户
app.get('/api/users', async (req, res) => {
try {
const users = await User.find(); // 查找所有用户
res.json(users);
} catch (err) {
res.status(500).json({ message: err.message });
}
});
// GET /api/users/:id - 获取单个用户
app.get('/api/users/:id', async (req, res) => {
try {
const user = await User.findById(req.params.id); // 根据 ID 查找用户
if (!user) {
return res.status(404).json({ message: '用户未找到' });
}
res.json(user);
} catch (err) {
// 如果 ID 格式不正确,Mongoose 会抛出 CastError
if (err.name === 'CastError') {
return res.status(400).json({ message: '无效的用户 ID 格式' });
}
res.status(500).json({ message: err.message });
}
});
// POST /api/users - 创建新用户
app.post('/api/users', async (req, res) => {
const { name, email, age } = req.body;
const newUser = new User({ name, email, age }); // 创建一个新的 User 文档实例
try {
const savedUser = await newUser.save(); // 保存到数据库
res.status(201).json(savedUser); // 201 Created
} catch (err) {
// Mongoose 验证错误
if (err.name === 'ValidationError') {
const errors = Object.values(err.errors).map(el => el.message);
return res.status(400).json({ message: errors.join(', ') });
}
// 唯一性错误 (例如邮箱重复)
if (err.code === 11000) { // MongoDB duplicate key error code
return res.status(400).json({ message: '邮箱已被注册' });
}
res.status(500).json({ message: err.message });
}
});
// PUT /api/users/:id - 更新用户 (完全替换)
app.put('/api/users/:id', async (req, res) => {
try {
// findByIdAndUpdate 默认返回更新前的文档,加 { new: true } 返回更新后的
// runValidators: true 确保在更新时也运行 Schema 中定义的验证规则
const updatedUser = await User.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true, runValidators: true }
);
if (!updatedUser) {
return res.status(404).json({ message: '用户未找到' });
}
res.json(updatedUser);
} catch (err) {
if (err.name === 'CastError') {
return res.status(400).json({ message: '无效的用户 ID 格式' });
}
if (err.name === 'ValidationError') {
const errors = Object.values(err.errors).map(el => el.message);
return res.status(400).json({ message: errors.join(', ') });
}
if (err.code === 11000) {
return res.status(400).json({ message: '邮箱已被注册' });
}
res.status(500).json({ message: err.message });
}
});
// DELETE /api/users/:id - 删除用户
app.delete('/api/users/:id', async (req, res) => {
try {
const deletedUser = await User.findByIdAndDelete(req.params.id);
if (!deletedUser) {
return res.status(404).json({ message: '用户未找到' });
}
res.status(204).send(); // 204 No Content
} catch (err) {
if (err.name === 'CastError') {
return res.status(400).json({ message: '无效的用户 ID 格式' });
}
res.status(500).json({ message: err.message });
}
});
// 启动服务器
app.listen(PORT, () => {
console.log(`Express 服务器运行在 http://localhost:${PORT}`);
console.log('请确保 MongoDB 服务正在运行在 localhost:27017');
console.log('测试 API:');
console.log(' GET /api/users');
console.log(' POST /api/users (Body: {"name": "John Doe", "email": "john@example.com", "age": 30})');
console.log(' GET /api/users/:id');
console.log(' PUT /api/users/:id (Body: {"name": "Jane Doe", "email": "jane@example.com"})');
console.log(' DELETE /api/users/:id');
});
// 错误处理中间件 (放在所有路由之后)
app.use((err, req, res, next) => {
console.error('未捕获的错误:', err.stack);
res.status(500).send('<h1>500 - 服务器内部错误</h1>');
});
运行步骤:
-
确保你已经安装并运行了 MongoDB 服务(例如通过 Docker 或本地安装)。
-
创建项目目录,
npm init -y。 -
安装依赖:
npm install express mongoose -
创建
db.js文件并粘贴连接代码。 -
创建
models/User.js文件并粘贴 Schema 和 Model 定义代码。 -
创建
app.js文件并粘贴完整的 Express 应用代码。 -
在终端中运行
node app.js。 -
使用 Postman、Insomnia 或
curl命令测试 API。
通过本节,你已经深入理解了 Express.js 中间件的各种类型和用法,并学会了如何将 Node.js 应用程序与 MongoDB 数据库集成,使用 Mongoose 进行数据建模和执行基本的 CRUD 操作。这是构建全栈 Node.js 应用的关键一步。
好的,我们继续深入 Node.js 后端开发,本节将专注于构建符合 RESTful 原则的 API,并引入用户认证与授权的核心机制——JWT。
第 15 节:构建 RESTful API
15.1 RESTful API 设计原则
REST (Representational State Transfer) 是一种架构风格,用于设计网络应用程序。它不是一个标准,而是一组指导原则。遵循 REST 原则的 API 被称为 RESTful API。
核心原则:
-
资源 (Resource):
-
API 中的所有事物都被视为资源。
-
资源通过唯一的 URI (Uniform Resource Identifier) 来标识。
-
示例:
/users,/products/123,/orders -
最佳实践: URI 应该使用名词(复数),而不是动词。例如,
GET /users而不是GET /getAllUsers。
-
-
统一接口 (Uniform Interface):
-
使用标准的 HTTP 方法来对资源执行操作。
-
GET: 从服务器获取资源。安全且幂等(多次请求结果相同)。
-
POST: 在服务器上创建新资源。非幂等。
-
PUT: 完全更新(替换)现有资源。幂等。
-
PATCH: 部分更新现有资源。非幂等。
-
DELETE: 从服务器删除资源。幂等。
-
示例:
-
GET /users:获取所有用户 -
GET /users/123:获取 ID 为 123 的用户 -
POST /users:创建新用户 -
PUT /users/123:更新 ID 为 123 的用户(替换整个资源) -
PATCH /users/123:更新 ID 为 123 的用户(部分更新) -
DELETE /users/123:删除 ID 为 123 的用户
-
-
-
无状态 (Stateless):
-
服务器不应该存储任何关于客户端会话的状态信息。
-
每个请求都必须包含处理该请求所需的所有信息。
-
这使得 API 更具可伸缩性,因为任何服务器都可以处理任何请求。
-
-
客户端-服务器分离 (Client-Server Separation):
- 客户端和服务器应该独立发展。客户端不关心数据如何存储,服务器不关心数据如何渲染。
-
分层系统 (Layered System):
- 客户端无法判断它是否直接连接到最终服务器,或者中间是否有代理、负载均衡器等。这增加了系统的灵活性和可伸缩性。
-
按需代码 (Code on Demand - 可选):
- 服务器可以通过发送可执行代码(如 JavaScript)来临时扩展客户端功能。这在现代 Web 应用中很常见,但不是 REST 的强制要求。
其他重要考虑:
-
HTTP 状态码: 使用正确的 HTTP 状态码来表示请求的结果(例如,200 OK, 201 Created, 204 No Content, 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 500 Internal Server Error)。
-
数据格式: 通常使用 JSON (JavaScript Object Notation) 作为数据交换格式。
-
版本控制 (Versioning - 可选但推荐): 当 API 发生重大变化时,通过 URL (如
/v1/users) 或请求头来区分版本,以避免破坏现有客户端。
15.2 使用 Express.js 和 Mongoose 实现 RESTful API 的 CRUD 接口
在第 14 节中,我们已经构建了一个基于 Express.js 和 Mongoose 的用户管理 API,它基本遵循了 RESTful 原则。这里我们再次强调其结构和设计。
文件结构:
my-api-app/
├── app.js # Express 应用主文件
├── db.js # 数据库连接配置
└── models/
└── User.js # Mongoose User 模型
models/User.js (与第 14 节相同):
const mongoose = require('mongoose');
const UserSchema = new mongoose.Schema({
name: {
type: String,
required: [true, '用户名是必填项'],
trim: true,
minlength: [3, '用户名至少需要3个字符']
},
email: {
type: String,
required: [true, '邮箱是必填项'],
unique: true,
lowercase: true,
match: [/.+@.+\..+/, '请输入有效的邮箱地址']
},
age: {
type: Number,
min: [0, '年龄不能为负数'],
max: [120, '年龄不能超过120']
},
createdAt: {
type: Date,
default: Date.now
}
});
module.exports = mongoose.model('User', UserSchema);
app.js (核心 API 路由,强调 RESTful):
const express = require('express');
const connectDB = require('./db');
const User = require('./models/User');
const app = express();
const PORT = 3000;
// 连接数据库
connectDB();
// 中间件
app.use(express.json()); // 解析 JSON 请求体
app.use(express.urlencoded({ extended: true })); // 解析 URL-encoded 请求体
// --- RESTful 用户 API 路由 ---
// GET /api/users - 获取所有用户
app.get('/api/users', async (req, res) => {
try {
const users = await User.find();
res.status(200).json(users); // 200 OK
} catch (err) {
// 错误处理将在下一节详细说明
res.status(500).json({ message: '获取用户失败', error: err.message });
}
});
// GET /api/users/:id - 获取单个用户
app.get('/api/users/:id', async (req, res) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({ message: '用户未找到' }); // 404 Not Found
}
res.status(200).json(user); // 200 OK
} catch (err) {
if (err.name === 'CastError') {
return res.status(400).json({ message: '无效的用户 ID 格式' }); // 400 Bad Request
}
res.status(500).json({ message: '获取用户失败', error: err.message });
}
});
// POST /api/users - 创建新用户
app.post('/api/users', async (req, res) => {
const { name, email, age } = req.body;
const newUser = new User({ name, email, age });
try {
const savedUser = await newUser.save();
res.status(201).json(savedUser); // 201 Created
} catch (err) {
// 错误处理将在下一节详细说明
res.status(500).json({ message: '创建用户失败', error: err.message });
}
});
// PUT /api/users/:id - 更新用户 (完全替换)
app.put('/api/users/:id', async (req, res) => {
try {
const updatedUser = await User.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true, runValidators: true } // 返回更新后的文档,并运行验证器
);
if (!updatedUser) {
return res.status(404).json({ message: '用户未找到' });
}
res.status(200).json(updatedUser); // 200 OK
} catch (err) {
// 错误处理将在下一节详细说明
res.status(500).json({ message: '更新用户失败', error: err.message });
}
});
// DELETE /api/users/:id - 删除用户
app.delete('/api/users/:id', async (req, res) => {
try {
const deletedUser = await User.findByIdAndDelete(req.params.id);
if (!deletedUser) {
return res.status(404).json({ message: '用户未找到' });
}
res.status(204).send(); // 204 No Content (成功删除,但没有响应体)
} catch (err) {
if (err.name === 'CastError') {
return res.status(400).json({ message: '无效的用户 ID 格式' });
}
res.status(500).json({ message: '删除用户失败', error: err.message });
}
});
// 启动服务器
app.listen(PORT, () => {
console.log(`Express 服务器运行在 http://localhost:${PORT}`);
console.log('请确保 MongoDB 服务正在运行在 localhost:27017');
});
// 全局错误处理中间件 (放在所有路由之后)
app.use((err, req, res, next) => {
console.error('未捕获的错误:', err.stack);
res.status(500).json({ message: '服务器内部错误', error: err.message });
});
15.3 数据验证与错误处理
1. 数据验证 (Data Validation):
-
Mongoose Schema 验证: 这是最基本也是最推荐的验证方式,直接在 Schema 定义中进行。
-
required: true:字段必填。 -
unique: true:字段值唯一。 -
minlength,maxlength:字符串长度限制。 -
min,max:数字范围限制。 -
match:正则表达式验证。 -
自定义验证器。
-
优点: 确保数据在写入数据库前是有效的,防止脏数据。
-
示例: 在
User.js中已经体现。
-
-
请求体验证 (Input Validation):
-
在路由处理函数中,你可能需要对
req.body中的数据进行更复杂的业务逻辑验证,或者在 Mongoose 验证之前进行初步检查。 -
对于更复杂的验证,可以使用第三方库,如
Joi或express-validator。 -
示例 (简单检查):
// 在 POST /api/users 路由中 app.post('/api/users', async (req, res) => { const { name, email, age } = req.body; if (!name || !email) { return res.status(400).json({ message: '姓名和邮箱是必填项' }); } // ... });
-
2. 错误处理 (Error Handling):
在 RESTful API 中,清晰的错误响应至关重要。
-
使用
try...catch块: 对于异步操作(如数据库查询),始终使用try...catch来捕获潜在的错误。 -
区分错误类型:
-
Mongoose
ValidationError: 当数据不符合 Schema 定义的验证规则时发生。-
err.name === 'ValidationError' -
err.errors对象包含详细的验证错误信息。
-
-
Mongoose
CastError: 当尝试将一个不兼容的值转换为 Mongoose Schema 中定义的类型时发生(例如,查找一个格式错误的 ID)。err.name === 'CastError'
-
MongoDB
duplicate key error(错误码 11000): 当尝试插入或更新一个违反unique: true约束的文档时发生。err.code === 11000
-
其他运行时错误: 任何未预料到的服务器端错误。
-
-
返回适当的 HTTP 状态码:
-
400 Bad Request:客户端发送的请求无效(如验证失败、参数错误)。 -
404 Not Found:请求的资源不存在。 -
409 Conflict:请求与目标资源的当前状态冲突(如尝试创建已存在的资源)。 -
500 Internal Server Error:服务器端发生未知错误。
-
-
提供有意义的错误信息: 响应体中应包含一个
message字段,描述错误原因,有时还可以包含更详细的error对象。
改进后的错误处理示例 (在 app.js 中):
// ... (之前的代码)
// POST /api/users - 创建新用户 (改进错误处理)
app.post('/api/users', async (req, res) => {
const { name, email, age } = req.body;
const newUser = new User({ name, email, age });
try {
const savedUser = await newUser.save();
res.status(201).json(savedUser);
} catch (err) {
if (err.name === 'ValidationError') {
// Mongoose 验证错误
const errors = Object.values(err.errors).map(el => el.message);
return res.status(400).json({ message: '数据验证失败', errors: errors });
}
if (err.code === 11000) {
// MongoDB 唯一性约束错误
return res.status(409).json({ message: '邮箱已被注册', field: 'email' }); // 409 Conflict
}
res.status(500).json({ message: '服务器内部错误', error: err.message });
}
});
// PUT /api/users/:id - 更新用户 (改进错误处理)
app.put('/api/users/:id', async (req, res) => {
try {
const updatedUser = await User.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true, runValidators: true }
);
if (!updatedUser) {
return res.status(404).json({ message: '用户未找到' });
}
res.status(200).json(updatedUser);
} catch (err) {
if (err.name === 'CastError') {
return res.status(400).json({ message: '无效的用户 ID 格式' });
}
if (err.name === 'ValidationError') {
const errors = Object.values(err.errors).map(el => el.message);
return res.status(400).json({ message: '数据验证失败', errors: errors });
}
if (err.code === 11000) {
return res.status(409).json({ message: '邮箱已被注册', field: 'email' });
}
res.status(500).json({ message: '服务器内部错误', error: err.message });
}
});
// ... (其他路由的错误处理也应类似改进)
// 全局错误处理中间件 (捕获未被路由处理的错误)
app.use((err, req, res, next) => {
console.error('未捕获的错误:', err.stack);
// 如果错误已经设置了状态码,则使用它,否则默认为 500
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
message: err.message || '服务器内部错误',
// 在开发环境中可以暴露堆栈信息,生产环境不建议
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
});
});
第 16 节:用户认证与授权 (JWT)
16.1 会话 (Session) 和令牌 (Token) 认证机制对比
1. 会话 (Session) 认证:
-
工作原理:
-
用户登录时,服务器验证凭据,并在服务器端创建一个会话记录。
-
服务器生成一个唯一的会话 ID,通常通过 Cookie 发送给客户端。
-
客户端在后续请求中携带此 Cookie。
-
服务器根据 Cookie 中的会话 ID 查找对应的会话记录,从而识别用户。
-
-
优点:
-
安全性相对较高,会话数据存储在服务器端。
-
易于撤销会话(只需删除服务器上的会话记录)。
-
-
缺点:
-
有状态: 服务器需要维护会话状态,增加了服务器的负担。
-
可伸缩性问题: 在分布式系统或负载均衡环境下,需要共享会话存储(如 Redis),增加了复杂性。
-
跨域问题: Cookie 默认受同源策略限制,跨域请求需要额外配置。
-
移动应用不友好: 移动应用通常不使用 Cookie。
-
2. 令牌 (Token) 认证 (以 JWT 为例):
-
工作原理:
-
用户登录时,服务器验证凭据,并生成一个加密的令牌 (Token),通常是 JWT。
-
服务器将令牌发送给客户端。
-
客户端将令牌存储在本地(如 localStorage, sessionStorage)。
-
客户端在后续请求中将令牌放在 HTTP 请求头(通常是
Authorization: Bearer <token>)中发送给服务器。 -
服务器接收到令牌后,验证其有效性(签名、过期时间),并从令牌中提取用户信息,无需查询数据库。
-
-
优点:
-
无状态: 服务器不存储会话状态,每个请求都包含所有必要信息,提高了可伸缩性。
-
跨域友好: 令牌通过 HTTP 头传递,不受同源策略限制,方便跨域 API 调用。
-
移动应用友好: 适用于各种客户端,包括移动应用。
-
性能: 减少了数据库查询,提高了认证效率。
-
-
缺点:
-
令牌无法撤销: 一旦签发,在过期前都有效(除非服务器维护黑名单)。
-
安全性: 令牌存储在客户端,容易受到 XSS 攻击。需要配合 HTTPS 和适当的存储策略。
-
令牌大小: 令牌中包含的信息越多,其大小越大,可能增加请求负载。
-
总结: JWT 认证更适合现代的、分布式的、跨平台的 Web 应用和 API。
16.2 JWT (JSON Web Token) 简介与工作原理
JWT 是什么?
JWT (JSON Web Token) 是一个开放标准 (RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间安全地传输信息,作为 JSON 对象。
JWT 的结构:
JWT 由三部分组成,用点 . 分隔:Header.Payload.Signature
-
Header (头部):
-
通常包含两部分:令牌的类型(即
JWT)和所使用的签名算法(如HMAC SHA256或RSA)。 -
示例:
{ "alg": "HS256", "typ": "JWT" } -
这个 JSON 对象会被 Base64Url 编码。
-
-
Payload (载荷):
-
包含声明 (claims),即关于实体(通常是用户)和附加数据的语句。
-
声明分为三类:
-
Registered claims (注册声明): 预定义的一些声明,非强制但推荐使用,如
iss(issuer),exp(expiration time),sub(subject),aud(audience) 等。 -
Public claims (公共声明): 自定义声明,但为了避免冲突,应在 IANA JSON Web Token Registry 中注册,或定义为 URI。
-
Private claims (私有声明): 自定义声明,用于在同意使用它们的各方之间共享信息。
-
-
示例:
{ "userId": "60d5ec49f8e7c20015f8e7c2", "username": "john.doe", "role": "admin", "iat": 1678886400, // Issued At (签发时间) "exp": 1678890000 // Expiration Time (过期时间) } -
这个 JSON 对象也会被 Base64Url 编码。
-
-
Signature (签名):
-
用于验证令牌的发送者,并确保令牌在传输过程中没有被篡改。
-
签名是通过将 Base64Url 编码的 Header、Base64Url 编码的 Payload、一个密钥 (secret) 和 Header 中指定的算法进行哈希计算而创建的。
-
计算方式:
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
-
JWT 工作原理:
-
用户登录: 客户端向服务器发送用户名和密码。
-
服务器验证: 服务器验证这些凭据。
-
生成 JWT: 如果凭据有效,服务器使用一个密钥(只有服务器知道)和选定的算法生成一个 JWT。JWT 中包含用户的身份信息(如用户 ID、角色等)和过期时间。
-
发送 JWT: 服务器将生成的 JWT 发送回客户端。
-
客户端存储: 客户端将 JWT 存储在本地(通常是
localStorage或sessionStorage)。 -
后续请求: 客户端在每次需要访问受保护资源时,将 JWT 放在 HTTP 请求的
Authorization头中(通常是Bearer <token>格式)发送给服务器。 -
服务器验证 JWT: 服务器接收到请求后,使用相同的密钥验证 JWT 的签名。
-
如果签名无效,或者令牌已过期,服务器拒绝请求。
-
如果签名有效且未过期,服务器从 Payload 中提取用户信息,并允许访问相应的资源。
-
-
发送响应: 服务器处理请求并发送响应。
16.3 使用 jsonwebtoken 库实现注册、登录和保护路由
我们将使用 jsonwebtoken 库来生成和验证 JWT,以及 bcryptjs 来安全地存储用户密码。
安装必要的库:
npm install jsonwebtoken bcryptjs
修改 models/User.js (添加密码字段和密码哈希方法):
// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs'); // 引入 bcryptjs
const UserSchema = new mongoose.Schema({
name: {
type: String,
required: [true, '用户名是必填项'],
trim: true,
minlength: [3, '用户名至少需要3个字符']
},
email: {
type: String,
required: [true, '邮箱是必填项'],
unique: true,
lowercase: true,
match: [/.+@.+\..+/, '请输入有效的邮箱地址']
},
password: { // 添加密码字段
type: String,
required: [true, '密码是必填项'],
minlength: [6, '密码至少需要6个字符'],
select: false // 默认情况下不返回密码字段
},
age: {
type: Number,
min: [0, '年龄不能为负数'],
max: [120, '年龄不能超过120']
},
createdAt: {
type: Date,
default: Date.now
}
});
// 在保存用户之前对密码进行哈希处理 (pre-save hook)
UserSchema.pre('save', async function(next) {
if (!this.isModified('password')) { // 只有当密码被修改时才进行哈希
return next();
}
const salt = await bcrypt.genSalt(10); // 生成盐
this.password = await bcrypt.hash(this.password, salt); // 哈希密码
next();
});
// 实例方法:比较用户输入的密码和数据库中存储的哈希密码
UserSchema.methods.matchPassword = async function(enteredPassword) {
return await bcrypt.compare(enteredPassword, this.password);
};
module.exports = mongoose.model('User', UserSchema);
app.js (集成认证和授权):
const express = require('express');
const connectDB = require('./db');
const User = require('./models/User');
const jwt = require('jsonwebtoken'); // 引入 jsonwebtoken
const app = express();
const PORT = 3000;
// 定义 JWT 密钥 (在生产环境中应从环境变量中获取)
const JWT_SECRET = process.env.JWT_SECRET || 'supersecretjwtkey';
const JWT_EXPIRES_IN = '1h'; // 令牌过期时间
// 连接数据库
connectDB();
// 中间件
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// --- 辅助函数:生成 JWT 令牌 ---
const generateToken = (id) => {
return jwt.sign({ id }, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
});
};
// --- 认证路由 ---
// POST /api/auth/register - 用户注册
app.post('/api/auth/register', async (req, res) => {
const { name, email, password, age } = req.body;
try {
const newUser = new User({ name, email, password, age });
const savedUser = await newUser.save();
// 不返回密码,即使它被 select: false 标记
const userResponse = savedUser.toObject();
delete userResponse.password;
const token = generateToken(savedUser._id);
res.status(201).json({
message: '用户注册成功',
user: userResponse,
token,
});
} catch (err) {
if (err.name === 'ValidationError') {
const errors = Object.values(err.errors).map(el => el.message);
return res.status(400).json({ message: '数据验证失败', errors: errors });
}
if (err.code === 11000) {
return res.status(409).json({ message: '邮箱已被注册', field: 'email' });
}
res.status(500).json({ message: '服务器内部错误', error: err.message });
}
});
// POST /api/auth/login - 用户登录
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ message: '请提供邮箱和密码' });
}
try {
// 查找用户,并显式选择密码字段
const user = await User.findOne({ email }).select('+password');
if (!user || !(await user.matchPassword(password))) {
return res.status(401).json({ message: '邮箱或密码不正确' }); // 401 Unauthorized
}
const token = generateToken(user._id);
// 不返回密码
const userResponse = user.toObject();
delete userResponse.password;
res.status(200).json({
message: '登录成功',
user: userResponse,
token,
});
} catch (err) {
res.status(500).json({ message: '服务器内部错误', error: err.message });
}
});
// --- 保护路由中间件 ---
const protect = async (req, res, next) => {
let token;
// 1. 检查请求头中是否有 token
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
token = req.headers.authorization.split(' ')[1];
}
if (!token) {
return res.status(401).json({ message: '未授权: 没有令牌' });
}
try {
// 2. 验证 token
const decoded = jwt.verify(token, JWT_SECRET);
// 3. 查找用户并将其附加到请求对象
// 排除密码字段
req.user = await User.findById(decoded.id).select('-password');
if (!req.user) {
return res.status(401).json({ message: '令牌无效: 用户不存在' });
}
next(); // 继续处理请求
} catch (err) {
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({ message: '令牌无效: ' + err.message });
}
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ message: '令牌已过期' });
}
res.status(500).json({ message: '服务器内部错误', error: err.message });
}
};
// --- 受保护的 API 路由 (需要认证) ---
// GET /api/users/me - 获取当前登录用户的信息
app.get('/api/users/me', protect, (req, res) => {
// req.user 由 protect 中间件设置
res.status(200).json({
message: '成功获取当前用户信息',
user: req.user,
});
});
// GET /api/protected-data - 示例受保护数据
app.get('/api/protected-data', protect, (req, res) => {
res.status(200).json({
message: `欢迎 ${req.user.name}! 这是只有认证用户才能看到的数据。`,
data: { secret: '非常机密的信息' },
});
});
// --- 其他 RESTful 用户 API 路由 (可以根据需要添加 protect 中间件) ---
// 例如,只有管理员才能获取所有用户列表
app.get('/api/users', protect, async (req, res) => {
try {
const users = await User.find().select('-password'); // 不返回密码
res.status(200).json(users);
} catch (err) {
res.status(500).json({ message: '获取用户失败', error: err.message });
}
});
// ... (其他 CRUD 路由,根据业务需求决定是否需要 protect)
// 启动服务器
app.listen(PORT, () => {
console.log(`Express 服务器运行在 http://localhost:${PORT}`);
console.log('请确保 MongoDB 服务正在运行在 localhost:27017');
console.log('测试认证 API:');
console.log(' POST /api/auth/register');
console.log(' POST /api/auth/login');
console.log('测试受保护路由 (需要 Authorization: Bearer <token>):');
console.log(' GET /api/users/me');
console.log(' GET /api/protected-data');
});
// 全局错误处理中间件 (放在所有路由之后)
app.use((err, req, res, next) => {
console.error('未捕获的错误:', err.stack);
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
message: err.message || '服务器内部错误',
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
});
});
测试步骤:
-
确保 MongoDB 正在运行。
-
创建项目目录,
npm init -y。 -
安装依赖:
npm install express mongoose jsonwebtoken bcryptjs -
创建
db.js文件并粘贴连接代码。 -
修改
models/User.js文件,添加密码字段和方法。 -
创建
app.js文件并粘贴完整的 Express 应用代码。 -
在终端中运行
node app.js。 -
使用 Postman 或 Insomnia 进行测试:
-
注册用户 (POST
http://localhost:3000/api/auth/register)-
Body (raw, JSON):
{ "name": "Test User", "email": "test@example.com", "password": "password123", "age": 25 } -
成功后会返回一个
token。
-
-
登录用户 (POST
http://localhost:3000/api/auth/login)-
Body (raw, JSON):
{ "email": "test@example.com", "password": "password123" } -
成功后会返回一个新的
token。
-
-
访问受保护路由 (GET
http://localhost:3000/api/users/me)-
Headers:
Authorization: Bearer <你从注册或登录获取到的token> -
如果成功,会返回当前用户的信息。
-
如果不带 token 或 token 无效/过期,会返回 401 错误。
-
-
通过本节,你已经掌握了 RESTful API 的设计原则,并使用 Express.js 和 Mongoose 实现了符合这些原则的 CRUD 接口。更重要的是,你学会了如何使用 JWT 实现用户认证和保护 API 路由,这是构建安全 Web 应用的关键。
第四阶段:高级概念与实战应用 (约4节)
好的,我们继续深入 Node.js 后端开发,本节将专注于构建符合 RESTful 原则的 API,并引入用户认证与授权的核心机制——JWT。
第 17 节:错误处理与日志
在任何健壮的应用程序中,有效的错误处理和日志记录都是至关重要的。它们帮助你识别、诊断和解决问题。
17.1 同步与异步错误的捕获
理解 Node.js 中同步和异步错误的捕获方式是正确处理错误的基础。
-
同步错误捕获 (
try...catch):-
对于同步代码,你可以使用标准的
try...catch块来捕获错误。 -
示例:
try { const result = someSynchronousFunctionThatMightThrow(); console.log(result); } catch (error) { console.error('捕获到同步错误:', error.message); }
-
-
异步错误捕获:
-
回调函数模式 (Callback Pattern):
-
传统的 Node.js 异步操作通常使用回调函数,错误作为回调的第一个参数传递(
err, data)。try...catch无法捕获回调函数内部的异步错误。 -
示例:
// 错误的方式:try...catch 无法捕获异步回调中的错误 // try { // fs.readFile('/nonexistent-file', (err, data) => { // if (err) throw err; // 这个 throw 不会被外层的 try...catch 捕获 // console.log(data); // }); // } catch (error) { // console.error('这个 catch 不会执行'); // } // 正确的方式:在回调函数内部处理错误 const fs = require('fs'); fs.readFile('/nonexistent-file', (err, data) => { if (err) { console.error('捕获到异步回调错误:', err.message); return; } console.log(data); });
-
-
Promise 和
async/await:-
现代 Node.js 开发中,Promise 和
async/await是处理异步操作的首选方式。它们使得异步错误处理与同步错误处理类似。 -
Promise: 使用
.catch()方法。 -
async/await: 结合try...catch。 -
示例:
// Promise 方式 function fetchData() { return new Promise((resolve, reject) => { setTimeout(() => { const success = Math.random() > 0.5; if (success) { resolve('数据获取成功'); } else { reject(new Error('数据获取失败')); } }, 1000); }); } fetchData() .then(data => console.log(data)) .catch(error => console.error('捕获到 Promise 错误:', error.message)); // async/await 方式 (推荐在 Express 路由中使用) app.get('/async-test', async (req, res, next) => { try { const data = await fetchData(); res.send(data); } catch (error) { // 将错误传递给 Express 的错误处理中间件 next(error); } });
-
-
Express.js 中的
next(err):-
在 Express.js 中,当你在路由或中间件中捕获到错误时,应该使用
next(error)将错误传递给下一个错误处理中间件。 -
对于
async路由处理函数,如果await的 Promise 被拒绝,Express 会自动捕获这个拒绝并将其传递给错误处理中间件(Node.js 12+)。但显式使用try...catch和next(error)仍然是良好的实践,可以让你在传递前对错误进行处理或包装。
-
-
17.2 Express.js 错误处理中间件
Express.js 提供了一种特殊的中间件来处理错误。它与常规中间件的区别在于其函数签名有四个参数:(err, req, res, next)。
-
特点:
-
必须放在所有其他路由和中间件的最后。
-
当任何路由或中间件中调用
next(err)时,Express 会跳过所有常规中间件,直接将控制权传递给错误处理中间件。
-
-
基本结构:
app.use((err, req, res, next) => { console.error(err.stack); // 打印错误堆栈到控制台 res.status(500).send('Something broke!'); // 发送通用错误响应 });
17.3 最佳实践:集中式错误处理
为了使错误处理更具可维护性和一致性,推荐采用集中式错误处理。
-
自定义错误类:
-
创建自定义错误类,继承自
Error,并添加statusCode和isOperational等属性。isOperational用于区分可预期的操作性错误(如验证失败、资源未找到)和不可预期的编程错误(如代码 bug)。 -
utils/AppError.js:class AppError extends Error { constructor(message, statusCode) { super(message); // 调用父类 Error 的构造函数 this.statusCode = statusCode; this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; this.isOperational = true; // 标记为操作性错误 Error.captureStackTrace(this, this.constructor); // 捕获堆栈信息 } } module.exports = AppError;
-
-
统一的错误处理中间件:
-
在
app.js的末尾,使用一个统一的错误处理中间件来处理所有错误。 -
根据
NODE_ENV(开发/生产环境) 返回不同的错误信息。 -
middleware/errorHandler.js:const AppError = require('../utils/AppError'); const handleCastErrorDB = err => { const message = `无效的 ${err.path}: ${err.value}.`; return new AppError(message, 400); }; const handleDuplicateFieldsDB = err => { const value = err.errmsg.match(/(["'])(\\?.)*?\1/)[0]; const message = `重复的字段值: ${value}。请使用另一个值!`; return new AppError(message, 409); }; const handleValidationErrorDB = err => { const errors = Object.values(err.errors).map(el => el.message); const message = `无效的输入数据: ${errors.join('. ')}`; return new AppError(message, 400); }; const handleJWTError = () => new AppError('无效的令牌。请重新登录!', 401); const handleJWTExpiredError = () => new AppError('你的令牌已过期!请重新登录。', 401); const sendErrorDev = (err, res) => { res.status(err.statusCode).json({ status: err.status, error: err, message: err.message, stack: err.stack }); }; const sendErrorProd = (err, res) => { // 操作性错误,发送给客户端 if (err.isOperational) { res.status(err.statusCode).json({ status: err.status, message: err.message }); // 编程或其他未知错误,不泄露细节 } else { console.error('ERROR 💥', err); // 记录到服务器日志 res.status(500).json({ status: 'error', message: '出错了!请稍后再试。' }); } }; module.exports = (err, req, res, next) => { err.statusCode = err.statusCode || 500; err.status = err.status || 'error'; if (process.env.NODE_ENV === 'development') { sendErrorDev(err, res); } else if (process.env.NODE_ENV === 'production') { let error = { ...err }; // 创建错误副本,避免直接修改原始错误对象 error.message = err.message; // 确保 message 属性被复制 if (error.name === 'CastError') error = handleCastErrorDB(error); if (error.code === 11000) error = handleDuplicateFieldsDB(error); if (error.name === 'ValidationError') error = handleValidationErrorDB(error); if (error.name === 'JsonWebTokenError') error = handleJWTError(); if (error.name === 'TokenExpiredError') error = handleJWTExpiredError(); sendErrorProd(error, res); } };
-
-
在路由中使用
next(new AppError(...)):const AppError = require('../utils/AppError'); // ... app.get('/api/users/:id', async (req, res, next) => { try { const user = await User.findById(req.params.id); if (!user) { return next(new AppError('用户未找到', 404)); // 使用自定义错误 } res.status(200).json(user); } catch (err) { next(err); // 将 Mongoose 错误传递给全局错误处理中间件 } }); -
捕获未处理的拒绝和异常:
-
对于未捕获的 Promise 拒绝和同步异常,Node.js 提供了全局事件。
-
app.js:// 捕获未处理的 Promise 拒绝 (例如,数据库连接失败) process.on('unhandledRejection', err => { console.error('UNHANDLED REJECTION! 💥 Shutting down...'); console.error(err.name, err.message); // 关闭服务器,然后退出进程 server.close(() => { // 假设你的 app.listen 返回一个 server 实例 process.exit(1); }); }); // 捕获未捕获的同步异常 (例如,代码中的拼写错误) process.on('uncaughtException', err => { console.error('UNCAUGHT EXCEPTION! 💥 Shutting down...'); console.error(err.name, err.message, err.stack); process.exit(1); }); // ... app.listen 代码 const server = app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); });
-
17.4 日志记录:使用 console 或第三方库(如 Winston)
1. 使用 console:
-
优点: 简单、开箱即用,适合开发环境的快速调试。
-
缺点:
-
无日志级别: 无法区分信息、警告、错误等不同重要性的日志。
-
无文件输出: 默认只输出到控制台,生产环境需要将日志写入文件。
-
无日志轮转: 日志文件会无限增长,不便于管理。
-
无结构化日志: 难以被日志分析工具解析。
-
性能: 大量
console.log会影响性能。
-
2. 使用第三方库 (如 Winston):
-
Winston 是 Node.js 中最流行和强大的日志库之一。它提供了灵活的日志级别、多种传输方式(控制台、文件、数据库、远程服务等)和格式化选项。
-
安装:
npm install winston -
基本使用示例:
// logger.js const winston = require('winston'); const logger = winston.createLogger({ level: 'info', // 默认日志级别 format: winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.errors({ stack: true }), // 包含错误堆栈 winston.format.json() // 输出 JSON 格式的日志 ), transports: [ new winston.transports.Console({ format: winston.format.combine( winston.format.colorize(), // 控制台输出带颜色 winston.format.simple() // 简洁格式 ) }), new winston.transports.File({ filename: 'error.log', level: 'error' }), // 错误日志写入文件 new winston.transports.File({ filename: 'combined.log' }) // 所有级别日志写入文件 ], exceptionHandlers: [ // 捕获未处理的异常 new winston.transports.File({ filename: 'exceptions.log' }) ], rejectionHandlers: [ // 捕获未处理的 Promise 拒绝 new winston.transports.File({ filename: 'rejections.log' }) ] }); // 在开发环境中,如果不是生产环境,也输出到控制台 if (process.env.NODE_ENV !== 'production') { logger.add(new winston.transports.Console({ format: winston.format.simple(), })); } module.exports = logger; -
在
app.js或其他模块中使用:const logger = require('./logger'); // 引入 logger // ... app.get('/', (req, res) => { logger.info('收到首页请求'); res.send('Hello World!'); }); app.post('/data', (req, res) => { logger.warn('收到 POST 请求,但未处理数据'); res.send('Data received.'); }); // 在错误处理中间件中使用 logger app.use((err, req, res, next) => { logger.error(`错误: ${err.message}`, { stack: err.stack, url: req.originalUrl }); // ... 其他错误处理逻辑 }); // 捕获全局异常和拒绝 process.on('uncaughtException', (ex) => { logger.error('未捕获的异常:', ex); process.exit(1); }); process.on('unhandledRejection', (ex) => { logger.error('未处理的 Promise 拒绝:', ex); process.exit(1); }); -
优点:
-
日志级别: 精细控制输出哪些日志。
-
传输器 (Transports): 将日志发送到多个目的地。
-
格式化: 支持 JSON、文本等多种格式,便于机器解析。
-
错误堆栈: 自动包含错误堆栈信息。
-
性能: 异步写入,对应用性能影响小。
-
可扩展性: 丰富的插件和社区支持。
-
第 18 节:实时应用:WebSockets 与 Socket.IO
传统的 HTTP 请求-响应模型在构建实时应用(如聊天室、在线游戏、实时数据仪表盘)时效率低下。WebSockets 和 Socket.IO 应运而生,解决了这个问题。
18.1 什么是 WebSockets?与 HTTP 的区别。
1. HTTP (Hypertext Transfer Protocol):
-
请求-响应模型: 客户端发送请求,服务器发送响应。
-
无状态: 服务器不保留客户端的会话信息(除非使用 Cookie 或 Session)。
-
短连接: 每次请求通常会建立新的连接或在短时间内保持连接(HTTP/1.1 Keep-Alive),然后关闭。
-
单向通信: 客户端发起通信。
-
头部开销大: 每个请求和响应都包含完整的 HTTP 头部信息。
-
适用场景: 传统的网页浏览、RESTful API 调用、文件下载等。
2. WebSockets:
-
全双工通信: 客户端和服务器可以同时发送和接收数据。
-
持久连接: 一旦建立连接,它会一直保持开放,直到客户端或服务器关闭。
-
有状态: 连接建立后,服务器和客户端都保留连接状态。
-
双向通信: 客户端和服务器都可以主动发起数据传输。
-
低延迟: 握手成功后,数据帧传输开销小,延迟低。
-
协议升级: WebSockets 连接通过 HTTP 握手开始(
Upgrade头),然后“升级”到 WebSocket 协议。 -
适用场景: 聊天应用、在线游戏、实时股票报价、协作工具、通知系统等需要低延迟、高频率双向通信的场景。
核心区别总结:
| 特性 | HTTP | WebSockets |
| :--------- | :--------------------------------- | :--------------------------------------- |
| 通信模式 | 请求-响应 (单向) | 全双工 (双向) |
| 连接 | 短连接 (每次请求或短时保持) | 持久连接 (一次握手,长期开放) |
| 状态 | 无状态 | 有状态 |
| 开销 | 每次请求头部开销大 | 握手后数据帧开销小 |
| 延迟 | 相对较高 (每次请求建立连接) | 极低 (连接已建立) |
| 适用 | 传统网页、REST API | 实时应用、聊天、游戏、通知 |
18.2 Socket.IO 简介与安装
什么是 Socket.IO?
Socket.IO 是一个基于 WebSockets 的 JavaScript 库,它使得实时、双向、基于事件的通信在 Web 客户端和服务器之间变得简单。
为什么选择 Socket.IO 而不是原生 WebSockets?
-
自动回退 (Fallback): 如果客户端或服务器不支持 WebSockets,Socket.IO 会自动降级到其他实时通信技术(如长轮询、Flash Socket 等),确保在各种浏览器和网络环境下都能工作。
-
自动重连: 当连接断开时,Socket.IO 客户端会自动尝试重新连接。
-
事件驱动: 提供简单的
emit(发送事件) 和on(监听事件) API,使得通信逻辑清晰。 -
房间 (Rooms) 和命名空间 (Namespaces): 方便地组织和管理连接,实现群聊、私聊等功能。
-
广播 (Broadcasting): 轻松向所有连接的客户端或特定房间的客户端发送消息。
-
心跳机制: 自动发送心跳包,检测连接是否存活。
安装 Socket.IO:
Socket.IO 包含两个部分:服务器端库和客户端库。
-
服务器端 (Node.js):
npm install socket.io -
客户端 (浏览器):
-
可以通过 CDN 引入:
<script src="/socket.io/socket.io.js"></script>(注意:当 Socket.IO 服务器启动后,它会自动在
/socket.io/socket.io.js路径提供客户端库) -
或者通过 npm 安装并在前端打包工具中使用:
npm install socket.io-client
-
18.3 构建一个简单的聊天室应用:实时消息发送与接收
我们将构建一个非常简单的聊天室,用户可以发送消息,所有连接的用户都能实时看到。
文件结构:
chat-app/
├── server.js # Node.js 服务器端代码
└── public/
└── index.html # 客户端 HTML 页面
1. 服务器端 (server.js):
const express = require('express');
const http = require('http'); // Node.js 内置的 http 模块
const { Server } = require('socket.io'); // 引入 Socket.IO 的 Server 类
const app = express();
const server = http.createServer(app); // 创建一个 HTTP 服务器
const io = new Server(server); // 将 Socket.IO 绑定到 HTTP 服务器
const PORT = process.env.PORT || 3000;
// 提供静态文件 (例如 index.html)
app.use(express.static('public'));
// 当有客户端连接时
io.on('connection', (socket) => {
console.log('一个用户连接了:', socket.id);
// 监听客户端发送的 'chat message' 事件
socket.on('chat message', (msg) => {
console.log('收到消息:', msg);
// 将消息广播给所有连接的客户端 (包括发送者自己)
io.emit('chat message', msg);
});
// 监听客户端断开连接事件
socket.on('disconnect', () => {
console.log('一个用户断开连接了:', socket.id);
});
});
// 启动 HTTP 服务器
server.listen(PORT, () => {
console.log(`聊天服务器运行在 http://localhost:${PORT}`);
console.log('请在浏览器中打开 http://localhost:3000');
});
2. 客户端 (public/index.html):
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>简单的聊天室</title>
<style>
body { margin: 0; padding-bottom: 3rem; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
#form { background: rgba(0, 0, 0, 0.15); padding: 0.25rem; position: fixed; bottom: 0; left: 0; right: 0; display: flex; height: 3rem; box-sizing: border-box; backdrop-filter: blur(10px); }
#input { border: none; padding: 0 1rem; flex-grow: 1; border-radius: 2rem; margin: 0.25rem; }
#input:focus { outline: none; }
#form > button { background: #333; border: none; padding: 0 1rem; margin: 0.25rem; border-radius: 3px; outline: none; color: #fff; }
#messages { list-style-type: none; margin: 0; padding: 0; }
#messages > li { padding: 0.5rem 1rem; }
#messages > li:nth-child(odd) { background: #eee; }
</style>
</head>
<body>
<ul id="messages"></ul>
<form id="form" action="">
<input id="input" autocomplete="off" /><button>发送</button>
</form>
<!-- 引入 Socket.IO 客户端库 -->
<script src="/socket.io/socket.io.js"></script>
<script>
// 连接到 Socket.IO 服务器
const socket = io();
const form = document.getElementById('form');
const input = document.getElementById('input');
const messages = document.getElementById('messages');
// 监听表单提交事件
form.addEventListener('submit', (e) => {
e.preventDefault(); // 阻止表单默认提交行为 (页面刷新)
if (input.value) {
// 向服务器发送 'chat message' 事件和消息内容
socket.emit('chat message', input.value);
input.value = ''; // 清空输入框
}
});
// 监听服务器发送的 'chat message' 事件
socket.on('chat message', (msg) => {
const item = document.createElement('li');
item.textContent = msg; // 将消息添加到列表中
messages.appendChild(item);
window.scrollTo(0, document.body.scrollHeight); // 滚动到底部
});
// 监听连接事件 (可选)
socket.on('connect', () => {
console.log('已连接到服务器');
});
// 监听断开连接事件 (可选)
socket.on('disconnect', () => {
console.log('已断开与服务器的连接');
});
</script>
</body>
</html>
运行和测试:
-
创建项目目录
chat-app。 -
在
chat-app目录下运行npm init -y。 -
安装依赖:
npm install express socket.io。 -
创建
server.js文件并粘贴服务器端代码。 -
创建
public目录,并在其中创建index.html文件并粘贴客户端代码。 -
在终端中运行
node server.js。 -
打开多个浏览器标签页或窗口,访问
http://localhost:3000。 -
在任何一个窗口中输入消息并发送,你会看到消息实时同步到所有打开的聊天室窗口中。
通过本节,你已经掌握了 Express.js 中错误处理的各种策略,包括同步/异步错误捕获、集中式错误处理和使用 Winston 进行日志记录。同时,你还学习了 WebSockets 的概念及其与 HTTP 的区别,并使用 Socket.IO 构建了一个简单的实时聊天应用,迈出了实时通信应用开发的第一步。
好的,我们继续学习 Node.js 的高级主题,包括命令行工具开发、进程管理、部署和性能优化。
第 19 节:命令行工具开发与进程管理
Node.js 不仅适用于构建 Web 服务器,也因其强大的文件系统和进程管理能力,成为开发命令行工具 (CLI) 的理想选择。
19.1 Node.js 作为命令行工具的优势
-
跨平台: Node.js 应用程序可以在 Windows、macOS 和 Linux 等操作系统上运行,这意味着你编写的 CLI 工具可以在任何支持 Node.js 的环境中运行。
-
JavaScript 熟悉度: 如果你已经熟悉 JavaScript,那么使用 Node.js 开发 CLI 工具可以复用你的技能栈。
-
NPM 生态系统: Node.js 拥有庞大的 npm 包生态系统,你可以轻松地引入各种库来处理文件操作、网络请求、数据解析等,极大地提高了开发效率。
-
异步 I/O: Node.js 的非阻塞 I/O 模型使其在处理大量文件或网络操作时表现出色,这对于需要快速处理数据的 CLI 工具非常有利。
-
易于分发: 通过 npm,你可以将你的 CLI 工具发布到 npm 仓库,供全球开发者安装和使用。
如何让 Node.js 脚本可执行:
在你的 Node.js 脚本文件的第一行添加 Shebang:#!/usr/bin/env node。
然后,给文件添加执行权限:chmod +x your-cli-script.js。
通过 package.json 发布 CLI 工具:
在 package.json 中添加 bin 字段,指向你的 CLI 脚本:
{
"name": "my-cli-tool",
"version": "1.0.0",
"description": "A simple Node.js CLI tool",
"main": "index.js",
"bin": {
"mycli": "./bin/mycli.js" // 当用户安装此包时,mycli 命令会链接到 bin/mycli.js
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
然后,在 bin/mycli.js 中写入你的 CLI 逻辑,并确保文件顶部有 #!/usr/bin/env node。
19.2 commander.js 或 yargs 库用于解析命令行参数
原生 Node.js 可以通过 process.argv 访问命令行参数,但它只返回一个字符串数组,解析起来比较麻烦。commander.js 和 yargs 是两个流行的库,它们提供了更强大和用户友好的方式来解析命令行参数、定义命令、选项和帮助信息。
这里我们以 commander.js 为例:
安装: npm install commander
示例 (mycli.js):
#!/usr/bin/env node
const { Command } = require('commander');
const program = new Command();
program
.name('mycli')
.description('一个简单的 Node.js 命令行工具')
.version('1.0.0');
// 定义一个命令 'greet'
program
.command('greet')
.description('向指定用户打招呼')
.argument('<name>', '要打招呼的用户名') // 必填参数
.option('-m, --message <string>', '自定义问候消息', '你好') // 可选选项,带默认值
.action((name, options) => {
console.log(`${options.message}, ${name}!`);
});
// 定义一个命令 'add'
program
.command('add')
.description('将两个数字相加')
.argument('<num1>', '第一个数字', parseInt) // 参数类型转换
.argument('<num2>', '第二个数字', parseInt)
.action((num1, num2) => {
if (isNaN(num1) || isNaN(num2)) {
console.error('错误: 请输入有效的数字。');
process.exit(1);
}
console.log(`结果: ${num1 + num2}`);
});
// 定义一个全局选项
program
.option('-v, --verbose', '启用详细输出')
.action((options) => {
if (options.verbose) {
console.log('详细模式已启用。');
}
});
program.parse(process.argv); // 解析命令行参数
使用方法:
-
保存为
bin/mycli.js。 -
chmod +x bin/mycli.js。 -
在项目根目录运行
npm link(这会在全局创建一个mycli命令的软链接)。 -
测试:
-
mycli --version -
mycli greet World -
mycli greet Alice --message "早上好" -
mycli add 5 3 -
mycli add hello world(会报错) -
mycli --help
-
19.3 子进程 (Child Process):spawn, exec, fork
Node.js 的 child_process 模块允许你创建和管理子进程,从而执行外部命令或运行其他 Node.js 脚本。
-
child_process.exec(command[, options][, callback]):-
特点:
-
在 shell 中执行命令。
-
缓冲所有输出(stdout 和 stderr),并在子进程结束后一次性传递给回调函数。
-
适合执行短时间运行、输出量较小的命令。
-
-
示例:
const { exec } = require('child_process'); exec('ls -lh /', (error, stdout, stderr) => { if (error) { console.error(`exec 错误: ${error.message}`); return; } if (stderr) { console.error(`exec stderr: ${stderr}`); return; } console.log(`exec stdout:\n${stdout}`); }); // 执行一个会报错的命令 exec('nonexistent-command', (error, stdout, stderr) => { if (error) { console.error(`exec 错误 (非存在命令): ${error.message}`); return; } console.log(`exec stdout: ${stdout}`); });
-
-
child_process.spawn(command[, args][, options]):-
特点:
-
直接启动一个新进程,不通过 shell。
-
流式处理输入/输出(stdin, stdout, stderr),适合处理大量数据或长时间运行的进程。
-
返回一个
ChildProcess对象,可以通过其stdout,stderr属性监听数据流。
-
-
示例:
const { spawn } = require('child_process'); const ls = spawn('ls', ['-lh', '/']); ls.stdout.on('data', (data) => { console.log(`spawn stdout:\n${data}`); }); ls.stderr.on('data', (data) => { console.error(`spawn stderr: ${data}`); }); ls.on('close', (code) => { console.log(`spawn 子进程退出,退出码 ${code}`); }); ls.on('error', (err) => { console.error(`spawn 启动失败: ${err.message}`); }); // 示例:长时间运行的进程 (ping) const ping = spawn('ping', ['-c', '4', 'google.com']); // -c 4 表示发送4个包 ping.stdout.on('data', (data) => { console.log(`ping stdout: ${data}`); }); ping.stderr.on('data', (data) => { console.error(`ping stderr: ${data}`); }); ping.on('close', (code) => { console.log(`ping 进程退出,退出码 ${code}`); });
-
-
child_process.fork(modulePath[, args][, options]):-
特点:
-
fork是spawn的一个特例,专门用于启动另一个 Node.js 进程。 -
在父子进程之间建立一个 IPC (Inter-Process Communication) 通道,允许它们通过
process.send()和child.on('message')互相发送消息。 -
非常适合创建工作进程 (worker processes) 来处理 CPU 密集型任务,从而不阻塞主事件循环。
-
-
示例:
-
parent.js(主进程):const { fork } = require('child_process'); const path = require('path'); const child = fork(path.join(__dirname, 'child.js')); child.on('message', (message) => { console.log('父进程收到消息:', message); }); child.send({ hello: '来自父进程' }); // 父进程向子进程发送消息 child.on('close', (code) => { console.log(`子进程退出,退出码 ${code}`); }); -
child.js(子进程):process.on('message', (message) => { console.log('子进程收到消息:', message); process.send({ hi: '来自子进程' }); // 子进程向父进程发送消息 }); // 模拟一些工作 let sum = 0; for (let i = 0; i < 1e7; i++) { sum += i; } console.log('子进程完成计算:', sum); // 子进程也可以直接退出 // process.exit(0);
-
-
运行:
node parent.js
-
选择哪个方法?
-
exec: 简单命令,输出小,需要 shell 功能(如管道)。 -
spawn: 长时间运行的进程,大量输出,不需要 shell 功能,需要流式处理。 -
fork: 运行另一个 Node.js 脚本,需要父子进程间通信。
第 20 节:部署与性能优化基础
20.1 常见部署方式简介
将 Node.js 应用程序从开发环境迁移到生产环境称为部署。
-
PM2 (Process Manager 2):
-
一个 Node.js 进程管理器,用于在生产环境中保持应用程序永远在线,并提供负载均衡、日志管理、监控等功能。
-
优势: 简单易用,功能强大,适合单服务器部署。
-
-
Docker:
-
一种容器化技术,允许你将应用程序及其所有依赖项打包到一个独立的、可移植的容器中。
-
优势: 环境一致性(“在我的机器上能跑,在你的机器上也能跑”),快速部署,易于扩展和管理。
-
通常与 Docker Compose (多容器应用) 或 Kubernetes (容器编排) 结合使用。
-
-
云服务:
-
IaaS (Infrastructure as a Service):
- AWS EC2, Google Compute Engine, Azure Virtual Machines: 提供虚拟机,你需要手动安装操作系统、Node.js、数据库等,并自行管理。灵活性最高,但管理成本也最高。
-
PaaS (Platform as a Service):
- Heroku, AWS Elastic Beanstalk, Google App Engine, Azure App Service: 你只需上传代码,平台会自动处理运行环境、扩展、负载均衡等。简化了部署和管理,但灵活性较低。
-
FaaS (Function as a Service) / Serverless:
- AWS Lambda, Google Cloud Functions, Azure Functions: 你只需编写函数代码,平台按需执行,按使用量计费。无需管理服务器,高度可伸缩,但适用于无状态、事件驱动的短时任务。
-
专门的 Node.js 托管平台:
- Vercel, Netlify: 主要用于前端应用,但也可以托管无服务器的 Node.js API。
-
20.2 PM2 进程管理器:保持应用运行、负载均衡
PM2 是 Node.js 生产部署的基石之一。
安装 PM2:
npm install -g pm2
基本命令:
-
启动应用:
pm2 start app.js # 或者指定名称 pm2 start app.js --name my-express-app -
列出所有应用:
pm2 list # 或 pm2 ls -
停止应用:
pm2 stop <id|name> pm2 stop all # 停止所有应用 -
重启应用:
pm2 restart <id|name> pm2 restart all -
删除应用:
pm2 delete <id|name> pm2 delete all -
查看日志:
pm2 logs <id|name> pm2 logs --lines 100 # 查看最近100行 pm2 logs --follow # 实时跟踪日志 -
监控应用:
pm2 monit # 实时显示 CPU、内存使用情况 -
开机自启:
pm2 startup # 生成启动脚本,让 PM2 在服务器重启后自动启动应用 pm2 save # 保存当前运行的应用列表,以便 startup 脚本加载
集群模式 (Cluster Mode) - 负载均衡:
PM2 可以利用 Node.js 的 cluster 模块,在多核 CPU 上运行多个应用实例,实现负载均衡,提高性能和可用性。
pm2 start app.js -i max # 根据 CPU 核心数启动最大数量的实例
# 或者指定实例数量
pm2 start app.js -i 4 # 启动 4 个实例
在集群模式下,PM2 会自动将请求分发到不同的实例。
使用配置文件 (ecosystem.config.js):
对于更复杂的部署,推荐使用 PM2 配置文件。
// ecosystem.config.js
module.exports = {
apps : [{
name: 'my-express-app', // 应用名称
script: 'app.js', // 启动脚本
instances: 'max', // 启动实例数量,'max' 表示 CPU 核心数
exec_mode: 'cluster', // 启用集群模式
watch: true, // 监听文件变化自动重启 (开发环境有用,生产环境慎用)
max_memory_restart: '300M', // 内存超过 300MB 自动重启
env: {
NODE_ENV: 'development', // 开发环境配置
PORT: 3000
},
env_production: {
NODE_ENV: 'production', // 生产环境配置
PORT: 80,
JWT_SECRET: 'your_production_secret_key' // 生产环境的密钥
}
}]
};
使用配置文件启动:
pm2 start ecosystem.config.js
# 启动生产环境配置
pm2 start ecosystem.config.js --env production
20.3 简单的性能优化技巧:缓存、Gzip 压缩
性能优化是一个复杂且持续的过程,但有一些基础技巧可以显著提升 Node.js 应用的性能。
-
缓存 (Caching):
-
目的: 减少重复计算或数据库查询,加快响应速度。
-
客户端缓存 (HTTP Caching):
-
通过设置 HTTP 响应头(如
Cache-Control,ETag,Last-Modified),指示浏览器或代理服务器缓存资源。 -
对于静态文件(图片、CSS、JS),这是最有效的优化之一。Express 的
express.static可以自动处理一些缓存头。
-
-
服务器端缓存 (Application-level Caching):
-
内存缓存: 将频繁访问的数据存储在应用程序的内存中。适用于数据量不大且不要求持久化的场景。
-
示例 (使用
node-cache库):npm install node-cacheconst NodeCache = require('node-cache'); const myCache = new NodeCache({ stdTTL: 600, checkperiod: 120 }); // 缓存10分钟 app.get('/api/products', async (req, res, next) => { const cacheKey = 'all_products'; let products = myCache.get(cacheKey); // 尝试从缓存获取 if (products) { console.log('从缓存获取产品数据'); return res.json(products); } try { products = await Product.find(); // 从数据库获取 myCache.set(cacheKey, products); // 存入缓存 console.log('从数据库获取产品数据并存入缓存'); res.json(products); } catch (err) { next(err); } });
-
-
分布式缓存 (如 Redis): 当应用部署在多个服务器实例上时,内存缓存不再适用。Redis 提供了高性能的键值存储,可以作为共享缓存层。
-
-
-
Gzip 压缩 (Gzip Compression):
-
目的: 减小 HTTP 响应体的大小,从而减少网络传输时间。
-
实现: 使用
compressionExpress 中间件。 -
安装:
npm install compression -
使用:
const express = require('express'); const compression = require('compression'); // 引入 compression const app = express(); // 在所有路由之前使用 compression 中间件 app.use(compression()); // 启用 Gzip 压缩 app.get('/', (req, res) => { // 发送一个较大的响应体来测试压缩效果 const largeData = Array(1000).fill('Hello World!').join('\n'); res.send(largeData); }); // ... 其他路由- 测试: 使用浏览器的开发者工具(Network 标签页),查看响应头的
Content-Encoding: gzip。
- 测试: 使用浏览器的开发者工具(Network 标签页),查看响应头的
-
-
数据库索引:
-
确保你的数据库查询字段(尤其是
_id、email、userId等)有适当的索引。索引可以显著加快查询速度。 -
在 Mongoose Schema 中,
unique: true会自动创建唯一索引。你也可以手动添加索引:UserSchema.index({ email: 1 }); // 为 email 字段创建升序索引
-
-
异步操作:
- Node.js 的核心优势在于其非阻塞 I/O。确保你的代码充分利用异步操作,避免使用同步方法(如
fs.readFileSync),除非在启动时或特殊情况下。
- Node.js 的核心优势在于其非阻塞 I/O。确保你的代码充分利用异步操作,避免使用同步方法(如
-
负载均衡:
- 当单个服务器无法处理所有请求时,通过在多个服务器实例之间分发请求来提高吞吐量和可用性。PM2 的集群模式就是一种简单的负载均衡。
20.4 项目总结与下一步学习方向
恭喜你!通过这 20 节的学习,你已经掌握了 Node.js 后端开发的核心概念和实践。我们从基础的 JavaScript 知识开始,逐步深入到:
-
Node.js 基础: 事件循环、模块系统、文件系统、HTTP 模块。
-
Express.js 框架: 路由、中间件、请求/响应处理。
-
数据库集成: MongoDB 和 Mongoose 的数据建模与 CRUD 操作。
-
RESTful API: 设计原则、数据验证与错误处理。
-
用户认证与授权: JWT 的工作原理与实现。
-
实时应用: WebSockets 与 Socket.IO。
-
命令行工具: 参数解析与子进程管理。
-
部署与优化: PM2、Docker 简介、缓存与 Gzip 压缩。
你现在已经具备了构建一个功能完善、具备基本安全和性能考量的 Node.js 后端应用的能力。
下一步学习方向:
-
深入学习:
-
测试: 学习单元测试 (Jest, Mocha)、集成测试和端到端测试 (Supertest, Cypress)。
-
TypeScript: 将 JavaScript 代码转换为 TypeScript,提高代码的可维护性和健壮性。
-
GraphQL: 学习另一种 API 设计风格,提供更灵活的数据查询能力。
-
微服务架构: 了解如何将大型应用拆分为更小的、独立的服务。
-
容器编排: 深入学习 Kubernetes,管理和扩展 Docker 容器。
-
CI/CD (持续集成/持续部署): 自动化代码测试、构建和部署流程。
-
监控与日志分析: 使用 Prometheus, Grafana, ELK Stack 等工具监控应用性能和分析日志。
-
安全性: 深入了解常见的 Web 安全漏洞(OWASP Top 10)及其防范措施。
-
高级数据库概念: 事务、聚合管道、数据库优化、其他 NoSQL 数据库(Redis, Cassandra)或关系型数据库(PostgreSQL, MySQL)。
-
-
实践项目:
-
尝试独立构建一个完整的项目,例如:
-
一个博客系统 (带用户管理、文章发布、评论)
-
一个电商网站 (带商品、订单、购物车)
-
一个实时协作文档应用
-
一个简单的社交媒体平台
-
-
在项目中应用你所学到的所有知识,并尝试引入新的技术。
-
-
阅读源码和社区:
-
阅读一些流行的 Node.js 库和框架的源码,了解它们是如何工作的。
-
积极参与 Node.js 社区,关注最新的技术趋势和最佳实践。
-
祝你在 Node.js 的学习旅程中取得更大的进步!