0-introduction
- 课程讲师: Scott Moss
- 拥有超过 15 年经验的软件工程师,现为投资者。
- 曾就职于 Netflix 并为其他公司提供咨询。
- 对 Node.js 充满热情,视其为自己的“母语”。
- 课程概述:
- 本课程为 "Intro to Node.js V3"。
- 将涵盖 Node.js 最新版本的变化以及新的开发方法。
- 强调了 Node.js 在现代前端开发中的重要性,几乎所有前端工作都离不开它。
- 课程笔记是供学员参考的工具,教学风格以实时编码和实例演示为主。
- 课程项目:
- 我们将构建一个笔记应用程序。
- 该应用包含两个主要部分:
- 一个命令行界面 (CLI),用于在终端中管理笔记。
- 一个网站组件,用于在网页上查看这些笔记。
- 通过这个项目,我们将学习 Node.js 的核心概念,包括:
- 模块 (Modules) 和 NPM
- 命令行界面 (CLIs)
- 服务器 (Servers)
- 文件系统交互
- 学习目标:
- 为学员打下坚实的 Node.js 基础。
- 理解 Node.js 是什么、如何使用它及其功能。
- 帮助来自其他语言(如 PHP)的开发者建立与 Node.js 的可比性认知。
- 虽然是入门课程,但欢迎提出更深入的问题。
- 课程资源:
- GitHub 仓库: 提供已完成项目的代码,可用于参考或追赶进度。
- 注意:现场编写的代码可能与仓库中的版本不完全一致。
- 重要链接:
- NPM (Node Package Manager) 官网。
- Node.js 官方文档。
- GitHub 仓库: 提供已完成项目的代码,可用于参考或追赶进度。
1-history-of-node-js
- 什么是 Node.js?
- 它是一个让你可以在浏览器之外运行 JavaScript 的运行时环境。
- 在 Node.js 出现之前,JavaScript 只能在浏览器中运行,被认为是一种“小儿科”的语言。
- 由 Ryan Dahl 创建,他将 Google Chrome 的 V8 JavaScript 引擎移植到了计算机上,使其成为一个独立的运行环境。
- 与浏览器 JavaScript 的主要区别
- 运行环境: Node.js 直接在操作系统上运行,而浏览器 JS 在浏览器环境中运行。
- 可用的 API:
- Node.js 没有浏览器特有的 API,如 DOM (文档对象模型) 或 CSS。
- 相反,它提供了与计算机交互的 API,例如文件系统(
fs)、网络(http)等。
- 语言 vs. 运行时:
- 语言本身仍然是 JavaScript。如果你了解 JavaScript,你就已经掌握了大约 90% 的 Node.js。
- 区别在于“运行时”提供的不同能力和全局变量。
- 发展历史与演变
- 诞生于 2009 年。
- 曾因版本更新停滞(在 v0.12 左右),导致社区分裂并创建了一个名为
IO.js的分支。 IO.js的出现促使 Node.js 核心团队重组并加快了开发步伐。- 如今,Node.js 拥有一个正式的管理委员会,紧跟 ECMAScript 标准,并定期发布新版本。
- 本课程将使用 Node.js v18。
- 现代应用场景
- 广泛用于生产环境,构建各种应用,如:
- Web 服务器
- 代理服务器
- 构建工具
- 在现代前端开发中至关重要。几乎所有的前端框架(React, Vue 等)都依赖 Node.js 作为其构建工具(例如,使用 Webpack 或 Vite 将 JSX 转换为浏览器可读的 JavaScript)。
- Node.js 已成为现代 Web 开发不可或缺的一部分。
- 广泛用于生产环境,构建各种应用,如:
2-non-blocking-i-o
-
核心概念: 非阻塞 I/O (Non-Blocking I/O)
- I/O: 指输入/输出操作,例如读取文件、发起网络请求、查询数据库等。
- 阻塞 (Blocking / Synchronous): 在许多传统语言中(如 Ruby, Python),当程序执行一个 I/O 操作时,它会“阻塞”或暂停,直到该操作完成后才能继续执行下一行代码。若要并行处理,通常需要创建和管理多个线程。
- 非阻塞 (Non-Blocking / Asynchronous): Node.js 采用单线程、非阻塞模型。当发起一个 I/O 操作时,程序不会等待其完成,而是继续执行后续代码。当该 I/O 操作完成后,会通过一个回调函数来通知你结果。
-
事件循环 (Event Loop)
- 这是实现非阻塞 I/O 的核心机制。
- 可以将其想象成一个“待办事项”的注册表和调度中心。
- 当一个异步操作开始时,它被交给系统底层处理。
- 事件循环会不断检查是否有已完成的操作。
- 当一个操作完成时,其对应的回调函数会被放入一个队列中。当主调用栈为空时,事件循环会从队列中取出回调函数并执行它。
- 这个模型使得 Node.js 能够用一个线程高效地处理大量并发请求。
-
性能特点
- 优点: 非常适合 I/O 密集型应用(如 Web 服务器、API 网关),因为它可以用很少的资源处理大量并发连接。
- 缺点: 不适合 CPU 密集型任务(如人工智能、机器学习、复杂计算),因为它默认只使用单个 CPU 核心。这类任务通常首选 Python 等语言。
-
代码示例
-
阻塞代码 (同步):
- 代码从上到下按顺序执行。
lookup函数会“阻塞”执行,直到返回结果。
function getUserSync(id) { const lookup = // ... a quick, blocking database lookup return lookup; } const user = getUserSync(1); // do something with user... // 这行代码必须等待 getUserSync 完成 -
非阻塞代码 (异步):
setTimeout模拟了一个耗时的 I/O 操作。- 程序在调用
getUserAsync后会立即执行后面的代码,而不会等待 1 秒。 - 1 秒后,回调函数才会被事件循环执行。
function getUserAsync(id, callback) { setTimeout(() => { const user = { id: id }; callback(user); // 任务完成后,执行回调 }, 1000); } getUserAsync(1, (user) => { // 这个函数里的代码会稍后执行 console.log(user); }); // 这行代码会先于上面的 console.log(user) 执行 console.log("Request sent!");
-
-
问与答
- 问: 浏览器里的 JavaScript 事件循环和 Node.js 的事件循环有区别吗?
- 答: 概念上是相同的。底层的实现细节不同,因为它们运行在不同的环境里(浏览器 vs. 操作系统),但作为开发者,你感知到的工作方式和行为模式基本一致。
- 问: “如果你懂 JS,就懂 90% 的 Node” 这句话能再解释一下吗?
- 答: 因为 Node.js 使用的编程语言就是 JavaScript。语法、数据类型、函数等核心语言特性是完全一样的。那 10% 的区别在于运行时环境提供的特定 API,例如 Node.js 提供了文件系统 (
fs) 模块,而浏览器提供了文档对象模型 (document)。
3-hello-world
-
安装 Node.js
-
推荐方法: NVM (Node Version Manager)
- 它是一个工具,可以让你在同一台电脑上安装和管理多个 Node.js 版本,并轻松切换。
- 可以通过其 GitHub 页面上的脚本来安装。
- 安装后,需要根据提示将 NVM 的配置添加到你的 shell 配置文件中 (如
.zshrc,.bash_profile等)。 - 使用
nvm install 18来安装 Node.js 的 18 版本。
-
备选方法: 官方安装包
- 访问 nodejs.org 官网。
- 下载 LTS (Long-Term Support) 版本,这是长期支持版,更稳定。
- 这种方法安装简单,但版本切换不便,且有时会遇到文件权限问题。
-
验证安装
- 安装完成后,在终端运行以下命令来检查版本:
node --version
-
-
编写你的第一个 Node.js 程序
-
创建一个新文件夹作为项目目录。
-
在文件夹中创建一个新文件,例如
index.js。 -
在
index.js中写入一些你熟悉的 JavaScript 代码:console.log("hello world"); -
打开终端,进入到该项目目录,然后运行以下命令:
node index.js -
你将会在终端看到输出
hello world。
-
-
关键概念
- 执行:
node <文件名>是使用 Node.js 运行时来执行一个 JavaScript 文件的命令。 console.log():- 在 Node.js 中,
console.log的作用与浏览器中类似。 - 不同之处在于,它的输出目标是终端 (Terminal),而不是浏览器的开发者控制台。
- 它等同于其他操作系统级别语言中的
print或puts。
- 在 Node.js 中,
- 执行:
-
环境差异示例
- Node.js 环境中没有浏览器特有的全局 API。
- 如果你尝试在 Node.js 中使用
alert(),程序会报错。 - 示例代码:
alert("This will not work"); - 运行结果:
ReferenceError: alert is not defined
- 这清晰地表明,尽管语言都是 JavaScript,但 Node.js 和浏览器是两个不同的运行时环境,它们提供的可用 API 是不同的。
4-browser-vs-node-js
-
全局对象 (Global Objects)
- 浏览器: 顶层的全局对象是
window。所有全局变量和函数都附加在它上面 (例如alert()实际上是window.alert())。 - Node.js: 顶层的全局对象是
global。- 你可以通过
console.log(global)来查看它的内容。 - 它包含了一些与浏览器共有的全局函数,如
setTimeout,setInterval,以及 Node.js 特有的对象。 - 在 Node.js 中使用
window会导致ReferenceError: window is not defined的错误。
- 你可以通过
- 浏览器: 顶层的全局对象是
-
模块系统 (Modules)
- 现在浏览器和 Node.js 都支持标准的 ES 模块 (
import/export)。 - 浏览器: 通常通过
<script type="module" src="..."></script>标签来加载模块。 - Node.js: 直接在 JavaScript 文件中使用
import和export语法。因为没有 DOM,所以不存在<script>标签。
- 现在浏览器和 Node.js 都支持标准的 ES 模块 (
-
DOM (Document Object Model)
- Node.js 中完全没有 DOM。
- 像
document.getElementById、document.querySelector这样的 API 是不可用的。 - 虽然 Node.js 服务器可以生成 HTML 字符串,但它本身不渲染页面,也无法操作页面上的元素。DOM 操作只能在浏览器环境中由客户端 JavaScript 执行。
-
服务器 (Server) vs. 客户端 (Client)
- Node.js: 主要用于创建服务器。服务器是一个持续运行的程序,它监听网络请求,并返回响应(如数据、HTML 文件等)。
- 浏览器中的 JS: 通常扮演客户端的角色,负责向服务器发送请求,并处理从服务器接收到的响应。
-
Node.js REPL
-
REPL 是 Read, Evaluate, Print, Loop (读取、求值、打印、循环) 的缩写。
-
它是一个交互式的命令行环境,可以让你即时编写和执行 JavaScript 代码。
-
如何启动: 在终端里直接输入
node命令,然后按回车。$ node Welcome to Node.js v18.14.0. Type ".help" for more information. > const a = 10; undefined > a + 5 15 > -
用途:
- 适合快速测试小段代码。
- 进行简单的计算。
- 验证某个 API 的行为。
- 不适合用来编写完整的应用程序,因为代码不会被保存。
-
如何退出:
- 连续按两次
Ctrl+C。 - 或者输入
.exit并按回车。
- 连续按两次
-
5-process-environment
-
构建 CLI (命令行界面)
- CLI (Command Line Interface) 是在终端中运行的应用程序,例如我们常用的
git,ls,npm等。 - 本课程的项目就是一个笔记管理 CLI。
- CLI (Command Line Interface) 是在终端中运行的应用程序,例如我们常用的
-
process全局对象- 这是 Node.js 提供的一个强大的全局对象,无需
require即可使用。 - 它提供了有关当前 Node.js 进程的信息和控制功能。
- 它是连接你的代码与操作系统的桥梁,允许你的程序根据运行环境动态调整行为。
- 这是 Node.js 提供的一个强大的全局对象,无需
-
process.argv(命令行参数)-
argv(Argument Vector) 是一个数组,包含了启动 Node.js 进程时传递的所有命令行参数。 -
这是为 CLI 应用获取用户输入的关键。
-
结构分析:
process.argv[0]: 总是 Node.js 可执行文件的路径。process.argv[1]: 总是当前执行的脚本文件的路径。process.argv[2]及以后: 用户传入的实际参数。
-
示例: 运行命令
node index.js hello worldconsole.log(process.argv); -
输出:
[ '/usr/local/bin/node', // [0] Node 可执行文件 '/path/to/project/index.js', // [1] 脚本文件 'hello', // [2] 第一个参数 'world' // [3] 第二个参数 ]
-
-
process.env(环境变量)- 这是一个包含了所有用户环境变量的对象。
- 这是在应用程序中访问敏感信息(如 API 密钥、数据库密码)和配置项的标准方式。
- 为什么重要: 它允许你将配置和代码分离。绝不能将密钥等敏感信息硬编码在代码中,而应通过环境变量注入。
- 常见约定:
NODE_ENV- 这是一个广泛使用的环境变量,用于标识应用的运行模式,常见的值有:
development(开发环境)production(生产环境)test(测试环境)
- 代码可以根据
NODE_ENV的值来改变行为。 - 示例:
- 在
development模式下开启详细的日志记录。 - 在
production模式下关闭调试功能,并启用性能优化。 - 像 React 这样的框架会利用它来决定是否显示警告信息或优化渲染。
- 在
- 这是一个广泛使用的环境变量,用于标识应用的运行模式,常见的值有:
-
问与答
- 问: 分享
.env(环境变量文件) 的最佳实践是什么? - 答: 这是一个复杂的安全问题,没有单一的完美答案,但关键原则是绝不将
.env文件提交到 Git 等版本控制系统。常见的做法包括:- 使用密钥管理服务:如 HashiCorp Vault, AWS Secrets Manager 等,通过 API 安全地获取密钥。
- 使用加密的密码管理器:如 1Password, LastPass,在团队成员之间安全地共享凭据。
- 本地开发:每个开发者在自己的机器上维护一份本地的
.env文件,里面是各自的开发环境配置。
- 问: 分享
6-custom-cli-setup
-
什么是 CLI?
- CLI (Command Line Interface): 在终端中运行的应用程序。
- 它可以由任何能在操作系统上运行的语言编写(如 Go, Rust, Python, Node.js)。
- 用户在使用 CLI 时,无需关心它是用什么语言构建的。
-
步骤 1: 初始化 Node.js 项目
-
要创建一个正式的 Node.js 项目,你需要一个
package.json文件来管理项目元数据和依赖。 -
在你的项目根目录下运行以下命令:
npm init -
你可以一路按回车接受默认值,或使用
npm init -y快速生成。 -
这会创建一个
package.json文件。
-
-
步骤 2: 在
package.json中定义 CLI 命令- 为了让 Node.js 知道你的项目提供了一个可执行的命令,需要在
package.json中添加一个bin字段。 bin是一个对象,其中:- 键 (key) 是你希望用户在终端中输入的命令名称 (例如
note)。 - 值 (value) 是当该命令被调用时,应该执行的脚本文件路径。
- 键 (key) 是你希望用户在终端中输入的命令名称 (例如
- 示例:
// package.json { "name": "my-notes-cli", "version": "1.0.0", "description": "A simple note-taking CLI", "main": "index.js", "bin": { "note": "./index.js" } // ... 其他字段 }
- 为了让 Node.js 知道你的项目提供了一个可执行的命令,需要在
-
步骤 3: 本地链接 CLI 命令
-
在开发阶段,为了能方便地测试你的 CLI 命令,而不需要每次修改后都重新发布和安装,你可以使用
npm link。 -
这个命令会在你的系统中创建一个符号链接 (symlink),将你在
bin字段中定义的命令(如note)链接到你当前的项目目录。 -
在项目根目录下运行:
npm link -
现在,你在任何地方运行
note命令,都会执行你项目中的index.js文件。你对代码的任何修改都会立即生效。
-
-
步骤 4: 添加 Hashbang (Shebang)
-
当你运行
note命令时,你的操作系统需要知道用哪个解释器 (interpreter) 来运行index.js文件(是 Bash, Python, 还是 Node?)。 -
你必须在你的可执行脚本文件 (
index.js) 的第一行添加一个特殊的注释,称为 Hashbang。 -
示例:
#!/usr/bin/env node console.log("My CLI is working!"); -
#!是 Hashbang 的标志。 -
/usr/bin/env node是一个标准的、可移植性强的写法,它告诉操作系统在当前用户的环境变量路径中查找node可执行程序,并用它来运行此脚本。 -
添加此行后,你的
note命令就应该可以正确执行了。
-
7-processing-cli-arguments
-
目标: 处理来自命令行的用户输入
- 我们希望能够从命令行捕获用户输入的文本,并用它来创建一条新笔记。
- 理想的命令格式:
note "这是一条新的笔记"
-
获取命令行参数
- 我们可以使用
process.argv来访问传递给 CLI 的参数。 - 记住,用户输入的参数是从数组的索引
2开始的。 - 为何需要引号?: 如果不使用引号将
这是一条新的笔记包裹起来,shell 会将每个词(被空格分开)视为一个独立的参数。引号确保整个句子被当作一个单独的字符串参数。
- 我们可以使用
-
创建简单的数据模型
-
我们将创建一个 JavaScript 对象来表示一条笔记。
-
这个对象将包含从命令行获取的内容以及一个唯一的 ID。
#!/usr/bin/env node // 从命令行参数的第 3 项 (索引为 2) 获取笔记内容 const noteContent = process.argv[2]; // 创建一个新的笔记对象 const newNote = { content: noteContent, id: Date.now(), // 使用当前时间戳作为简单的唯一 ID }; // 打印新创建的笔记对象,以验证它是否工作正常 console.log(newNote);
-
-
运行 CLI 并查看结果
-
命令:
note "我的第一条笔记" -
预期输出 (ID 会不同):
{ "content": "我的第一条笔记", "id": 1678886400000 }
-
-
当前方法的局限性
- 1. 缺乏持久化 (Persistence):
- 笔记只在程序运行时存在于内存中。程序执行完毕后,这条笔记就消失了。
- 我们需要一种方法来保存笔记,以便将来可以再次读取它们。
- 2. 功能单一:
- 目前的实现只能创建笔记。
- 无法实现列出所有笔记、搜索、删除或编辑等更复杂的功能。
- 3. 参数解析简陋:
- 手动解析
process.argv数组非常繁琐且容易出错。 - 当需要处理更复杂的命令(如带有标志
-tag或选项t)时,这种方法会变得难以管理。
- 手动解析
- 1. 缺乏持久化 (Persistence):
-
后续步骤
- 我们将引入 Node.js 的内置模块和第三方库来解决这些问题:
- 实现数据的持久化(保存到文件)。
- 使用专门的库来简化命令行参数的解析。
- 我们将引入 Node.js 的内置模块和第三方库来解决这些问题:
8-modules-overview
-
什么是模块 (Module)?
- 核心目的:代码隔离与封装。
- 将代码封装在自己的作用域内,避免污染全局作用域,使其像可复用的“乐高积木”。
- 历史上的做法是使用 IIFE (立即调用函数表达式) 来模拟模块,以保护代码不被其他脚本干扰。
-
Node.js 中的模块类型
- 内置模块 (Internal Modules): Node.js 核心自带的模块,如
http(网络)、fs(文件系统)。 - 用户创建的模块 (User-created Modules): 我们自己在项目中创建的文件,用于组织和拆分代码。这些模块也可以发布到社区供他人使用。
- 第三方模块 (Third-party Modules): 由其他开发者创建并发布到 npm 等平台的模块,我们可以下载并在项目中使用。
- 内置模块 (Internal Modules): Node.js 核心自带的模块,如
-
模块系统:CommonJS vs. ES Modules
- CommonJS (CJS): Node.js 最初的、传统的模块系统。
- 使用
require()导入模块。 - 使用
module.exports导出模块。 - 示例:
const fs = require('fs');
- 使用
- ES Modules (ESM): ECMAScript 官方标准化的模块系统,也是现代前端开发的主流。
- 使用
import导入模块。 - 使用
export导出模块。 - 这是本课程将使用的系统。
- 使用
- CommonJS (CJS): Node.js 最初的、传统的模块系统。
-
如何在 Node.js 中启用 ES Modules
-
在项目的
package.json文件中,添加一个顶级字段:"type": "module" -
这个设置会告诉 Node.js 将项目中的
.js文件默认当作 ES 模块来处理。
-
-
创建和使用模块 (ESM 语法)
- 导出 (Export):
- 命名导出 (Named Export): 导出时带有特定名称。
// utils.js export function add(a, b) { return a + b; } - 默认导出 (Default Export): 每个文件只能有一个默认导出。
// utils.js export default { // ... some object or value };
- 命名导出 (Named Export): 导出时带有特定名称。
- 导入 (Import):
- 导入命名导出: 必须使用
{}并且名称要完全匹配。import { add } from "./utils.js"; - 导入默认导出: 可以使用任意名称,且无需
{}。import myUtils from "./utils.js";
- 导入命名导出: 必须使用
- 重要提示:文件扩展名
- 在 Node.js 中使用 ES 模块时,导入本地文件必须包含文件扩展名 (
.js)。 - 示例:
import { count } from './utils.js';(正确) vsimport { count } from './utils';(错误)。 - 这与许多前端构建工具(如 React)的行为不同,但在 Node.js 中是强制要求,因为它需要明确知道要加载的文件类型。
- 在 Node.js 中使用 ES 模块时,导入本地文件必须包含文件扩展名 (
- 导出 (Export):
9-importing-exporting-modules
-
导入模块的三种方式
-
导入自定义模块 (本地文件): 路径必须是相对或绝对路径,通常以
./或../开头。import { count } from "./utils.js"; -
导入核心模块 (Node.js 内置): 直接使用模块名,Node.js 会知道这是内置模块。
import fs from "fs";- 新式语法 (推荐): 使用
node:前缀可以明确表示这是一个核心模块,避免与第三方包同名冲突。import fs from "node:fs";
- 新式语法 (推荐): 使用
-
导入第三方模块 (来自
node_modules): 直接使用包名,Node.js 会在node_modules文件夹中查找。import lodash from "lodash";
-
-
requirevs.import(CommonJS vs. ESM) 语法对比-
了解 CommonJS 仍然很重要,因为大量现有的 Node.js 项目还在使用它。
-
导入 (Importing)
- ESM:
import fs from "fs"; import { count } from "./utils.js"; - CommonJS:
const fs = require("fs"); const { count } = require("./utils.js");
- ESM:
-
导出 (Exporting)
-
ESM:
// 命名导出 export function count() { /* ... */ } // 默认导出 export default { count }; -
CommonJS:
// 类似于命名导出 exports.count = function () { /* ... */ }; // 类似于默认导出 (更常见) module.exports = { count };
-
-
-
历史与演进
- CommonJS 是在 JavaScript 语言本身没有标准化模块系统时,由 Node.js 社区创建的解决方案。
- ES Modules (ESM) 是后来 TC39 委员会为 JavaScript 语言制定的官方标准。
- Node.js 正在逐步转向并原生支持 ESM,使其成为未来的标准实践。
-
模块依赖图 (Dependency Graph)
- 你的应用程序是由一系列
import和export语句连接起来的模块组成的。这形成了一个依赖树或图。 - Node.js 会自动处理这个图,包括解析复杂的循环依赖关系(例如,文件 A 导入 B,B 导入 C,C 又导入 A)。
- 你的应用程序是由一系列
10-thinking-in-modules
-
模块化思考的核心原则
- 保持文件小而专注: 每个模块应该只做好一件事。将相关的逻辑功能组织在一起。
- 不要吝啬创建模块: 在 Node.js 中,创建新文件(模块)的成本几乎为零。这不像在浏览器中,更多的文件意味着更多的网络请求。
- 模块化的好处:
- 易于测试: 小而独立的模块更容易进行单元测试。
- 减少合并冲突: 在团队协作中,如果不同的人在不同的文件(模块)中工作,代码合并冲突的概率会大大降低。
- 代码复用和可维护性: 组织良好的模块更容易被复用和理解。
-
组织模块的两种常见模式
- 按功能组织 (Group by feature): 将所有与特定功能相关的代码放在一个模块中。例如,所有与用户处理相关的函数都放在
user.js中。 - 按类型组织 (Group by type): 将所有相似类型的函数放在一起。例如,将所有通用的辅助函数,即使它们彼此无关,也放在一个
utils.js文件中。
- 按功能组织 (Group by feature): 将所有与特定功能相关的代码放在一个模块中。例如,所有与用户处理相关的函数都放在
-
index.js模式-
这是一种非常强大的组织模式,用于从一个目录中统一导出多个模块。
-
工作方式:
-
创建一个文件夹,例如
components。 -
在该文件夹内,创建多个独立的模块文件,如
button.js,input.js。 -
在
components文件夹的根目录下,创建一个名为index.js的文件。 -
这个
index.js文件作为该目录的“公共出口”,它导入并重新导出其他所有模块。// components/index.js export * from "./button.js"; export * from "./input.js"; -
现在,从其他地方导入时,你可以直接从文件夹导入,Node.js 会自动查找
index.js。// app.js import { Button, Input } from "./components";
-
-
优点: 使导入路径更简洁,并将一个目录的内部结构细节隐藏起来,只暴露其公共 API。
-
-
最佳实践:
importvs.require- 强烈推荐使用
import(ESM)。 - 原因:
- 与前端保持一致: 现代前端开发生态(React, Vue 等)完全基于 ES Modules。在后端也使用
import可以为全栈开发提供统一、流畅的体验。 - 面向未来: ES Modules 是 JavaScript 的官方标准。未来 Node.js 很可能会将其作为默认模块系统,不再需要
"type": "module"的配置。
- 与前端保持一致: 现代前端开发生态(React, Vue 等)完全基于 ES Modules。在后端也使用
- 强烈推荐使用
11-internal-3rd-party-modules
-
有用的内置模块 (Internal Modules)
fs(File System): 用于与计算机的文件系统进行交互。可以读/写文件、创建/删除目录等。非常强大,是构建工具(如create-react-app)生成项目文件的基础。http: 用于创建 HTTP 服务器和处理网络请求。它相对底层,通常开发者会使用基于它构建的框架(如 Express.js)。path: 用于处理和转换文件路径,在不同操作系统之间提供了一致性。
-
npm(Node Package Manager) 和第三方模块-
npm是 Node.js 的默认包管理器,用于安装和管理外部依赖。 -
安装一个包:
npm install <package-name> # 简写 npm i <package-name> -
安装后会发生什么:
node_modules文件夹被创建: 这个文件夹包含了你安装的包以及其所有依赖项的代码。永远不要将node_modules提交到版本控制系统(如 Git)。package.json文件被更新:dependencies字段会添加新安装的包及其版本范围。package-lock.json文件被创建/更新:- 这是一个至关重要的文件,它记录了
node_modules目录中每个包的精确版本。 - 作用: 确保团队中的每个成员以及部署服务器安装的都是完全相同的依赖版本,从而避免“在我电脑上能跑”的问题。
- 这是一个至关重要的文件,它记录了
-
-
团队协作与部署流程
- 开发者将
package.json和package-lock.json提交到 Git。 - 其他人拉取代码后,在本地终端运行
npm install。 npm会读取package-lock.json文件,并精确地下载所有指定的依赖项到node_modules文件夹中。
- 开发者将
-
NPM 包的安全性
- 的确存在风险,可能会有恶意或损坏的包。
- 一些检查方法:
- 查看包的 GitHub 仓库:检查更新频率、issue 数量和社区活跃度。
- 检查每周下载量。
- 大公司通常会有内部的包白名单。
- 实际上,遇到年久失修或已损坏的包的概率远大于遇到恶意包的概率。
-
卸载一个包
npm uninstall <package-name>- 这个命令会从
node_modules、package.json和package-lock.json中移除该包。
- 这个命令会从
12-using-the-yargs-module
-
问题: 手动解析 CLI 参数太麻烦
- 直接处理
process.argv数组来构建一个功能丰富的命令行界面(CLI)是非常繁琐且容易出错的。
- 直接处理
-
解决方案: 使用第三方库
- 我们将使用一个流行的库
yargs来帮助我们构建交互式命令行工具。它能轻松地解析参数并自动生成优雅的用户界面(如帮助菜单)。
- 我们将使用一个流行的库
-
安装
yargsnpm install yargs -
重构项目结构
-
为了保持代码整洁,我们进行以下调整:
-
入口文件 (
index.js) 保持最小化: 它的唯一职责是导入并执行应用的主逻辑。 -
创建
src目录: 用来存放所有核心应用代码。 -
移动 CLI 逻辑: 将所有
yargs相关的代码移动到一个新文件,例如src/commands.js。 -
更新
index.js:#!/usr/bin/env node import "./src/commands.js";
- 这样,
index.js就成为了一个清晰的程序入口,而所有复杂的逻辑都被封装在src目录中。
-
-
-
yargs的基本用法-
hideBin(process.argv): 这是一个辅助函数,用于从process.argv数组中移除前两个元素(node 执行路径和脚本路径),只留下用户真正输入的参数。 -
.command(): 用于定义一个 CLI 命令。例如npm的install就是一个命令。 -
.demandCommand(1): 要求用户至少输入一个命令,否则yargs会报错并显示帮助信息。 -
.parse(): 启动yargs的解析过程。 -
示例代码 (
src/commands.js):import yargs from "yargs"; import { hideBin } from "yargs/helpers"; yargs(hideBin(process.argv)) .command( "curl <url>", "抓取一个 URL 的内容", () => {}, (argv) => { console.log(argv); // argv 是一个解析后的对象,而不是原始数组 } ) .demandCommand(1) .parse();
-
-
yargs带来的好处- 参数对象化:
yargs将命令行参数解析成一个方便使用的对象,而不是让你手动处理数组。 - 自动生成帮助菜单:
yargs能根据你的命令定义,自动生成一个标准的帮助菜单。用户可以通过note --help来查看所有可用的命令和选项。这是 CLI 工具的一个通用标准,而我们无需编写任何代码即可获得。
- 参数对象化:
13-notes-app-commands
-
目标: 使用
yargs搭建笔记应用的命令结构- 我们将定义应用所需的所有命令,但暂时只搭建框架,不实现具体逻辑。
-
yargs的.command()方法详解- 该方法用于定义一个新命令,其参数结构如下:
.command(command, description, [builder], [handler])
command(字符串): 定义命令的格式。new <note>:new是命令名,<note>是必需的位置参数。find [query]:[query]是可选的位置参数。
description(字符串): 命令的描述,会显示在帮助菜单中。builder(对象或函数): 用于配置该命令的特定选项(flags)。yargs.positional(): 详细配置位置参数(如类型、描述)。yargs.option(): 定义命名选项,如-tags。
handler(函数): 当用户执行该命令时,此函数会被调用。它接收一个包含所有解析后参数的argv对象。
- 该方法用于定义一个新命令,其参数结构如下:
-
示例:构建
new命令.command('new <note>', '创建一个新笔记', (yargs) => { // Builder 函数:配置 'new' 命令的参数和选项 return yargs .positional('note', { type: 'string', description: '要创建的笔记内容' }) .option('tags', { alias: 't', // 别名,可以使用 -t type: 'string', description: '为笔记添加标签' }); }, (argv) => { // Handler 函数:处理 'new' 命令的逻辑 // 之后我们会在这里实现保存笔记的逻辑 console.log('笔记内容:', argv.note); console.log('标签:', argv.tags); }) -
位置参数 (Positional Arguments) vs. 选项 (Options/Flags)
- 位置参数: 命令后面直接跟的值,如
note new "我的笔记"中的"我的笔记"。 - 选项: 以
-或 开头的键值对,如note new "..." --tags="work,urgent"。它们可以有别名 (alias)。
- 位置参数: 命令后面直接跟的值,如
-
笔记应用所需的所有命令
new <note>: 创建一条新笔记。all: 列出所有笔记。find <filter>: 根据关键词查找笔记。remove <id>: 根据 ID 删除一条笔记。web [port]: 启动一个 Web 服务器来查看笔记,端口是可选的。clean: 清空所有笔记。
-
验证设置
- 在定义完所有命令后,可以在终端运行
note --help。 yargs会自动生成一个格式精美的帮助文档,清晰地列出所有可用的命令、它们的参数、选项和描述。
- 在定义完所有命令后,可以在终端运行
14-async-code
- 数据持久化策略
- 在真实的应用中,数据通常存储在数据库中。
- 在本课程中,为了简化,我们将使用一个文件 (
db.json) 作为我们的“数据库”来持久化存储笔记数据。
- 理解异步 (Asynchronous) JavaScript
- 在 Node.js 中,异步编程比在客户端 JavaScript 中更常见、更重要。
- 核心概念: 异步代码的执行顺序不一定与它被编写的顺序一致。Node.js 通过调度任务在稍后执行来实现并行处理(并发),而不是真正地在同一时刻执行多个任务(并行,这需要多线程/多进程)。
- 主要触发异步的场景 (99%的情况):
- 网络操作: 发起 HTTP 请求、与外部 API 通信。
- 文件系统/存储操作: 读取/写入文件、与数据库交互。
- 定时器: 使用
setTimeout,setInterval等。
- 处理异步的三种方式 (演进过程)
- 回调函数 (Callbacks)
- 最早的处理方式。将一个函数作为参数传递给另一个函数,当异步操作完成时,这个回调函数会被执行。
- 问题: 当多个异步操作相互依赖时,容易产生“回调地狱 (Callback Hell)”——代码层层嵌套,形成金字塔形状,难以阅读和维护。
- 注意: 并非所有使用回调的函数都是异步的。例如,数组的
map,forEach方法接受回调,但它们是同步的,因为不涉及网络、文件或定时器。
- Promises (承诺)
- 为了解决回调地狱而引入的模式。一个 Promise 对象代表一个尚未完成但最终会完成的异步操作。
- 使用
.then()方法来处理成功的结果,.catch()处理错误。 - 优点: 允许链式调用(
.then().then()...),将嵌套的代码结构拉平,使得代码在视觉上更清晰,始终保持一层嵌套。 - 你可以手动将一个基于回调的函数“Promise 化”(promisify)。
async/await- 这是建立在 Promises 之上的“语法糖”,是目前处理异步操作的最佳实践和首选方法。
- 工作方式:
async: 标记一个函数是异步的,这个函数会自动返回一个 Promise。await: 只能在async函数内部使用,它会“暂停”函数的执行,等待一个 Promise 完成,然后返回その结果。
- 优点: 它让异步代码看起来和写起来都像同步代码,从上到下顺序执行,非常直观,易于理解和调试。
- Top-Level Await: 在最新版本的 Node.js 中,你可以在文件的顶层直接使用
await,而无需将其包裹在async函数中,这极大地简化了脚本编写。
- 回调函数 (Callbacks)
15-fs-module
-
fs模块简介fs是 Node.js 的内置核心模块,代表 File System (文件系统)。- 它提供了一套 API,让你的程序能够以编程方式与计算机的文件系统进行交互,执行如读、写、创建、删除文件或目录等操作。
-
常用
fs方法fs.mkdir(): 创建目录 (文件夹)。fs.readdir(): 读取目录内容。fs.stat(): 获取文件或目录的元信息 (如大小、创建时间)。fs.unlink(): 删除文件。fs.rename(): 重命名文件。fs.readFile(): 读取文件内容。fs.writeFile(): 写入文件内容。
-
fs模块的 Promise 版本fs模块的原始 API 是基于回调的,为了更方便地使用async/await,Node.js 提供了一个 Promise 化的版本。- 导入方式:
import fs from "node:fs/promises"; - 这样导入后,所有
fs的方法(如readFile,writeFile)都会返回 Promise,可以直接配合await使用。
-
处理文件路径
- 在使用 ES Modules (
"type": "module") 时,传统的全局变量__dirname和__filename是不可用的。 - 为了构造一个指向项目文件的绝对路径,你需要使用
import.meta.url和URL对象。// 构造指向 'package.json' 的路径 const filePath = new URL("../package.json", import.meta.url);
- 在使用 ES Modules (
-
代码示例: 读写文件
import fs from "node:fs/promises"; import { URL } from "node:url"; // 确保导入 URL 类 async function operateOnFiles() { // --- 读取文件 --- const packageJsonPath = new URL("../package.json", import.meta.url); // 'utf-8' 编码告诉 readFile 将二进制数据解码为人类可读的文本 const content = await fs.readFile(packageJsonPath, "utf8"); const packageJson = JSON.parse(content); console.log("读取到的包名:", packageJson.name); // --- 写入文件 --- const newFilePath = new URL("../demo.js", import.meta.url); const scriptContent = "console.log('Hello from the new file!');"; await fs.writeFile(newFilePath, scriptContent); console.log("文件写入成功!"); } operateOnFiles(); -
调试技巧
- 对于复杂的异步流程或回调地狱,简单的
console.log是一个非常有效的调试工具。 - 调试流程:
- 从最内层的函数调用开始。
- 在每个异步操作前后添加日志,打印出变量的值。
- 将实际输出与你的预期进行比较。通常问题在于你告诉计算机做的事情和你以为你告诉它做的事情之间有偏差。
- 在编写不熟悉的代码时,养成“写一点,测一点”的习惯,确保每一步都符合预期,避免问题累积。
- 对于复杂的异步流程或回调地狱,简单的
16-using-a-file-as-a-db
-
目标: 创建一个文件作为我们的数据库
- 我们将创建一个
db.json文件来持久化存储我们的笔记数据。 - 同时,我们会创建一个
db.js模块,封装所有与这个文件交互的底层逻辑,为上层应用提供一个简洁的 API。
- 我们将创建一个
-
步骤 1: 创建
db.json文件- 在项目的根目录下创建一个名为
db.json的文件。 - 初始化内容如下,定义一个
notes数组来存放所有笔记:{ "notes": [] }
- 在项目的根目录下创建一个名为
-
步骤 2: 创建数据库交互模块 (
src/db.js)-
这个模块将作为我们自制的“ORM”(对象关系映射)或“SDK”,负责所有底层的读写操作。
-
导入依赖并设置路径:
import fs from "node:fs/promises"; import { URL } from "node:url"; // 构造到 db.json 的绝对路径 const DB_PATH = new URL("../db.json", import.meta.url); -
创建核心工具函数:
-
getDB(): 读取并解析整个数据库文件。export const getDB = async () => { const db = await fs.readFile(DB_PATH, "utf-8"); return JSON.parse(db); }; -
saveDB(db): 将一个 JavaScript 对象转换成 JSON 字符串,并覆盖写入到数据库文件。export const saveDB = async (db) => { await fs.writeFile(DB_PATH, JSON.stringify(db, null, 2)); return db; };JSON.stringify的后两个参数(null, 2)用于格式化输出的 JSON,使其带有缩进,更易于阅读。
-
insertDB(note): 插入一条新笔记。- 这需要先读取整个数据库,修改
notes数组,然后再保存回去。 - 这是一个“读-改-写”的原子操作。
export const insertDB = async (note) => { const db = await getDB(); db.notes.push(note); await saveDB(db); return note; }; - 这需要先读取整个数据库,修改
-
-
-
抽象的意义
- 我们将文件系统的底层操作 (
fs模块) 封装在了db.js中。 - 上层应用(如我们的命令处理逻辑)不应直接调用
fs.readFile或fs.writeFile,而应只使用getDB,saveDB,insertDB这些更高级、更具业务含义的函数。 - 这种分层设计使得代码更清晰、更易于维护,并且未来如果想把数据存储从文件切换到真正的数据库(如 MySQL),我们只需要修改
db.js这个模块,而上层应用代码无需改动。
- 我们将文件系统的底层操作 (
17-crud-methods-create
-
为什么需要另一层抽象?
db.js模块提供了与整个数据库文件交互的通用方法(如getDB,saveDB)。- 但我们的应用逻辑是围绕**笔记(Notes)**进行的。
- 为了保持
db.js的通用性(将来可能添加users等其他数据),我们将创建一个新的notes.js模块,专门封装所有与笔记相关的 CRUD 操作。
-
什么是 CRUD?
- Create (创建)
- Read (读取)
- Update (更新)
- Delete (删除)
- 这四个操作构成了绝大多数应用程序的核心功能。
-
步骤 1: 创建
src/notes.js模块- 这个模块将是我们业务逻辑的核心,它会使用
db.js提供的底层方法来实现具体功能。 - 导入依赖:
import { getDB, saveDB, insertDB } from "./db.js";
- 这个模块将是我们业务逻辑的核心,它会使用
-
步骤 2: 实现创建 (Create) 和读取 (Read) 方法
-
newNote(noteContent, tags): 创建一条新笔记。- 它接收笔记内容和标签。
- 构造一个包含
id,content,tags的新笔记对象。 - 调用
insertDB将这个新笔记对象插入数据库。
export const newNote = async (noteContent, tags) => { const newNote = { content: noteContent, id: Date.now(), // 使用时间戳作为唯一 ID tags: tags || [], // 如果没有提供标签,则默认为空数组 }; await insertDB(newNote); return newNote; }; -
getAllNotes(): 获取所有笔记。- 调用
getDB获取整个数据库对象。 - 使用对象解构只返回
notes数组。
export const getAllNotes = async () => { const { notes } = await getDB(); return notes; }; - 调用
-
-
对象解构 (Destructuring) 快速回顾
- 这是一种从对象或数组中提取值的便捷语法。
- 对象解构:
const data = { shooting: 99, dribbling: 50 }; const { shooting, dribbling } = data; // 创建了两个新变量: shooting 和 dribbling // 等同于: const shooting = data.shooting; const dribbling = data.dribbling; - 数组解构:
const nums = [10, 20, 30]; const [first, second] = nums; // first 为 10, second 为 20 - 你也可以在函数参数中直接使用解构,这非常常用。
function printPlayer({ shooting }) { console.log(shooting); } printPlayer({ shooting: 99, dribbling: 50 }); // 输出 99
18-crud-methods-read-delete
-
目标: 在
notes.js中继续实现读取和删除的逻辑 -
findNotes(filter): 查找笔记 (Read)- 这个方法实现一个简单的全文搜索功能。
- 逻辑流程:
- 调用
getAllNotes()获取所有的笔记。 - 使用数组的
filter方法遍历所有笔记。 - 对于每条笔记,将其
content和搜索词filter都转换为小写,以实现不区分大小写的匹配。 - 使用字符串的
includes()方法检查笔记内容是否包含搜索词。 - 返回所有匹配的笔记组成的数组。
- 调用
- 代码实现:
export const findNotes = async (filter) => { const { notes } = await getDB(); // 获取所有笔记 return notes.filter((note) => note.content.toLowerCase().includes(filter.toLowerCase()) ); };
-
removeNote(id): 删除单条笔记 (Delete)-
逻辑流程:
- 获取所有笔记。
- 使用
find()检查是否存在具有给定id的笔记。 - 如果找到匹配项,则使用
filter()创建一个新的笔记数组,其中不包含要删除的笔记。- 这是一个不可变 (immutable) 的操作,我们不直接修改原始数组,而是创建一个新的。
- 调用
saveDB()将这个新的笔记数组写回数据库。 - 返回被删除的笔记的
id,表示操作成功。 - 如果未找到匹配项,默认返回
undefined。
-
代码实现:
export const removeNote = async (id) => { const { notes } = await getDB(); const match = notes.find((note) => note.id === id); if (match) { const newNotes = notes.filter((note) => note.id !== id); await saveDB({ notes: newNotes }); return id; } }; -
注意: 在比较
id时,使用===(严格相等) 是最佳实践。
-
-
removeAllNotes(): 删除所有笔记 (Delete)- 这个方法最为简单。
- 逻辑流程:
- 调用
saveDB()方法。 - 传入一个
notes属性为空数组的对象,直接覆盖整个数据库。
- 调用
- 代码实现:
export const removeAllNotes = () => { return saveDB({ notes: [] }); }; - 代码技巧:
- 这是一个单行箭头函数,可以省略
{}和return关键字。 - 因为
removeAllNotes函数内部没有在saveDB之后执行其他await操作,所以可以不使用async/await,直接返回saveDB返回的 Promise。
- 这是一个单行箭头函数,可以省略
19-using-the-crud-methods
-
目标: 将
notes.js中创建的 CRUD 方法集成到commands.js中- 现在,我们将把之前定义的命令处理程序(Handlers)与实际的业务逻辑连接起来。
-
更新
new命令- 导入: 从
./notes.js中导入newNote函数。 - 处理标签: 从
argv.tags获取的标签是一个字符串,需要将其分割成数组。 - 调用业务逻辑: 调用
newNote函数,并传入笔记内容和处理后的标签数组。 - 输出结果: 打印新创建的笔记对象。
// in commands.js import { newNote, getAllNotes, findNotes, removeNote, removeAllNotes } from './notes.js'; // ... .command('new <note>', '...', (yargs) => { /* builder... */ }, async (argv) => { const tags = argv.tags ? argv.tags.split(',') : []; const note = await newNote(argv.note, tags); console.log('新笔记已添加!', note); }) - 导入: 从
-
更新
all命令- 创建一个可复用的
listNotes辅助函数来格式化并打印笔记列表。 - 逻辑: 调用
getAllNotes()获取所有笔记,然后传递给listNotes进行显示。
const listNotes = (notes) => { notes.forEach(({ id, content, tags }) => { console.log("ID:", id); console.log("标签:", tags.join(", ")); console.log("内容:", content); console.log("\\n"); // 添加换行符以分隔笔记 }); }; // ... in 'all' command handler const notes = await getAllNotes(); listNotes(notes); - 创建一个可复用的
-
更新
find命令- 逻辑: 调用
findNotes()并传入过滤条件argv.filter,然后将返回的匹配结果用listNotes显示出来。
// ... in 'find' command handler const matches = await findNotes(argv.filter); listNotes(matches); - 逻辑: 调用
-
更新
remove命令- 逻辑: 调用
removeNote()并传入要删除的笔记 IDargv.id。根据返回结果判断是否删除成功。
// ... in 'remove' command handler const id = await removeNote(argv.id); if (id) { console.log("笔记已删除:", id); } else { console.log("未找到该 ID 的笔记。"); } - 逻辑: 调用
-
更新
clean命令- 逻辑: 直接调用
removeAllNotes()清空数据库。
// ... in 'clean' command handler await removeAllNotes(); console.log("数据库已清空!"); - 逻辑: 直接调用
-
总结
- 经过这些更新,我们的 CLI 应用现在功能完备。
- 我们成功地将命令定义 (
yargsincommands.js)、业务逻辑 (notes.js) 和数据持久化 (db.js) 清晰地分离开来。 - 这种分层结构使得代码易于理解、维护和扩展。
20-types-of-tests
- 引言
- 测试是软件开发中至关重要的一环,尽管编写测试可能不那么有趣,但拥有测试却能带来巨大的好处。
- 测试的主要类型
- 单元测试 (Unit Testing)
- 定义: 针对代码中最小的可测试单元(通常是一个函数或一个模块)进行的测试。
- 目的: 验证该单元在隔离的环境下,对于给定的输入,是否能产生预期的输出。它不关心整个应用的流程。
- 集成测试 (Integration Testing)
- 定义: 测试多个单元组合在一起时是否能协同工作。
- 目的: 检查不同模块或服务之间的交互是否正确。例如,测试一个完整的注册流程,这个流程可能涉及调用用户服务、数据库服务和邮件服务。
- 端到端测试 (End-to-End Testing / E2E)
- 定义: 模拟真实用户的使用场景,从用户的角度测试整个应用程序的完整流程。
- 目的: 验证从用户界面(UI)的交互开始,到后端服务器处理,再到数据库操作,最后返回响应的整个链路是否通畅。
- 特点: 通常需要一个浏览器环境来模拟用户的点击、滚动等操作。
- 无头浏览器 (Headless Browser): 是一种没有图形用户界面(GUI)的浏览器。它可以在后台运行,执行浏览器代码,非常适合在服务器或终端中进行自动化测试,因为它更快、更节省资源。
- API 测试 (API Testing)
- 定义: 专注于测试应用程序的 API 接口。
- 目的: 验证 API 是否能正确响应,不仅检查返回的数据(逻辑),还检查 HTTP 状态码、响应头等 API 协议层面的内容是否符合预期。
- 单元测试 (Unit Testing)
- 其他测试类型
- 还包括回归测试 (Regression Testing)、快照测试 (Snapshot Testing) 等多种类型。
- 一个项目通常不会实现所有类型的测试,这取决于项目规模和团队的测试文化。
- 推荐资源
- Frontend Masters 提供了更多关于测试的深入课程,例如 Kent C. Dodds 的 JavaScript 测试课程和 Steve Kinney 的 Cypress 测试课程。
21-unit-testing-with-jest
-
测试框架: Jest
- Jest 是一个由 Facebook 创建的流行 JavaScript 测试框架。
- 它借鉴了早期框架(如 Mocha, Jasmine)的优点,并集成了断言库、mocking 工具等,提供了一站式的测试体验。
- Jest 不仅可以用于后端测试,也广泛用于前端测试。
-
设置测试环境
-
创建
tests目录: 在项目根目录创建一个名为tests的文件夹。 -
创建测试文件: 测试文件通常遵循命名约定,如
notes.test.js。Jest 会自动查找并运行文件名中包含.test.或.spec.的文件。 -
安装 Jest: 将 Jest 作为开发依赖项安装。
npm install jest --save-devdevDependenciesvs.dependencies:devDependencies: 只在开发过程中需要的工具(如测试、打包、代码检查),不会被打包到最终的生产环境中。dependencies: 应用程序在生产环境中运行时必须的库。
-
配置
package.json: 在scripts对象中,修改test命令来运行 Jest。"scripts": { "test": "jest" }
-
-
测试用例的基本结构
test(description, callback): 定义一个测试用例。description: 一个描述测试目的的字符串。callback: 包含测试逻辑的函数。
expect(value): Jest 的断言函数,包裹你想要验证的值。- 匹配器 (Matchers): 链接在
expect后面的方法,用于进行具体的比较,如.toBe(),.toEqual()。
// 一个简单的加法函数 const add = (a, b) => a + b; test("add 函数应该能正确计算两个数的和", () => { // 1. 调用被测试的函数 const result = add(1, 2); // 2. 使用 expect 和匹配器进行断言 expect(result).toBe(3); }); -
运行测试
- 在终端中运行
npm test命令。 - Jest 会执行所有找到的测试文件,并报告通过或失败的结果。
- 在终端中运行
-
测试驱动开发 (TDD)
- 一种开发流程,又称“红-绿-重构 (Red-Green-Refactor)”。
- 红 (Red): 首先编写一个描述预期功能的、会失败的测试。
- 绿 (Green): 编写最简单的代码来让测试通过。
- 重构 (Refactor): 在测试保持通过的前提下,优化和重构代码。
- 虽然在实际工作中不总是严格遵循,但它是一种非常有价值的实践,尤其是在大型团队中。
-
代码覆盖率 (Code Coverage)
- 这是一个衡量你的测试覆盖了多少代码的指标。
- 许多团队会设定一个最低的代码覆盖率阈值(如 80%),如果达不到,CI/CD 流程就会失败。
22-testing-with-mocks
-
核心概念: Mocking (模拟)
- 定义: 在测试中,用一个“假的”或“存根 (stub)”实现来替换掉真实的依赖项(如数据库模块、API 调用等)。
- 目的: 隔离被测试单元。当我们测试
notes.js中的逻辑时,我们不希望它真的去读写文件系统。我们假设db.js模块是正常工作的,我们只想验证notes.js是否正确地调用了它。 - Spy (间谍): 一个被 mock 的函数通常也是一个“Spy”。它能记录自身被调用的情况,比如被调用了多少次、接收了什么参数等。这让我们可以进行类似
expect(mockedFunction).toHaveBeenCalledWith('some-argument')这样的断言。
-
在 Jest 中使用 Mock
beforeEach(callback): 这是一个生命周期钩子函数,它会在当前文件中的每一个测试用例运行之前执行。- 作用: 用于重置状态,确保每个测试都是独立的。一个常见的用途是在每个测试前调用
jest.clearAllMocks()来清除所有 mock 函数的调用记录,防止测试之间相互干扰。
-
ES Modules 环境下的 Mocking (关键且复杂)
- 由于 ES Modules 是静态的(在编译时确定依赖),而 mocking 是动态的(在运行时替换),Jest 对 ESM 的 mock 支持比较新,语法也更复杂。
-
jest.unstable_mockModule(modulePath, factory): 这是在 ESM 环境下 mock 模块的方法。 -
动态导入
await import(...): 你必须在调用jest.unstable_mockModule之后,再使用动态import()来导入你想要测试的模块。这是因为 mock 必须在模块被首次加载前完成设置。 -
配置
package.json: 为了让 Jest 能正确处理 ESM,需要更新test脚本,添加 Node.js 的实验性标志。"scripts": { "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js" }
-
测试
newNote函数的示例import { jest } from "@jest/globals"; // 导入 Jest 全局变量 // 1. 在所有测试之前 mock db.js 模块 jest.unstable_mockModule("../src/db.js", () => ({ insertDB: jest.fn(), // 将 insertDB mock 成一个 jest 函数 getDB: jest.fn(), })); // 2. 动态导入被 mock 的模块和要测试的模块 const { insertDB } = await import("../src/db.js"); const { newNote } = await import("../src/notes.js"); // 3. 在每个测试前清除 mock 记录 beforeEach(() => { insertDB.mockClear(); }); test("newNote 应该插入数据并返回它", async () => { const noteContent = "测试笔记"; const noteTags = ["test"]; // 调用 newNote const result = await newNote(noteContent, noteTags); // 断言:内容和标签应该匹配 expect(result.content).toBe(noteContent); expect(result.tags).toEqual(noteTags); // 使用 toEqual 比较数组 }); -
对象和数组的比较
.toBe(): 使用===进行严格相等比较,不适用于对象或数组,因为它们是引用类型。.toEqual(): 进行“深层”比较,递归地检查对象的所有属性或数组的所有元素是否相等。在比较对象或数组时,应使用.toEqual()。
23-additional-test-examples
-
测试
getAllNotes函数- 策略:
- Mock
getDB函数,使其在被调用时返回一个预设的、包含笔记的数据库对象。 - 调用
getAllNotes。 - 断言
getAllNotes返回的笔记数组与我们预设的笔记数组内容相同 (.toEqual())。
- Mock
- 策略:
-
测试
removeNote函数 (边缘情况)- 策略:
- Mock
getDB返回一个已知的数据库状态。 - 调用
removeNote并传入一个不存在的 ID。 - 断言
removeNote的返回值是undefined,因为没有找到匹配的笔记可以删除。
- Mock
- 策略:
-
使用
describe组织测试describe(name, fn): Jest 提供的一个全局函数,用于将相关的测试用例分组。- 好处:
- 在测试报告中,测试会以分组的形式展现,结构更清晰。
- 方便对一组测试应用共同的设置,例如在
describe块内部使用beforeEach。
describe("CLI App - Note Functions", () => { // 可以在这里放一个 beforeEach,它只作用于这个 describe 块 test("newNote 应该能创建笔记", () => { // ... }); test("getAllNotes 应该能获取所有笔记", () => { // ... }); }); -
it是test的别名- 在 Jest 中,你可以使用
it来代替test,它们的功能完全相同。 it('should do something...', () => { ... });- 这是一种风格选择,源自于行为驱动开发 (BDD) 的理念,旨在让测试描述读起来更像一句通顺的英文句子。
- 在 Jest 中,你可以使用
24-creating-a-basic-server
-
回顾与展望
- 至此,我们已经学习了构建 CLI、操作文件系统和异步编程。仅用这些知识就可以构建很多强大的工具,例如打包工具 (Webpack, Vite)、代码检查工具 (Linters) 等。
- 接下来,我们将探讨 Node.js 最常见的应用场景:创建服务器。
-
什么是服务器?
- 在 Node.js 的上下文中,服务器就是一个持续运行的程序,它监听网络请求并返回响应。
- 它可以响应各种数据类型,如 HTML, JSON, CSS, 图片, 视频流等。
-
使用 Node.js 内置的
http模块创建服务器-
导入模块:
import http from "node:http"; -
创建服务器实例:
- 使用
http.createServer()方法,它接收一个回调函数作为参数。 - 这个回调函数会在每次接收到请求时被执行。
- 回调函数接收两个核心对象:
req(请求对象) 和res(响应对象)。
const server = http.createServer((req, res) => { // 处理请求和响应的逻辑写在这里 }); - 使用
-
-
处理响应 (
res对象)res.statusCode = 200;: 设置 HTTP 状态码。- 2xx: 成功 (e.g.,
200 OK) - 3xx: 重定向/缓存 (e.g.,
301 Moved Permanently) - 4xx: 客户端错误 (e.g.,
404 Not Found,401 Unauthorized) - 5xx: 服务器端错误 (e.g.,
500 Internal Server Error)
- 2xx: 成功 (e.g.,
res.setHeader('Content-Type', 'text/plain');: 设置响应头。Content-Type告诉浏览器服务器返回的是什么类型的数据(MIME 类型)。res.end('Hello there');: 发送响应体内容,并结束本次响应。这是必须的步骤。
-
启动服务器
server.listen(port, callback): 让服务器在一个指定的端口 (port) 上开始监听。- 端口: 是一个数字,用于区分同一台机器上的不同网络服务。常见的开发端口有 3000, 4000, 8080 等。
localhost: 是一个特殊的主机名,代表“本机”。
-
完整示例:
import http from "node:http"; const port = 4000; const server = http.createServer((req, res) => { res.statusCode = 200; res.setHeader("Content-Type", "text/plain"); res.end("Hello there"); }); server.listen(port, () => { console.log(`服务器正在 <http://localhost>:${port}/ 上运行`); }); -
重要特性
- 与之前的脚本不同,服务器启动后进程不会自动退出。它会一直运行,等待并处理新的请求,直到你手动停止它(例如,在终端按
Ctrl+C)。
- 与之前的脚本不同,服务器启动后进程不会自动退出。它会一直运行,等待并处理新的请求,直到你手动停止它(例如,在终端按
25-interpolating-data-formatting-notes
-
目标: 创建一个网页来展示我们的笔记
- 我们将创建一个服务器,它能从
db.json读取数据,然后将这些数据动态地渲染到一个 HTML 页面上,并发送给浏览器。
- 我们将创建一个服务器,它能从
-
核心概念: 数据插值 (Interpolation)
- 定义: 将动态数据嵌入到静态模板中的过程。
- 示例: 在一个字符串模板
"Hello, {{ name }}!"中,用一个真实的名字(如 "Scott")替换掉{{ name }}这个占位符。
-
步骤 1: 创建 HTML 模板 (
src/template.html)- 这是一个基础的 HTML 文件,其中包含一个特殊的占位符,用来标记动态内容将被插入的位置。
<!DOCTYPE html> <html> <head> <title>我的笔记</title> </head> <body> <h1>所有笔记</h1> <div class="notes">{{ notes }}</div> </body> </html> -
步骤 2: 安装
open包open是一个方便的工具库,可以在代码中自动用用户的默认浏览器打开一个指定的 URL。- 这在开发中非常有用,可以省去手动复制粘贴地址的步骤。
npm install open -
步骤 3: 编写数据处理函数 (
src/server.js)-
interpolate(template, data)函数:- 接收一个 HTML 模板字符串和一个包含数据的对象。
- 使用正则表达式查找模板中所有
{{ key }}形式的占位符。 - 将每个占位符替换为
data对象中对应key的值。
-
formatNotes(notes)函数:- 接收从数据库中读取的笔记对象数组。
- 使用
Array.prototype.map()方法遍历这个数组。 - 对于每一个笔记对象,它会生成一段代表该笔记的 HTML 字符串。
- 最后,使用
Array.prototype.join('')将所有 HTML 片段连接成一个大的 HTML 字符串。
// 简化版示例 const formatNotes = (notes) => { return notes .map( (note) => ` <div class="note"> <p>${note.content}</p> <div class="tags"> ${note.tags .map((tag) => `<span class="tag">${tag}</span>`) .join("")} </div> </div> ` ) .join(""); };- 这个过程本质上就是服务器端渲染 (Server-Side Rendering, SSR) 的核心思想,类似于 React 或 Vue 在服务器上做的事情。
-
26-sending-notes-to-the-client
-
目标: 组合所有部分,完成服务器逻辑并与 CLI 集成
-
createServer(notes)函数- 这是一个工厂函数,接收笔记数据并返回一个配置好的 HTTP 服务器实例。
- 工作流程:
- 在每次请求时,异步读取
template.html文件的内容。 - 调用
formatNotes(notes)将笔记数组转换为 HTML 字符串。 - 调用
interpolate()函数,将格式化后的笔记 HTML 注入到模板中,生成最终的完整 HTML 页面。 - 设置响应头,特别是
Content-Type: 'text/html',告诉浏览器这是一个 HTML 文档。 - 使用
res.end(html)将最终的 HTML 发送给客户端。
- 在每次请求时,异步读取
-
start(notes, port)函数- 这是启动服务器的入口函数。
- 工作流程:
- 调用
createServer(notes)创建服务器。 - 让服务器在指定的
port上开始监听。 - 在
listen的回调函数中(表示服务器已成功启动):- 打印服务器的地址到控制台。
- 调用
open()函数,自动在浏览器中打开该地址。
- 调用
- 这个函数被导出,以便在其他模块(如
commands.js)中调用。
-
模块化与封装
- 在
server.js中,只有start函数被导出了。 createServer,formatNotes,interpolate等函数都是该模块的内部实现细节,对外部是不可见的。- 这就是模块化的好处:封装实现,只暴露公共 API。这使得代码更易于管理和维护。
- 在
-
更新
web命令 (commands.js)- 逻辑:
- 从
./notes.js导入getAllNotes,从./server.js导入start。 - 在
web命令的处理程序中: a.await getAllNotes()从数据库获取所有笔记数据。 b. 调用start(notes, argv.port),将数据和端口号传递给服务器启动函数。
- 从
// in commands.js .command('web [port]', '启动 Web 服务器', /*...*/ async (argv) => { const notes = await getAllNotes(); start(notes, argv.port); }); - 逻辑:
-
运行和结果
- 首先,使用
note new "..."添加一些笔记。 - 然后运行
note web。 - 终端会显示服务器已启动,并且会自动打开一个浏览器窗口,页面上会显示所有你添加的笔记。
- 这就是服务器端渲染 (Server-Side Rendering, SSR) 的一个完整示例。
- 首先,使用
-
关于
httpvs.Express- 直接使用 Node.js 内置的
http模块来构建复杂的服务器是可行的,但很繁琐。 - 在实际项目中,开发者通常会使用像 Express.js 这样的框架,它在
http模块之上提供了更高级、更方便的 API 来处理路由、中间件等。
- 直接使用 Node.js 内置的
27-wrapping-up
- 课程项目回顾
- 我们从头构建了一个功能完整的应用,涵盖了:
- 命令行界面 (CLI): 使用
yargs创建了一个可以进行增删改查 (CRUD) 操作的笔记工具。 - 数据持久化: 使用
fs文件系统模块,将db.json文件作为我们的数据库。 - 模块化: 学习了如何组织代码,创建和使用内置、第三方和自定义模块。
- 异步编程: 掌握了处理异步操作的演进,从回调到 Promises,再到
async/await。 - 测试: 使用
Jest对我们的业务逻辑进行了单元测试,并学习了mocking的概念。 - Web 服务器: 使用 Node.js 内置的
http模块创建了一个简单的服务器,实现了服务器端渲染 (SSR) 来展示笔记。
- 命令行界面 (CLI): 使用
- 我们从头构建了一个功能完整的应用,涵盖了:
- 核心理念
- 绝大多数 Web 应用的本质都是:从数据源(数据库)拉取数据,将其插入(插值)到一个模板中(HTML/JS),然后发送给客户端。
- 所有的现代框架(如 React, Vue, Svelte)都是对这个核心流程的抽象和优化。理解了这个基础,学习任何新框架都会变得更容易。
- 后续学习建议 (Next Steps)
- 学习 Web 框架:
- Express.js: Node.js 社区最经典、资源最丰富的后端框架,是学习构建 API 的绝佳起点。
- 使用真实数据库:
- 尝试将文件数据库替换为 PostgreSQL 或 MongoDB。
- 学习 ORM/查询构建器:
- Prisma: 一个现代化的、类型安全的数据库工具集,强烈推荐。它能极大地简化与数据库的交互。
- 发布 NPM 包:
- 尝试将你创建的工具(甚至就是这个笔记应用)发布到 NPM。这是一个很好的实践,能让你了解包发布的完整流程。
- 掌握 TypeScript:
- 在现代 JavaScript 开发中,TypeScript 几乎是必备技能。
- 学习部署 (Deployment):
- 将你的服务器应用部署到云平台,如 Vercel, AWS, DigitalOcean 等。让你的应用在真实的服务器上运行是至关重要的一步。
- 构建个人项目:
- 将所学知识应用到解决你个人生活中的问题上,创建一些小型自动化工具。
- 示例:
- 编写一个脚本,使用无头浏览器定时检查某个网站(如球鞋网站)是否有货,并通过短信或邮件通知你。
- 编写一个脚本,自动登录并支付你的每月账单。
- 学习 Web 框架: