兰 亭 墨 苑
期货 · 量化 · AI · 终身学习
首页
归档
编辑文章
标题 *
URL 别名 *
内容 *
(支持 Markdown 格式)
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?** 1. **高性能与高并发:** * **非阻塞 I/O 和事件循环:** 这是 Node.js 最大的优势。它能够以极低的资源消耗处理大量并发连接,非常适合 I/O 密集型应用(如实时聊天、API 服务)。 * **V8 引擎:** 编译执行 JavaScript,速度快。 2. **统一语言栈 (Full-stack JavaScript):** * 前端和后端都使用 JavaScript,这意味着开发者可以复用代码、共享知识,提高开发效率。 * 团队协作更顺畅,减少了语言切换带来的心智负担。 3. **庞大的生态系统 (NPM - Node Package Manager):** * NPM 是世界上最大的开源库生态系统,拥有数百万个可重用的模块。 * 这意味着你可以轻松找到并集成各种功能,从数据库驱动到 Web 框架,大大加速开发进程。 4. **快速开发周期:** * JavaScript 的灵活性和 NPM 的丰富模块使得 Node.js 项目的开发速度非常快。 * 热重载、模块化等特性也提升了开发体验。 5. **活跃的社区支持:** * 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)** 版本,因为它更稳定,适合生产环境。 **安装步骤:** 1. **访问官方网站:** 前往 Node.js 官方网站:[https://nodejs.org/](https://nodejs.org/) 2. **下载安装包:** 在首页你会看到两个下载选项:LTS (长期支持) 和 Current (最新特性)。点击 LTS 版本的下载按钮,它会自动识别你的操作系统并提供相应的安装包(.msi for Windows, .pkg for macOS, .tar.xz for Linux)。 3. **运行安装程序:** * **Windows/macOS:** 双击下载的安装包,按照安装向导的提示一步步操作即可。通常,一路点击“Next”并接受默认设置即可。安装程序会自动配置环境变量。 * **Linux:** 可以通过包管理器(如 `apt` for Debian/Ubuntu, `yum` for CentOS/RHEL)或使用 `nvm` (Node Version Manager) 进行安装。推荐使用 `nvm`,因为它允许你在同一台机器上轻松切换不同版本的 Node.js。 * **使用 nvm 安装 (推荐,尤其对于开发者):** ```bash 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版本 ``` 4. **验证安装:** 打开你的终端或命令提示符,输入以下命令: ```bash 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` 并按回车键: ```bash 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 文件。 1. **创建文件:** 在你选择的目录下创建一个新文件,命名为 `hello.js`。 2. **编写代码:** 在 `hello.js` 文件中输入以下内容: ```javascript // 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全局对象 ``` 3. **打开终端/命令提示符:** 导航到你保存 `hello.js` 文件的目录。 * 例如,如果文件在 `C:\Users\YourUser\my-node-app`,则在终端中输入 `cd C:\Users\YourUser\my-node-app`。 4. **运行文件:** 在终端中输入以下命令: ```bash 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` 文件: ```javascript // args.js console.log("命令行参数:", process.argv); // 访问自定义参数 (跳过前两个默认参数) const customArgs = process.argv.slice(2); if (customArgs.length > 0) { console.log("你传入的自定义参数是:", customArgs.join(', ')); } else { console.log("没有传入自定义参数。"); } ``` 在终端运行: ```bash 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 引擎。 1. **执行 JavaScript 代码:** V8 引擎是 Node.js 执行 JavaScript 代码的“心脏”。当你运行一个 Node.js 应用程序时,V8 负责解析、编译和执行你的 JavaScript 代码。 2. **高性能:** V8 的高性能特性使得 Node.js 能够快速处理大量的请求和复杂的业务逻辑。 3. **跨平台:** V8 引擎本身是跨平台的,这使得 Node.js 可以在 Windows、macOS、Linux 等多种操作系统上运行。 4. **提供核心对象:** 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 的核心机制。它是一个持续运行的循环,负责检查调用栈、事件队列,并决定何时执行哪些回调函数。 #### **核心组件:** 1. **调用栈 (Call Stack):** 存放正在执行的同步 JavaScript 代码。当函数被调用时,它被推入栈中;当函数执行完毕时,它被弹出。 2. **Node.js APIs (C++ APIs):** Node.js 提供的异步操作接口,如 `fs.readFile()`, `http.get()`, `setTimeout()` 等。当调用这些 API 时,它们会将耗时操作交给底层系统处理,并注册一个回调函数。 3. **事件队列 (Event Queue / Callback Queue / Macrotask Queue):** 当异步操作完成时,其对应的回调函数会被放入这个队列中等待执行。 4. **微任务队列 (Microtask Queue):** 优先级更高的队列,用于存放 `process.nextTick()` 和 Promise 的 `.then()`, `.catch()`, `.finally()` 回调。 #### **事件循环的执行流程 (Phases):** Node.js 的事件循环分为几个阶段,每个阶段都有自己的特定任务和队列。当事件循环进入某个阶段时,它会执行该阶段的所有回调,直到队列为空或达到执行上限,然后进入下一个阶段。 1. **`timers` (定时器阶段):** * 执行 `setTimeout()` 和 `setInterval()` 的回调。 * 这些回调的执行时间取决于它们设定的延迟时间,但并不保证精确。 2. **`pending callbacks` (待定回调阶段):** * 执行一些系统操作的回调,例如 TCP 错误。 3. **`idle, prepare` (空闲/准备阶段):** * Node.js 内部使用,不直接与用户代码相关。 4. **`poll` (轮询阶段):** * **核心阶段。** * **检查 I/O 事件:** 大多数 I/O 回调(如文件读取、网络请求、数据库查询)都在此阶段执行。 * **检查定时器:** 如果 `timers` 队列为空,并且有新的定时器到期,事件循环可能会在此阶段停留,等待新的 I/O 事件或定时器到期。 * **执行 `setImmediate`:** 如果 `poll` 队列为空,并且 `setImmediate` 队列中有回调,事件循环会跳到 `check` 阶段执行 `setImmediate` 回调。 5. **`check` (检查阶段):** * 执行 `setImmediate()` 的回调。 6. **`close callbacks` (关闭回调阶段):** * 执行一些关闭事件的回调,例如 `socket.on('close')`。 **每次事件循环迭代的顺序:** 当调用栈清空后,事件循环会按照上述阶段的顺序进行迭代。在每个阶段之间,以及在每个阶段执行完其所有(或部分)回调之后,事件循环都会检查并清空**微任务队列**。 #### **微任务 (Microtasks) vs. 宏任务 (Macrotasks):** 这是理解事件循环中回调执行顺序的关键。 * **宏任务 (Macrotasks):** * 包括:`setTimeout()`, `setInterval()`, `setImmediate()`, I/O 操作的回调(如 `fs.readFile` 的回调)、`requestAnimationFrame` (浏览器环境)。 * **执行特点:** 事件循环的每个阶段都会处理其对应的宏任务队列。在处理完一个阶段的宏任务后,会检查并清空微任务队列,然后才进入下一个阶段。 * **微任务 (Microtasks):** * 包括:`process.nextTick()`, Promise 的 `.then()`, `.catch()`, `.finally()` 回调。 * **执行特点:** 具有更高的优先级。在**当前调用栈清空后**,以及**事件循环的每个阶段之间**,都会优先清空所有微任务队列中的回调,然后再处理下一个宏任务或进入下一个事件循环阶段。 **优先级总结:** 1. **当前正在执行的同步代码** (Call Stack) 2. **`process.nextTick()`** (最高优先级的微任务) 3. **其他微任务** (Promise 回调) 4. **事件循环的各个阶段** (宏任务,按顺序执行) **示例:** ```javascript 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 ``` **解释:** 1. `Start` 和 `End` 是同步代码,立即执行。 2. `process.nextTick 1` 是最高优先级的微任务,在当前同步代码执行完毕后立即执行。 3. `Promise 1` 是另一个微任务,在 `process.nextTick` 之后执行。 4. 此时,所有微任务都已清空。事件循环进入 `timers` 阶段,执行 `setTimeout 1`。 5. `setTimeout 1` 内部又创建了一个 Promise 微任务 `Promise inside setTimeout`,它会在 `setTimeout 1` 执行完毕后立即执行(因为微任务优先级高)。 6. 所有微任务再次清空。事件循环进入 `poll` 阶段(如果 `poll` 队列为空),然后进入 `check` 阶段,执行 `setImmediate 1`。 --- ### **2.4 `setTimeout()`, `setInterval()`, `setImmediate()`, `process.nextTick()` 的区别** 这些函数都用于调度异步代码的执行,但它们在事件循环中的执行时机和优先级有所不同。 1. **`process.nextTick(callback)`:** * **执行时机:** 在当前执行栈清空后,**立即执行**,且在事件循环的**任何阶段开始之前**。它比所有其他微任务(包括 Promise 回调)和宏任务都优先执行。 * **用途:** 用于在当前操作完成后,但又不想进入事件循环的下一个阶段时,执行一些代码。例如,在处理完一个请求后,立即发送响应,或者在异步操作中确保回调在同步代码之后但在任何 I/O 之前执行。 * **注意:** 连续调用 `process.nextTick` 会导致事件循环无法进入下一个阶段,可能导致 I/O 饥饿。 2. **`setTimeout(callback, delay)`:** * **执行时机:** 在 `delay` 毫秒后,将 `callback` 放入**定时器队列**。当事件循环进入 `timers` 阶段时,如果 `delay` 时间已到,就会执行该回调。 * **精度:** `delay` 只是一个**最小延迟时间**,不保证精确。实际执行时间会受事件循环中其他任务的影响。例如,`setTimeout(fn, 0)` 并不意味着立即执行,它仍然需要等待当前同步代码执行完毕,并等待微任务队列清空,然后才能进入 `timers` 阶段。 * **用途:** 延迟执行代码,例如动画、定时任务等。 3. **`setInterval(callback, delay)`:** * **执行时机:** 类似于 `setTimeout`,但它会**重复**地将 `callback` 放入定时器队列,每隔 `delay` 毫秒执行一次。 * **精度:** 同样不保证精确,可能会有“漂移”现象,即实际间隔时间会比 `delay` 长。 * **用途:** 周期性执行任务,例如轮询数据。 4. **`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 遇到一个耗时操作(如文件读写、网络请求、数据库查询)时,它会: 1. **发起操作:** 将操作交给底层系统。 2. **注册回调:** 提供一个回调函数,告诉系统操作完成后应该做什么。 3. **继续执行:** 主线程立即继续执行后续的 JavaScript 代码,不等待当前操作完成。 4. **事件循环:** 当耗时操作完成时,其回调函数会被放入事件队列。Node.js 的事件循环会不断检查事件队列,当主线程空闲时,就会取出并执行这些回调函数。 这种模型使得 Node.js 能够以极高的效率处理大量并发连接,因为它从不等待 I/O 操作,而是利用空闲时间处理其他请求。 --- ### **3.2 回调函数 (Callbacks) 及 "回调地狱" 问题** **回调函数 (Callbacks):** 回调函数是异步编程最基本、最原始的实现方式。它是一个函数,作为参数传递给另一个函数,并在那个函数完成其任务后被调用。 **示例:** ```javascript // 模拟一个异步操作:读取文件 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):** 当多个异步操作需要按顺序执行,并且后一个操作依赖于前一个操作的结果时,使用回调函数会导致代码层层嵌套,形成一个难以阅读、维护和调试的结构,形似金字塔。 **示例:** ```javascript // 模拟异步操作: 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 的三种状态:** 1. **`pending` (待定):** 初始状态,既没有成功也没有失败。 2. **`fulfilled` (已成功 / resolved):** 异步操作成功完成,Promise 拥有一个结果值。 3. **`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` 作为拒绝原因。 **示例:** ```javascript 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**。这使得你可以将多个异步操作串联起来。 **链式调用的原理:** 1. `onFulfilled` 或 `onRejected` 回调函数可以返回一个值,这个值会作为下一个 `.then()` 的成功结果。 2. `onFulfilled` 或 `onRejected` 回调函数也可以返回一个新的 Promise。在这种情况下,下一个 `.then()` 会等待这个新的 Promise 解决,并以其结果作为自己的结果。 **示例:解决回调地狱问题** ```javascript // 假设 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()` 中发生的错误。 **示例:** ```javascript 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,允许你继续链式调用。 **示例:** ```javascript 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 的**语法糖**。它的核心目标是让你能够以一种**同步的、更直观的方式**来编写和阅读异步代码,而不会阻塞主线程。 #### **核心概念:** 1. **`async` 关键字:** * 用于修饰一个函数,使其成为一个异步函数。 * 一个 `async` 函数**总是返回一个 Promise**。 * 如果 `async` 函数内部返回一个非 Promise 的值,这个值会被自动包装成一个已解决(resolved)的 Promise。 * 如果 `async` 函数内部抛出一个错误,这个错误会被自动包装成一个已拒绝(rejected)的 Promise。 * `await` 关键字只能在 `async` 函数内部使用。 2. **`await` 关键字:** * 只能在 `async` 函数内部使用。 * 它会**暂停** `async` 函数的执行,直到它等待的 Promise **解决 (resolved)** 或 **拒绝 (rejected)**。 * 如果 Promise 解决了,`await` 会返回 Promise 解决的值。 * 如果 Promise 拒绝了,`await` 会抛出一个错误(这个错误可以用 `try...catch` 捕获)。 * **重要:** `await` 只是暂停了**当前 `async` 函数**的执行,它**不会阻塞 JavaScript 引擎的主线程**。这意味着其他代码(例如事件循环中的其他任务)仍然可以继续执行。 #### **简化异步代码的示例对比** 让我们通过一个模拟网络请求的例子来对比 Promise 链和 `async/await`。 **场景:** 模拟从服务器获取用户数据,然后根据用户ID获取该用户的帖子列表。 **1. 使用 Promise 链:** ```javascript 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`:** ```javascript // 假设 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` 语句来捕获这些异步错误。 **示例:模拟一个失败的请求** ```javascript 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 的关系至关重要: 1. **`async/await` 是基于 Promise 的:** * `async` 函数的返回值**总是**一个 Promise。 * `await` 关键字**只能**等待一个 Promise。如果你 `await` 一个非 Promise 的值,它会被立即解析。 * 这意味着 `async/await` 并没有取代 Promise,而是提供了一种更优雅、更易读的方式来**使用和管理 Promise**。 2. **可以混合使用:** * 你可以在 `async` 函数内部使用 `Promise.all()`、`Promise.race()` 等 Promise 静态方法来处理并行异步操作。 * 你也可以在非 `async` 函数中使用 `.then()` 和 `.catch()` 来处理 `async` 函数返回的 Promise。 **示例:`async/await` 与 `Promise.all` 结合** 当你有多个不相互依赖的异步操作需要并行执行时,`Promise.all` 是一个很好的选择。结合 `async/await`,代码会更加简洁。 ```javascript 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 主要支持两种模块系统: 1. **CommonJS 模块系统**:Node.js 早期和默认的模块系统。 2. **ES Modules (ESM)**:ECMAScript 官方标准,逐渐成为主流。 --- ### **5.1 CommonJS 模块系统** CommonJS 是 Node.js 默认的模块系统,它采用同步加载的方式。 #### **核心概念:** * **`require`**: 用于导入(加载)模块。 * **`module.exports`**: 用于导出模块的公共接口。 * **`exports`**: 一个指向 `module.exports` 的快捷方式,用于导出多个成员。 #### **模块的导出规则** 每个 Node.js 文件都被视为一个独立的模块。在一个模块内部,有两个重要的变量: 1. **`module` 对象**: 代表当前模块本身。它有一个 `exports` 属性,即 `module.exports`。 2. **`exports` 对象**: 这是一个指向 `module.exports` 的引用。 **1. `module.exports` (推荐和常用)** 这是模块真正导出的对象。当你需要导出一个**单一的值**(一个函数、一个对象、一个类、一个原始值)时,直接赋值给 `module.exports`。 * **特点:** 赋值给 `module.exports` 会覆盖掉之前的所有导出。`require()` 返回的就是 `module.exports` 的值。 **示例 1:导出一个函数** `myFunction.js` ```javascript // my_function.js function greet(name) { return `Hello, ${name}!`; } module.exports = greet; // 导出 greet 函数本身 ``` **示例 2:导出一个对象(包含多个成员)** `myModule.js` ```javascript // 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` ```javascript // 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` ```javascript // 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()` 函数在解析模块路径时有一套规则: 1. **核心模块 (Core Modules):** * 如果路径是 Node.js 内置模块的名称(如 `fs`, `http`, `path`),Node.js 会直接加载这些内置模块。 * 示例:`require('fs')`, `require('path')` 2. **相对路径模块 (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')` 3. **第三方模块 (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)** 可以导出多个命名成员,导入时需要使用相同的名称。 * **直接导出:** ```javascript // math.mjs export const PI = 3.14159; export function add(a, b) { return a + b; } export class Calculator { /* ... */ } ``` * **列表导出:** ```javascript // utils.mjs const greet = (name) => `Hello, ${name}!`; const farewell = (name) => `Goodbye, ${name}!`; export { greet, farewell }; // 导出多个已声明的变量/函数 ``` * **重命名导出:** ```javascript // constants.mjs const MY_CONSTANT = 100; export { MY_CONSTANT as ConstantValue }; // 导出时重命名 ``` **2. 默认导出 (Default Export)** 每个模块只能有一个默认导出。导入时可以为它指定任意名称。 * **语法:** ```javascript // 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';` ```javascript // 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';` ```javascript // app.mjs import myFunc from './my_default_module.mjs'; // 导入默认导出,可以任意命名 myFunc(); ``` **3. 混合导入 (默认导出和命名导出同时导入):** * **语法:** `import defaultMember, { namedMember1, namedMember2 } from 'modulePath';` ```javascript // 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';` ```javascript // 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` 扩展名。 * **示例:** ```json // 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` 的值。 * ```javascript // 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()` 表达式。 * ```javascript // 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 中 `require` ESM 仍然是限制。 #### **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 的默认包管理器,它由两部分组成: 1. **命令行工具 (CLI)**:用于与 NPM 注册表交互,执行安装、卸载、更新等操作。 2. **NPM 注册表 (Registry)**:一个巨大的在线数据库,包含了数百万个开源的 Node.js 包(模块)。 **NPM 的作用:** * **管理项目依赖:** 轻松安装、更新和删除项目所需的第三方库。 * **代码共享与复用:** 开发者可以将自己的代码打包成模块发布到 NPM 注册表,供其他人使用。 * **自动化工作流:** 通过 `package.json` 中的 `scripts` 字段,可以定义和运行各种项目任务。 --- #### **6.1 `package.json` 文件详解** `package.json` 是每个 Node.js 项目的“身份证”或“清单文件”。它是一个 JSON 格式的文件,位于项目的根目录,记录了项目的元数据、依赖信息和可执行脚本等。 **核心字段:** 1. **`name`**: 项目的名称。必须是小写字母,不能有空格,可以包含连字符或下划线。 2. **`version`**: 项目的版本号。遵循**语义化版本 (SemVer)** 规范。 3. **`description`**: 项目的简短描述。 4. **`main`**: 项目的入口文件。当其他模块 `require()` 或 `import` 你的包时,默认会加载这个文件。 5. **`scripts`**: 一个对象,定义了可以在命令行中运行的脚本命令。这是 NPM 自动化工作流的核心。 * **示例:** ```json "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`)。 6. **`dependencies`**: 生产环境依赖。这些是项目在运行时所必需的第三方包。当你的项目部署到生产环境时,这些包也会被安装。 * **示例:** ```json "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`: 安装最新版本(不推荐,可能导致不兼容)。 7. **`devDependencies`**: 开发环境依赖。这些包只在开发、测试或构建过程中需要,在生产环境中不需要。例如测试框架、打包工具、代码检查工具等。 * **示例:** ```json "devDependencies": { "jest": "^27.0.6", "webpack": "^5.50.0", "eslint": "^7.32.0" } ``` 8. **`author`**: 作者信息。 9. **`license`**: 项目的开源许可证。 10. **`repository`**: 项目的代码仓库地址。 **`package-lock.json` 文件:** 当运行 `npm install` 时,除了 `node_modules` 文件夹,还会生成一个 `package-lock.json` 文件。 * **作用:** 它记录了项目安装时**所有依赖包的精确版本号**,包括直接依赖和间接依赖(依赖的依赖)。 * **重要性:** 确保团队成员在不同机器上或在不同时间点执行 `npm install` 时,都能安装到完全相同的依赖版本,从而保证**构建的可复现性**。这个文件应该被提交到版本控制系统(如 Git)。 --- #### **6.2 常用 NPM 命令** 1. **`npm init`**: * **作用:** 在当前目录初始化一个新的 Node.js 项目,引导你创建一个 `package.json` 文件。 * **用法:** * `npm init`: 交互式地填写项目信息。 * `npm init -y` 或 `npm init --yes`: 快速生成一个默认的 `package.json` 文件,所有信息都使用默认值。 2. **`npm install`**: * **作用:** 安装项目依赖。 * **用法:** * `npm install`: 在项目根目录运行,会根据 `package.json` 和 `package-lock.json` 文件安装所有 `dependencies` 和 `devDependencies` 中列出的包到 `node_modules` 文件夹。 * `npm install <package-name>`: 安装指定的包到 `node_modules` 文件夹。 * **从 NPM 5.0 开始,默认会将包添加到 `dependencies` 中。** * `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`: **全局安装**包(通常用于命令行工具)。 3. **`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`: 卸载全局安装的包。 4. **`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 包可以安装在两个不同的位置: 1. **本地安装 (Local Installation)**: * **位置:** 包会被安装到当前项目目录下的 `node_modules` 文件夹中。 * **目的:** 用于项目特定的依赖。每个项目都有自己独立的 `node_modules` 文件夹,即使不同项目依赖同一个包的不同版本,也不会冲突。 * **如何安装:** `npm install <package-name>` (默认行为)。 * **如何使用:** * 在代码中通过 `require()` 或 `import` 导入。 * 如果包提供了命令行工具(例如 `webpack`, `jest`),可以通过 `package.json` 中的 `scripts` 字段来运行,或者使用 `npx` 命令来执行本地安装的二进制文件(例如 `npx webpack`)。 2. **全局安装 (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 使用 `/`)的路径时尤其有用,因为它会根据当前操作系统自动调整。 **引入方式:** ```javascript const path = require('path'); ``` **常用方法:** 1. **`path.join([...paths])`**: * **作用:** 将所有给定的 `path` 片段连接在一起,并规范化结果路径。它会处理多余的斜杠、点号等,并根据操作系统使用正确的路径分隔符。 * **优点:** 跨平台兼容性好,避免手动拼接路径可能导致的错误。 * **示例:** ```javascript 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 提供的一个全局变量,表示当前文件所在的目录的绝对路径。 2. **`path.resolve([...paths])`**: * **作用:** 将一系列路径或路径片段解析为绝对路径。它从右到左处理路径片段,直到构造出绝对路径。 * **与 `path.join` 的区别:** * `join` 只是简单拼接并规范化,不保证是绝对路径。 * `resolve` 总是返回一个绝对路径。如果解析过程中没有遇到根路径(如 `/` 或 `C:\`),它会使用当前工作目录作为基础路径。 * **示例:** ```javascript 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 ``` 3. **`path.basename(path[, ext])`**: * **作用:** 返回路径的最后一部分(文件名或目录名)。 * **`ext` 参数:** 可选,如果提供,则会从返回的名称中移除指定的文件扩展名。 * **示例:** ```javascript 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 ``` 4. **`path.dirname(path)`**: * **作用:** 返回路径的目录名(即路径的最后一部分之前的所有内容)。 * **示例:** ```javascript 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')); // . (当前目录) ``` 5. **`path.extname(path)`**: * **作用:** 返回路径的扩展名(包括点 `.`)。如果没有扩展名,则返回空字符串。 * **示例:** ```javascript 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')); // '' ``` 6. **`path.parse(path)`**: * **作用:** 返回一个对象,其属性表示路径的各个组成部分。 * **返回对象属性:** * `root`: 根目录(如 `/` 或 `C:\`) * `dir`: 目录路径 * `base`: 文件名(包括扩展名) * `ext`: 扩展名 * `name`: 文件名(不包括扩展名) * **示例:** ```javascript const parsedPath = path.parse('/home/user/dir/file.txt'); console.log(parsedPath); /* { root: '/', dir: '/home/user/dir', base: 'file.txt', ext: '.txt', name: 'file' } */ ``` 7. **`path.format(pathObject)`**: * **作用:** 从一个路径对象返回一个路径字符串。与 `path.parse()` 相反。 * **示例:** ```javascript const pathObject = { root: '/', dir: '/home/user/dir', base: 'file.txt', ext: '.txt', name: 'file' }; console.log(path.format(pathObject)); // /home/user/dir/file.txt ``` 8. **`path.sep`**: * **作用:** 提供平台特定的路径片段分隔符。 * **示例:** ```javascript console.log(path.sep); // 在 Linux/macOS 上是 '/',在 Windows 上是 '\' ``` --- #### **7.2 `fs` 模块:同步 (Synchronous) 文件操作** `fs` (File System) 模块提供了与文件系统交互的 API。它提供了同步和异步两种操作方式。本节我们先关注**同步操作**。 **引入方式:** ```javascript const fs = require('fs'); ``` **常用同步方法:** 1. **`fs.readFileSync(path[, options])`**: * **作用:** 同步地读取文件的全部内容。 * **`path`:** 文件路径。 * **`options`:** 可选,可以是一个字符串(表示编码,如 `'utf8'`)或一个对象。 * **返回值:** 如果指定了编码,则返回字符串;否则返回 `Buffer` 对象。 * **示例:** ```javascript // 创建一个测试文件 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); } ``` 2. **`fs.writeFileSync(path, data[, options])`**: * **作用:** 同步地将数据写入文件。如果文件不存在,则创建文件;如果文件已存在,则覆盖其内容。 * **`path`:** 文件路径。 * **`data`:** 要写入的数据,可以是字符串或 `Buffer`。 * **`options`:** 可选,可以是一个字符串(表示编码)或一个对象。 * **示例:** ```javascript try { fs.writeFileSync('output.txt', '这是通过同步方式写入的内容。\n第二行内容。', 'utf8'); console.log('文件 output.txt 同步写入成功!'); } catch (err) { console.error('同步写入文件失败:', err.message); } ``` 3. **`fs.appendFileSync(path, data[, options])`**: * **作用:** 同步地将数据追加到文件末尾。如果文件不存在,则创建文件。 * **参数:** 与 `writeFileSync` 类似。 * **示例:** ```javascript try { fs.appendFileSync('output.txt', '\n这是追加的新内容。', 'utf8'); console.log('文件 output.txt 同步追加成功!'); } catch (err) { console.error('同步追加文件失败:', err.message); } ``` 4. **`fs.mkdirSync(path[, options])`**: * **作用:** 同步地创建目录。 * **`options.recursive`:** 布尔值,默认为 `false`。如果设置为 `true`,则可以创建嵌套目录(即父目录不存在时也会一并创建)。 * **示例:** ```javascript 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); } ``` 5. **`fs.rmSync(path[, options])`**: (Node.js 14.14.0+ 引入,推荐替代 `unlinkSync` 和 `rmdirSync`) * **作用:** 同步地删除文件或目录。 * **`options.recursive`:** 布尔值,默认为 `false`。如果设置为 `true`,则可以递归删除非空目录。 * **`options.force`:** 布尔值,默认为 `false`。如果设置为 `true`,则忽略不存在的路径和权限错误。 * **示例:** ```javascript // 先创建一些文件和目录用于删除 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); } ``` 6. **`fs.existsSync(path)`**: * **作用:** 同步地检查指定路径的文件或目录是否存在。 * **返回值:** 布尔值。 * **注意:** 尽管是同步方法,但由于其简单性且不涉及长时间 I/O,在某些简单场景下(如启动时检查配置),偶尔使用是可以接受的。但在频繁的运行时检查中,仍推荐异步方法。 * **示例:** ```javascript if (fs.existsSync('test.txt')) { console.log('文件 test.txt 存在。'); } else { console.log('文件 test.txt 不存在。'); } ``` --- #### **重要提示:生产环境应避免同步 I/O!** 这是 Node.js 异步编程中一个**非常非常重要**的原则。 **为什么?** 1. **阻塞事件循环:** Node.js 的核心是单线程的事件循环。当执行一个同步 I/O 操作时(例如 `fs.readFileSync`),Node.js 的主线程会**完全停滞**,直到该 I/O 操作完成。 2. **性能瓶颈:** 在服务器环境中,这意味着当一个用户请求触发了同步 I/O 操作时,所有其他用户的请求都必须等待,直到这个 I/O 操作完成。这会导致服务器的吞吐量急剧下降,响应时间变长,用户体验极差。在高并发场景下,这几乎是灾难性的。 3. **无法处理并发:** 同步 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` 方法通常有两种形式: 1. **回调函数 (Callback-based):** 这是 Node.js 早期和传统的方式,将一个回调函数作为最后一个参数传入,当操作完成时,回调函数会被调用,通常第一个参数是错误对象 `err`,第二个参数是结果数据。 2. **Promise-based (使用 `fs.promises`):** Node.js 10 引入了 `fs.promises` API,它提供了所有异步 `fs` 方法的 Promise 版本,使得可以使用 `async/await` 语法来编写更清晰、更易于维护的异步代码。**这是现代 Node.js 开发中推荐的方式。** **引入方式:** ```javascript const fs = require('fs'); // 用于回调函数版本 const fsPromises = require('fs/promises'); // 用于 Promise 版本 (推荐) ``` **常用异步方法示例:** 1. **`fs.readFile(path[, options], callback)` / `fsPromises.readFile(path[, options])`**: * **作用:** 异步地读取文件的全部内容。 * **示例 (回调函数):** ```javascript fs.readFile('test.txt', 'utf8', (err, data) => { if (err) { console.error('异步读取文件失败 (回调):', err.message); return; } console.log('异步读取文件内容 (回调):', data); }); ``` * **示例 (Promise / async/await - 推荐):** ```javascript async function readMyFile() { try { const data = await fsPromises.readFile('test.txt', 'utf8'); console.log('异步读取文件内容 (Promise):', data); } catch (err) { console.error('异步读取文件失败 (Promise):', err.message); } } readMyFile(); ``` 2. **`fs.writeFile(path, data[, options], callback)` / `fsPromises.writeFile(path, data[, options])`**: * **作用:** 异步地将数据写入文件。如果文件不存在,则创建文件;如果文件已存在,则覆盖其内容。 * **示例 (回调函数):** ```javascript fs.writeFile('output_async.txt', '这是异步写入的内容。', 'utf8', (err) => { if (err) { console.error('异步写入文件失败 (回调):', err.message); return; } console.log('文件 output_async.txt 异步写入成功 (回调)!'); }); ``` * **示例 (Promise / async/await - 推荐):** ```javascript 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(); ``` 3. **`fs.appendFile(path, data[, options], callback)` / `fsPromises.appendFile(path, data[, options])`**: * **作用:** 异步地将数据追加到文件末尾。 * **示例 (Promise / async/await):** ```javascript 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(); ``` 4. **`fs.mkdir(path[, options], callback)` / `fsPromises.mkdir(path[, options])`**: * **作用:** 异步地创建目录。 * **示例 (Promise / async/await):** ```javascript async function createMyDir() { try { await fsPromises.mkdir('my_async_dir', { recursive: true }); console.log('目录 my_async_dir 异步创建成功!'); } catch (err) { console.error('异步创建目录失败:', err.message); } } createMyDir(); ``` 5. **`fs.rm(path[, options], callback)` / `fsPromises.rm(path[, options])`**: * **作用:** 异步地删除文件或目录。 * **示例 (Promise / async/await):** ```javascript 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(); ``` 6. **`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):** ```javascript 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 中有四种基本类型的流:** 1. **`Readable Stream` (可读流):** * 用于从数据源读取数据。 * 例如:`fs.createReadStream()` (读取文件), HTTP 请求的 `req` 对象。 * 事件: * `data`: 当有数据块可用时触发。 * `end`: 当没有更多数据可读时触发。 * `error`: 当读取过程中发生错误时触发。 * `close`: 当底层资源关闭时触发。 2. **`Writable Stream` (可写流):** * 用于向数据目的地写入数据。 * 例如:`fs.createWriteStream()` (写入文件), HTTP 响应的 `res` 对象。 * 事件: * `drain`: 当写入缓冲区清空,可以继续写入更多数据时触发(用于流量控制/背压)。 * `finish`: 当所有数据都已写入底层系统时触发。 * `error`: 当写入过程中发生错误时触发。 * `close`: 当底层资源关闭时触发。 3. **`Duplex Stream` (双工流):** * 既是可读的又是可写的。 * 例如:TCP sockets。 4. **`Transform Stream` (转换流):** * 一种特殊的双工流,它可以在数据读入和写出之间修改或转换数据。 * 例如:压缩流 (zlib)。 --- #### **8.3 使用流进行大文件读写 (`createReadStream`, `createWriteStream`)** `fs` 模块提供了创建文件可读流和可写流的方法。 1. **`fs.createReadStream(path[, options])`**: * **作用:** 创建一个可读文件流。 * **`options`:** * `encoding`: 编码方式 (如 `'utf8'`)。 * `highWaterMark`: 内部缓冲区的大小(字节),默认为 64KB。当缓冲区达到此限制时,流会暂停读取。 * `start`, `end`: 指定读取文件的起始和结束字节位置。 * **示例:读取大文件** ```javascript 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); ``` 2. **`fs.createWriteStream(path[, options])`**: * **作用:** 创建一个可写文件流。 * **`options`:** * `encoding`: 编码方式。 * `flags`: 文件打开模式,默认为 `'w'` (写入,覆盖)。`'a'` 表示追加。 * `mode`: 文件权限。 * **示例:写入大文件** ```javascript 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` 最经典的用例,也是最能体现其优势的场景。 ```javascript 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` 连接起来,形成一个数据处理管道。 ```javascript // 示例:读取文件 -> 压缩 -> 写入新文件 // 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` 模块: ```javascript const EventEmitter = require('events'); ``` 你可以创建一个 `EventEmitter` 的实例,或者让你的自定义类继承它。 **1. 注册事件 (`on`/`addListener`)** * **`emitter.on(eventName, listener)`**: 注册一个事件监听器。当 `eventName` 事件被触发时,`listener` 函数会被调用。 * **`emitter.addListener(eventName, listener)`**: 与 `on` 方法功能完全相同,`on` 是更常用的别名。 **示例:** ```javascript 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` 会作为参数传递给监听器函数。 **示例:** ```javascript // 触发 '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` 未指定,则移除所有事件的所有监听器。 **注意:** 要成功移除监听器,你必须传递**同一个函数引用**。匿名函数无法被移除。 **示例:** ```javascript 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` 第一次被触发后,该监听器会自动移除。 **示例:** ```javascript 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` 事件注册一个监听器,以防止应用程序意外崩溃。 **示例:** ```javascript 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 服务器** **引入方式:** ```javascript const http = require('http'); ``` **创建服务器:** `http.createServer()` 方法返回一个 `http.Server` 实例。它接受一个回调函数作为参数,这个回调函数会在每次接收到 HTTP 请求时被调用。 回调函数有两个参数: * `req` (Request): 一个 `http.IncomingMessage` 对象,包含了客户端请求的所有信息。 * `res` (Response): 一个 `http.ServerResponse` 对象,用于向客户端发送响应。 ```javascript 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); }); ``` **如何运行和测试:** 1. 将上述代码保存为 `server.js`。 2. 在终端中运行:`node server.js` 3. 打开浏览器,访问 `http://localhost:3000/`。 4. 或者在另一个终端中使用 `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` 来路由请求并发送不同的响应。 ```javascript 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` 命令: ```bash 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?** 1. **简化路由:** `http` 模块需要手动解析 `req.url` 和 `req.method` 来决定如何响应。Express.js 提供了简洁的 API (`app.get()`, `app.post()`, `app.put()`, `app.delete()`) 来定义不同 URL 路径和 HTTP 方法的处理器。 2. **中间件 (Middleware) 机制:** 这是 Express.js 最强大的特性之一。中间件函数可以访问请求对象 (`req`)、响应对象 (`res`) 和应用程序的请求-响应循环中的 `next` 中间件函数。它们可以执行各种任务,如日志记录、身份验证、解析请求体、处理会话等。这使得代码模块化、可复用性高。 3. **模板引擎集成:** Express.js 方便地与各种模板引擎(如 Pug/Jade, EJS, Handlebars)集成,用于渲染动态 HTML 页面。 4. **错误处理:** 提供统一的错误处理机制,使得捕获和响应应用程序错误更加容易。 5. **社区和生态系统:** 作为 Node.js 最流行的 Web 框架,Express.js 拥有庞大而活跃的社区,以及丰富的第三方模块(npm 包),可以轻松扩展功能。 6. **性能:** Express.js 本身非常轻量和快速,因为它只提供了 Web 应用所需的核心功能,其他功能通过中间件按需添加。 **总结:** Express.js 的作用是提供一个结构化、高效且易于使用的框架,来简化 Node.js Web 应用程序和 API 的开发,让你能够专注于业务逻辑而不是底层 HTTP 细节。 #### **11.2 安装 Express.js** Express.js 是一个第三方模块,需要通过 npm 进行安装。 1. **创建一个新的项目目录并初始化 npm:** ```bash mkdir my-express-app cd my-express-app npm init -y # -y 会跳过所有提问,直接生成默认的 package.json ``` 2. **安装 Express.js:** ```bash npm install express ``` 这会将 Express.js 安装到 `node_modules` 目录,并将其添加到 `package.json` 的 `dependencies` 中。 #### **11.3 创建第一个 Express.js 应用:基本路由设置** 创建一个名为 `app.js` 的文件: ```javascript // 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'); }); ``` **运行和测试:** 1. 在终端中进入 `my-express-app` 目录。 2. 运行:`node app.js` 3. 打开浏览器访问: * `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` 开头的请求。 **示例:日志中间件** ```javascript // 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` **示例:** ```javascript // 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` 对象 **示例:** ```javascript // 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 内置中间件:** 1. **`express.json()`**: 用于解析 `Content-Type: application/json` 格式的请求体。 2. **`express.urlencoded({ extended: true })`**: 用于解析 `Content-Type: application/x-www-form-urlencoded` 格式的请求体(通常是 HTML 表单提交的数据)。`extended: true` 允许解析更复杂的嵌套对象和数组。 **示例:** ```javascript // 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 数据:** ```bash curl -X POST -H "Content-Type: application/json" -d '{"username": "testuser", "email": "test@example.com"}' http://localhost:3000/submit-form ``` **使用 curl 测试 URL-encoded 数据:** ```bash 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** ```javascript // 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 中的中间件可以根据其作用范围和功能分为几种类型: 1. **应用级中间件 (Application-level Middleware):** * 使用 `app.use()` 或 `app.METHOD()` (如 `app.get()`, `app.post()`) 绑定到 `app` 对象实例。 * **`app.use([path], callback)`**: 如果没有指定 `path`,则应用于所有请求。如果指定了 `path`,则应用于以该 `path` 开头的所有请求。 * **`app.METHOD(path, callback)`**: 绑定到特定的 HTTP 方法和路径。这些实际上也是中间件,但它们通常是请求处理链的终点。 * **示例:** ```javascript 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 数据' }); }); ``` 2. **路由级中间件 (Router-level Middleware):** * 使用 `express.Router()` 实例来定义。它与应用级中间件的工作方式相同,但它被绑定到 `express.Router()` 的实例而不是 `app` 实例。这有助于模块化路由。 * **示例:** ```javascript 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); // ... 其他应用级中间件和路由 ``` 3. **错误处理中间件 (Error-handling Middleware):** * 与其他中间件不同,错误处理中间件函数有四个参数:`(err, req, res, next)`。 * 它们必须定义在所有其他路由和中间件之后。 * 当任何路由或中间件中发生错误(例如,通过 `next(err)` 传递错误)时,Express 会跳过所有常规中间件,直接将控制权交给错误处理中间件。 * **示例:** ```javascript 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)` 的函数签名。 **示例:身份验证中间件** ```javascript // 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 的强大之处在于其庞大的中间件生态系统。 1. **`morgan` (HTTP 请求日志):** * **作用:** 记录 HTTP 请求的详细信息,如请求方法、URL、状态码、响应时间等。 * **安装:** `npm install morgan` * **使用:** ```javascript 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')); ``` 运行后,每次请求都会在控制台打印日志。 2. **`cors` (跨域资源共享):** * **作用:** 启用 CORS (Cross-Origin Resource Sharing),允许或限制来自不同源的 Web 应用程序访问你的服务器资源。 * **安装:** `npm install cors` * **使用:** ```javascript const cors = require('cors'); // ... app.use(cors()); // 允许所有来源的跨域请求 (开发环境常用) // 或者配置特定来源 // app.use(cors({ // origin: 'http://example.com', // 只允许来自 example.com 的请求 // methods: ['GET', 'POST'], // allowedHeaders: ['Content-Type', 'Authorization'] // })); ``` 3. **`helmet` (安全):** * **作用:** 通过设置各种 HTTP 头来帮助保护 Express 应用程序免受一些常见的 Web 漏洞攻击。 * **安装:** `npm install helmet` * **使用:** ```javascript const helmet = require('helmet'); // ... app.use(helmet()); // 启用所有默认的 Helmet 中间件 // 你也可以选择性地启用或禁用某些模块 // app.use(helmet.contentSecurityPolicy()); // app.use(helmet.xssFilter()); ``` 4. **`express-session` (会话管理):** * **作用:** 提供会话管理功能,允许你在用户请求之间存储用户特定的数据。 * **安装:** `npm install express-session` * **使用:** (需要一个 secret 字符串来签名会话 ID cookie) ```javascript 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 有多种方式: 1. **官方安装包:** 访问 MongoDB 官网下载并按照指南安装适合你操作系统的版本。 * [MongoDB Community Edition Download](https://www.mongodb.com/try/download/community) 2. **Docker:** 对于开发环境,使用 Docker 是一个非常方便的选择。 ```bash docker pull mongo docker run --name my-mongo -p 27017:27017 -d mongo ``` 3. **云服务:** 在生产环境中,通常会使用 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:** ```bash npm install mongoose ``` **连接 MongoDB:** ```javascript // 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** ```javascript // 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。 ```javascript // 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>'); }); ``` **运行步骤:** 1. 确保你已经安装并运行了 MongoDB 服务(例如通过 Docker 或本地安装)。 2. 创建项目目录,`npm init -y`。 3. 安装依赖:`npm install express mongoose` 4. 创建 `db.js` 文件并粘贴连接代码。 5. 创建 `models/User.js` 文件并粘贴 Schema 和 Model 定义代码。 6. 创建 `app.js` 文件并粘贴完整的 Express 应用代码。 7. 在终端中运行 `node app.js`。 8. 使用 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。 **核心原则:** 1. **资源 (Resource):** * API 中的所有事物都被视为资源。 * 资源通过唯一的 URI (Uniform Resource Identifier) 来标识。 * **示例:** `/users`, `/products/123`, `/orders` * **最佳实践:** URI 应该使用名词(复数),而不是动词。例如,`GET /users` 而不是 `GET /getAllUsers`。 2. **统一接口 (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 的用户 3. **无状态 (Stateless):** * 服务器不应该存储任何关于客户端会话的状态信息。 * 每个请求都必须包含处理该请求所需的所有信息。 * 这使得 API 更具可伸缩性,因为任何服务器都可以处理任何请求。 4. **客户端-服务器分离 (Client-Server Separation):** * 客户端和服务器应该独立发展。客户端不关心数据如何存储,服务器不关心数据如何渲染。 5. **分层系统 (Layered System):** * 客户端无法判断它是否直接连接到最终服务器,或者中间是否有代理、负载均衡器等。这增加了系统的灵活性和可伸缩性。 6. **按需代码 (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 节相同):** ```javascript 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):** ```javascript 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`。 * **示例 (简单检查):** ```javascript // 在 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` 中):** ```javascript // ... (之前的代码) // 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) 认证:** * **工作原理:** 1. 用户登录时,服务器验证凭据,并在服务器端创建一个会话记录。 2. 服务器生成一个唯一的会话 ID,通常通过 Cookie 发送给客户端。 3. 客户端在后续请求中携带此 Cookie。 4. 服务器根据 Cookie 中的会话 ID 查找对应的会话记录,从而识别用户。 * **优点:** * 安全性相对较高,会话数据存储在服务器端。 * 易于撤销会话(只需删除服务器上的会话记录)。 * **缺点:** * **有状态:** 服务器需要维护会话状态,增加了服务器的负担。 * **可伸缩性问题:** 在分布式系统或负载均衡环境下,需要共享会话存储(如 Redis),增加了复杂性。 * **跨域问题:** Cookie 默认受同源策略限制,跨域请求需要额外配置。 * **移动应用不友好:** 移动应用通常不使用 Cookie。 **2. 令牌 (Token) 认证 (以 JWT 为例):** * **工作原理:** 1. 用户登录时,服务器验证凭据,并生成一个加密的令牌 (Token),通常是 JWT。 2. 服务器将令牌发送给客户端。 3. 客户端将令牌存储在本地(如 localStorage, sessionStorage)。 4. 客户端在后续请求中将令牌放在 HTTP 请求头(通常是 `Authorization: Bearer <token>`)中发送给服务器。 5. 服务器接收到令牌后,验证其有效性(签名、过期时间),并从令牌中提取用户信息,无需查询数据库。 * **优点:** * **无状态:** 服务器不存储会话状态,每个请求都包含所有必要信息,提高了可伸缩性。 * **跨域友好:** 令牌通过 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` 1. **Header (头部):** * 通常包含两部分:令牌的类型(即 `JWT`)和所使用的签名算法(如 `HMAC SHA256` 或 `RSA`)。 * **示例:** ```json { "alg": "HS256", "typ": "JWT" } ``` * 这个 JSON 对象会被 Base64Url 编码。 2. **Payload (载荷):** * 包含声明 (claims),即关于实体(通常是用户)和附加数据的语句。 * 声明分为三类: * **Registered claims (注册声明):** 预定义的一些声明,非强制但推荐使用,如 `iss` (issuer), `exp` (expiration time), `sub` (subject), `aud` (audience) 等。 * **Public claims (公共声明):** 自定义声明,但为了避免冲突,应在 IANA JSON Web Token Registry 中注册,或定义为 URI。 * **Private claims (私有声明):** 自定义声明,用于在同意使用它们的各方之间共享信息。 * **示例:** ```json { "userId": "60d5ec49f8e7c20015f8e7c2", "username": "john.doe", "role": "admin", "iat": 1678886400, // Issued At (签发时间) "exp": 1678890000 // Expiration Time (过期时间) } ``` * 这个 JSON 对象也会被 Base64Url 编码。 3. **Signature (签名):** * 用于验证令牌的发送者,并确保令牌在传输过程中没有被篡改。 * 签名是通过将 Base64Url 编码的 Header、Base64Url 编码的 Payload、一个密钥 (secret) 和 Header 中指定的算法进行哈希计算而创建的。 * **计算方式:** `HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)` **JWT 工作原理:** 1. **用户登录:** 客户端向服务器发送用户名和密码。 2. **服务器验证:** 服务器验证这些凭据。 3. **生成 JWT:** 如果凭据有效,服务器使用一个密钥(只有服务器知道)和选定的算法生成一个 JWT。JWT 中包含用户的身份信息(如用户 ID、角色等)和过期时间。 4. **发送 JWT:** 服务器将生成的 JWT 发送回客户端。 5. **客户端存储:** 客户端将 JWT 存储在本地(通常是 `localStorage` 或 `sessionStorage`)。 6. **后续请求:** 客户端在每次需要访问受保护资源时,将 JWT 放在 HTTP 请求的 `Authorization` 头中(通常是 `Bearer <token>` 格式)发送给服务器。 7. **服务器验证 JWT:** 服务器接收到请求后,使用相同的密钥验证 JWT 的签名。 * 如果签名无效,或者令牌已过期,服务器拒绝请求。 * 如果签名有效且未过期,服务器从 Payload 中提取用户信息,并允许访问相应的资源。 8. **发送响应:** 服务器处理请求并发送响应。 #### **16.3 使用 `jsonwebtoken` 库实现注册、登录和保护路由** 我们将使用 `jsonwebtoken` 库来生成和验证 JWT,以及 `bcryptjs` 来安全地存储用户密码。 **安装必要的库:** ```bash npm install jsonwebtoken bcryptjs ``` **修改 `models/User.js` (添加密码字段和密码哈希方法):** ```javascript // 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` (集成认证和授权):** ```javascript 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 }); }); ``` **测试步骤:** 1. 确保 MongoDB 正在运行。 2. 创建项目目录,`npm init -y`。 3. 安装依赖:`npm install express mongoose jsonwebtoken bcryptjs` 4. 创建 `db.js` 文件并粘贴连接代码。 5. 修改 `models/User.js` 文件,添加密码字段和方法。 6. 创建 `app.js` 文件并粘贴完整的 Express 应用代码。 7. 在终端中运行 `node app.js`。 8. 使用 Postman 或 Insomnia 进行测试: * **注册用户 (POST `http://localhost:3000/api/auth/register`)** * Body (raw, JSON): ```json { "name": "Test User", "email": "test@example.com", "password": "password123", "age": 25 } ``` * 成功后会返回一个 `token`。 * **登录用户 (POST `http://localhost:3000/api/auth/login`)** * Body (raw, JSON): ```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 中同步和异步错误的捕获方式是正确处理错误的基础。 1. **同步错误捕获 (`try...catch`):** * 对于同步代码,你可以使用标准的 `try...catch` 块来捕获错误。 * **示例:** ```javascript try { const result = someSynchronousFunctionThatMightThrow(); console.log(result); } catch (error) { console.error('捕获到同步错误:', error.message); } ``` 2. **异步错误捕获:** * **回调函数模式 (Callback Pattern):** * 传统的 Node.js 异步操作通常使用回调函数,错误作为回调的第一个参数传递(`err, data`)。`try...catch` 无法捕获回调函数内部的异步错误。 * **示例:** ```javascript // 错误的方式: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`。 * **示例:** ```javascript // 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 会跳过所有常规中间件,直接将控制权传递给错误处理中间件。 * **基本结构:** ```javascript app.use((err, req, res, next) => { console.error(err.stack); // 打印错误堆栈到控制台 res.status(500).send('Something broke!'); // 发送通用错误响应 }); ``` #### **17.3 最佳实践:集中式错误处理** 为了使错误处理更具可维护性和一致性,推荐采用集中式错误处理。 1. **自定义错误类:** * 创建自定义错误类,继承自 `Error`,并添加 `statusCode` 和 `isOperational` 等属性。`isOperational` 用于区分可预期的操作性错误(如验证失败、资源未找到)和不可预期的编程错误(如代码 bug)。 * **`utils/AppError.js`:** ```javascript 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; ``` 2. **统一的错误处理中间件:** * 在 `app.js` 的末尾,使用一个统一的错误处理中间件来处理所有错误。 * 根据 `NODE_ENV` (开发/生产环境) 返回不同的错误信息。 * **`middleware/errorHandler.js`:** ```javascript 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); } }; ``` 3. **在路由中使用 `next(new AppError(...))`:** ```javascript 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 错误传递给全局错误处理中间件 } }); ``` 4. **捕获未处理的拒绝和异常:** * 对于未捕获的 Promise 拒绝和同步异常,Node.js 提供了全局事件。 * **`app.js`:** ```javascript // 捕获未处理的 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` * **基本使用示例:** ```javascript // 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` 或其他模块中使用:** ```javascript 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 包含两个部分:服务器端库和客户端库。 1. **服务器端 (Node.js):** ```bash npm install socket.io ``` 2. **客户端 (浏览器):** * 可以通过 CDN 引入: ```html <script src="/socket.io/socket.io.js"></script> ``` (注意:当 Socket.IO 服务器启动后,它会自动在 `/socket.io/socket.io.js` 路径提供客户端库) * 或者通过 npm 安装并在前端打包工具中使用: ```bash npm install socket.io-client ``` #### **18.3 构建一个简单的聊天室应用:实时消息发送与接收** 我们将构建一个非常简单的聊天室,用户可以发送消息,所有连接的用户都能实时看到。 **文件结构:** ``` chat-app/ ├── server.js # Node.js 服务器端代码 └── public/ └── index.html # 客户端 HTML 页面 ``` **1. 服务器端 (`server.js`):** ```javascript 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`):** ```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> ``` **运行和测试:** 1. 创建项目目录 `chat-app`。 2. 在 `chat-app` 目录下运行 `npm init -y`。 3. 安装依赖:`npm install express socket.io`。 4. 创建 `server.js` 文件并粘贴服务器端代码。 5. 创建 `public` 目录,并在其中创建 `index.html` 文件并粘贴客户端代码。 6. 在终端中运行 `node server.js`。 7. 打开多个浏览器标签页或窗口,访问 `http://localhost:3000`。 8. 在任何一个窗口中输入消息并发送,你会看到消息实时同步到所有打开的聊天室窗口中。 通过本节,你已经掌握了 Express.js 中错误处理的各种策略,包括同步/异步错误捕获、集中式错误处理和使用 Winston 进行日志记录。同时,你还学习了 WebSockets 的概念及其与 HTTP 的区别,并使用 Socket.IO 构建了一个简单的实时聊天应用,迈出了实时通信应用开发的第一步。 好的,我们继续学习 Node.js 的高级主题,包括命令行工具开发、进程管理、部署和性能优化。 --- ### **第 19 节:命令行工具开发与进程管理** Node.js 不仅适用于构建 Web 服务器,也因其强大的文件系统和进程管理能力,成为开发命令行工具 (CLI) 的理想选择。 #### **19.1 Node.js 作为命令行工具的优势** 1. **跨平台:** Node.js 应用程序可以在 Windows、macOS 和 Linux 等操作系统上运行,这意味着你编写的 CLI 工具可以在任何支持 Node.js 的环境中运行。 2. **JavaScript 熟悉度:** 如果你已经熟悉 JavaScript,那么使用 Node.js 开发 CLI 工具可以复用你的技能栈。 3. **NPM 生态系统:** Node.js 拥有庞大的 npm 包生态系统,你可以轻松地引入各种库来处理文件操作、网络请求、数据解析等,极大地提高了开发效率。 4. **异步 I/O:** Node.js 的非阻塞 I/O 模型使其在处理大量文件或网络操作时表现出色,这对于需要快速处理数据的 CLI 工具非常有利。 5. **易于分发:** 通过 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 脚本: ```json { "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`):** ```javascript #!/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); // 解析命令行参数 ``` **使用方法:** 1. 保存为 `bin/mycli.js`。 2. `chmod +x bin/mycli.js`。 3. 在项目根目录运行 `npm link` (这会在全局创建一个 `mycli` 命令的软链接)。 4. 测试: * `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 脚本。 1. **`child_process.exec(command[, options][, callback])`:** * **特点:** * 在 shell 中执行命令。 * **缓冲**所有输出(stdout 和 stderr),并在子进程结束后一次性传递给回调函数。 * 适合执行短时间运行、输出量较小的命令。 * **示例:** ```javascript 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}`); }); ``` 2. **`child_process.spawn(command[, args][, options])`:** * **特点:** * 直接启动一个新进程,不通过 shell。 * **流式**处理输入/输出(stdin, stdout, stderr),适合处理大量数据或长时间运行的进程。 * 返回一个 `ChildProcess` 对象,可以通过其 `stdout`, `stderr` 属性监听数据流。 * **示例:** ```javascript 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}`); }); ``` 3. **`child_process.fork(modulePath[, args][, options])`:** * **特点:** * `fork` 是 `spawn` 的一个特例,专门用于启动另一个 Node.js 进程。 * 在父子进程之间建立一个 **IPC (Inter-Process Communication)** 通道,允许它们通过 `process.send()` 和 `child.on('message')` 互相发送消息。 * 非常适合创建工作进程 (worker processes) 来处理 CPU 密集型任务,从而不阻塞主事件循环。 * **示例:** * **`parent.js` (主进程):** ```javascript 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` (子进程):** ```javascript 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 应用程序从开发环境迁移到生产环境称为部署。 1. **PM2 (Process Manager 2):** * 一个 Node.js 进程管理器,用于在生产环境中保持应用程序永远在线,并提供负载均衡、日志管理、监控等功能。 * **优势:** 简单易用,功能强大,适合单服务器部署。 2. **Docker:** * 一种容器化技术,允许你将应用程序及其所有依赖项打包到一个独立的、可移植的容器中。 * **优势:** 环境一致性(“在我的机器上能跑,在你的机器上也能跑”),快速部署,易于扩展和管理。 * 通常与 Docker Compose (多容器应用) 或 Kubernetes (容器编排) 结合使用。 3. **云服务:** * **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:** ```bash npm install -g pm2 ``` **基本命令:** * **启动应用:** ```bash pm2 start app.js # 或者指定名称 pm2 start app.js --name my-express-app ``` * **列出所有应用:** ```bash pm2 list # 或 pm2 ls ``` * **停止应用:** ```bash pm2 stop <id|name> pm2 stop all # 停止所有应用 ``` * **重启应用:** ```bash pm2 restart <id|name> pm2 restart all ``` * **删除应用:** ```bash pm2 delete <id|name> pm2 delete all ``` * **查看日志:** ```bash pm2 logs <id|name> pm2 logs --lines 100 # 查看最近100行 pm2 logs --follow # 实时跟踪日志 ``` * **监控应用:** ```bash pm2 monit # 实时显示 CPU、内存使用情况 ``` * **开机自启:** ```bash pm2 startup # 生成启动脚本,让 PM2 在服务器重启后自动启动应用 pm2 save # 保存当前运行的应用列表,以便 startup 脚本加载 ``` **集群模式 (Cluster Mode) - 负载均衡:** PM2 可以利用 Node.js 的 `cluster` 模块,在多核 CPU 上运行多个应用实例,实现负载均衡,提高性能和可用性。 ```bash pm2 start app.js -i max # 根据 CPU 核心数启动最大数量的实例 # 或者指定实例数量 pm2 start app.js -i 4 # 启动 4 个实例 ``` 在集群模式下,PM2 会自动将请求分发到不同的实例。 **使用配置文件 (`ecosystem.config.js`):** 对于更复杂的部署,推荐使用 PM2 配置文件。 ```javascript // 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' // 生产环境的密钥 } }] }; ``` **使用配置文件启动:** ```bash pm2 start ecosystem.config.js # 启动生产环境配置 pm2 start ecosystem.config.js --env production ``` #### **20.3 简单的性能优化技巧:缓存、Gzip 压缩** 性能优化是一个复杂且持续的过程,但有一些基础技巧可以显著提升 Node.js 应用的性能。 1. **缓存 (Caching):** * **目的:** 减少重复计算或数据库查询,加快响应速度。 * **客户端缓存 (HTTP Caching):** * 通过设置 HTTP 响应头(如 `Cache-Control`, `ETag`, `Last-Modified`),指示浏览器或代理服务器缓存资源。 * 对于静态文件(图片、CSS、JS),这是最有效的优化之一。Express 的 `express.static` 可以自动处理一些缓存头。 * **服务器端缓存 (Application-level Caching):** * **内存缓存:** 将频繁访问的数据存储在应用程序的内存中。适用于数据量不大且不要求持久化的场景。 * **示例 (使用 `node-cache` 库):** ```bash npm install node-cache ``` ```javascript const 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 提供了高性能的键值存储,可以作为共享缓存层。 2. **Gzip 压缩 (Gzip Compression):** * **目的:** 减小 HTTP 响应体的大小,从而减少网络传输时间。 * **实现:** 使用 `compression` Express 中间件。 * **安装:** `npm install compression` * **使用:** ```javascript 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`。 3. **数据库索引:** * 确保你的数据库查询字段(尤其是 `_id`、`email`、`userId` 等)有适当的索引。索引可以显著加快查询速度。 * 在 Mongoose Schema 中,`unique: true` 会自动创建唯一索引。你也可以手动添加索引: ```javascript UserSchema.index({ email: 1 }); // 为 email 字段创建升序索引 ``` 4. **异步操作:** * Node.js 的核心优势在于其非阻塞 I/O。确保你的代码充分利用异步操作,避免使用同步方法(如 `fs.readFileSync`),除非在启动时或特殊情况下。 5. **负载均衡:** * 当单个服务器无法处理所有请求时,通过在多个服务器实例之间分发请求来提高吞吐量和可用性。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 后端应用的能力。 **下一步学习方向:** 1. **深入学习:** * **测试:** 学习单元测试 (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)。 2. **实践项目:** * 尝试独立构建一个完整的项目,例如: * 一个博客系统 (带用户管理、文章发布、评论) * 一个电商网站 (带商品、订单、购物车) * 一个实时协作文档应用 * 一个简单的社交媒体平台 * 在项目中应用你所学到的所有知识,并尝试引入新的技术。 3. **阅读源码和社区:** * 阅读一些流行的 Node.js 库和框架的源码,了解它们是如何工作的。 * 积极参与 Node.js 社区,关注最新的技术趋势和最佳实践。 祝你在 Node.js 的学习旅程中取得更大的进步!
配图 (可多选)
选择新图片文件或拖拽到此处
标签
更新文章
删除文章