同学们,准备好了吗?!咱们的 Node.js “全栈之旅”正式启程!
第一个大章节,我们要打好地基,这是最最关键的!咱们要好好聊聊 Node.js 到底是个啥、它为啥这么火,以及它背后最核心的运行机制。这就像学武功,你得先知道内功心法,才能开始练招式嘛!
来,咱们把 Node.js 的“武功秘籍”第一章翻开!
第一阶段:Node.js 基础与核心概念 (约6节) — 打好地基,高楼才稳!
学习目标:
在这一阶段,我们的目标是让你能够清晰地回答出:“Node.js 是什么?它为什么这么火?它高效的秘密在哪里?”并且,你将掌握最最基本的 Node.js 环境配置和代码运行方法。就像盖房子,地基不牢,万丈高楼也得塌!
第 1 节:Node.js 入门:初见端倪与环境搭建(这是个体力活,但很关键!)
各位老铁,同学们好! 很高兴能在这里和大家一起开启 Node.js 的学习之旅。今天,咱们先来个“开胃菜”,聊聊 Node.js 到底是个什么玩意儿,以及怎么把它请到你的电脑里。别看这步简单,很多初学者都是在这里“卡壳”的,所以,咱们一步步来,包教包会!
1.1 什么是 Node.js?为什么选择 Node.js?
咱们先来个灵魂拷问:什么是 Node.js?
用最简单的话说,Node.js 是一个开源的、跨平台的 JavaScript 运行时环境。听起来是不是有点“不明觉厉”?别急,我给你拆解一下:
-
运行时环境 (Runtime Environment): 你可以把它想象成一个“翻译官”或者“执行器”。以前你的 JavaScript 代码只能在浏览器这个“小黑屋”里跑,Node.js 呢,它提供了一个更广阔的“大舞台”,让 JavaScript 代码可以在服务器、桌面应用、命令行工具,甚至物联网设备这些地方“撒欢儿”地跑。就像 Java 有 JVM(Java Virtual Machine)一样,Node.js 就是 JavaScript 的那个“虚拟机”,让它不再是浏览器里的“小可爱”,而是一个能独当一面的“多面手”!
-
基于 Chrome V8 引擎: 划重点啦!Node.js 的核心是 Google Chrome 浏览器使用的那个大名鼎鼎的 V8 JavaScript 引擎。你想想 Chrome 浏览器为啥打开网页那么快?处理 JavaScript 那么流畅?核心就是 V8!它负责把你的 JavaScript 代码直接编译成高效的机器码,计算机可以直接执行,那速度,简直是“飞沙走石”!Node.js 直接把这个“核武器”拿过来用了,所以你的 JavaScript 代码在服务器端也能跑得飞快!
-
事件驱动 (Event-driven): 这是 Node.js 的一个重要特质。你可以理解为,Node.js 并不是那种“一根筋”的程序,它不会傻傻地等一个任务完成。它更像一个“机灵的服务员”:当你点了一杯咖啡(发起一个耗时操作),他不会站在你旁边傻等咖啡煮好,而是立马去招呼下一桌客人(处理其他请求)。等咖啡煮好了(耗时操作完成),他会“叮”一声通知你(触发一个事件),你再过来拿(执行对应的回调)。Node.js 就是通过这种“监听和响应事件”的方式来执行代码的。
-
非阻塞 I/O (Non-blocking I/O): 兄弟们,这个特性是 Node.js 能够处理高并发的“镇店之宝”!I/O 指的是输入/输出操作,比如文件读写、网络请求、数据库查询等等。这些操作通常比 CPU 运算慢得多。
-
在传统的阻塞 I/O 模型里,当你让程序去读一个大文件或者请求一个外部 API 时,程序会“愣”在那里,啥也不干,就等着这个操作完成。就像你去餐厅点了个菜,厨师在后厨炒菜,服务员就站在你旁边,啥也不干,就等你这菜炒完才能去招呼下一桌客人。这效率,啧啧…
-
但 Node.js 采用的是非阻塞 I/O。当它遇到一个耗时的 I/O 操作时,它会立刻把这个任务甩给底层系统(比如操作系统内核或者一个线程池)去处理,然后自己立刻回来,继续执行后面的 JavaScript 代码,根本不会“傻等”!当那个耗时操作完成了,底层系统会通知 Node.js,然后 Node.js 会通过回调函数或者 Promise 来接收结果。这就像服务员点完菜就去招呼下一桌了,等厨师菜炒好了,再通知他一声,他再把菜给你端过来。效率是不是飙升?!
-
-
单线程 (Single-threaded): Node.js 的 JavaScript 执行是单线程的。是的,你没听错,只有一个线程!这听起来是不是有点慌?“一个线程怎么处理那么多请求啊?”
-
好处: 单线程简化了并发模型,避免了多线程编程中常见的复杂问题,比如“死锁”(两个线程互相等待对方释放资源,谁也动不了)和“竞态条件”(多个线程抢着改同一个数据,结果乱七八糟)。这让你的代码写起来更简单,Bug 也更少!
-
挑战: 如果你写了一个需要长时间计算的同步任务(CPU 密集型),它就会把这唯一的线程给“霸占”了,导致整个应用程序“卡死”。但别担心,Node.js 通过非阻塞 I/O 和事件循环完美地解决了这个并发处理的难题,它不是靠多线程来“忙”,而是靠“不傻等”和“高周转”来“忙”的!
-
搞明白了 Node.js 是啥,那问题来了,为什么我们非要选择它?它到底有啥魔力?
-
高性能与高并发:
-
前面提到的非阻塞 I/O 和事件循环就是它的“杀手锏”!它能够以极低的资源消耗处理大量并发连接,特别适合处理那些“你来我往”频繁、数据量不大的应用,比如在线聊天、各种 API 服务(想想要是你微信每次发消息都得服务器“卡”一下,你会不会想砸手机?)。
-
V8 引擎的编译执行能力,让你的 JavaScript 代码跑得飞快,性能杠杠的!
-
-
统一语言栈 (Full-stack JavaScript):
-
这简直就是JavaScript 程序员的“福音”!以前前端写 JavaScript,后端还得学 Java、Python、PHP、Ruby… 学得你头皮发麻。现在好了,前端你用 JavaScript,后端你还用 JavaScript!
-
这意味着啥?代码复用!知识共享!开发效率倍增! 团队协作也更顺畅了,因为大家说的是同一种“语言”,沟通成本大大降低。你甚至可以轻松地从前端转后端,或者从后端转前端,成为一名真正的“全栈工程师”!是不是感觉打通了任督二脉,头发都省下了几根?!
-
-
庞大的生态系统 (NPM - Node Package Manager):
-
NPM 是世界上最大的开源库生态系统,没有之一! 截至目前,上面已经有数百万个可重用的模块。
-
这意味着你想要啥功能,无论是连接数据库、构建 Web 框架、处理图片、加密解密,基本上都能在 NPM 上找到现成的“轮子”。你只需要
npm install一下,然后拿来就用,简直是“懒人福音”,大大加速了开发进程!
-
-
快速开发周期:
-
JavaScript 本身的灵活性,加上 NPM 丰富的模块,让 Node.js 项目的开发速度非常快。很多想法都能快速落地,迅速验证。
-
而且,Node.js 的热重载、模块化等特性,也让开发体验非常棒!
-
-
活跃的社区支持:
- Node.js 拥有一个非常庞大且活跃的开发者社区。这意味着你在学习和开发过程中遇到任何问题,都能轻松地找到文档、教程,或者在社区里找到大神帮你解决。
总结: Node.js 的出现,彻底改变了 Web 开发的格局。它不仅让 JavaScript 成为一门真正的全栈语言,更凭借其高性能、高并发、统一语言和庞大生态的优势,成为现代 Web 开发中不可或缺的重要组成部分。选择 Node.js,你绝对不会后悔!
1.2 Node.js 的应用场景
Node.js 这家伙,因为它的“独门绝技”,在很多领域都混得风生水起,简直是个“全能型选手”!来,咱们看看它都在哪些地方“称王称霸”:
-
Web 服务器和 API 服务:
- 这绝对是它的“看家本领”!构建那些高性能、高并发的 RESTful API 和微服务,简直不要太爽。你想想电商网站、社交媒体的后端,动不动就是千万甚至上亿的请求,Node.js 就能稳稳地扛住!
-
实时应用:
- 如果你想开发个聊天应用、在线游戏、多人协作工具(比如 Google Docs 那种),Node.js 简直就是为你量身定做的!它和 WebSocket 配合起来,能实现服务器与客户端之间的双向通信,消息瞬间就到达,延迟低到你感觉不到!
-
数据流应用:
- 处理文件上传、视频流、或者那些“哗哗”不断产生的日志数据,Node.js 的 Stream(流) 机制简直是“效率狂魔”,它能一块一块地处理数据,而不是一次性把所有数据都加载到内存里,内存压力小,处理速度快!
-
命令行工具 (CLI Tools):
- 哎,你知道吗?很多你每天都在用的前端构建工具(比如 Webpack, Gulp, Grunt)和包管理器(比如 npm, yarn)都是用 Node.js 写的!它强大的文件系统和进程管理能力,让它在开发这类工具时游刃有余。
-
服务器端渲染 (SSR):
- 如果你用 React、Vue 这些前端框架,想让你的网站首屏加载速度更快,SEO 更好,就可以把它们和 Node.js 结合起来搞服务器端渲染。用户一访问,服务器就直接把渲染好的 HTML 扔给他,体验贼棒!
-
物联网 (IoT):
- 物联网设备通常资源有限,Node.js 轻量级、事件驱动的特性,让它在这些小设备上也能跑得欢,用来处理传感器数据、控制设备啥的,简直不要太方便!
-
桌面应用:
- 你没看错!使用 Electron 框架,你可以用 Node.js 和 Web 技术(HTML, CSS, JavaScript)来构建跨平台的桌面应用。你平时用的 VS Code、Slack、Discord,甚至桌面版的微信,底层都有 Electron 的影子!
看到了吧?Node.js 这家伙,简直是无所不能!是不是有点心动了?那就赶紧把它请到你的电脑里,咱们继续!
1.3 安装 Node.js (LTS 版本) — 请“神仙”下凡!
好了,说了这么多 Node.js 的好,现在是时候把它请到你的电脑里了!
敲黑板!划重点! 推荐大家安装 LTS (Long Term Support) 版本。啥叫 LTS?就是“长期支持版”。它就像一个成熟稳重、久经考验的老大哥,虽然可能没有最新版本那么“潮”,但它最稳定,Bug 最少,更适合你在生产环境里“搞事情”!搞开发嘛,稳定是第一生产力!
安装步骤(Windows/macOS 用户请注意,Linux 用户也有特殊福利!):
-
访问官方网站: 首先,请打开你的浏览器,直奔 Node.js 的“老巢”:https://nodejs.org/
- 温馨提示: 认准官方网站,别去乱七八糟的地方下载,小心下载到“捆绑包”!
-
下载安装包: 进入官网首页,你会看到两个大大的下载按钮:一个写着 LTS (Recommended For Most Users),另一个写着 Current (Latest Features)。咱们是来学习和搞稳定开发的,所以毫不犹豫地点击 LTS 版本的下载按钮!它会很智能地识别你的操作系统(Windows、macOS 还是 Linux),并提供对应的安装包(比如
.msi格式的 Windows 安装包,或者.pkg格式的 macOS 安装包)。 -
运行安装程序:
-
Windows/macOS 用户: 双击你刚刚下载的安装包,然后就像你平时安装其他软件一样,跟着安装向导的提示一步步操作就行。通常情况下,一路点击“Next”并接受默认设置是最好的选择。安装程序会自动帮你配置好环境变量,省去了不少麻烦!
-
Linux 用户(福利时间!): Linux 用户可以选择通过包管理器(比如 Debian/Ubuntu 上的
apt,或者 CentOS/RHEL 上的yum)来安装。但作为一个 Node.js 资深讲师,我强烈推荐你使用nvm(Node Version Manager) 来安装! 为什么呢?因为它允许你在同一台机器上轻松地安装和切换不同版本的 Node.js。这对于你未来可能需要同时开发不同 Node.js 版本的项目来说,简直是“神器”!使用
nvm安装 Node.js 的步骤:# 1. 下载并运行 nvm 安装脚本 # 别怕,这是官方推荐的方式,安全可靠! curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash # 2. 安装完成后,重启你的终端(或者执行下面的命令,让 nvm 生效) # 如果你用的是 Bash: source ~/.bashrc # 如果你用的是 Zsh (比如 macOS Catalina 及以上默认的 shell): source ~/.zshrc # 3. 安装最新的 LTS 版本 Node.js nvm install --lts # 这会安装当前最新的 LTS 版本,例如 v20.x.x # 4. 使用 LTS 版本 nvm use --lts # 告诉 nvm,你现在要用这个 LTS 版本 # 5. 设置默认版本(可选,但推荐) nvm alias default lts # 这样下次打开终端,就自动使用 LTS 版本了
-
-
验证安装: 无论你用哪种方式安装的,安装完成后,都得来个“验货”!打开你的终端(Windows 用户是“命令提示符”或 PowerShell,macOS/Linux 用户是 Terminal),输入以下两条命令,然后按回车:
node -v # 查看 Node.js 的版本 npm -v # 查看 npm 的版本 (npm 是随 Node.js 一起安装的包管理器)如果一切顺利,它们会分别显示 Node.js 和 npm 的版本号,类似
v20.11.1和10.2.4。恭喜你,“Node.js 神仙”已经成功请到你的电脑里了!
如果在这一步遇到任何问题,别慌! 通常是环境变量配置问题。你可以去百度/Google 搜一下“Node.js 环境变量配置”,或者在我们的学习群里提问,会有同学或我来帮你解决!
1.4 REPL (Read-Eval-Print Loop) 交互式环境使用 — 你的 JavaScript “练功房”!
Node.js 除了能运行 .js 文件,还提供了一个非常方便的交互式命令行环境,叫做 REPL。
REPL 是什么意思?
-
Read (读取):读取你输入的代码。
-
Eval (执行):执行你输入的代码。
-
Print (打印):打印出执行结果。
-
Loop (循环):不断重复上述过程。
简而言之,它就是一个让你能够快速测试 JavaScript 代码片段、调试一些小功能、或者学习 Node.js 内置 API 的“练功房”!你不用每次都写一个文件,保存,再运行,效率杠杠的!
如何进入 REPL:
很简单,打开你的终端或命令提示符,输入 node,然后按回车键:
node
你就会看到一个熟悉的 > 提示符,这表示你已经成功进入 Node.js 的 REPL 环境了!
基本使用:
进入 REPL 后,你就可以直接输入 JavaScript 代码,然后按回车执行,结果会立刻显示出来:
> console.log("Hello, REPL!");
Hello, REPL!
undefined # console.log() 函数本身没有返回值,所以打印 undefined
> 1 + 1
2
> let name = "Node.js";
undefined
> name
'Node.js'
> function greet(n) { return `Hi, ${n}!`; }
undefined
> greet("World")
'Hi, World!'
> 2 * 3.14
6.28
看到了吧?就像在浏览器控制台里一样,非常方便!
REPL 特殊命令(掌握这些,让你如虎添翼!):
-
.help: 输入这个命令,会显示所有 REPL 可用的特殊命令,非常实用! -
.exit: 退出 REPL 环境。你也可以连按两次Ctrl + C来退出。 -
.save <filename>: 把你当前 REPL 会话中输入的所有代码都保存到一个文件中。有时候你写了一段很棒的代码,想保存下来,就用它! -
.load <filename>: 加载并执行指定文件中的 JavaScript 代码。 -
.clear: 清除当前的上下文(相当于把当前的所有变量和函数都清空了),但不会清除你之前输入过的历史命令。 -
Tab键:自动补全! 当你输入了一部分变量名、函数名或者 Node.js 内置模块名时,按下Tab键,REPL 会自动帮你补全,或者给出提示。这功能简直不要太方便! -
上/下箭头键: 浏览你之前输入过的历史命令。
小练习:
-
进入 REPL 环境。
-
输入几行 JavaScript 代码,比如定义一个变量,写一个简单的函数。
-
尝试使用
Tab键进行补全。 -
使用上下箭头键浏览历史命令。
-
最后使用
.exit或者Ctrl + C退出。
REPL 是你学习 Node.js API 和快速验证想法的好帮手,一定要多用多练!
1.5 运行第一个 Node.js 文件 — Hello Node.js World!
好了,理论说得差不多了,是时候来点实际的了!咱们来运行你的第一个 Node.js 文件,感受一下代码在浏览器之外执行的快感!
这将是你开启 Node.js 编程生涯的里程碑!
-
创建文件:
-
在你电脑上选择一个你喜欢的目录(比如
C:\my-node-projects或者/Users/yourname/node-apps),创建一个新的文件。 -
给它取个名字,就叫
hello.js吧。记住,Node.js 脚本文件通常以.js结尾。
-
-
编写代码:
-
用你熟悉的任何文本编辑器(比如 VS Code, Sublime Text, Notepad++)打开
hello.js文件。 -
在文件中输入以下内容:
// hello.js console.log("Hello from Node.js!"); // 你的第一声问候 // 接下来,咱们玩点酷的!引入Node.js内置的'os'模块 // 'os' 模块提供了操作系统相关的信息和方法 const os = require('os'); console.log(`你的操作系统是: ${os.platform()}`); // 打印操作系统类型 // 再来个猛料!使用process全局对象,获取Node.js版本 // process 是 Node.js 给我们提供的一个“大管家”对象,关于当前Node.js进程的信息它都知道 console.log(`Node.js 版本: ${process.version}`); -
代码解释:
-
console.log():这是老朋友了,跟浏览器里的console.log一样,都是往控制台打印信息的。 -
require('os'):这是 Node.js 中导入模块的方式。os是 Node.js 内置的一个核心模块,专门用来获取操作系统信息的,你不需要单独安装它,直接用就行。 -
os.platform():os模块里的一个方法,返回当前操作系统的平台名称,比如win32(Windows),darwin(macOS),linux。 -
process.version:process是一个全局对象(你不需要require它,直接就能用!),它提供了当前 Node.js 进程的各种信息,version属性就是 Node.js 的版本号。
-
-
-
打开终端/命令提示符:
-
现在,你得打开一个终端窗口(Windows 用户是“命令提示符”或 PowerShell,macOS/Linux 用户是 Terminal)。
-
使用
cd命令导航到你保存hello.js文件的那个目录。-
示例: 如果你把
hello.js放在了C:\Users\YourUser\my-node-app这个路径下,那么就在终端输入:cd C:\Users\YourUser\my-node-app -
或者,如果你是 macOS/Linux 用户,放在
/Users/yourname/documents/my-node-scripts下,就输入:cd /Users/yourname/documents/my-node-scripts
-
-
小技巧: 大多数现代终端(如 VS Code 内置终端、Windows Terminal 等)都支持直接在文件管理器中右键点击文件夹,选择“在此处打开终端/PowerShell/命令行”之类的选项,这样就不用手动
cd了。
-
-
运行文件:
-
当你的终端已经定位到
hello.js文件所在的目录后,输入以下命令,然后按回车:node hello.js -
神奇的事情发生了!你将看到类似以下的输出(具体的操作系统和 Node.js 版本会根据你的实际情况显示):
Hello from Node.js! 你的操作系统是: win32 (或者 darwin/linux) Node.js 版本: v20.11.1 (或者你安装的实际版本)
恭喜你! 你已经成功运行了你的第一个 Node.js 文件!从这一刻起,你就是一名 Node.js 开发者了!是不是感觉有点小激动?
-
1.6 process 全局对象简介 — Node.js 的“大管家”!
在上面的 hello.js 例子中,我们偷偷用了一个叫 process.version 的东西。这个 process 可不是一般的变量,它是 Node.js 给我们提供的一个全局对象!
全局对象是什么意思呢?就是它随时随地都能用,你不需要像 os 模块那样用 require() 去引入它。它就像 Node.js 的“大管家”,手里握着关于当前这个 Node.js 进程的所有信息和控制权。你想知道你 Node.js 跑了多久?内存用了多少?命令行里传了什么参数?问它就行!
process 对象的一些常用属性和方法:
-
process.argv: 这是一个数组,里面包含了启动 Node.js 进程时,你在命令行里输入的所有参数。-
数组的第一个元素永远是
node命令的绝对路径(也就是 Node.js 解释器的路径)。 -
第二个元素是当前你正在执行的 JavaScript 文件的绝对路径(比如咱们的
hello.js)。 -
后续的元素就是你自己在命令行中传入的其他参数了。
-
小提示: 因为前两个参数是固定的,所以如果你要获取自己传入的参数,通常会从
process.argv[2]开始取,或者更优雅地用process.argv.slice(2)。
-
-
process.env: 这是一个对象,里面包含了你操作系统当前环境的所有环境变量。比如PATH变量、各种 API Key 等(当然,敏感信息不建议直接放在环境变量里)。通过它可以访问操作系统级别的配置。- 示例:
console.log(process.env.PATH);(会输出你的系统 PATH 环境变量)
- 示例:
-
process.cwd(): Current Working Directory 的缩写,返回 Node.js 进程的当前工作目录的绝对路径。就是你运行node命令时所在的那个目录。 -
process.exit([code]): 这是个“杀手锏”!用来终止当前的 Node.js 进程。-
code是可选的退出码,默认是0,表示程序成功执行并正常退出。 -
如果是非零值(比如
1),通常表示程序在执行过程中遇到了错误,非正常退出。在脚本中遇到不可恢复的错误时,常常会用process.exit(1)。
-
-
process.version: Node.js 的版本字符串(咱们刚刚用过啦,比如v20.11.1)。 -
process.platform: 运行 Node.js 的操作系统平台(比如win32代表 Windows,darwin代表 macOS,linux代表 Linux)。 -
process.uptime(): 返回 Node.js 进程已经运行的秒数。可以用来简单地看看你的服务器跑了多久没重启。 -
process.memoryUsage(): 返回 Node.js 进程的内存使用情况(以字节为单位)。这是个对象,里面有rss,heapTotal,heapUsed等属性,对于内存优化和监控很有用。 -
process.nextTick(callback): 这个方法有点特殊,它会把你的回调函数放到“微任务队列”里,然后在当前事件循环迭代的末尾(在任何 I/O 操作之前)执行。这是个优先级很高的调度器!咱们在后面讲事件循环的时候会详细聊它,现在先混个脸熟。
示例:使用 process.argv 来获取命令行参数
咱们来创建一个新的文件,叫 args.js:
// args.js
console.log("命令行参数:", process.argv);
// 访问自定义参数 (跳过前两个默认参数,它们是 node 路径和文件路径)
const customArgs = process.argv.slice(2);
if (customArgs.length > 0) {
console.log("你传入的自定义参数是:", customArgs.join(', '));
} else {
console.log("没有传入自定义参数。");
}
保存 args.js 文件后,打开终端,导航到它所在的目录,然后这样运行它:
node args.js hello world 123 --user=Alice --debug
你将看到类似以下的输出:
命令行参数: [
'/usr/local/bin/node', # 或者 C:\Program Files\nodejs\node.exe
'/path/to/your/args.js', # 你的 args.js 文件路径
'hello',
'world',
'123',
'--user=Alice',
'--debug'
]
你传入的自定义参数是: hello, world, 123, --user=Alice, --debug
是不是很方便?通过 process.argv,你就可以让你的 Node.js 脚本根据命令行参数执行不同的逻辑,这在开发命令行工具时非常有用!
总结一下:
恭喜各位!通过本节的学习,你已经对 Node.js 有了初步的认识,知道它是个啥,为啥这么牛,能干啥。更重要的是,你已经成功地安装了 Node.js,学会了在 REPL 里“练功”,还成功运行了你的第一个 Node.js 文件,甚至还认识了 Node.js 的“大管家”——process 全局对象。
这一步虽然是基础,但却是你 Node.js 学习之路的坚实起点!
下节预告:
下节课,我们将深入探讨 Node.js 的“心脏”和“大脑”——V8 引擎和事件循环 (Event Loop)。这可是 Node.js 高性能和异步特性的真正奥秘所在,理解了它,你就能真正明白 Node.js 为何如此强大!
好了,第一节课就到这里!课后作业: 尝试用 process.env 打印出你的 HOME 或 TEMP 环境变量,再用 process.uptime() 看看你的 Node.js 进程跑了多久。下节课不见不散!
好的,同学们,上节课咱们成功把 Node.js 请进了家门,还跟它的“大管家”process 对象打了个照面。是不是感觉有点小激动?
别急,好戏才刚刚开始!今天这节课,咱们要深入 Node.js 的“心脏”和“大脑”,去探索它为什么能处理那么多的请求,还能跑得飞快,简直就像个“时间管理大师”!这就是我们今天要揭秘的——V8 引擎与事件循环 (Event Loop)。理解了这两点,你就抓住了 Node.js 高性能的“任督二脉”!
第 2 节:揭秘 Node.js 心脏:V8 引擎与事件循环 (Event Loop) — 懂了它,你就懂了 Node.js!
学习目标:
本节课,我们将深入理解 Node.js 如何运行 JavaScript,掌握事件驱动和非阻塞 I/O 的核心思想,并彻底搞明白那个让无数人“懵圈”又“着迷”的事件循环机制!
2.1 Node.js 如何运行 JavaScript?V8 引擎的作用
同学们,你有没有想过,你写的那些 JavaScript 代码,Node.js 它是怎么理解并执行的呢? 难道 Node.js 自带一个“翻译机”?
Bingo!你猜对了一半!这个“翻译机”就是咱们前面提到过的——V8 引擎!
V8 引擎是什么?
-
JavaScript 引擎中的“战斗机”: V8 是 Google 大佬们开发的开源高性能 JavaScript 和 WebAssembly 引擎。它最开始是给 Chrome 浏览器用的,所以 Chrome 浏览器的 JavaScript 执行速度那叫一个快!
-
它的“超能力”: V8 的主要任务就是把你的 JavaScript 代码编译成机器码,然后计算机就可以直接“阅读”并执行这些机器码了。想当年,JavaScript 代码都是被“解释”着一行行执行的,那速度… 感人。V8 不一样,它是个“即时编译 (JIT - Just-In-Time Compilation)”高手!也就是说,它在你的代码运行的时候,就能“边跑边编译”,把 JS 代码变成高效的机器码。这一招,直接让 JavaScript 的执行速度飙升了好几个量级!
-
内存管理大师: 除了编译代码,V8 还负责内存的分配和“垃圾回收”(就是自动清理那些不再需要的内存,防止内存泄漏)。
V8 引擎在 Node.js 中的作用:
可以说,Node.js 的核心就是 V8 引擎!没有 V8,就没有我们今天用的 Node.js!
-
执行 JavaScript 代码的“心脏”: V8 引擎是 Node.js 运行 JavaScript 代码的“发动机”。当你敲下
node your_app.js的那一刻,就是 V8 在幕后辛勤工作,解析、编译、然后执行你的代码。 -
性能的保障: 正是因为 V8 引擎的超高性能特性,Node.js 才能够快速响应大量的请求,处理复杂的业务逻辑,让你觉得它“飞快”!
-
跨平台的基石: V8 引擎本身就是跨平台的(能在 Windows、macOS、Linux 上跑),所以,Node.js 才能在各种操作系统上“称霸一方”!
-
提供核心对象: V8 不仅能跑你的代码,它还提供了 JavaScript 的那些基本对象(比如
Object,Array,Function等)和基本的运行时环境。
一句话总结: 你可以把 Node.js 理解成一个“精装修的房子”,而 V8 引擎就是这个房子里最核心的“大心脏”和“中央处理器”。Node.js 在 V8 的基础上,又添加了许多用 C++ 编写的“模块”(比如文件系统、网络通信、加密解密这些浏览器里没有的功能),然后通过 V8 提供的接口,把这些强大的功能暴露给 JavaScript。这样,你的 JavaScript 就能在服务器端“呼风唤雨”啦!
2.2 理解事件驱动、非阻塞 I/O 模型
好了,V8 引擎咱们知道了,它让 JavaScript 跑得飞快。但光快还不行,服务器得能同时处理很多很多请求啊!这时候,Node.js 的另外两个“独门绝技”就登场了——单线程、事件驱动和非阻塞 I/O!
这三者,是 Node.js 区别于很多传统服务器端语言(比如 PHP、Ruby on Rails、Java Servlet)的关键特性,也是它能够实现“高并发”的秘密所在。
单线程 (Single-threaded):
-
前面提过,Node.js 执行 JavaScript 代码是单线程的。这意味着在任何给定时刻,你的 JavaScript 代码都只在一个线程上“奔跑”。
-
优点: 单线程好啊!简化了并发模型,你不用担心多线程里那些“妖魔鬼怪”,比如“死锁”(两个任务互相锁住对方资源,谁也动不了,程序就僵死了)和“竞态条件”(多个任务抢着修改同一个数据,结果数据乱七八糟)。这让你的代码写起来更简单,Bug 也更容易发现。
-
挑战: 如果你写了一个需要长时间进行计算的同步任务(比如一个非常复杂的数学计算,CPU 密集型),它就会把这唯一的线程“霸占”住,导致整个应用程序“卡住”,其他请求就得等着,这可就尴尬了。但别慌,Node.js 早就想好解决方案了,那就是下面的“事件驱动”和“非阻塞 I/O”!
事件驱动 (Event-driven):
-
Node.js 应用程序的核心就是事件循环(我们马上就要深入聊它了)。
-
它的工作方式很像一个“咖啡厅”:当一个操作完成了(比如咖啡煮好了,文件读取完了,网络请求收到响应了),它会“啪”地一声,触发一个“事件”。
-
而你的 Node.js 应用程序呢,它会一直“竖着耳朵”去“监听”这些事件。一旦某个事件发生了,它就立刻把对应的“回调函数”拿出来执行。
-
这种模型让 Node.js 能够非常高效地处理大量并发连接。因为它不是为每个连接都新开一个线程(那样会消耗大量资源),而是通过“事件”和“回调”这种轻量级的方式来管理它们。就像那个“机灵的服务员”,他不是为每个客人单独服务,而是把所有客人的需求都“记住”,然后“事件驱动”地去处理。
非阻塞 I/O (Non-blocking I/O):
-
I/O (Input/Output): 再次强调,就是输入/输出操作,比如读取文件、写入数据库、发送网络请求等等。这些操作通常比 CPU 的计算慢得多,慢到你怀疑人生。
-
阻塞 I/O (Blocking I/O): 在传统模式里,当一个 I/O 操作开始时,程序会“原地待命”,直到这个 I/O 操作完全完成并返回结果,才能继续执行后面的代码。这期间,CPU 就像个“傻瓜”,干等着,浪费了宝贵的计算资源,也无法处理其他请求。
-
非阻塞 I/O (Non-blocking I/O): 这就是 Node.js 的“神来之笔”!当你发起一个 I/O 操作时,Node.js 会立刻把这个任务甩给底层系统(比如操作系统内核或一个专门处理 I/O 的线程池),然后它自己会立刻返回,继续执行后面的 JavaScript 代码,根本不会“傻等”!
-
那怎么知道 I/O 完成了呢? 当底层系统把耗时操作处理完了,它会“通知”Node.js,并将相应的回调函数放进一个“事件队列”里。Node.js 的事件循环会在主线程“闲”下来的时候,从这个队列里把回调函数取出来,然后执行它。
总结一下:
Node.js 的“单线程”是它的“身躯”,它简化了内部逻辑。而“事件驱动”和“非阻塞 I/O”则是它的“大脑”和“手脚”,让这个单线程的“身躯”能够以一种“多任务并行”的姿态去处理海量的并发请求!它根本不浪费时间“傻等”,而是把等待的时间都用来处理其他请求了。这就是 Node.js 能够构建高性能、高并发网络应用的核心秘密!理解了这一点,你的 Node.js 学习之路就打通了一大半!
2.3 事件循环机制深入解析 (Phases, Microtasks vs Macrotasks) — Node.js 的‘永动机’原理!
好了,同学们,前面我们反复提到了“事件循环”(Event Loop),说它是 Node.js 实现非阻塞 I/O 的核心。那这玩意儿到底是怎么运作的?它就像一个复杂的“指挥官”,调度着所有的异步操作,确保 Node.js 永远不会“卡壳”。
核心组件:
在深入事件循环的“内脏”之前,我们先认识几个关键的“零部件”:
-
调用栈 (Call Stack):
-
这个咱们写 JavaScript 的都熟!它是一个 LIFO (Last-In, First-Out,后进先出) 的栈结构。
-
所有正在执行的同步 JavaScript 代码,都会被推入这个栈中。当一个函数被调用时,它被“压栈”;当函数执行完毕时,它被“弹栈”。
-
记住:JavaScript 代码永远在这个调用栈中执行。
-
-
Node.js APIs (C++ APIs / 底层):
-
这些是 Node.js 提供的异步操作接口,比如
fs.readFile()(读文件)、http.get()(发网络请求)、setTimeout()(定时器) 等。 -
当你调用这些 API 时,它们会把耗时操作(比如读文件、网络请求)交给 Node.js 的底层(通常是 C++ 编写的,它们可能利用操作系统提供的异步能力,或者 Node.js 内部的线程池)去处理。然后,它们会注册一个“回调函数”,告诉底层:你忙完了,就通知我一声,我好执行这个回调!
-
-
事件队列 (Event Queue / Callback Queue / Macrotask Queue):
-
当底层系统完成了异步操作后(比如文件读完了,网络请求有响应了),它会把对应的“回调函数”悄悄地放到这个队列里,排队等待执行。
-
你可以把它想象成“服务员放小票的盘子”:菜做好了,厨师就把“上菜小票”放到这个盘子里。
-
-
微任务队列 (Microtask Queue):
-
这是一个优先级更高的队列!它专门用来存放
process.nextTick()和 Promise 的.then(),.catch(),.finally()这些回调。 -
可以把它想象成“VIP 快速通道”:这里面的任务,总是在事件循环的每个阶段之间,以及当前同步代码执行完毕后,优先被清空!
-
事件循环的执行流程 (Phases) — Node.js 的‘六脉神剑’!
Node.js 的事件循环不是一口气跑到底的,它是一个持续运行的循环,并且分为几个“阶段”(或者说“脉”),每个阶段都有自己的特定任务和对应的队列。当事件循环进入某个阶段时,它会把该阶段队列里所有的(或部分)回调函数都执行完,然后才进入下一个阶段。
Node.js 的事件循环主要有以下几个阶段(从 Node.js 11 开始,这几个阶段的顺序和行为更加稳定):
-
timers(定时器阶段):-
负责啥: 执行
setTimeout()和setInterval()的回调函数。 -
注意: 这些回调的执行时间取决于你设定的延迟时间,但不保证精确!比如你设了个
setTimeout(fn, 0),它可不一定立马执行,它得排队呢!
-
-
pending callbacks(待定回调阶段):-
负责啥: 执行一些系统操作的回调,例如 TCP 错误(比如连接被拒绝)的回调。
-
通常我们写业务代码很少直接关心这个。
-
-
idle, prepare(空闲/准备阶段):- 负责啥: Node.js 内部使用,不直接与我们用户代码相关。可以理解为事件循环在做一些内部准备工作。
-
poll(轮询阶段):-
核心阶段! 这是事件循环的“主力军”!
-
检查 I/O 事件: 大多数 I/O 操作的回调(比如文件读取完成的回调、网络请求收到响应的回调、数据库查询完成的回调)都在这个阶段执行。
-
检查定时器: 如果
timers队列(就是setTimeout/setInterval那个队列)是空的,并且有新的定时器到期了,事件循环可能会在这个阶段“停留”一下,等待新的 I/O 事件或者定时器到期。 -
执行
setImmediate: 如果poll阶段自身的 I/O 队列是空的,并且setImmediate的队列里有回调在等着,事件循环会跳到check阶段去执行setImmediate的回调。
-
-
check(检查阶段):- 负责啥: 专门执行
setImmediate()的回调函数。
- 负责啥: 专门执行
-
close callbacks(关闭回调阶段):- 负责啥: 执行一些关闭事件的回调,比如
socket.on('close')(套接字关闭时)。
- 负责啥: 执行一些关闭事件的回调,比如
每次事件循环迭代的顺序:
当调用栈(Call Stack)清空后(也就是所有的同步 JavaScript 代码都执行完了),事件循环就开始“工作”了,它会按照上述阶段的顺序进行迭代。
最最关键的是: 在每个阶段之间,以及在每个阶段执行完其所有(或部分)回调之后,事件循环都会“顺便”检查并清空微任务队列!
微任务 (Microtasks) vs. 宏任务 (Macrotasks) — 这才是‘谁先谁后’的终极秘密!
这是理解事件循环中回调执行顺序的“核心机密”!面试必考,敲黑板,划重点!
-
宏任务 (Macrotasks):
-
包括:
setTimeout(),setInterval(),setImmediate(), I/O 操作的回调(比如fs.readFile读文件完成后的回调)、requestAnimationFrame(这个是浏览器环境的)。 -
执行特点: 事件循环的每个阶段都会处理它自己的宏任务队列。只有当一个阶段的宏任务处理完了(或者达到某个内部限制),事件循环才会去检查微任务队列,然后清空微任务,最后再进入下一个宏任务阶段。
-
-
微任务 (Microtasks):
-
包括:
process.nextTick(), Promise 的.then(),.catch(),.finally()回调。 -
执行特点: 具有更高的优先级!它们就像插队小能手。在当前调用栈清空后(也就是你所有的同步代码都跑完了),以及事件循环的每个阶段之间,都会优先把微任务队列里的回调全部清空,然后再处理下一个宏任务或者进入下一个事件循环阶段。
-
优先级总结(记住这个口诀!):
-
当前正在执行的同步代码 (Call Stack) — 皇帝,最高权力,必须先执行完!
-
process.nextTick()(最高优先级的微任务) — 皇太子,皇帝一下位,他立马接班,比其他大臣都快! -
其他微任务 (Promise 回调) — 文武百官,等皇太子忙完了,他们也赶紧处理。
-
事件循环的各个阶段 (宏任务,按顺序执行) — 普通百姓,等皇帝、皇太子、文武百官都忙完了,他们才能按部就班地进行。
来个实战演练,看看它们到底谁先谁后:
console.log('Start'); // 同步代码 1
setTimeout(() => {
console.log('setTimeout 1'); // 宏任务 (timers 阶段)
Promise.resolve().then(() => {
console.log('Promise inside setTimeout'); // 微任务 (在 setTimeout 回调内部产生的微任务)
});
}, 0); // 延迟0毫秒,但它仍然是宏任务
setImmediate(() => {
console.log('setImmediate 1'); // 宏任务 (check 阶段)
});
Promise.resolve().then(() => {
console.log('Promise 1'); // 微任务 (Promise 的 then 回调)
});
process.nextTick(() => {
console.log('process.nextTick 1'); // 微任务 (优先级最高的那种)
});
console.log('End'); // 同步代码 2
你猜输出顺序是啥?别急着看答案,先自己推演一遍!
可能的输出顺序(通常是这样,但取决于系统和 Node.js 版本):
Start
End
process.nextTick 1
Promise 1
setTimeout 1
Promise inside setTimeout
setImmediate 1
咱们来“复盘”一下这个执行过程:
-
第一次循环 - 同步代码执行:
-
console.log('Start')立刻执行。 -
setTimeout、setImmediate、Promise.then、process.nextTick它们都是异步任务,会被放到各自的队列里排队。 -
console.log('End')立刻执行。 -
此时,调用栈清空。
-
-
第一次循环 - 微任务清空:
-
调用栈清空后,事件循环会立即检查并清空微任务队列。
-
process.nextTick(() => { console.log('process.nextTick 1'); });优先级最高,先执行! -
Promise.resolve().then(() => { console.log('Promise 1'); });紧随其后执行! -
此时,所有当前轮的微任务都清空了。
-
-
第一次循环 - 宏任务阶段执行:
-
事件循环进入第一个宏任务阶段:
timers阶段。 -
setTimeout(() => { console.log('setTimeout 1'); ... }, 0);满足条件,执行回调!输出setTimeout 1。 -
注意! 在
setTimeout的回调函数内部,又产生了一个 Promise 的then回调:Promise.resolve().then(() => { console.log('Promise inside setTimeout'); });。这个新的微任务会立即被添加到微任务队列! -
timers阶段的任务执行完了(就一个)。
-
-
第一次循环 - 再次清空微任务:
-
一个宏任务阶段执行完毕后,事件循环会再次检查并清空微任务队列!
-
所以,
Promise inside setTimeout会立刻执行,输出Promise inside setTimeout。 -
此时,微任务队列再次清空。
-
-
第一次循环 - 宏任务阶段继续:
-
事件循环继续向后走,可能会经过
pending callbacks、poll阶段(如果它们是空的或者没有符合条件的任务,就直接略过)。 -
最终,事件循环进入
check阶段。 -
setImmediate(() => { console.log('setImmediate 1'); });满足条件,执行回调!输出setImmediate 1。 -
check阶段的任务执行完了。
-
-
第一次循环 - 结束:
- 事件循环继续向后走,到
close callbacks阶段。如果没任务,一轮循环就结束了。
- 事件循环继续向后走,到
是不是有点绕?没关系,多看几遍,自己多敲几遍,慢慢就熟了。理解了这段,你就能搞定 Node.js 里大部分的异步执行顺序问题了!这可是 Node.js 的“武林秘籍”里最深奥的一招!
2.4 setTimeout(), setInterval(), setImmediate(), process.nextTick() 的区别 — 异步调度器的‘爱恨情仇’!
好了,同学们,既然我们深入学习了事件循环,那这几个经常让人“傻傻分不清楚”的异步调度函数,咱们今天就给它们彻底“洗白白”,搞清楚它们的“江湖地位”和“脾气秉性”!
它们都用于调度异步代码的执行,但它们在事件循环中的执行时机和优先级可是大相径庭!
-
process.nextTick(callback):-
江湖地位: 异步调度中的“皇太子”!优先级最高的那批微任务!
-
执行时机: 在当前执行栈清空后,立即执行,而且在事件循环的任何阶段开始之前。它比所有其他微任务(包括 Promise 回调)和所有宏任务都要优先执行。可以理解为:同步代码刚执行完,事件循环还没来得及“喘口气”,
nextTick里的任务就插队进来了! -
用途: 常常用于在当前操作完成后,但又不想立即进入事件循环的下一个阶段时,执行一些代码。比如,你想在处理完一个 HTTP 请求后,立刻发送响应,但又想在发送前做一些最后的清理工作,就可以用它。
-
小警告: 连续调用
process.nextTick会导致事件循环无法进入下一个阶段,可能导致 I/O 饥饿(I/O 操作的回调一直得不到执行)! 所以,不要滥用它,除非你非常清楚你在做什么!
-
-
setTimeout(callback, delay):-
江湖地位: 普通的“定时快递员”,宏任务!
-
执行时机: 在
delay毫秒后(注意是“至少”delay毫秒,不保证精确!),将callback放入定时器队列(属于timers阶段)。当事件循环进入timers阶段时,如果delay时间已到,就会执行该回调。 -
精度: 你设的
delay只是一个最小延迟时间。实际执行时间会受事件循环中其他任务的影响。比如你写了个setTimeout(fn, 0),它可不意味着立即执行!它仍然需要等待当前所有同步代码执行完毕,并且等待微任务队列清空,然后才能进入timers阶段,最后才轮到它。 -
用途: 延迟执行代码,比如动画、定时任务等。
-
-
setInterval(callback, delay):-
江湖地位: 周期性“定时快递员”,也是宏任务!
-
执行时机: 类似于
setTimeout,但它会重复地将callback放入定时器队列,每隔delay毫秒执行一次。 -
精度: 同样不保证精确,可能会有“漂移”现象(即实际间隔时间会比
delay长,因为每次执行完回调后,才会再次计算下一个delay)。 -
用途: 周期性执行任务,例如轮询数据、每隔一段时间清理缓存等。
-
-
setImmediate(callback):-
江湖地位: “检查员”,也是宏任务,属于
check阶段。 -
执行时机: 将
callback放入检查队列。当事件循环进入check阶段时(通常是在poll阶段执行完 I/O 回调之后),会执行该回调。 -
与
setTimeout(fn, 0)的区别(这俩最容易混淆!):-
在 I/O 回调内部: 如果你在一个 I/O 回调函数内部(比如
fs.readFile的回调)同时调用setTimeout(fn, 0)和setImmediate(fn),那么setImmediate总是比setTimeout(fn, 0)先执行。因为 I/O 回调在poll阶段执行,而poll阶段之后就是check阶段,然后才是timers阶段。 -
在顶层模块代码中(也就是你的脚本直接运行的地方): 执行顺序不确定!有时
setTimeout(fn, 0)先执行,有时setImmediate先执行。这取决于你系统的性能和事件循环的准备情况。可以理解为,它们两个都在排队,但起跑线有点模糊。
-
-
用途: 常用于将一些计算密集型任务分解,避免阻塞 I/O。如果你想确保某个任务在当前 I/O 周期结束后立即执行,
setImmediate是一个好选择。
-
总结表格(一图胜千言!):
| 特性 | process.nextTick() | setTimeout(fn, 0) | setImmediate() | setInterval() |
| :--------------- | :----------------------------- | :------------------------------- | :------------------------------- | :----------------------------- |
| 队列类型 | 微任务队列 (优先级最高) | 宏任务队列 (timers 阶段) | 宏任务队列 (check 阶段) | 宏任务队列 (timers 阶段) |
| 执行时机 | 当前操作完成后,立即执行,在事件循环任何阶段之前清空 | 至少 delay ms 后,在 timers 阶段执行 | 在 poll 阶段后,立即执行,在 check 阶段清空 | 至少 delay ms 后,在 timers 阶段重复执行 |
| 优先级 | 最高 | 低于 process.nextTick 和 Promise,与 setImmediate 相比不确定 (顶层) | 低于 process.nextTick 和 Promise,与 setTimeout(fn,0) 相比不确定 (顶层) | 同 setTimeout |
| 常见场景 | 在当前异步操作中进行“最后”调整,不希望阻塞下一轮事件循环,或在错误处理中立即抛出 | 延迟执行,如动画、简单定时任务 | 在 I/O 回调中快速响应,或分解任务,避免阻塞 I/O | 周期性任务 |
| 与 I/O 关系 | 总是优先于任何 I/O 回调 | 位于 I/O 回调之后 (通常) | 位于 I/O 回调之后 (通常),且在 I/O 回调内部调用时,总是优先于 setTimeout(fn, 0) | 位于 I/O 回调之后 (通常) |
小练习:
尝试自己写一些代码,结合 setTimeout(fn, 0) 和 setImmediate(fn),分别在顶层作用域和 fs.readFile 的回调中运行,观察它们的输出顺序,加深理解!
本节总结:
各位 Node.js 的“准高手”们,今天咱们算是把 Node.js 的“内功心法”——V8 引擎和事件循环彻底搞明白了!
-
你现在知道,V8 引擎就是那个让 JavaScript 跑得飞快的“发动机”。
-
你也理解了 Node.js 的“单线程”是如何通过事件驱动和非阻塞 I/O 来实现“高并发”的“魔术”!
-
最重要的是,你掌握了事件循环的各个“阶段”,以及微任务和宏任务的优先级关系,特别是
process.nextTick、setTimeout、setInterval和setImmediate这几个“异步调度员”的脾气秉性!
理解这些,就如同你拿到了 Node.js 的“操作手册”,知道它为什么能高效运转。这对于你未来编写高性能、高并发的 Node.js 应用至关重要!
下节预告:
掌握了事件循环,咱们就来聊聊 JavaScript 异步编程的“进化史”!从让人头疼的“回调地狱”,到优雅的 Promise,再到未来感十足的 async/await,让你写异步代码像写同步代码一样舒服!下节课,不见不散!
好的,同学们,上节课咱们深度剖析了 Node.js 的“心脏”和“大脑”——V8 引擎和事件循环。相信你现在对 Node.js 为什么这么高效,以及它背后的异步机制已经有了更深刻的理解。
既然我们已经知道 Node.js 是一个天生异步的“家伙”,那如何优雅地编写和管理异步代码就成了咱们的必修课!今天,咱们就来一起经历 JavaScript 异步编程的“进化史”:从让人头疼的“回调地狱”,到优雅的 Promise,再到未来感十足的 async/await!这绝对是 Node.js 开发中的“核心技能树”!
第 3 节:JavaScript 异步编程:告别“回调地狱”,拥抱光明未来!(你的代码终于能“拉直”了!)
学习目标:
在本节课中,你将理解为什么异步编程在 Node.js 中如此重要,掌握回调函数的基本用法及其“回调地狱”问题,并学会如何使用 Promise 及其各种方法来管理异步操作,让你的代码变得更加清晰和可维护。
3.1 为什么需要异步编程?
各位老铁,同学们好! 再次强调一个 Node.js 的核心设计理念:单线程、非阻塞 I/O 模型。
-
单线程 (Single-threaded): 这意味着你的 Node.js 进程只有一个主线程,来执行你所有的 JavaScript 代码。就像一个服务员,他一次只能处理一个客人的请求。
-
阻塞 I/O (Blocking I/O): 想象一下,如果这个服务员去后厨帮你炒菜(这是一个耗时操作),他必须傻站在炉子旁边,看着菜炒好,才能去招呼下一桌客人。在这期间,其他客人都在“干等”,是不是很糟糕?
-
非阻塞 I/O (Non-blocking I/O): Node.js 里的服务员可聪明多了!他点完菜,立马把菜谱扔给后厨,自己就去招呼下一桌客人了。等厨师菜炒好了,会“叮”一声通知他,他再把菜给你端过来。这样,服务员就永远不会“闲着”,总是在处理请求,效率大大提高!
所以,为什么我们需要异步编程?
想象一下,如果 Node.js 采用的是阻塞 I/O:
-
当你的服务器同时收到 1000 个请求,其中 999 个请求都需要从数据库里查数据(假设每次查询需要 500 毫秒)。
-
如果 Node.js 是阻塞的,那么第一个请求会占用主线程 500 毫秒,在这期间,其他 999 个请求都得排队等着。等第一个请求处理完了,第二个请求才开始,又等 500 毫秒……
-
结果就是:用户请求的响应时间会无限拉长,服务器的吞吐量低到令人发指,你网站的用户体验直接“负分”!你的老板可能会拿着菜刀追着你跑!
异步编程的解决方案:
通过异步编程,当 Node.js 遇到一个耗时操作(比如文件读写、网络请求、数据库查询)时,它会:
-
发起操作: 把这个任务甩给底层系统去处理。
-
注册回调: 同时告诉底层系统:“你忙完了,就调一下我给你的这个函数(也就是回调函数),我会来处理结果的!”
-
继续执行: Node.js 的主线程立即撒腿就跑,继续执行后续的 JavaScript 代码,根本不会在这里等待。
-
事件循环: 当那个耗时操作完成后,底层系统会通知 Node.js,然后它的回调函数就会被放到“事件队列”里。Node.js 的事件循环会不断地检查这个队列,只要主线程一“闲”下来,它就会把队列里的回调函数取出来,然后执行它!
这种模型使得 Node.js 能够以极高的效率处理大量并发连接,因为它从不等待 I/O 操作。它利用等待 I/O 的“空闲时间”去处理其他请求了!就像那个咖啡厅的服务员,永远在“忙”着,但却不“阻塞”。
理解了吗?异步编程就是 Node.js 的“呼吸方式”!如果你想在 Node.js 里“顺畅呼吸”,就必须掌握异步编程!
3.2 回调函数 (Callbacks) 及 "回调地狱" 问题
好了,异步编程很重要,那怎么实现呢?
最基本、最原始、也是 JavaScript 异步编程的“老祖宗”就是——回调函数 (Callbacks)!
什么是回调函数?
它就是一个函数,你把它作为参数传递给另一个函数,然后告诉那个函数:“兄弟,你忙完你的事儿之后,记得调一下我这个函数啊!”
来个栗子(模拟异步操作):
// 模拟一个异步操作:读取文件
// filePath: 你要读取的文件路径
// callback: 文件读取完成后要调用的函数
function readFileAsync(filePath, callback) {
console.log(`[异步开始] 开始读取文件: ${filePath}`);
// 咱们用 setTimeout 模拟文件读取的耗时,比如 1 秒
setTimeout(() => {
const content = `这是文件 ${filePath} 的内容。`;
console.log(`[异步完成] 文件读取完成: ${filePath}`);
// 读取成功了!调用你给我的回调函数,把内容(data)传给你
// 约定俗成:回调函数的第一个参数通常是错误对象 (error),第二个是成功数据 (data)
callback(null, content); // null 表示没有错误
}, 1000); // 假设读取文件需要 1000 毫秒
}
console.log("程序开始执行...");
// 调用异步函数,并传递一个匿名回调函数
readFileAsync("file1.txt", (error, data) => {
if (error) { // 检查是否有错误
console.error("读取文件失败:", error);
return; // 有错误就直接返回,不再执行后续逻辑
}
console.log("成功读取到数据:", data);
console.log("所有操作完成。");
});
console.log("后续代码继续执行,不等待文件读取..."); // 这行会先于 readFileAsync 里面的 console.log 打印
运行结果:
程序开始执行...
[异步开始] 开始读取文件: file1.txt
后续代码继续执行,不等待文件读取...
[异步完成] 文件读取完成: file1.txt
成功读取到数据: 这是文件 file1.txt 的内容。
所有操作完成。
看到了吗?后续代码继续执行... 这句话是先打印的,这就是非阻塞的体现!当 readFileAsync 开始执行后,主线程并没有停下来等它,而是继续执行后面的代码。等 1 秒后文件读完,回调函数才被执行。
“回调地狱” (Callback Hell / Pyramid of Doom) — 噩梦的开始!
回调函数虽然解决了异步问题,但是!但是!但是!当你的多个异步操作需要按顺序执行,而且后一个操作依赖于前一个操作的结果时,使用回调函数就会导致代码层层嵌套,缩进越来越多,最终形成一个像“金字塔”一样的结构,或者像一碗“面条”一样,纠缠不清。这就是令人闻风丧胆的——回调地狱!
来个“地狱”栗子:
// 模拟多个异步操作,后一个依赖前一个的结果
function step1(callback) {
setTimeout(() => {
console.log("Step 1 完成,得到数据A");
callback(null, "数据A");
}, 500);
}
function step2(dataA, callback) { // step2 依赖 step1 的数据A
setTimeout(() => {
console.log(`Step 2 完成,使用了 ${dataA},得到数据B`);
callback(null, "数据B");
}, 700);
}
function step3(dataB, callback) { // step3 依赖 step2 的数据B
setTimeout(() => {
console.log(`Step 3 完成,使用了 ${dataB},得到最终结果`);
callback(null, "最终结果");
}, 300);
}
console.log("--- 回调地狱示例 ---");
step1((err1, result1) => { // 第一层回调
if (err1) { console.error("Step 1 失败:", err1); return; }
step2(result1, (err2, result2) => { // 第二层回调,嵌套进去了
if (err2) { console.error("Step 2 失败:", err2); return; }
step3(result2, (err3, result3) => { // 第三层回调,又嵌套进去了!
if (err3) { console.error("Step 3 失败:", err3); return; }
console.log("所有步骤完成,最终结果:", result3);
});
});
});
console.log("主程序继续执行,不受回调地狱影响...");
运行结果:
--- 回调地狱示例 ---
主程序继续执行,不受回调地狱影响...
Step 1 完成,得到数据A
Step 2 完成,使用了 数据A,得到数据B
Step 3 完成,使用了 数据B,得到最终结果
所有步骤完成,最终结果: 最终结果
你看到了吗?代码一层套一层,当逻辑再复杂一点,嵌套更多的时候,你根本不知道自己在哪里,更别提维护和调试了!
回调地狱的问题总结:
-
可读性差: 缩进一层又一层,眼花缭乱,代码像面条一样纠缠。
-
维护困难: 任何逻辑的修改都可能牵一发而动全身,影响多层嵌套。
-
错误处理复杂: 每个回调都需要单独判断
if (error),很容易遗漏。如果你忘了处理一个中间的错误,程序可能会直接“崩溃”,或者产生意想不到的结果。 -
控制流混乱: 难以判断代码的实际执行顺序,因为它们不是按书写顺序执行的。
所以,为了拯救我们这些“苦命”的程序员,Promise 登场了!它就是来解决“回调地狱”这个大魔头的!
3.3 Promise (Promises) 基础:异步编程的“救世主”!
好了,各位“身陷地狱”的兄弟们,擦干眼泪!Promise 来了,它是 ES6 (ECMAScript 2015) 引入的一种异步编程解决方案,旨在解决回调地狱的噩梦,提供更优雅、可预测的异步操作管理方式。
Promise 的概念:
你可以把 Promise 想象成一个“承诺书”或者“未来值”。它代表了一个异步操作最终会成功(兑现承诺)或失败(拒绝承诺),以及它最终会产生一个什么结果。
Promise 的三种状态:
一个 Promise 对象从创建到结束,只有这三种状态:
-
pending(待定/进行中): 初始状态,表示异步操作正在进行,还没有成功,也没有失败。就像你点了个外卖,正在路上,还没送到。 -
fulfilled(已成功 / resolved): 异步操作成功完成,Promise 兑现了它的承诺,并且有了一个最终的结果值。外卖送到了,你拿到了香喷喷的饭菜。 -
rejected(已失败): 异步操作失败了,Promise 拒绝了它的承诺,并且有一个失败的原因(通常是一个 Error 对象)。外卖洒了,或者外卖小哥跑路了。
Promise 的特点:
-
一旦改变,永不逆转: 一旦 Promise 的状态从
pending变为fulfilled或rejected,它就不可逆转了!它不会再从fulfilled变回pending,也不会从rejected变回pending。 -
一次性: 一个 Promise 只能成功一次或失败一次。
创建 Promise:写下你的“承诺书”!
使用 new Promise() 构造函数来创建一个 Promise 实例。它接收一个 executor 函数作为参数。这个 executor 函数会立即执行,并且它本身又会接收两个参数:resolve 和 reject。
-
resolve(value): 当异步操作成功时,你调用resolve(),把成功的结果value传给它。这会将 Promise 的状态从pending变为fulfilled。 -
reject(reason): 当异步操作失败时,你调用reject(),把失败的原因reason传给它(通常是一个Error对象)。这会将 Promise 的状态从pending变为rejected。
来,咱们把刚才那个 delay 函数用 Promise 写一遍:
// 模拟一个异步操作:延迟指定毫秒数
function delay(ms) {
return new Promise((resolve, reject) => { // 返回一个 Promise 对象
if (ms < 0) { // 简单加个错误判断
reject(new Error("延迟时间不能为负数!")); // 异步操作失败,调用 reject
return; // 别忘了 return,阻止后续代码执行
}
setTimeout(() => {
resolve(`恭喜你,成功延迟了 ${ms} 毫秒!`); // 异步操作成功,调用 resolve
}, ms);
});
}
console.log("--- Promise 创建示例 ---");
// 调用 delay 函数,它会返回一个 Promise
delay(2000) // 延迟 2 秒
.then(message => { // Promise 成功时,会调用 .then() 里面的第一个回调函数
console.log(message); // 输出:恭喜你,成功延迟了 2000 毫秒!
})
.catch(error => { // Promise 失败时,会调用 .catch() 里面的回调函数
console.error("发生错误:", error.message);
});
// 模拟一个错误情况
delay(-500)
.then(message => {
console.log(message); // 这行不会执行,因为 Promise 会被 reject
})
.catch(error => {
console.error("发生错误:", error.message); // 输出:发生错误: 延迟时间不能为负数!
});
console.log("主程序继续执行,不会等待延迟..."); // 这行会立即打印
运行结果:
--- Promise 创建示例 ---
主程序继续执行,不会等待延迟...
发生错误: 延迟时间不能为负数!
恭喜你,成功延迟了 2000 毫秒!
是不是感觉代码结构清晰多了?成功和失败的逻辑分开了!
链式调用 (.then()):解决“回调地狱”的“屠龙刀”!
Promise 最最强大的地方,就是它的链式调用能力!它彻底解决了“回调地狱”层层嵌套的问题,让你的异步代码可以像同步代码一样,从上到下“一根筋”地写下去。
-
.then(onFulfilled, onRejected):-
onFulfilled: 当 Promise 成功时(fulfilled状态)调用的回调函数。 -
onRejected: 当 Promise 失败时(rejected状态)调用的回调函数(这个参数是可选的,通常我们用.catch()来统一处理错误)。 -
关键点:
.then()方法总是返回一个新的 Promise 对象!这就是它能链式调用的秘密所在。你可以把这个新的 Promise 再.then()一下,无限套娃(好的那种)。
-
链式调用的原理:
-
当你
.then()了一个 Promise 后,它会返回一个新的 Promise。 -
如果你在
onFulfilled或onRejected回调函数中返回一个非 Promise 的值,那么这个值会作为下一个.then()的成功结果。 -
如果你在回调函数中返回一个新的 Promise,那么下一个
.then()会“等待”这个新的 Promise 解决,并以它最终的结果作为自己的结果。
来,咱们把前面那个“回调地狱”的例子,用 Promise 链式调用来“救赎”一下!
// 假设 step1, step2, step3 现在都返回 Promise 对象
function step1Promise() {
return new Promise(resolve => {
setTimeout(() => {
console.log("Step 1 完成 (Promise),得到数据A");
resolve("数据A"); // 成功了,resolve 数据A
}, 500);
});
}
function step2Promise(dataA) { // step2 依赖 step1 的数据A
return new Promise(resolve => {
setTimeout(() => {
console.log(`Step 2 完成 (Promise),使用了 ${dataA},得到数据B`);
resolve("数据B"); // 成功了,resolve 数据B
}, 700);
});
}
function step3Promise(dataB) { // step3 依赖 step2 的数据B
return new Promise(resolve => {
setTimeout(() => {
console.log(`Step 3 完成 (Promise),使用了 ${dataB},得到最终结果`);
resolve("最终结果"); // 成功了,resolve 最终结果
}, 300);
});
}
console.log("\n--- Promise 链式调用示例 (告别地狱!) ---");
step1Promise() // 调用第一个 Promise
.then(result1 => { // 当 step1Promise 成功后,执行这里的回调
console.log("Step 1 回调处理中...");
return step2Promise(result1); // 返回一个新的 Promise,让下一个 .then() 等待它
})
.then(result2 => { // 当 step2Promise 成功后,执行这里的回调
console.log("Step 2 回调处理中...");
return step3Promise(result2); // 再次返回一个新的 Promise
})
.then(finalResult => { // 当 step3Promise 成功后,执行这里的回调
console.log("Step 3 回调处理中...");
console.log("所有步骤完成,最终结果 (Promise):", finalResult);
})
.catch(error => { // 统一处理链中任何环节的错误,这一个 catch 就能搞定前面所有错误!
console.error("Promise 链中发生错误:", error);
});
console.log("主程序继续执行,不受 Promise 链影响...");
运行结果:
--- Promise 链式调用示例 (告别地狱!) ---
主程序继续执行,不受 Promise 链影响...
Step 1 完成 (Promise),得到数据A
Step 1 回调处理中...
Step 2 完成 (Promise),使用了 数据A,得到数据B
Step 2 回调处理中...
Step 3 完成 (Promise),使用了 数据B,得到最终结果
Step 3 回调处理中...
所有步骤完成,最终结果 (Promise): 最终结果
看到没?!代码是不是瞬间“拉直”了?!没有了层层嵌套,可读性大大提高!这就是 Promise 的魅力!
错误处理 (.catch()): Promise 的“安全网”!
在 Promise 链中,错误处理变得非常优雅。Promise.prototype.catch() 方法是 .then(null, onRejected) 的语法糖,它专门用于捕获 Promise 链中任何环节抛出的错误(即任何一个 Promise 被 rejected)。
- 特点: 一个
.catch()可以捕获其之前所有.then()中发生的错误!你只需要在链的末尾放一个.catch()就能搞定所有错误!
示例:
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); // 这行不会执行,因为 Promise 失败了
})
.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()): Promise 的“收尾工作”!
Promise.prototype.finally() 方法是 ES9 (ECMAScript 2018) 新增的,它为 Promise 链提供了一个完美的“收尾”机制。
-
特点: 无论 Promise 最终是
fulfilled(成功)还是rejected(失败),onFinally回调函数都会被执行。 -
不传参: 它不接收任何参数,因为它并不知道 Promise 是成功了还是失败了。
-
用途: 主要用于执行一些清理工作,例如关闭加载指示器、释放资源、不管结果如何都要做的事情等。
-
返回 Promise:
.finally()也会返回一个 Promise,允许你继续链式调用。
示例:
function doSomethingAsync(succeed) {
console.log("开始执行异步操作...");
return new Promise((resolve, reject) => {
setTimeout(() => {
if (succeed) {
resolve("操作成功完成!");
} else {
reject(new Error("操作失败了!"));
}
}, 1000);
});
}
console.log("\n--- Promise .finally() 示例 ---");
doSomethingAsync(true) // 成功的情况
.then(result => {
console.log("结果:", result);
})
.catch(error => {
console.error("错误:", error.message);
})
.finally(() => { // 无论成功或失败,都会执行
console.log("无论成功或失败,都会执行清理工作,比如关闭加载动画。");
});
doSomethingAsync(false) // 失败的情况
.then(result => {
console.log("结果:", result);
})
.catch(error => {
console.error("错误:", error.message);
})
.finally(() => { // 无论成功或失败,都会执行
console.log("无论成功或失败,都会执行清理工作,比如关闭加载动画。");
});
本节总结:
好了,各位同学,通过这节课,我们彻底搞明白了:
-
异步编程是 Node.js 高性能的关键,它通过非阻塞 I/O 避免了主线程的阻塞。
-
回调函数是异步编程的基础,但多层嵌套会导致**“回调地狱”**,代码变得难以阅读和维护。
-
Promise 是异步编程的“救星”!它提供了更结构化、更易于管理异步操作的方式。通过链式调用 (
.then()),它完美地解决了回调地狱问题;通过.catch(),它提供了统一的错误处理机制;而.finally()则用于执行最终的清理操作。
Promise 是现代 JavaScript 异步编程的基石,为我们后续学习更高级、更“漂亮”的 async/await 语法打下了坚实的基础。
下节预告:
下节课,我们将迎来异步编程的“终极形态”——async/await!它能让你以一种你想象不到的“同步”方式去编写异步代码,让你的代码既强大又优雅!准备好了吗?咱们下节课不见不散!
好的,同学们,上节课咱们经历了 JavaScript 异步编程的“进化史”,从让人头疼的“回调地狱”走到了优雅的 Promise 链式调用。是不是感觉代码瞬间清爽了许多?
但你有没有觉得,虽然 Promise 已经很棒了,但 then().then().catch() 这种链式写法,离我们写同步代码那种“从上到下、一气呵成”的感觉还是差了点意思?有没有一种方法,能让我们写异步代码就像写同步代码一样简单直观,但又不会阻塞主线程呢?
答案是:有! 隆重推出现代 JavaScript 异步编程的“终极利器”——Async/Await!它就像给你的异步代码施展了“魔法”,让它瞬间变得像同步代码一样好读好写!
第 4 节:现代异步编程的终极奥义:Async/Await(写异步代码像写同步代码一样爽!)
学习目标:
在本节课中,你将彻底掌握 async 和 await 关键字的用法,理解它们如何简化异步代码,学会使用 try...catch 处理异步错误,并清楚地认识到 async/await 与 Promise 之间的“血缘关系”。
4.1 async/await 语法糖:如何简化异步代码
同学们,注意了!这是 JavaScript 异步编程的“王者荣耀”时刻!
async/await 是 ECMAScript 2017 (ES8) 引入的新特性。说白了,它就是 Promise 的一个**“语法糖”。啥叫语法糖?就是它骨子里还是 Promise,但穿了一件“漂亮的外衣”,让你写起来、读起来都更舒服、更直观,就像写同步代码一样!但它绝对不会阻塞主线程**,这点要牢牢记住!
核心概念:两个“魔法”关键字
-
async关键字:-
作用: 用来修饰一个函数,把它变成一个“异步函数”。
-
返回值: 一个
async函数总是返回一个 Promise 对象!-
如果你在
async函数内部返回一个非 Promise 的值(比如一个数字、一个字符串、一个对象),这个值会被 JavaScript 自动包装成一个“已解决”(resolved)的 Promise。 -
如果你在
async函数内部抛出一个错误,这个错误会被 JavaScript 自动包装成一个“已拒绝”(rejected)的 Promise。
-
-
使用条件:
await关键字只能在async函数内部使用。你不能在普通的函数里直接用await,否则会报错。
-
-
await关键字:-
作用: 只能在
async函数内部使用。它会“告诉” JavaScript 引擎:“老铁,等等我!等我后面这个 Promise 搞定了,你再往下执行!” -
暂停执行: 当
await遇到一个 Promise 时,它会暂停当前async函数的执行,直到这个 Promise 被解决 (resolved) 或者被拒绝 (rejected)。 -
取值/抛错:
-
如果 Promise 成功解决了,
await会直接返回 Promise 解决的那个值。 -
如果 Promise 失败被拒绝了,
await会像同步代码一样,直接抛出一个错误(这个错误你可以用try...catch来捕获)。
-
-
最重要的点:
await只是暂停了当前async函数的执行,它不会阻塞 JavaScript 引擎的主线程!这一点非常非常关键!这意味着,当一个async函数因为await而暂停时,Node.js 的事件循环仍然在愉快地运行,处理其他的请求和任务,你的服务器并不会“卡住”!
-
简化异步代码的示例对比(看看它是怎么把代码“拉直”的!)
咱们通过一个模拟网络请求的场景来感受一下 async/await 的魔力。
场景: 咱们要模拟从服务器获取一个用户的数据,然后根据这个用户 ID,再去获取这个用户的帖子列表。这是一个典型的“串行异步操作”,后一个依赖前一个的结果。
先看看“苦日子”:使用 Promise 链:
// 模拟获取用户数据的 Promise 函数
function fetchUserData(userId) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`[Promise] 成功获取用户 ${userId} 的数据`);
resolve({ id: userId, name: `用户_${userId}_大宝` }); // 假设返回用户对象
}, 1000); // 模拟 1 秒网络延迟
});
}
// 模拟获取用户帖子列表的 Promise 函数
function fetchUserPosts(userId) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`[Promise] 成功获取用户 ${userId} 的帖子`);
resolve([`帖子 A by ${userId}`, `帖子 B by ${userId}`]); // 假设返回帖子数组
}, 800); // 模拟 0.8 秒网络延迟
});
}
console.log("--- Promise 链式调用示例 ---");
fetchUserData(123) // 第一步:获取用户数据
.then(user => { // 用户数据获取成功后,执行这里
console.log("用户数据:", user);
return fetchUserPosts(user.id); // 返回一个新的 Promise,继续链式调用
})
.then(posts => { // 用户帖子获取成功后,执行这里
console.log("用户帖子:", posts);
console.log("恭喜!所有数据都搞定了!");
})
.catch(error => { // 链中任何一步出错,都会被这里捕获
console.error("哎呀,Promise 链中出错了:", error);
});
console.log("主线程继续执行,不会等待 Promise 链...");
再看看“幸福生活”:使用 async/await:
// 咱们假设 fetchUserData 和 fetchUserPosts 函数和上面 Promise 例子里的一样,都返回 Promise。
async function getAllUserData(userId) { // 看!函数前面加了个 async!
console.log("\n--- Async/Await 示例 ---");
try { // 看到没?可以直接用 try...catch 处理错误了!
// 第一步:获取用户数据
// await 会让当前这个 async 函数暂停!直到 fetchUserData(userId) 这个 Promise 成功解决。
// 就像同步代码一样,直接把结果赋给 user 变量,多爽!
const user = await fetchUserData(userId);
console.log("用户数据:", user);
// 第二步:获取用户帖子
// 同样,await 会再次暂停!直到 fetchUserPosts(user.id) 这个 Promise 成功解决。
const posts = await fetchUserPosts(user.id);
console.log("用户帖子:", posts);
console.log("恭喜!所有数据都搞定了!");
} catch (error) {
// 如果 await 等待的任何 Promise 失败了,错误就会被这里捕获,跟同步代码抛错一模一样!
console.error("哎呀,Async/Await 中出错了:", error);
}
}
getAllUserData(456); // 调用 async 函数
console.log("主线程继续执行,不会等待 async 函数..."); // 这行会立即打印
运行结果(两个示例都会):
--- Promise 链式调用示例 ---
主线程继续执行,不会等待 Promise 链...
--- Async/Await 示例 ---
主线程继续执行,不会等待 async 函数...
[Promise] 成功获取用户 123 的数据 // Promise 链的第一步
用户数据: { id: 123, name: '用户_123_大宝' }
[Async/Await] 成功获取用户 456 的数据 // Async/Await 的第一步
用户数据: { id: 456, name: '用户_456_大宝' }
[Promise] 成功获取用户 123 的帖子 // Promise 链的第二步
用户帖子: [ '帖子 A by 123', '帖子 B by 123' ]
恭喜!所有数据都搞定了!
[Async/Await] 成功获取用户 456 的帖子 // Async/Await 的第二步
用户帖子: [ '帖子 A by 456', '帖子 B by 456' ]
恭喜!所有数据都搞定了!
对比分析:
-
可读性: 你看看
async/await的代码!是不是就像写同步代码一样?从上到下顺序执行,一目了然!再看看 Promise 链,虽然解决了地狱,但还是需要通过.then()的回调来组织逻辑,多了一层“套娃”。async/await简直就是“所见即所得”! -
流程控制: 在
async/await中,你可以自由地使用普通的if/else、for循环、while循环等等这些“同步”的流程控制语句来处理异步操作的结果。这在 Promise 链里,通常需要更复杂的嵌套或者一些“骚操作”才能实现。 -
错误处理:
async/await可以使用咱们最熟悉的标准try...catch语句来捕获异步操作中抛出的错误!这简直是“回归本源”!相比 Promise 的.catch(),它更符合我们处理同步错误的习惯,逻辑上更统一。
一句话:async/await 让你的异步代码既强大又优雅,简直是“程序员的福音”!
4.2 try...catch 处理异步错误
既然 await 关键字能像同步代码一样“抛出”错误,那么,使用 try...catch 来捕获这些异步错误就变得自然而然,而且异常直观。
示例:模拟一个可能失败的请求
// 模拟一个可能失败的网络请求
function simulateFailedRequest() {
return new Promise((resolve, reject) => {
setTimeout(() => {
const success = Math.random() > 0.5; // 50% 的几率失败
if (success) {
console.log("请求成功!");
resolve("这是服务器返回的成功数据。");
} else {
console.error("请求失败!");
reject(new Error("网络错误或服务器抽风了。")); // 模拟失败时 reject 一个 Error 对象
}
}, 1000); // 模拟 1 秒网络延迟
});
}
async function fetchDataWithErrorHandling() {
console.log("\n--- Async/Await 错误处理示例 ---");
try {
// await 尝试等待这个 Promise。
// 如果 Promise 成功,结果赋给 result。
// 如果 Promise 失败(被 reject),它就会像同步代码一样抛出那个错误!
const result = await simulateFailedRequest();
console.log("成功接收到数据:", result);
} catch (error) {
// 哎呀!如果 simulateFailedRequest 内部的 Promise 被 reject 了,
// 那个错误就会被这个 try...catch 的 catch 块捕获到!
console.error("捕获到错误:", error.message); // 打印错误信息
} finally {
// 无论成功还是失败,finally 块都会执行,通常用于清理工作
console.log("异步操作完成,无论是成功还是失败。");
}
}
// 多次调用,看看成功和失败的不同情况
fetchDataWithErrorHandling();
fetchDataWithErrorHandling();
fetchDataWithErrorHandling();
运行结果(每次运行可能不同,因为有随机性):
--- Async/Await 错误处理示例 ---
请求成功!
成功接收到数据: 这是服务器返回的成功数据。
异步操作完成,无论是成功还是失败。
请求失败!
捕获到错误: 网络错误或服务器抽风了。
异步操作完成,无论是成功还是失败。
请求成功!
成功接收到数据: 这是服务器返回的成功数据。
异步操作完成,无论是成功还是失败。
在这个例子中,如果 simulateFailedRequest() 返回的 Promise 被拒绝了(也就是它内部调用了 reject()),那么 await 就会把那个错误“扔”出来,然后 try...catch 块中的 catch 部分就会像捕获同步错误一样,轻松地把它“抓住”,然后执行相应的错误处理逻辑。这使得异步错误的管理与同步错误一样简单明了,简直不要太方便!
4.3 与 Promise 的关系
同学们,再次强调一个核心点!async/await 并不是凭空出现的“黑科技”!它和 Promise 之间有着“剪不断理还乱”的“血缘关系”!
-
async/await是基于 Promise 的:-
async函数的返回值永远是一个 Promise! 无论你在async函数里是return了一个普通值,还是throw了一个错误,它最终都会被包装成一个 Promise 对象返回。 -
await关键字只能等待一个 Promise! 如果你await的后面不是一个 Promise(比如你await 123),JavaScript 会自动把它包装成一个已解决的 Promise,然后立即解析。 -
这说明啥?这意味着
async/await并没有取代 Promise!它只是提供了一种更优雅、更易读的方式来使用和管理 Promise。Promise 仍然是异步编程的底层基石!
-
-
可以混合使用:
-
你可以在
async函数内部,继续使用Promise.all()、Promise.race()等 Promise 的静态方法来处理并行异步操作,然后用await等待这些结果。 -
你也可以在非
async函数中,使用.then()和.catch()来处理一个async函数返回的 Promise。
-
示例:async/await 与 Promise.all 结合使用(并行执行任务,效率更高!)
当你有多个异步操作,它们之间没有依赖关系,可以同时进行时,Promise.all 就是一个非常棒的选择。结合 async/await,代码会更加简洁和强大。
// 模拟获取用户列表,耗时 1.5 秒
function fetchUsers() {
return new Promise(resolve => setTimeout(() => {
console.log("[并行任务] 成功获取用户列表。");
resolve(['Alice', 'Bob', 'Charlie']);
}, 1500));
}
// 模拟获取产品列表,耗时 1 秒
function fetchProducts() {
return new Promise(resolve => setTimeout(() => {
console.log("[并行任务] 成功获取产品列表。");
resolve(['Laptop', 'Mouse', 'Keyboard']);
}, 1000));
}
async function fetchAllDataConcurrently() {
console.log("\n--- 并行执行任务示例 (Promise.all + Async/Await) ---");
try {
// Promise.all 会同时(并行)执行 fetchUsers() 和 fetchProducts() 这两个 Promise。
// await 会暂停当前这个 async 函数,直到 Promise.all 返回的那个 Promise 解决。
// 解构赋值 [users, products] 会直接拿到两个 Promise 成功的结果!
const [users, products] = await Promise.all([
fetchUsers(), // 这个会先完成
fetchProducts() // 这个会后完成,所以 await 会等它俩都完成
]);
console.log("\n所有并行数据都已获取完毕!耗时取最长的那个!");
console.log("用户列表:", users);
console.log("产品列表:", products);
} catch (error) {
console.error("哎呀,并行获取数据时出错了:", error);
}
}
fetchAllDataConcurrently();
console.log("主线程继续执行,不会等待并行任务...");
运行结果:
--- 并行执行任务示例 (Promise.all + Async/Await) ---
主线程继续执行,不会等待并行任务...
[并行任务] 成功获取产品列表。
[并行任务] 成功获取用户列表。
所有并行数据都已获取完毕!耗时取最长的那个!
用户列表: [ 'Alice', 'Bob', 'Charlie' ]
产品列表: [ 'Laptop', 'Mouse', 'Keyboard' ]
看到了吗?fetchUsers() 和 fetchProducts() 会同时开始执行,而不是一个接一个。整个 await Promise.all() 的操作时间取决于其中最慢的那个 Promise 的完成时间(在这个例子中是 fetchUsers() 的 1.5 秒),这极大地提高了效率!而且代码依然非常整洁!
总结:Async/Await,你的异步编程“王牌”!
好了,同学们,通过这节课的深入学习,你已经完全掌握了 async/await 这个强大的异步编程利器:
-
async/await是 Promise 的“语法糖”,它让你的异步代码看起来和写起来都更像同步代码,可读性瞬间提升几个档次! -
async函数总是返回 Promise,并且只有在async函数内部才能使用await。 -
await会暂停当前async函数的执行,直到它等待的 Promise 解决或拒绝,然后返回其结果或抛出错误。 -
try...catch是处理async/await异步错误的标准方式,和处理同步错误一样简单。 -
async/await和 Promise 并非“竞争对手”,而是相辅相成的“好搭档”!你可以结合使用Promise.all()等方法来优化并行操作。
掌握 async/await 是现代 Node.js 和前端开发中处理异步操作的核心技能,它能显著提升你代码的可读性和可维护性。从今天开始,请拥抱 async/await,让你的异步代码变得更加优雅和强大吧!
下节预告:
掌握了异步编程,咱们再来聊聊 Node.js 代码组织和复用的“秘密武器”——模块化开发!Node.js 是如何让你把大项目拆分成小模块,方便管理和协作的呢?咱们下节课揭晓!不见不散!
好的,同学们,上节课咱们成功掌握了 async/await 这对“王炸组合”,彻底告别了“回调地狱”,让异步代码写起来像同步代码一样丝滑。是不是感觉功力又精进了不少?
现在,咱们继续深入 Node.js 的世界,这次我们来聊聊它的“内务管理”——模块化开发。你想想看,写代码可不是随便一堆文件扔那儿就行的,得有条不紊,分门别类,才能写出高楼大厦般的复杂应用。Node.js 在这方面可是个“模范生”,从骨子里就支持模块化,让你的代码管理变得井井有条!
Node.js 主要支持两种模块系统,就像你家里装修,既有传统的中式风格,也有流行的欧式风格。咱们今天就来把它们都掰扯清楚!
第 5 节:Node.js 模块化:代码管理的小秘密(让你的代码不再‘面条化’!)
学习目标:
本节课,你将理解 Node.js 为什么要搞模块化,掌握 Node.js 默认的 CommonJS 模块系统的导入导出规则,以及它的路径解析机制。同时,我们还会介绍未来趋势——ES Modules,让你提前武装自己!
5.1 CommonJS 模块系统 — Node.js 的“老规矩”!
各位同学,CommonJS 是 Node.js 诞生之初就采用的、也是默认的模块系统。你可以把它想象成 Node.js 的“传统武术”,虽然有点年头了,但依然强大实用,而且 Node.js 的很多内置模块和大部分早期发布的第三方库都用的是它。它的加载方式是同步的,也就是“我没加载完,你别想往下走!”
核心概念:你的“传家宝”和“求人符”!
-
require:-
作用: 这是你的“求人符”!你想用别人(其他模块)写好的功能?没问题,
require()一下,它就把那个模块“请”过来了。 -
特点: 同步加载,当
require()被调用时,它会暂停当前代码的执行,直到被请求的模块完全加载并执行完毕,才继续往下走。
-
-
module.exports:-
作用: 这是你的“传家宝”!你想把自己模块里的某个功能(比如一个函数、一个对象、一个类)分享出去,让别人
require进来用?那你就把它赋值给module.exports! -
核心:
require()函数最终返回的就是这个module.exports对象的值。所以,它才是模块真正的“出口”!
-
-
exports:-
作用: 这是
module.exports的一个“快捷方式”或者说“别名”。当你需要导出多个成员(比如好几个函数、好几个变量)时,可以用它来偷懒。 -
小心陷阱!:
exports只是module.exports的一个引用!你不能直接赋值给exports,比如exports = { a: 1 },否则会切断它和module.exports的联系,导致你的模块导不出去! 只能通过exports.xxx = ...这种形式来添加属性。
-
模块的导出规则:如何把你的“宝贝”分享出去?
在 Node.js 的 CommonJS 模块中,每个文件都被视为一个独立的模块。在每个模块内部,都有两个重要的“幕后英雄”:module 对象和 exports 对象。
1. module.exports (推荐和常用,导出一个“大宝贝”!)
这是模块最终对外暴露的对象。当你只想导出一个单一的值(比如一个函数、一个类、一个配置对象、一个字符串,甚至一个数字)时,直接把它赋值给 module.exports 就行了。
- 特点: 只要你给
module.exports重新赋值了,它就会覆盖掉之前所有的导出。require()最终拿到的,就是你最后赋值给module.exports的那个东西。
示例 1:导出一个函数(最常见的)
my_function.js
// my_function.js
function greet(name) {
return `Hello, ${name}! 欢迎来到 Node.js 的世界!`;
}
module.exports = greet; // 导出 greet 函数本身
// 简单粗暴,把 greet 函数当成整个模块的“主角”导出去
示例 2:导出一个对象(包含多个成员,像个“宝库”)
my_module.js
// my_module.js
const PI = 3.1415926;
function add(a, b) {
return a + b;
}
const subtract = (a, b) => a - b;
// 导出一个包含 PI, add, subtract 的对象
// 就像把一个装满宝物的箱子整体导出去
module.exports = {
PI, // ES6 的对象属性简写
add,
subtract
};
2. exports (作为 module.exports 的“小秘书”,添加“小零碎”!)
exports 对象其实就是 module.exports 的一个引用。你可以通过给 exports 添加属性来导出多个成员。
- 特点: 你只能通过添加属性的方式来使用
exports。千万不能直接赋值exports = { ... }! 如果你这么做了,exports和module.exports之间的“心电感应”就被切断了,require()进来的模块就会是空的,或者不是你想要的东西。
示例 3:通过 exports 导出多个成员
another_module.js
// another_module.js
exports.name = "Node.js 学习模块"; // 给 exports 对象添加一个 name 属性
exports.version = "v1.0.0";
exports.sayHello = function() { // 给 exports 对象添加一个 sayHello 方法
console.log("Hello from another module! 我是 exports 导出的!");
};
重要提示:exports 与 module.exports 的“三角关系”!
-
module.exports是模块真正对外暴露的接口,是最终的“大当家”! -
exports只是module.exports的一个引用,是“大当家”的“代理人”! 就像exports = module.exports这行代码一直在模块内部默默执行着。 -
如果你想导出一个单一的“大宝贝”(比如一个构造函数、一个类),请务必使用
module.exports = ...。 -
如果你想导出多个命名成员,你可以使用
exports.member = ...,或者更推荐直接使用module.exports = { member1, member2 }导出对象字面量,这样更清晰,也能避免“切断心电感应”的问题。 -
敲黑板!划重点! 永远不要直接对
exports进行赋值操作,例如exports = { a: 1 }! 这会破坏exports和module.exports之间的引用关系,导致require()得到的是一个空对象或其他意外结果。
模块的导入规则:如何“召唤”别人的“宝贝”?
使用我们前面讲过的 require() 函数来导入模块。
-
语法:
const moduleName = require('modulePath'); -
返回值:
require()返回的,就是被导入模块的那个module.exports对象的值。 -
缓存机制: 模块在第一次被
require()的时候,会被加载和执行一遍。之后,如果你再次require()同一个模块,Node.js 会很聪明地直接从缓存中返回这个模块的导出,而不会重复加载和执行。这大大提高了效率!
示例:导入上面定义的模块
app.js (这是一个新的文件,用于演示导入)
// app.js
// 导入导出的函数
const greet = require('./my_function'); // 注意路径是相对路径
console.log(greet('小明')); // 输出: Hello, 小明! 欢迎来到 Node.js 的世界!
// 导入导出的对象
const myModule = require('./my_module');
console.log(`圆周率PI是: ${myModule.PI}`); // 输出: 圆周率PI是: 3.1415926
console.log(`5 + 3 = ${myModule.add(5, 3)}`); // 输出: 5 + 3 = 8
// 导入通过 exports 方式导出的对象
const anotherModule = require('./another_module');
console.log(`模块名称: ${anotherModule.name}`); // 输出: 模块名称: Node.js 学习模块
anotherModule.sayHello(); // 输出: Hello from another module! 我是 exports 导出的!
// ---------------- 缓存机制演示 ----------------
console.log('\n--- 模块缓存演示 ---');
const sameModule1 = require('./my_module');
const sameModule2 = require('./my_module');
console.log(sameModule1 === sameModule2); // 输出: true (它们是同一个对象引用!)
// 在模块内部改变一个值,看看会不会影响到其他地方的引用
myModule.PI = 3.14; // 修改通过 myModule 导入的 PI
console.log(`修改后,sameModule1 的 PI 变成了: ${sameModule1.PI}`); // 输出: 3.14
console.log(`修改后,sameModule2 的 PI 变成了: ${sameModule2.PI}`); // 输出: 3.14
// 验证了它们都是同一个模块的引用,第一次 require 后就被缓存了。
路径解析机制:require() 怎么找到你的文件?
require() 函数在查找模块路径时,可不是瞎找的,它有一套严格的“寻宝规则”!
-
核心模块 (Core Modules):
-
如果
require()的参数是 Node.js 内置模块的名称(比如fs文件系统、http网络、path路径),Node.js 会直接加载这些内置模块。因为它们是 Node.js 自己“生”的,不用找。 -
示例:
require('fs'),require('http'),require('path')
-
-
相对路径模块 (Relative Path Modules):
-
如果
require()的参数以./(表示当前目录) 或../(表示上级目录) 或/(表示根目录) 开头,Node.js 会把它当成一个文件路径来找。 -
它会尝试按顺序查找:
-
精确匹配的文件名: 例如
require('./my-module.js'),直接找my-module.js。 -
自动添加扩展名: 如果没有指定扩展名,它会尝试添加
.js(例如require('./my-module')会先尝试找my-module.js)。 -
然后尝试
.json扩展名: 如果没找到.js,会尝试找.json文件(例如require('./data')会尝试找data.json)。 -
最后尝试
.node扩展名:.node通常是编译后的 C++ 插件。 -
如果路径是一个目录: 如果你
require('./my-dir'),Node.js 会先看my-dir目录下有没有一个package.json文件。如果有,它会查找package.json里main字段指定的入口文件。 -
如果
main字段不存在或无效: 它会尝试查找my-dir/index.js文件。
-
-
示例:
require('./utils/helper')(会找utils/helper.js或utils/helper/index.js),require('../config.json')
-
-
第三方模块 (Node Modules):
-
如果
require()的参数既不是核心模块名称,也不是相对路径(比如它就是一个单纯的包名,像express,lodash),Node.js 会认为这是一个第三方模块。 -
它会从当前文件所在的目录开始,向上级目录逐级查找名为
node_modules的文件夹。 -
一旦找到
node_modules文件夹,它会尝试在该文件夹内查找对应的模块(也是遵循上面“目录查找”的规则:先找package.json里的main字段,再找index.js)。 -
示例:
require('express'),require('lodash') -
理解这个很重要: 这就是为什么你的
node_modules文件夹总是那么庞大,因为它包含了你项目里所有第三方依赖以及它们各自的依赖!
-
5.2 ES Modules (ESM) 简介及在Node.js中的使用 — JavaScript 的“国际标准”!
好了,CommonJS 是 Node.js 的“传统武术”,那 ES Modules (ESM) 就是 JavaScript 家族的“国际标准”! 它是 ECMAScript 2015 (ES6) 引入的官方模块标准,目标是统一浏览器和 Node.js 的模块化方案。它采用静态加载的方式,这意味着模块的导入和导出在代码执行前就已经确定了,这对于工具进行优化(比如“Tree Shaking”,摇掉那些没用的代码)非常有帮助!
核心语法:新的“魔法”关键词 export 和 import!
-
export: 用于导出模块的成员。 -
import: 用于导入模块的成员。
导出规则 (export):分享你的“宝贝”,更清晰!
ESM 提供了两种主要的导出方式:命名导出 (Named Exports) 和默认导出 (Default Export)。
1. 命名导出 (Named Exports) — 导出一堆“有名字的宝贝”!
你可以导出多个有名字的成员,导入的时候也得用同样的名字来“点名”!
-
直接导出: 在声明变量、函数、类的时候直接加上
export关键字。// math.mjs (注意文件扩展名,后面会讲为什么是 .mjs) export const PI = 3.14159; export function add(a, b) { return a + b; } export class Calculator { // ... } -
列表导出: 先声明好,然后用花括号
{}统一导出。// utils.mjs const greet = (name) => `Hello, ${name}!`; const farewell = (name) => `Goodbye, ${name}!`; export { greet, farewell }; // 导出一堆已声明的变量/函数 -
重命名导出: 导出的时候给它换个名字。
// constants.mjs const MY_CONSTANT = 100; export { MY_CONSTANT as ConstantValue }; // 导出时重命名,别人 import 的时候就得用 ConstantValue
2. 默认导出 (Default Export) — 导出一个“默认宝贝”,别人爱咋叫咋叫!
每个模块只能有一个默认导出!导入的时候,你可以给它指定任意的名字,因为它就是这个模块的“默认值”。
-
语法:
export default// my_default_module.mjs const myDefaultFunction = () => console.log("我是这个模块的默认导出,你想叫我啥都行!"); export default myDefaultFunction; // 导出一个默认函数 // 你也可以直接导出匿名函数、类、或任何值: // export default class MyClass { /* ... */ } // export default 42;
导入规则 (import):去“召唤”别人的“宝贝”!
1. 导入命名导出:
-
语法:
import { member1, member2 } from 'modulePath';花括号里写具体的名字。 -
重命名导入:
import { member1 as newName } from 'modulePath';导入的时候给它换个名字。 -
导入所有命名导出 (作为一个命名空间对象):
import * as moduleAlias from 'modulePath';把一个模块里所有具名导出的东西都放到一个对象里。// app.mjs (用来演示导入) import { PI, add } from './math.mjs'; // 导入命名成员 console.log(PI); console.log(add(2, 3)); import { greet as sayHi } from './utils.mjs'; // 导入并重命名 sayHi('Bob'); import * as myMath from './math.mjs'; // 导入所有命名成员到 myMath 对象 console.log(myMath.PI);
2. 导入默认导出:
-
语法:
import defaultMember from 'modulePath';导入默认导出时,直接写一个名字就行,这个名字你可以随便取!// app.mjs import whateverYouWant from './my_default_module.mjs'; // 导入默认导出,我给它起了个名 whateverYouWant();
3. 混合导入 (默认导出和命名导出同时导入):
-
你可以在一行
import语句里,同时导入默认导出和命名导出。 -
语法:
import defaultMember, { namedMember1, namedMember2 } from 'modulePath';// mixed_exports.mjs export default function defaultFunc() { console.log("Default Export!"); } export const namedVar = "Named Export!"; // app.mjs import defaultFunc, { namedVar } from './mixed_exports.mjs'; defaultFunc(); console.log(namedVar);
4. 仅为副作用导入 (Side-effect Import):
-
有些模块,你可能不需要导入它的任何具体功能,只是想让它执行一下里面的代码(比如一个全局的 polyfill 脚本)。
-
语法:
import 'modulePath';// polyfill.mjs // 假设这里有一些兼容性代码,会修改 Array.prototype Array.prototype.myCustomMethod = function() { console.log("我是一个自定义数组方法!"); }; console.log("polyfill 脚本已执行。"); // app.mjs import './polyfill.mjs'; // 仅仅执行 polyfill.mjs 中的代码 // 此时,你就可以在 app.mjs 中使用 Array.prototype.myCustomMethod 了 [1, 2, 3].myCustomMethod();
ES Modules 在 Node.js 中的使用 — 新旧共存的“姿势”!
Node.js 对 ESM 的支持经历了一些“折腾”,但现在已经非常成熟了。主要有两种方式来告诉 Node.js 你这个文件是 ESM 模块,还是 CommonJS 模块:
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 模块,怎么办? 简单!把那个 CommonJS 文件命名为
.cjs扩展名就行!Node.js 会把它识别为 CommonJS 模块。 -
示例:
// 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文件都会被 Node.js 默认视为 CommonJS 模块。 -
在这种情况下,如果你想使用 ESM,就必须使用
.mjs扩展名。
一句话总结:
-
如果你想在文件层面控制:用
.mjs(ESM) 和.cjs(CommonJS)。 -
如果你想在项目层面默认控制:在
package.json里设置"type": "module"(默认 JS 文件是 ESM) 或"type": "commonjs"(默认 JS 文件是 CommonJS)。
ESM 与 CommonJS 的互操作性 — “老外”和“土著”怎么打交道?
在 Node.js 中,ESM 和 CommonJS 是可以“和平共处”,互相调用的,但有一些规矩:
-
在 ESM 中导入 CommonJS 模块:
-
你可以使用
import语句导入 CommonJS 模块。Node.js 会把那个 CommonJS 模块的module.exports值,当作它的“默认导出”来对待。 -
示例:
// commonjs_lib.js (这是一个 CommonJS 模块) module.exports = { foo: 'bar', baz: () => 'qux from CommonJS' }; // esm_app.mjs (这是一个 ES Modules 模块) import commonjsLib from './commonjs_lib.js'; // 注意 CommonJS 模块只有默认导出! console.log(commonjsLib.foo); // bar console.log(commonjsLib.baz()); // qux from CommonJS // import { foo } from './commonjs_lib.js'; // 这种写法是无效的!CommonJS 没命名导出! -
动态导入: 如果你需要动态地(异步地)导入 CommonJS 模块,可以使用
import()表达式,它返回一个 Promise。// esm_app.mjs async function loadDynamic() { const commonjsModule = await import('./commonjs_lib.js'); console.log(commonjsModule.default.foo); // 注意要访问 default 属性 } loadDynamic();
-
-
在 CommonJS 中导入 ESM 模块:
-
直接
require()ESM 模块是不支持的! 为什么?因为require()是同步加载的,而 ESM 的设计是异步加载的。这俩“脾气不合”!如果你硬要require()一个.mjs文件,会报错。 -
解决方案: 如果你非要在 CommonJS 模块中加载 ESM 模块,就只能使用动态
import()表达式。因为import()是异步的,它返回一个 Promise。 -
示例:
// esm_lib.mjs (这是一个 ESM 模块) export const esmVar = "Hello from ESM!"; // commonjs_app.js (这是一个 CommonJS 模块) async function loadEsm() { // 动态 import() 返回一个 Promise,需要 await const esmModule = await import('./esm_lib.mjs'); console.log(esmModule.esmVar); // Hello from ESM! } loadEsm(); -
注意: 这种情况仍然需要 Node.js 运行环境支持
import()语句。
-
ESM 的优势(为啥它是未来趋势?)
-
静态分析: ESM 的导入导出关系在代码执行前(编译时)就能确定。这对于像 Tree Shaking 这样的优化工具非常有利,它们可以分析出哪些代码没有被使用,然后直接“摇”掉,最终打包的代码就更小,性能更好!
-
异步加载: 虽然在 Node.js 环境下
import看起来是同步的,但它的设计是为异步加载而生的。这在浏览器环境中尤为重要,可以按需加载模块,提升网页性能。 -
标准统一: 统一了浏览器和 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 (默认), .cjs (明确 CommonJS) | .mjs (明确 ESM) 或 package.json 中 "type": "module" |
| 循环依赖 | 容易出现问题 (可能返回部分加载的模块) | 更好地处理 (返回已加载的部分,未加载的为 undefined,避免死锁) |
| 优点 | 简单直接,生态成熟,支持同步加载 | 统一标准,静态分析,未来趋势,更好的性能优化可能性 |
好了,同学们,现在你对 Node.js 的两种主要模块系统——CommonJS 和 ES Modules 都有了全面的了解!
在咱们新的 Node.js 项目中,我强烈推荐你优先使用 ES Modules,因为它代表了 JavaScript 模块化的未来方向,并提供了更好的静态分析能力和统一的编程体验。但如果你在维护旧项目,或者遇到某些第三方库还在使用 CommonJS,你也要知道怎么和它们“打交道”!
本节总结:
你现在已经学会了如何组织和管理 Node.js 代码,让你的项目从一开始就整洁有序。模块化是大型应用开发的基础,好好掌握它!
下节预告:
模块搞定了,咱们再来聊聊 Node.js 生态系统中最最最重要的一个工具——NPM (Node Package Manager)!它是你管理项目依赖、安装各种“轮子”的“瑞士军刀”!没有它,你简直寸步难行!咱们下节课,不见不散!
好的,同学们,上节课咱们搞定了 Node.js 的模块化,学会了怎么把代码组织得漂漂亮亮,并且知道了 CommonJS 和 ES Modules 这两个“双生子”。现在,咱们继续深入 Node.js 的“内务管理”,请出咱们的“超级管家”——NPM (Node Package Manager)!
说句毫不夸张的话,如果你在 Node.js 开发中离了 NPM,那简直就是“寸步难行”!它管理着你项目里所有的“轮子”和“工具”,是整个 Node.js 生态的基石。所以,这节课,咱们要好好扒一扒 NPM 的“老底”!
第 6 节:NPM (Node Package Manager) 包管理:你的 Node.js 项目‘超级管家’(没有它,你寸步难行!)
学习目标:
本节课,你将彻底理解 NPM 的作用和组成,学会如何利用 package.json 文件管理项目信息和依赖,掌握 NPM 的各种常用命令,并理解语义化版本和全局/本地安装的区别。
6.1 NPM (Node Package Manager) 包管理简介
同学们,什么是 NPM?
简单来说,NPM 是 Node.js 的默认包管理器。它可不是一个单纯的命令,它由两大部分组成:
-
命令行工具 (CLI):就是你平时在终端里敲的那些
npm install、npm init、npm run之类的命令。这个工具负责帮你与 NPM 注册表进行交互,执行各种包的管理操作。 -
NPM 注册表 (Registry):你可以把它想象成一个巨大的“公共仓库”或者“应用商店”,里面储存着数百万个由全球开发者共享的开源 Node.js 包(也就是模块)。你要用啥“轮子”,直接去这个仓库里“领”就行!
NPM 的作用(为啥它是“超级管家”?)
-
管理项目依赖: 这是它的核心功能!你的项目可能需要用到 Express.js、Mongoose、lodash 等各种第三方库。NPM 帮你轻松地安装、更新和删除这些“外援”,让你不用手动下载和管理那些乱七八糟的文件。
-
代码共享与复用: 不仅可以拿别人的,你也可以把自己写的牛逼代码打包成模块,然后发布到 NPM 注册表上,供全世界的开发者使用!这不就是开源精神的体现嘛!
-
自动化工作流: 通过
package.json里的scripts字段,你可以定义各种自定义的脚本命令,比如启动服务器、运行测试、打包项目等等,大大简化你的开发流程,实现“一键操作”!
6.2 package.json 文件详解 — 你的 Node.js 项目“身份证”!
各位老铁,同学们,这个 package.json 文件,简直就是每个 Node.js 项目的“户口本”或者“身份证”! 它是你项目的心脏,一个 JSON 格式的文件,通常就躺在你的项目根目录里。里面记录了项目的各种“基本信息”、项目依赖了哪些“外援”,以及你能运行哪些“骚操作”脚本。
核心字段(这些都是重点,一定要搞明白!):
-
name: 你的项目叫啥名?必须是小写字母,不能有空格,可以包含连字符或下划线。这个名字是独一无二的,因为如果你想把项目发布到 NPM 上,它的名字就不能跟别人重名。 -
version: 你的项目当前是哪个版本?这个版本号可不是随便写的,它得遵循一个叫做**“语义化版本 (SemVer)”** 的规范(咱们后面会详细讲)。 -
description: 你的项目是干嘛的?简单描述一下。 -
main: 你的项目入口文件在哪儿?当其他模块require()或import你的包时,默认就会去加载这个文件。比如你的项目是一个库,别人require('你的库名'),就会加载main指定的文件。 -
scripts: 这个字段非常非常重要! 它是一个对象,里面定义了你可以在命令行中运行的各种自定义脚本命令。这可是 NPM 自动化工作流的“核心动力”!-
示例:
"scripts": { "start": "node app.js", // 运行你的主应用(比如服务器) "test": "jest", // 运行你的测试 "dev": "nodemon server.js", // 开发模式下启动服务器,文件变动自动重启 "build": "webpack --config webpack.config.js" // 构建你的项目,比如把前端代码打包 } -
运行方式: 绝大多数自定义脚本,你都得用
npm run <script-name>来运行(例如npm run dev,npm run build)。 -
“特权”脚本: 对于
start,test,stop,restart等少数几个“特权”脚本,你可以直接使用npm <script-name>来运行(例如npm start,npm test),不用加run。
-
-
dependencies: 生产环境依赖! 这些是你项目在正常运行时所必需的第三方包。当你的项目部署到线上服务器时,这些包也必须得安装上!-
示例:
"dependencies": { "express": "^4.17.1", // Web 框架 "mongoose": "~5.10.0", // MongoDB 驱动 "lodash": "4.17.21" // 工具库 } -
版本前缀(敲黑板!划重点!):
-
^(caret/插入符): 这是最常见的,也是 NPM 默认安装时给的版本前缀。 它表示“兼容性更新”。例如^4.17.1意味着可以安装4.x.x系列的最新版本,只要主版本号不变就行。但它不会安装5.0.0或更高版本(因为主版本号变化通常意味着不兼容的 API 修改)。所以,它允许你在不破坏现有代码的情况下,获取最新的 Bug 修复和次要功能更新。 -
~(tilde/波浪号): 表示“次要版本兼容”。例如~5.10.0意味着可以安装5.10.x系列的最新版本(只更新修订版本号),但不会安装5.11.0或更高版本。比^更严格一点。 -
无前缀 (精确匹配): 例如
4.17.21。表示只能安装这个精确的版本,任何一个 Bug 修复版本都不能自动升级。这种方式非常严格,但能保证环境的绝对一致性。 -
*或latest: 安装最新版本(不推荐! 可能导致你的代码因为依赖更新而突然“爆炸”!)。
-
-
-
devDependencies: 开发环境依赖! 这些包只在你项目的开发、测试或构建过程中需要,在生产环境中(也就是你的应用跑在服务器上给用户提供服务时)它们是不需要的。比如:测试框架、打包工具、代码检查工具、热重载工具等等。-
示例:
"devDependencies": { "jest": "^27.0.6", // 测试框架 "webpack": "^5.50.0", // 打包工具 "eslint": "^7.32.0", // 代码检查工具 "nodemon": "^2.0.12" // 开发时的热重载工具 }
-
-
author: 项目作者信息。 -
license: 你的项目遵循什么开源许可证。 -
repository: 你的项目代码放在哪个 Git 仓库里。
package-lock.json 文件:你的“保险箱”!
当你第一次运行 npm install 时,除了在 node_modules 文件夹里安装一堆包之外,NPM 还会悄咪咪地在你的项目根目录里生成一个 package-lock.json 文件。
-
作用: 它记录了你项目安装时,所有依赖包的精确版本号,包括你直接依赖的包,以及这些包所依赖的包(也就是“依赖的依赖”)。
-
重要性: 想象一下,你和你的团队成员在不同的机器上,或者在不同的时间点,都运行
npm install。如果没有package-lock.json,由于版本前缀(比如^),不同时间安装的依赖版本可能不一样,导致你的代码在别人机器上“跑飞”!package-lock.json就像一个“快照”,它锁定了所有依赖的精确版本,确保无论谁在什么时候运行npm install,都能安装到完全相同的依赖版本!这保证了构建的可复现性,避免了“在我的机器上能跑,在你机器上就报错”的尴尬! -
敲黑板!划重点! 这个文件应该被提交到版本控制系统(比如 Git)!
6.3 常用 NPM 命令 — 你的“瑞士军刀”!
掌握这些 NPM 命令,你就是项目依赖管理的“小能手”!
-
npm init: 初始化一个新项目!-
作用: 在你当前所在的目录里,创建一个新的 Node.js 项目骨架,并且最重要地,会引导你一步步地创建一个
package.json文件。 -
用法:
-
npm init: 这会以交互式的方式,问你一堆问题(项目名、版本、描述、作者等等),让你填写项目信息。 -
npm init -y或npm init --yes: 这是“懒人模式”!它会快速生成一个默认的package.json文件,所有问题都用默认值帮你填好。推荐在快速开始项目时使用。
-
-
-
npm install: 安装项目依赖!-
作用: 你的项目要用到各种库,全靠它帮你“请”过来!
-
用法:
-
npm install(单独运行): 在项目根目录运行这个命令,NPM 会根据package.json里的dependencies和devDependencies列表,以及package-lock.json文件里记录的精确版本,把所有需要的包都下载到你项目目录下的node_modules文件夹里。 -
npm install <package-name>: 安装指定的某个包到node_modules文件夹。- 从 NPM 5.0 版本开始,这个命令默认就会将包的信息添加到
package.json的dependencies字段中! 所以你现在基本不用加--save了。
- 从 NPM 5.0 版本开始,这个命令默认就会将包的信息添加到
-
npm install <package-name> --save或npm install <package-name> -S: 这是旧版本 NPM 的默认行为,明确告诉 NPM 把这个包添加到dependencies中。现在通常可以省略--save。 -
npm install <package-name> --save-dev或npm install <package-name> -D: 这个很重要!明确告诉 NPM 把这个包添加到devDependencies中。比如你的测试框架、打包工具,就应该这么装。 -
npm install <package-name> --global或npm install <package-name> -g: 全局安装! 这个命令是把包安装到你系统全局的node_modules文件夹里。通常用于安装那些你希望在任何地方都能直接运行的命令行工具(CLI tools),比如nodemon,pm2等。
-
-
-
npm uninstall: 卸载项目依赖!-
作用: 当你不再需要某个包时,用它来“请走”它!
-
用法:
-
npm uninstall <package-name>: 从node_modules文件夹中移除指定的包。默认情况下,它也会同时从package.json中移除对应的依赖记录! -
npm uninstall <package-name> --save或npm uninstall <package-name> -S: 明确从dependencies中移除。 -
npm uninstall <package-name> --save-dev或npm uninstall <package-name> -D: 明确从devDependencies中移除。 -
npm uninstall <package-name> -g: 卸载全局安装的包。
-
-
-
npm update: 更新项目依赖!-
作用: 当你项目里有些包有新版本了,你想更新它们,就用它!
-
用法:
-
npm update: 更新package.json中所有依赖包到其允许的最新版本(就是根据版本号前的^,~等前缀规则)。 -
npm update <package-name>: 更新指定的某个包到其允许的最新版本。
-
-
注意:
npm update通常不会帮你更新到主版本号有变化的版本(比如不会把^4.x.x自动更新到5.x.x),因为主版本号变化意味着可能存在不兼容的修改。如果你想升级主版本号,需要手动修改package.json中的版本号,然后重新npm install。
-
6.4 版本语义化 (SemVer) — 版本号里的“江湖规矩”!
同学们,你有没有注意到,我们前面在 package.json 里提到版本号都是 MAJOR.MINOR.PATCH 这种格式?这就是“语义化版本”(Semantic Versioning,简称 SemVer)!
它不是随便定的,它是一种版本号命名规范,旨在通过版本号本身,就能一眼看出软件更新的类型和兼容性。这就像你看到一辆车,从外观就能知道它是轿车、SUV 还是跑车一样。
版本号格式:MAJOR.MINOR.PATCH (主版本号.次版本号.修订版本号)
-
MAJOR(主版本号):当你做了不兼容的 API 修改时,才增加主版本号。这意味着你如果升级了这个版本,你的老代码可能就跑不了了,需要修改才能兼容新版本。- 例如: 从
1.x.x更新到2.0.0。这就意味着,2.0.0可能和1.x.x的某个功能用法不一样了,你得改代码。
- 例如: 从
-
MINOR(次版本号):当你做了向下兼容的功能性新增时,增加次版本号。这意味着你升级了这个版本,你的老代码仍然可以正常工作,而且还能使用它新增的功能!- 例如: 从
1.0.x更新到1.1.0。这是个好消息,你代码不用改,还能用新功能!
- 例如: 从
-
PATCH(修订版本号):当你做了向下兼容的 Bug 修复时,增加修订版本号。这意味着你升级了这个版本,只是修复了一些 Bug,功能没变,老代码也能正常工作。- 例如: 从
1.0.0更新到1.0.1。这就是修 Bug 版本,赶紧更新!
- 例如: 从
预发布版本和构建元数据:
-
预发布版本: 比如
1.0.0-alpha,1.0.0-beta.1,1.0.0-rc.2。这些版本通常是不稳定的,在正式版本发布前测试用。 -
构建元数据: 比如
1.0.0+20130313144700。用于构建信息,不影响版本号的比较。
SemVer 的重要性:
-
可预测性: 开发者只要看看版本号,就能大概知道这个更新会不会“搞砸”我的项目,需不需要花时间改代码。
-
稳定性: 帮助项目维护者和使用者更好地管理依赖,避免因为不兼容的更新导致项目“爆炸”。
-
自动化: 使得包管理器(比如 NPM)能够根据这些规则自动更新依赖,比如前面讲的
^(caret) 符号,就是利用了 SemVer 规则。
6.5 全局安装与本地安装的区别 — 你的包是“私家车”还是“共享单车”?
NPM 包可以安装在两个不同的位置,这决定了它们的使用范围:
-
本地安装 (Local Installation) — “私家车”:
-
位置: 包会被安装到你当前项目目录下的一个叫做
node_modules的文件夹中。 -
目的: 主要用于你这个项目特有的依赖。每个项目都有自己独立的
node_modules文件夹,即使不同项目依赖同一个包的不同版本,它们之间也不会互相冲突。你的“私家车”只为你这个项目服务。 -
如何安装:
npm install <package-name>(这是默认行为)。 -
如何使用:
-
在你的代码中通过
require()或import语句导入并使用。 -
如果这个包提供了一些命令行工具(比如
webpack打包工具,jest测试框架),你可以通过package.json中的scripts字段来运行它们,或者使用npx命令(npx会在node_modules/.bin里找到并执行本地安装的二进制文件,非常方便!例如npx webpack)。
-
-
-
全局安装 (Global Installation) — “共享单车”:
-
位置: 包会被安装到你系统全局的
node_modules文件夹中(具体路径取决于你的操作系统和 Node.js 安装方式)。 -
目的: 主要用于提供那些你希望在系统的任何位置都能直接运行的命令行工具 (CLI tools),它们不属于任何特定的项目。就像共享单车,你可以在任何地方“扫码”骑走。
-
如何安装:
npm install -g <package-name>。 -
如何使用: 直接在命令行中输入包提供的命令(例如
npm本身,npx,或者一些你全局安装的脚手架工具,如vue-cli,create-react-app)。 -
常见全局安装的包:
nodemon(开发时自动重启服务器的工具),pm2(生产环境进程管理工具),webpack-cli(Webpack 的命令行接口),create-react-app(React 项目脚手架) 等。
-
何时选择全局安装,何时选择本地安装?
-
本地安装 (强烈推荐):
-
所有项目依赖(比如 Express、React、Vue、Mongoose、Lodash 等等)。
-
项目构建工具(比如 Webpack、Babel、ESLint)。即使这些工具提供了 CLI 接口,也强烈建议本地安装,并通过
package.json的scripts脚本或者npx来运行。这样可以确保你的项目使用的工具版本与团队其他成员一致,避免“我这能跑,你那不能跑”的问题,而且也不会污染你全局的环境。
-
-
全局安装:
- 纯粹的命令行工具,它们不属于任何特定项目,你希望在任何地方都能直接运行它们(例如
npm本身,npx,或者一些你个人常用的辅助工具)。
- 纯粹的命令行工具,它们不属于任何特定项目,你希望在任何地方都能直接运行它们(例如
总结:
NPM 是 Node.js 开发的基石,它极大地简化了依赖管理和项目构建。理解 package.json、常用命令、语义化版本以及全局/本地安装的区别,是成为一名高效 Node.js 开发者的关键!掌握了它,你就能在 Node.js 的世界里“呼风唤雨”了!
本阶段总结:
好了,同学们,恭喜你们完成了 Node.js 学习的第一阶段!在这个阶段,我们:
-
深入理解了 Node.js 的本质:一个基于 V8 引擎的 JavaScript 运行时环境,它如何通过事件驱动和非阻塞 I/O 来实现高性能。
-
揭秘了事件循环的奥秘:搞懂了微任务、宏任务的执行顺序,以及
setTimeout、setInterval、setImmediate和process.nextTick这些异步调度器的区别。 -
经历了异步编程的“进化”:从“回调地狱”的痛苦,到 Promise 的优雅链式,再到
async/await的“同步”写法,你现在已经拥有处理异步操作的强大武器! -
掌握了 Node.js 的模块化机制:学会了 CommonJS 和 ES Modules 的导入导出规则,以及如何组织和管理你的代码。
-
驯服了 NPM 这个“超级管家”:你现在可以熟练使用 NPM 命令,管理项目依赖,理解
package.json和版本语义化,知道何时全局安装何时本地安装。
可以说,你已经为 Node.js 的“高楼大厦”打下了坚实的地基!你现在已经具备了编写和运行基本 Node.js 代码,并理解其背后原理的能力。
下个阶段预告:
接下来,我们将进入第二阶段的学习:Node.js 核心模块与文件操作。我们会深入学习 Node.js 内置的 path 和 fs 模块,让你学会如何与文件系统“打交道”,进行文件读写、目录操作,特别是要掌握处理大文件的“神兵利器”——流 (Streams)!
准备好了吗?咱们稍作休息,马上进入下一阶段的精彩内容!
好的,同学们,欢迎回到我们的 Node.js 课堂!
上个阶段,咱们把 Node.js 的“地基”打得那叫一个结实!从 V8 引擎到事件循环,从异步编程到模块化,再到 NPM 这个“超级管家”,你现在应该对 Node.js 的核心原理有了深刻的理解,并且具备了编写和管理基本 Node.js 代码的能力。
现在,咱们要开始学习 Node.js 的“动手能力”了!进入第二阶段:Node.js 核心模块与文件操作。在这个阶段,我们将深入 Node.js 提供的那些“开箱即用”的内置模块,特别是如何与文件系统“交流”,以及如何构建最简单的 Web 服务器。这就像给你的 Node.js 应用装上“眼睛”和“嘴巴”,让它能“看”文件,“说”HTTP!
第二阶段:Node.js 核心模块与文件操作 (约4节) — 让你的应用学会‘读写’和‘说话’!
学习目标:
在这一阶段,你将掌握 Node.js 内置的 path、fs、events、http 等核心模块的使用。你将学会如何处理文件路径,进行文件的同步和异步读写,特别是理解和运用“流”来处理大文件。同时,你还能用 Node.js 的 http 模块从零开始搭建最基础的 Web 服务器。
第 7 节:核心模块:path 和 fs (文件系统) — 你的文件系统‘操作手册’!
各位老铁,同学们好啊! 今天我们要聊的,是 Node.js 里的两个“劳模”模块——path 和 fs。它们是 Node.js 与文件系统打交道的“左膀右臂”。你平时要处理文件路径、读写文件、创建目录啥的,都得找它们帮忙!
记住,它们都是 Node.js 的内置核心模块,你不需要 npm install,直接 require() 就能用!方便得很!
7.1 path 模块:路径拼接、解析、规范化等 — 告别路径的“坑”!
同学们,你们有没有遇到过这种烦恼: 在 Windows 上,路径用的是反斜杠 \,在 macOS/Linux 上用的是斜杠 /?有时候路径拼接错了,多了一个斜杠或者少了一个,程序就“跑飞”了?
别慌!path 模块就是来拯救你的!它提供了超多实用的工具,专门用来处理文件和目录路径,而且它还能自动适应不同操作系统,保证你的代码“跨平台无忧”!
引入方式:
const path = require('path');
常用方法(这些你日常开发肯定会用到!):
-
path.join([...paths]):-
作用: “路径拼接大师”!它会把所有你给它的路径片段,像乐高积木一样,一块一块地拼接起来,并且自动帮你处理多余的斜杠、点号(
./表示当前目录,../表示上级目录),最后根据你当前操作系统的规则,给你一个规范化的路径。 -
优点: 强力推荐使用!它能保证你的路径在 Windows、macOS 和 Linux 上都能正确工作,避免了手动拼接可能导致的“血泪教训”!
-
示例:
console.log(path.join('/foo', 'bar', 'baz/asdf', 'quux', '..')); // 在 Linux/macOS 上,输出: /foo/bar/baz/asdf // 在 Windows 上,输出: \foo\bar\baz\asdf // 看到没?它自动帮你把 'quux/..' 这种“返回上级”的路径也处理掉了,牛吧! // 结合 Node.js 提供的全局变量 __dirname (当前文件所在目录的绝对路径) // 和 __filename (当前文件的绝对路径及文件名) console.log(path.join(__dirname, 'data', 'users.json')); // 假设当前这个文件(你运行的这个脚本)在 /project/src 目录下 // 那么输出就是:/project/src/data/users.json // 这招在项目里找文件路径特别常用!小提示:
__dirname和__filename是 Node.js 给你的“作弊器”,可以直接用,不用require!它们帮你省去了手动获取当前文件路径的麻烦。
-
-
path.resolve([...paths]):-
作用: “绝对路径计算器”!它会把一堆路径或路径片段,从右往左地“捋”一遍,最终给你一个绝对路径。
-
与
path.join的区别(重要!):-
join只是简单拼接和规范化,不保证结果是绝对路径(除非你第一个参数就是绝对路径)。 -
resolve总是返回一个绝对路径。如果在解析过程中它没遇到根路径(比如/或C:\),它就会把你的当前工作目录(就是你执行node命令的那个目录)作为基础路径来计算。
-
-
示例:
console.log(path.resolve('/foo/bar', './baz')); // 输出: /foo/bar/baz (从 /foo/bar 往下,找到 baz) console.log(path.resolve('/foo/bar', '/tmp/file/')); // 输出: /tmp/file/ (因为 '/tmp/file/' 是一个根路径,前面那个 '/foo/bar' 就被“抛弃”了) 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 (从 /a/b 往上退一级到 /a,再找到 c)敲黑板!
path.resolve在处理带有根目录的路径时,会“覆盖”掉前面的非根目录路径。理解这个很重要,不然很容易掉坑!
-
-
path.basename(path[, ext]):-
作用: “文件名提取器”!返回你给的路径的最后一部分,通常就是文件名(或者目录名)。
-
ext参数: 可选,如果你提供了文件扩展名(比如.html),它会帮你把文件名中的这个扩展名“掐掉”。 -
示例:
console.log(path.basename('/foo/bar/baz/asdf.html')); // 输出: asdf.html console.log(path.basename('/foo/bar/baz/asdf.html', '.html')); // 输出: asdf console.log(path.basename('/foo/bar/baz/')); // 输出: baz (即使是目录,也会取最后一个片段) console.log(path.basename('index.js')); // 输出: index.js
-
-
path.dirname(path):-
作用: “目录名提取器”!返回你给的路径的目录名(也就是路径的最后一部分之前的所有内容)。
-
示例:
console.log(path.dirname('/foo/bar/baz/asdf/quux.html')); // 输出: /foo/bar/baz/asdf console.log(path.dirname('/foo/bar/baz/')); // 输出: /foo/bar console.log(path.dirname('index.js')); // 输出: . (表示当前目录)
-
-
path.extname(path):-
作用: “文件扩展名提取器”!返回你给的路径的文件扩展名(注意,它会包含那个点
.)。如果没有扩展名,就返回空字符串。 -
示例:
console.log(path.extname('index.html')); // 输出: .html console.log(path.extname('index.js')); // 输出: .js console.log(path.extname('index.coffee.md'));// 输出: .md (只取最后一个) console.log(path.extname('index.')); // 输出: . (如果有尾随的点) console.log(path.extname('index')); // 输出: '' (没有扩展名)
-
-
path.parse(path):-
作用: “路径解析器”!它会把一个路径字符串,解析成一个包含各个组成部分的对象。方便你程序化地访问路径的各个部分。
-
返回对象属性:
-
root: 根目录(比如 Windows 上的C:\或 Linux/macOS 上的/) -
dir: 目录路径 -
base: 文件名(包括扩展名) -
ext: 扩展名 -
name: 文件名(不包括扩展名)
-
-
示例:
const parsedPath = path.parse('/home/user/dir/file.txt'); console.log(parsedPath); /* 输出类似: { root: '/', dir: '/home/user/dir', base: 'file.txt', ext: '.txt', name: 'file' } */
-
-
path.format(pathObject):-
作用: “路径格式化器”!它和
path.parse()正好相反,你给它一个路径对象,它给你拼回一个路径字符串。 -
示例:
const pathObject = { root: '/', dir: '/home/user/dir', base: 'file.txt', ext: '.txt', name: 'file' // name 和 ext 优先级高于 base }; console.log(path.format(pathObject)); // 输出: /home/user/dir/file.txt
-
-
path.sep:-
作用: 平台特定的路径片段分隔符。
-
示例:
console.log(path.sep); // 在 Linux/macOS 上是 '/',在 Windows 上是 '\'。有时候手动拼接会用到这个!
-
7.2 fs 模块:同步 (Synchronous) 文件操作 — 虽然简单,但小心‘卡死’!
好了,搞定了路径,咱们来动真格的,开始和文件系统“交流”了!
fs (File System) 模块就是 Node.js 的“文件系统管理员”,它提供了各种 API,让你能够对文件和目录进行操作,比如读取、写入、删除、创建等等。
fs 模块提供了两种操作方式:同步(Synchronous) 和 异步(Asynchronous)。今天这节课,咱们先聊聊同步操作。
引入方式:
const fs = require('fs');
常用同步方法(这些方法名后面都带着 Sync):
-
fs.readFileSync(path[, options]):-
作用: 同步地读取文件的全部内容。
-
path: 文件路径。 -
options: 可选参数,可以是一个字符串(用来指定编码,比如'utf8')或一个对象。如果你指定了编码,它就会把文件内容解码成字符串给你;否则,它会返回一个Buffer对象(原始的二进制数据)。 -
示例:
// 咱们先手动创建一个测试文件 test.txt,里面随便写点东西 // 比如在终端里敲:echo "Hello Node.js FS!" > test.txt try { const data = fs.readFileSync('test.txt', 'utf8'); // 指定 utf8 编码,直接拿到字符串 console.log('同步读取文件内容:', data); } catch (err) { // 如果文件不存在,或者没权限,这里就会捕获到错误 console.error('同步读取文件失败:', err.message); }
-
-
fs.writeFileSync(path, data[, options]):-
作用: 同步地将数据写入文件。
-
如果文件不存在,它就帮你创建一个新文件。
-
如果文件已经存在了,注意!它会无情地覆盖掉文件里原来的所有内容! (所以用的时候要小心!)
-
-
data: 你要写入的数据,可以是字符串或Buffer对象。 -
options: 可选,可以是一个字符串(表示编码)或一个对象。 -
示例:
try { fs.writeFileSync('output.txt', '这是通过同步方式写入的内容。\n第二行内容。', 'utf8'); console.log('文件 output.txt 同步写入成功!'); } catch (err) { console.error('同步写入文件失败:', err.message); }
-
-
fs.appendFileSync(path, data[, options]):-
作用: 同步地将数据追加到文件末尾。
-
如果文件不存在,它也会帮你创建一个新文件。
-
如果文件已经存在,它不会覆盖,而是把新数据加在文件最后。
-
-
参数: 和
writeFileSync差不多。 -
示例:
try { fs.appendFileSync('output.txt', '\n这是追加的新内容。', 'utf8'); console.log('文件 output.txt 同步追加成功!'); } catch (err) { console.error('同步追加文件失败:', err.message); }
-
-
fs.mkdirSync(path[, options]):-
作用: 同步地创建目录(也就是文件夹)。
-
options.recursive: 一个布尔值,默认为false。如果设置为true,那它就厉害了,可以帮你递归地创建嵌套目录(也就是说,如果父目录不存在,它也会一并帮你创建,就像一次性帮你搭好几层楼一样)。 -
示例:
try { fs.mkdirSync('my_new_dir'); console.log('目录 my_new_dir 同步创建成功!'); fs.mkdirSync('nested/dir/structure', { recursive: true }); // 一次性创建多层目录 console.log('嵌套目录 nested/dir/structure 同步创建成功!'); } catch (err) { console.error('同步创建目录失败:', err.message); }
-
-
fs.rmSync(path[, options]): (Node.js 14.14.0+ 版本引入,推荐替代老旧的unlinkSync和rmdirSync)-
作用: 同步地删除文件或目录。
-
options.recursive: 布尔值,默认为false。如果设置为true,则可以递归删除非空目录(也就是能把一个文件夹及其里面的所有文件和子文件夹都删光光!)。 -
options.force: 布尔值,默认为false。如果设置为true,则会忽略那些不存在的路径和权限错误,让删除操作更“暴力”一些。 -
示例:
// 为了测试删除,咱们先创建一些文件和目录 fs.writeFileSync('file_to_delete.txt', 'delete me'); fs.mkdirSync('dir_to_delete/subdir', { recursive: true }); fs.writeFileSync('dir_to_delete/subdir/file.txt', 'delete me too'); try { fs.rmSync('file_to_delete.txt'); console.log('文件 file_to_delete.txt 同步删除成功!'); // 递归删除非空目录,强制删除 fs.rmSync('dir_to_delete', { recursive: true, force: true }); console.log('目录 dir_to_delete 及其内容同步删除成功!'); } catch (err) { console.error('同步删除失败:', err.message); }
-
-
fs.existsSync(path):-
作用: 同步地检查指定路径的文件或目录是否存在。
-
返回值: 布尔值(
true表示存在,false表示不存在)。 -
注意: 虽然它是个同步方法,但因为它不涉及长时间的 I/O 操作,在某些非常简单的场景下(比如应用程序启动时检查一个配置文件是否存在),偶尔使用是可以接受的。但在你的应用程序运行时,如果需要频繁地检查文件状态,还是强烈推荐使用异步方法,避免阻塞主线程!
-
示例:
if (fs.existsSync('test.txt')) { console.log('文件 test.txt 存在。'); } else { console.log('文件 test.txt 不存在。'); }
-
重要提示:生产环境应避免同步 I/O!— 这是 Node.js 的“禁忌”!
各位同学,敲黑板!划重点! 这是 Node.js 异步编程中一个非常非常重要的原则,可以说,是 Node.js 的**“禁忌”**!
为什么?!为啥要避免同步 I/O?它用起来多简单啊!
-
它会“阻塞”事件循环: 回忆一下咱们上节课讲的事件循环,它是 Node.js 的“心脏”,是一个单线程的“永动机”。当你的代码里执行一个同步 I/O 操作时(比如
fs.readFileSync),Node.js 的主线程会完全停滞!完全停滞!完全停滞! 直到这个 I/O 操作完成并返回结果,它才能继续往下执行。 -
性能的“灾难”: 在服务器环境里,这意味着当一个用户请求触发了同步 I/O 操作时,所有其他用户的请求都得“干等着”,直到这个耗时的 I/O 操作完成。这会导致你的服务器吞吐量直线下降,响应时间暴增,用户体验差到极点!在高并发场景下,这简直是“自杀”式的行为!你的服务器就成了一台“卡车”,一次只能拉一车货,其他货都得排队!
-
违背 Node.js 核心优势: Node.js 之所以强大,就是因为它擅长处理高并发的 I/O 密集型任务,它靠的就是非阻塞 I/O。如果你大量使用同步 I/O,那 Node.js 的这个核心优势就完全被你给“废”了!
那啥时候可以“稍微”用一下同步 I/O 呢?
-
应用程序启动脚本: 在你的 Node.js 应用刚启动,还没有用户请求进来的时候,做一些初始化配置文件的读取、目录的创建之类的操作,因为这个时候阻塞主线程的影响可以忽略不计。
-
简单的命令行工具: 如果你写的 Node.js 脚本是一个简单的、一次性执行的命令行工具,不涉及到高并发,那么使用同步 I/O 可能会让代码更简洁,方便你快速实现功能。
-
学习和测试: 在你学习和测试阶段,用同步方法可以帮你快速验证功能,但请你务必在心里面给它“贴个标签”:此乃“测试专用”,生产禁用!
最佳实践(敲黑板!划重点!):
在生产环境和任何需要处理并发请求的场景中,始终!永远!优先使用 fs 模块的异步版本(比如 fs.readFile, fs.writeFile 等)!它们通常以回调函数或者 Promise 的形式提供。我们将在下节课详细介绍这些“正确的姿势”!
本节总结:
好了,同学们,通过本节的学习,你已经掌握了 path 模块这个“路径处理专家”,以及 fs 模块的同步文件操作。你现在可以自如地拼接、解析路径,也能对文件和目录进行创建、读写、删除等操作。
但请务必将“生产环境避免同步 I/O”这个金科玉律牢记于心!这不仅仅是一个建议,它更是 Node.js 编程的“生命线”!
下节预告:
下节课,咱们就来学习 fs 模块的异步操作,让你的文件操作不再“卡壳”!更重要的是,我们会深入了解 Node.js 处理大文件的“神兵利器”——文件流 (Streams),它能让你高效、内存友好地处理 TB 级别的数据!精彩不容错过,咱们下节课再见!
好的,同学们,欢迎回到 Node.js 的课堂!
上节课咱们搞定了 path 模块,也初步接触了 fs 模块的同步文件操作,并且反复强调了“生产环境禁用同步 I/O”这个金科玉律!
今天,咱们就要揭示 fs 模块的正确使用姿势——异步操作!而且,咱们还要请出 Node.js 处理大文件的“超级英雄”——文件流 (Streams)!理解了流,你的 Node.js 应用在处理大数据量时,就能像“神龙摆尾”一样,高效而优雅,告别内存爆炸的烦恼!
第 8 节:fs (文件系统) 模块:异步操作与流 — 处理大文件的‘神兵利器’!
学习目标:
本节课,你将掌握 fs 模块的异步操作方法(包括回调版本和 Promise 版本),理解为什么它们是 Node.js 的推荐用法。更重要的是,你将深入学习“流”的概念,学会如何使用可读流和可写流来高效地处理大文件,并掌握流的“管道”(pipe)操作,让你成为文件处理的“老司机”!
8.1 异步文件操作 (readFile, writeFile, appendFile 等) — 这才是 Node.js 的‘真爱’!
各位老铁,同学们好啊! 还记得上节课的“禁忌”吗?同步 I/O 会阻塞事件循环,让你的 Node.js 应用“卡死”!所以,咱们现在就来学习 fs 模块的“救赎之道”——异步操作!这才是 Node.js 处理 I/O 的推荐方式,也是它能够实现高并发的基石!
异步 fs 方法通常有两种形式,就像你点菜,既可以用口头传达(回调),也可以用点菜小程序(Promise):
-
回调函数模式 (Callback-based):
-
这是 Node.js 早期和传统的异步操作方式。你把一个回调函数作为异步方法的最后一个参数传进去。当异步操作完成时,Node.js 就会“悄悄地”调用这个回调函数。
-
约定俗成: 回调函数的第一个参数永远是错误对象
err(如果有错误),第二个参数才是成功返回的数据data。所以你得习惯先判断if (err),这是“安全第一”的原则。
-
-
Promise-based (使用
fs.promises):-
这是 Node.js 10 版本引入的“福音”! Node.js 官方给
fs模块的所有异步方法都提供了 Promise 版本,这些方法都放在fs.promises对象下面。 -
优点: 使用 Promise 版本后,你就可以结合咱们前面学的
async/await语法来编写代码了!代码会更清晰、更易读、更易维护,简直是“优雅到哭”!所以,这是现代 Node.js 开发中处理异步文件操作的强烈推荐方式!
-
引入方式:
const fs = require('fs'); // 如果你想用回调函数版本的异步方法
const fsPromises = require('fs/promises'); // 如果你想用 Promise 版本的异步方法 (强烈推荐!)
常用异步方法示例(请对比它们的同步版本,感受异步的魅力!):
-
fs.readFile(path[, options], callback)/fsPromises.readFile(path[, options]):-
作用: 异步地读取文件的全部内容。
-
示例 (回调函数版本,老派但稳定):
fs.readFile('test.txt', 'utf8', (err, data) => { // 回调函数作为第三个参数 if (err) { // 永远先判断错误! console.error('异步读取文件失败 (回调):', err.message); return; } console.log('异步读取文件内容 (回调):', data); }); console.log('我先走了,文件读取中,回头通知你!'); // 这行会先打印! -
示例 (Promise / async/await 版本,时尚又好用!):
async function readMyFile() { try { const data = await fsPromises.readFile('test.txt', 'utf8'); // await 让代码看起来像同步 console.log('异步读取文件内容 (Promise):', data); } catch (err) { // 用 try...catch 来捕获 Promise 的拒绝 console.error('异步读取文件失败 (Promise):', err.message); } } readMyFile(); console.log('我先走了,文件读取中,回头通知你!'); // 这行依然会先打印!敲黑板! 无论你用回调还是 Promise,
readFile都是把整个文件内容都加载到内存里!对于大文件,这可是个“隐形炸弹”!咱们后面讲“流”的时候会解决它。
-
-
fs.writeFile(path, data[, options], callback)/fsPromises.writeFile(path, data[, options]):-
作用: 异步地将数据写入文件。如果文件不存在,则创建;如果已存在,则覆盖。
-
示例 (Promise / async/await 版本):
async function writeMyFile() { try { await fsPromises.writeFile('output_async_promise.txt', '这是异步写入的 Promise 内容。', 'utf8'); console.log('文件 output_async_promise.txt 异步写入成功 (Promise)!'); } catch (err) { console.error('异步写入文件失败 (Promise):', err.message); } } writeMyFile();
-
-
fs.appendFile(path, data[, options], callback)/fsPromises.appendFile(path, data[, options]):-
作用: 异步地将数据追加到文件末尾。
-
示例 (Promise / async/await 版本):
async function appendMyFile() { try { await fsPromises.appendFile('output_async_promise.txt', '\n这是异步追加的新内容。', 'utf8'); console.log('文件 output_async_promise.txt 异步追加成功 (Promise)!'); } catch (err) { console.error('异步追加文件失败 (Promise):', err.message); } } appendMyFile();
-
-
fs.mkdir(path[, options], callback)/fsPromises.mkdir(path[, options]):-
作用: 异步地创建目录。
recursive: true参数依然好用! -
示例 (Promise / async/await 版本):
async function createMyDir() { try { await fsPromises.mkdir('my_async_dir', { recursive: true }); console.log('目录 my_async_dir 异步创建成功!'); } catch (err) { console.error('异步创建目录失败:', err.message); } } createMyDir();
-
-
fs.rm(path[, options], callback)/fsPromises.rm(path[, options]): (Node.js 14.14.0+ 引入的“新宠”,比旧的unlink和rmdir更强大!)-
作用: 异步地删除文件或目录。
-
options.recursive:true可以递归删除非空目录。 -
options.force:true会忽略不存在的路径和权限错误,让删除操作更“暴力”。 -
示例 (Promise / async/await 版本):
async function removeMyStuff() { // 为了测试,咱们先创建一些文件和目录 await fsPromises.writeFile('file_to_delete_async.txt', 'delete me'); await fsPromises.mkdir('dir_to_delete_async/subdir', { recursive: true }); try { await fsPromises.rm('file_to_delete_async.txt'); console.log('文件 file_to_delete_async.txt 异步删除成功!'); await fsPromises.rm('dir_to_delete_async', { recursive: true, force: true }); console.log('目录 dir_to_delete_async 及其内容异步删除成功!'); } catch (err) { console.error('异步删除失败:', err.message); } } removeMyStuff();
-
-
fs.access(path[, mode], callback)/fsPromises.access(path[, mode]):-
作用: 异步地检查用户对文件或目录的权限。这是检查文件或目录是否存在以及是否有权限访问的推荐方式,因为它不会引发“竞争条件”(Race Condition,比如你先用
existsSync检查文件是否存在,然后文件被删了,你再操作就报错了)。 -
mode: 可选,用于指定要检查的权限。常用常量:fs.constants.F_OK(存在),fs.constants.R_OK(可读),fs.constants.W_OK(可写),fs.constants.X_OK(可执行)。 -
示例 (Promise / async/await 版本):
async function checkFileAccess() { try { await fsPromises.access('test.txt', fs.constants.F_OK); // 检查文件是否存在 console.log('文件 test.txt 存在且可访问。'); } catch (err) { console.error('文件 test.txt 不存在或无法访问:', err.message); } } checkFileAccess();
-
8.2 文件流 (Streams) 概念:ReadableStream, WritableStream — 处理大文件的‘超级英雄’!
同学们,前面我们用 fs.readFile 和 fs.writeFile 解决了阻塞问题,但它们是不是完美无缺呢?
对于小文件来说,它们确实挺方便的。但是,如果你要处理一个超大文件(比如几百 MB、几个 GB 的日志文件、视频文件),把整个文件内容都一股脑儿地加载到服务器内存里,那可就出大问题了!
-
内存溢出 (Out of Memory): 你的应用程序可能会因为内存不够用而直接“原地爆炸”!
-
性能下降: 即使内存足够,加载和处理整个大文件也会消耗大量时间和资源,让你的服务器变得“迟钝”!
这时候,咱们的“超级英雄”——文件流 (Streams) 就闪亮登场了! 它就是为了解决这种“大文件难题”而生的!
什么是流?
流是 Node.js 中处理数据的一种抽象接口。它允许你以分块 (chunks) 的方式处理数据,而不是一次性将所有数据加载到内存中。你可以把流想象成一根水管:
-
数据源 (Source): 水管的一端是数据源(比如一个大文件,或者一个网络请求)。
-
数据目的地 (Destination): 另一端是数据目的地(比如另一个文件,或者一个网络响应)。
-
数据流动: 数据就像水一样,一小块一小块地从源头流向目的地,而不是一次性“洪水猛兽”般涌过来。
流的优势(为什么它是“超级英雄”?):
-
内存效率: 它只在内存中保留当前正在处理的数据块,大大减少了内存占用!你的服务器就不用一下子背上一个“大胖子”了。
-
时间效率: 数据一旦可用,就可以立即处理,不用等整个文件都加载完。就像你在线看视频,不用等整个视频文件下载完才能播放,边下边看!
-
可组合性: 流可以像管道一样,一节一节地连接起来,形成一个复杂的数据处理链。比如,你可以把一个文件的读取流,连接到一个压缩流,再连接到一个网络写入流,实现“边读边压边发送”!
Node.js 中有四种基本类型的流(就像四种不同的水管):
-
Readable Stream(可读流):-
用于从数据源读取数据。
-
例子:
fs.createReadStream()(从文件里读数据), HTTP 请求的req对象 (从客户端请求体里读数据)。 -
事件(关注这些事件):
-
data: 当有数据块可用时,这个事件就会“叮”一声触发! -
end: 当没有更多数据可读时,表示读取结束。 -
error: 读取过程中发生错误时触发。 -
close: 底层资源关闭时触发。
-
-
-
Writable Stream(可写流):-
用于向数据目的地写入数据。
-
例子:
fs.createWriteStream()(往文件里写数据), HTTP 响应的res对象 (往客户端响应体里写数据)。 -
事件:
-
drain: 当写入缓冲区清空,可以继续写入更多数据时触发(这个事件很重要,用于“流量控制”,防止你写得太快,把接收方“淹死”!)。 -
finish: 当所有数据都已写入底层系统时触发。 -
error: 写入过程中发生错误时触发。 -
close: 底层资源关闭时触发。
-
-
-
Duplex Stream(双工流):-
“可读可写”,既能读又能写。
-
例子: TCP sockets (网络套接字)。
-
-
Transform Stream(转换流):-
一种特殊的双工流,它可以在数据读入和写出之间修改或转换数据。
-
例子: 压缩流 (zlib 模块)。你读入一堆数据,它帮你转换成压缩后的数据再写出去。
-
8.3 使用流进行大文件读写 (createReadStream, createWriteStream) — ‘抽水机’和‘浇水机’!
fs 模块提供了创建文件可读流和可写流的“工厂方法”。
-
fs.createReadStream(path[, options]):-
作用: 创建一个可读文件流,就像一个“抽水机”,帮你从文件里一小块一小块地抽数据。
-
options:-
encoding: 编码方式 (如'utf8')。 -
highWaterMark: 内部缓冲区的大小(字节),默认是 64KB。当流内部的数据达到这个限制时,流就会“暂停”读取,等待消费者处理。这用于背压(backpressure),防止生产者把消费者“淹死”。 -
start,end: 可以指定只读取文件的某个部分(起始和结束字节位置)。
-
-
示例:读取大文件
const fs = require('fs'); // 为了测试,咱们先创建一个约 1MB 的大文件 // 你可以在终端运行:node -e "require('fs').writeFileSync('large_file.txt', 'a'.repeat(1024 * 1024))" // 或者手动创建一个巨大的文本文件 const readStream = fs.createReadStream('large_file.txt', { encoding: 'utf8', highWaterMark: 16 * 1024 }); // 缓冲区设为 16KB let chunkCount = 0; let totalBytes = 0; console.log('开始读取大文件...'); readStream.on('data', (chunk) => { // 当有数据块可用时 chunkCount++; totalBytes += chunk.length; console.log(`接收到第 ${chunkCount} 个数据块,大小: ${chunk.length} 字节`); // console.log('数据块内容 (部分):', chunk.substring(0, 50) + '...'); // 打印部分内容,别打太多,会刷屏! // 在这里,你可以处理每个数据块,比如保存到数据库、进行计算等等 }); readStream.on('end', () => { // 读取完毕 console.log(`文件读取完毕!总共接收到 ${chunkCount} 个数据块,总大小 ${totalBytes} 字节。`); }); readStream.on('error', (err) => { // 发生错误 console.error('读取文件时发生错误:', err.message); });
-
-
fs.createWriteStream(path[, options]):-
作用: 创建一个可写文件流,就像一个“浇水机”,帮你把数据一小块一小块地写入文件。
-
options:-
encoding: 编码方式。 -
flags: 文件打开模式,默认为'w'(写入,会覆盖原有文件)。'a'表示追加模式,不会覆盖。 -
mode: 文件权限。
-
-
示例:写入大文件
const fs = require('fs'); const writeStream = fs.createWriteStream('output_large_file.txt', { encoding: 'utf8' }); console.log('开始写入大文件...'); let writeCount = 0; const totalLines = 100000; function writeManyLines() { let ok = true; do { writeCount++; const data = `这是第 ${writeCount} 行数据,内容很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长的
-
// fs.promises 模块通常被推荐使用,因为它支持 Promise 和 async/await,
// 但在一些老旧或者对性能要求极高的场景,或者对底层 I/O 控制有特殊需求的场景,
// 开发者可能仍会直接使用 fs 模块的回调 API 或 fs.createReadStream/createWriteStream。
// 本节主要讲的是 fs.createReadStream 和 fs.createWriteStream
// 所以会使用 const fs = require('fs');
const fs = require('fs');
const path = require('path');
// 咱们就用 Promise 版本的 fs 来创建和清理测试文件,因为写起来更简洁
const fsPromises = require('fs/promises');
// 确保存在测试文件 large_file.txt,如果不存在就创建一个
const sourceFilePath = path.join(__dirname, 'large_file.txt');
const destinationFilePath = path.join(__dirname, 'copied_large_file.txt');
async function ensureLargeFileExists() {
try {
await fsPromises.access(sourceFilePath, fs.constants.F_OK);
console.log(`[准备] 测试文件 ${sourceFilePath} 已存在。`);
} catch (e) {
console.log(`[准备] 创建测试文件: ${sourceFilePath} (约 2.8MB)`);
// 生成一个足够大的文本内容,方便测试流
const dummyData = 'This is a large file content.\n'.repeat(100000); // 10万行,每行约28字节
await fsPromises.writeFile(sourceFilePath, dummyData, 'utf8');
console.log(`[准备] 测试文件 ${sourceFilePath} 创建成功。`);
}
}
// 确保清理掉之前可能生成的复制文件
async function cleanupCopiedFile() {
try {
await fsPromises.rm(destinationFilePath, { force: true });
console.log(`[清理] 之前复制的文件 ${destinationFilePath} 已清理。`);
} catch (e) {
// 如果文件不存在,access 会报错,rm 也会报错,force: true 会忽略不存在的,所以这里一般不会触发
}
}
// 运行异步操作的主函数
async function runExamples() {
await ensureLargeFileExists();
await cleanupCopiedFile(); // 确保测试前是干净的
// --- 8.3 使用流进行大文件读写 ---
console.log('\n--- 8.3 演示: 使用 fs.createReadStream 读取大文件 ---');
const readStream = fs.createReadStream(sourceFilePath, {
encoding: 'utf8',
highWaterMark: 16 * 1024 // 缓冲区设为 16KB,默认是 64KB (65536字节)
});
let chunkCount = 0;
let totalBytesRead = 0;
let streamReadStartTime = Date.now();
console.log('开始读取大文件...');
readStream.on('data', (chunk) => { // 当有数据块可用时
chunkCount++;
totalBytesRead += chunk.length;
// console.log(`接收到第 ${chunkCount} 个数据块,大小: ${chunk.length} 字节`);
// 可以在这里处理每个数据块,比如计算哈希、传输到其他地方等
});
readStream.on('end', () => { // 读取完毕
let streamReadEndTime = Date.now();
console.log(`文件读取完毕!总共接收到 ${chunkCount} 个数据块,总大小 ${totalBytesRead} 字节。`);
console.log(`流式读取耗时: ${streamReadEndTime - streamReadStartTime} ms`);
});
readStream.on('error', (err) => { // 发生错误
console.error('读取文件时发生错误:', err.message);
});
// 简单地对比一下 readFile 的耗时 (如果文件不是特别大,可能感知不明显)
try {
let readFileStartTime = Date.now();
const wholeFileContent = await fsPromises.readFile(sourceFilePath, 'utf8');
let readFileEndTime = Date.now();
console.log(`\n--- 8.3 对比: 使用 fsPromises.readFile 读取整个文件 ---`);
console.log(`一次性读取文件耗时: ${readFileEndTime - readFileStartTime} ms (文件大小: ${wholeFileContent.length} 字节)`);
console.log('注意:对于非常大的文件,一次性读取会消耗更多内存,甚至导致内存溢出!');
} catch (e) {
console.error(`一次性读取文件失败: ${e.message}`);
}
// --- 8.4 管道 (pipe) 操作 ---
console.log('\n--- 8.4 演示: 使用 pipe 复制大文件 ---');
console.log(`开始复制文件从 ${sourceFilePath} 到 ${destinationFilePath}...`);
const readStreamForPipe = fs.createReadStream(sourceFilePath);
const writeStreamForPipe = fs.createWriteStream(destinationFilePath);
let pipeStartTime = Date.now();
// 使用 pipe 连接读写流,一行代码搞定大文件复制!
// pipe 会自动处理背压,确保写入速度跟得上
readStreamForPipe.pipe(writeStreamForPipe);
// 监听完成事件
writeStreamForPipe.on('finish', () => {
let pipeEndTime = Date.now();
console.log(`文件复制成功!新文件在 ${destinationFilePath}`);
console.log(`pipe 复制耗时: ${pipeEndTime - pipeStartTime} ms`);
});
// 监听错误事件 (任何一个流出错都会触发)
readStreamForPipe.on('error', (err) => {
console.error('读取流发生错误:', err.message);
});
writeStreamForPipe.on('error', (err) => {
console.error('写入流发生错误:', err.message);
});
}
// 执行所有示例
runExamples();
输出解释:
-
准备和清理信息:首先会看到测试文件创建或检查的信息,以及清理旧的复制文件的信息。
-
流式读取大文件:
fs.createReadStream会开始读取large_file.txt。你会看到控制台不断输出“接收到第 N 个数据块”的信息,表示数据正在一块一块地被处理。最后,它会告诉你总共接收了多少块,总大小是多少,以及耗时。 -
一次性读取对比:
fsPromises.readFile会把整个文件内容一次性读入内存。如果文件真的很大(比如几 GB),你可能会感觉到明显的卡顿,甚至程序会因为内存不足而崩溃(这里模拟的是 2.8MB,不会崩溃,但能对比时间)。这里会提示你,对于大文件,一次性读取的潜在风险。 -
管道复制文件:
fs.createReadStream(sourceFilePath).pipe(fs.createWriteStream(destinationFilePath))会以流的方式高效地将large_file.txt的内容复制到copied_large_file.txt。你会看到“文件复制成功!”的提示和耗时。在这个过程中,内存占用非常低。
pipe 的优点(划重点!):
-
简洁性: 你看,一行代码
readStream.pipe(writeStream)就实现了复杂的数据传输和流量控制!相比手动监听data、drain、pause、resume事件,简直是天壤之别! -
自动化背压 (Backpressure): 这点太重要了!
pipe()会自动处理“背压”问题。如果可写流(接收方,比如硬盘写入慢)的写入速度跟不上可读流(发送方,比如 CPU 读取快)的读取速度,pipe()会自动暂停可读流的读取,直到可写流“喘过气来”准备好接收更多数据。这就像水管,如果下游堵了,上游的水流会自动减小,防止“爆管”!它防止了内存溢出,保证了数据传输的稳定性。 -
错误传播: 默认情况下,如果可读流发生错误,这个错误会沿着管道传播到可写流,并触发可写流的
error事件,方便你统一处理。 -
可链式调用: 你可以把多个流通过
pipe串联起来,形成一个复杂的数据处理管道。// 示例:读取文件 -> 压缩 -> 写入新文件 const zlib = require('zlib'); // Node.js 内置的压缩模块 readStreamForPipe.pipe(zlib.createGzip()).pipe(writeStreamForPipe); // 这行代码就实现了:读取大文件 -> 对其进行 Gzip 压缩 -> 将压缩后的数据写入新文件。是不是很酷?!
本节总结:
好了,同学们,通过本节的学习,你已经掌握了 Node.js 中 fs 模块的异步操作,知道如何优雅地读写文件,告别了同步 I/O 的“坑”!
更重要的是,你已经深入理解了**“流”(Streams)** 这个“超级英雄”的概念,学会了如何使用 fs.createReadStream 和 fs.createWriteStream 来高效、内存友好地处理大文件,并掌握了“化繁为简”的**pipe() 管道操作**!
在实际开发中,尤其是在处理日志、文件上传、数据传输等大文件或大数据流的场景时,异步操作和流是构建高性能 Node.js 应用不可或缺的“神兵利器”!好好消化这部分内容,它将让你在 Node.js 的世界里“如鱼得水”!
下节预告:
接下来,咱们继续 Node.js 核心模块的学习。下一节,我们将深入探讨 Node.js 事件驱动编程的基石——events 模块和 EventEmitter 类,以及如何利用它们来构建我们最简单的 Web 服务器——http 模块!这就像给你的 Node.js 应用装上“神经系统”和“嘴巴”!咱们下节课不见不散!
好的,同学们,欢迎回来!
上节课咱们搞定了 fs 模块的异步操作和“流”的艺术,现在你应该知道怎么高效、内存友好地和文件系统“打交道”了。是不是感觉自己的 Node.js “内力”又增厚了不少?
今天,咱们要继续深入 Node.js 的“核心骨架”!这节课,咱们将揭示 Node.js 能够处理高并发请求的幕后英雄——事件驱动编程的基石,也就是**events 模块和 EventEmitter 类**。接着,我们还要立刻“学以致用”,用 Node.js 内置的 http 模块,从零开始搭建咱们的第一个“简陋”Web 服务器!这就像给你的 Node.js 应用装上“神经系统”和“嘴巴”,让它能“感知”事件并“回应”请求!
第 9 节:核心模块:events 与 EventEmitter — Node.js 的‘神经中枢’!
学习目标:
本节课,你将理解 Node.js 应用程序中事件驱动编程的重要性,掌握 EventEmitter 类的基本用法,包括如何注册、触发和移除事件监听器,以及如何正确处理 error 事件,避免程序“原地爆炸”!
9.1 事件驱动编程范式 — Node.js 的‘说话’方式!
各位老铁,同学们好啊! 咱们前面反复强调了,Node.js 的核心是事件驱动 (Event-driven) 和非阻塞 I/O (Non-blocking I/O)。这就像 Node.js 的“说话”方式和“做事”风格:它不是那种“埋头苦干”的傻大个,而是个“话痨”兼“多面手”!
- 事件驱动: 你可以理解为,Node.js 并不主动去“问”一个操作完成了没有。它更像一个“监听者”:我把任务派发出去了,然后就等着你(操作系统、数据库、网络等等)“通知”我。当一个操作完成了,它就会“叮”一声,触发一个“事件”!然后,你的 Node.js 应用程序会“竖着耳朵”去“监听”这些事件。一旦某个事件发生了,它就会立刻执行你预先设置好的“回调函数”或者说“事件监听器”。
这种“你忙完了通知我一声,我再行动”的范式,非常适合处理高并发的 I/O 密集型任务。因为它避免了传统多线程模型中为每个请求都创建新线程的巨大开销和上下文切换的复杂性。
而支撑这一切的,就是 Node.js 中那个无处不在的“幕后英雄”——EventEmitter!
EventEmitter 是什么?
它是 Node.js 中所有事件驱动的“老祖宗”!很多 Node.js 内置模块(比如 fs.createReadStream 处理文件流、http.Server 处理 HTTP 请求、net.Socket 处理网络连接等等)都继承自 EventEmitter,或者在内部使用了它。所以,理解 EventEmitter,就是理解 Node.js 的“神经中枢”!
9.2 EventEmitter 类:注册事件 (on/addListener), 触发事件 (emit), 移除事件 (removeListener) — 你的‘监听器’和‘发令枪’!
要使用 EventEmitter,首先你得把它“请”过来:
const EventEmitter = require('events');
然后,你可以创建一个 EventEmitter 的实例,或者让你的自定义类“继承”它,这样你的自定义类也能拥有事件的能力。
1. 注册事件 (on/addListener) — ‘竖起耳朵’听!
-
emitter.on(eventName, listener): 这是最常用的方法!用来注册一个事件监听器。当指定eventName的事件被触发时,你提供的listener函数就会被调用。 -
emitter.addListener(eventName, listener): 和on方法的功能是一模一样的!on只是一个更常用、更简洁的别名。
示例:咱们创建一个简单的事件发射器来玩玩!
const myEmitter = new EventEmitter();
// 注册一个名为 'greet' 的事件监听器
myEmitter.on('greet', (name) => { // 监听 'greet' 事件,当它发生时,执行这个函数
console.log(`Hello, ${name}!`);
});
// 你可以为同一个事件注册多个监听器,它们会按注册顺序依次执行
myEmitter.on('greet', (name) => {
console.log(`很高兴见到你,${name}。`);
});
// 注册一个名为 'data' 的事件监听器,接收一个 payload(载荷)
myEmitter.on('data', (payload) => {
console.log('接收到数据:', payload);
});
console.log('事件监听器已注册完毕,等待事件触发...');
2. 触发事件 (emit) — ‘发令枪’响了!
emitter.emit(eventName[, ...args]): 这个方法就是用来“发射”或“触发”一个事件的!当它被调用时,所有注册到eventName这个事件上的监听器,都会按照它们被注册的顺序同步调用。...args后面跟着的参数,都会原封不动地传递给监听器函数。
示例:现在,咱们来“发射”事件!
// 接着上面的代码...
// 触发 'greet' 事件,并传递 'Alice' 作为参数
console.log('\n--- 触发 greet 事件 ---');
myEmitter.emit('greet', 'Alice');
// 输出:
// Hello, Alice!
// 很高兴见到你,Alice。
// 触发 'data' 事件,并传递一个对象作为参数
console.log('\n--- 触发 data 事件 ---');
myEmitter.emit('data', { id: 101, value: 'Node.js 真有趣!' });
// 输出:
// 接收到数据: { id: 101, value: 'Node.js 真有趣!' }
是不是很简单?on 负责听,emit 负责说!
3. 移除事件 (removeListener/off) — ‘取消订阅’!
有时候你不再需要监听某个事件了,或者某个监听器完成了它的使命,就可以把它移除掉,避免内存泄漏,或者避免不必要的执行。
-
emitter.removeListener(eventName, listener): 移除指定eventName的指定listener函数。 -
emitter.off(eventName, listener): 和removeListener功能完全相同,是它的别名,更简洁。 -
emitter.removeAllListeners([eventName]): 移除所有指定eventName的监听器。如果你不指定eventName,它就会移除所有事件的所有监听器!这招有点“狠”,慎用!
注意: 要成功移除监听器,你必须传递同一个函数引用!如果你用匿名函数(比如 () => {} 这种),每次创建都是新的引用,就无法移除了。所以,最好给你的监听器起个名字,或者把它们定义成单独的函数。
示例:
const myEmitter2 = new EventEmitter();
function callbackA() {
console.log('Callback A 被调用了!');
}
function callbackB() {
console.log('Callback B 被调用了!');
}
myEmitter2.on('testEvent', callbackA); // 注册 callbackA
myEmitter2.on('testEvent', callbackB); // 注册 callbackB
myEmitter2.on('testEvent', () => console.log('这是一个匿名回调,我可能移不掉!')); // 匿名函数
console.log('\n--- 第一次触发 testEvent ---');
myEmitter2.emit('testEvent');
// 输出:
// Callback A 被调用了!
// Callback B 被调用了!
// 这是一个匿名回调,我可能移不掉!
// 移除 callbackA
console.log('\n--- 移除 Callback A 后再次触发 ---');
myEmitter2.removeListener('testEvent', callbackA);
myEmitter2.emit('testEvent');
// 输出:
// Callback B 被调用了!
// 这是一个匿名回调,我可能移不掉!
// 移除所有 'testEvent' 的监听器
console.log('\n--- 移除所有 testEvent 监听器后再次触发 ---');
myEmitter2.removeAllListeners('testEvent');
myEmitter2.emit('testEvent'); // 不会输出任何东西了,因为都移除了
9.3 一次性事件 (once) — ‘一锤子买卖’!
emitter.once(eventName, listener): 注册一个特殊的事件监听器,它只会被触发一次!当eventName事件第一次被触发后,这个监听器就会自动从列表中移除,不再响应后续的触发。
示例:
const myEmitter3 = new EventEmitter();
myEmitter3.once('setup', () => { // 注册一个一次性监听器
console.log('应用程序初始化设置:只执行一次的配置完成!');
});
myEmitter3.on('log', (message) => { // 注册一个普通监听器,可以触发多次
console.log('日志记录:', message);
});
console.log('\n--- 第一次触发 setup 事件 ---');
myEmitter3.emit('setup'); // 第一次触发 'setup',监听器会执行
console.log('\n--- 触发 log 事件 ---');
myEmitter3.emit('log', '用户登录了。'); // log 监听器会执行
console.log('\n--- 第二次触发 setup 事件 ---');
myEmitter3.emit('setup'); // 第二次触发 'setup',但监听器已被移除,不会再执行!
console.log('\n--- 再次触发 log 事件 ---');
myEmitter3.emit('log', '数据更新了。'); // log 监听器依然会执行
// 输出:
// 应用程序初始化设置:只执行一次的配置完成!
// 日志记录: 用户登录了。
// 日志记录: 数据更新了。
看到没?once 就像一个“一锤子买卖”,用完就“撒手不管”了!
9.4 错误事件处理 — 防止你的程序‘原地爆炸’!
同学们,这是 EventEmitter 最最最重要,也是最容易被新手忽略,从而导致“血案”的一个点!
error 事件是一个特殊的事件!当 EventEmitter 实例发出 error 事件时,如果你没有注册任何监听器来处理它,那么 Node.js 进程会直接崩溃并退出! 而且还会打印出长长的堆栈跟踪信息。
这在生产环境中是绝对不能接受的! 想象一下你的 Web 服务器,因为一个未处理的错误事件,突然就“嗝屁”了,用户全部无法访问!所以,在生产环境中,你几乎总是需要为 error 事件注册一个监听器,以防止应用程序意外崩溃。
示例 1:没有监听器(这是错误的示范!会导致崩溃!)
const myEmitter4 = new EventEmitter();
// 模拟抛出一个错误事件
// myEmitter4.emit('error', new Error('哎呀!大事不妙,我出错了!'));
// ^^^^^^ 如果你把上面这行代码真的运行了,你的 Node.js 进程会直接崩溃!
// 不信你可以取消注释试试(但要准备好重启)!
示例 2:注册监听器(这是正确的姿势!救你的程序一命!)
const myEmitter4 = new EventEmitter();
// 注册一个 'error' 事件的监听器
myEmitter4.on('error', (err) => {
console.error('捕获到错误事件!程序得以幸存!错误信息:', err.message);
// 在这里,你可以进行错误日志记录、优雅地关闭资源、给管理员发送邮件等等操作
// 通常,你不应该在这里直接 process.exit(1),除非是不可恢复的严重错误
// 而是尝试让程序继续运行或优雅地重启。
});
// 现在,咱们来触发一个 'error' 事件
console.log('\n--- 触发 error 事件 ---');
myEmitter4.emit('error', new Error('文件读取权限不足!'));
// 输出:
// 捕获到错误事件!程序得以幸存!错误信息: 文件读取权限不足!
console.log('程序继续运行,没有崩溃...'); // 看到没?程序没有崩溃!
敲黑板!划重点! 永远要为 EventEmitter 的 error 事件注册一个监听器!这是你 Node.js 应用健壮性的基本保障!
第 10 节:核心模块:http (构建基础 Web 服务器) — 你的应用要‘说话’了!
好了,同学们,咱们学会了事件驱动的“神经系统” EventEmitter,现在是时候让你的 Node.js 应用“开口说话”了!
http 模块是 Node.js 内置的核心模块,它就是用来创建 HTTP 服务器和客户端的!它是所有 Node.js Web 应用的“祖宗”,像 Express.js 这样的流行框架,也都是基于它构建的!虽然它写起来可能有点“原始”,但理解它,能让你对 Web 服务器的运作机制有更深刻的认识!
10.1 使用 http 模块创建最简单的 Web 服务器 — Hello Web Server!
引入方式:
const http = require('http');
创建服务器(就像你开了一家小餐馆):
http.createServer() 方法会返回一个 http.Server 实例。它需要一个回调函数作为参数,这个回调函数会在每次接收到 HTTP 请求时被调用!
这个回调函数有两个非常重要的参数,你得好好认识它们:
-
req(Request): 一个http.IncomingMessage对象,包含了客户端请求的所有信息(比如,客人说了啥,要点啥菜)。 -
res(Response): 一个http.ServerResponse对象,用于向客户端发送响应(比如,你给客人上菜,并告诉他菜好了)。
const http = require('http');
// 创建一个 HTTP 服务器实例
const server = http.createServer((req, res) => { // 每次收到请求,这个匿名函数就会执行
// 打印一下请求信息,看看客人说了啥
console.log(`收到请求: 方法=${req.method}, URL=${req.url}`);
// 设置响应头:告诉浏览器,我给你的是纯文本内容,而且是 UTF-8 编码
// 就像你给客人递菜单,告诉他这饭店是啥菜系,用啥餐具
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
// 设置 HTTP 状态码:告诉浏览器,这个请求处理成功了 (200 OK)
// 就像你告诉客人,你的菜“已就绪”
res.statusCode = 200;
// 发送响应体数据:这就是你要回复给客户端(浏览器)的具体内容了
res.write('Hello, Node.js Web Server!\n'); // 可以多次调用 write
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/');
});
// 监听服务器错误(就像 EventEmitter 一样,也要防着点错误!)
server.on('error', (err) => {
console.error('哎呀,服务器发生错误了:', err.message);
});
如何运行和测试:
-
将上述代码保存为
server.js。 -
打开终端,导航到
server.js所在的目录。 -
运行命令:
node server.js -
打开你的浏览器,访问
http://localhost:3000/。你将看到页面上显示“Hello, Node.js Web Server! 这是我的第一个 HTTP 响应,简单粗暴!” -
或者,如果你想更“Geek”一点,在另一个终端中使用
curl命令测试:curl http://localhost:3000/
是不是很简单?恭喜你,你的第一个 Web 服务器已经跑起来了!
10.2 理解请求 (req) 和响应 (res) 对象 — ‘客人’和‘服务员’的对话!
在 http.createServer 的回调函数里,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) 接收的!是的,你没听错,就是咱们上节课讲的“流”!这意味着你不能直接通过req.body拿到数据(这是 Express.js 框架的福利,原生http模块没有!)。你需要监听data和end事件来一点点地收集数据。这个咱们在处理 POST 请求的时候会详细讲。
res (Response) 对象 — 你要‘回复’客人啥?
res 对象是 http.ServerResponse 的实例,它就是你的“回信”,用来构建并发送响应给客户端。
-
res.statusCode: 设置 HTTP 响应状态码(默认是200)。比如200 OK(成功),404 Not Found(没找到),500 Internal Server Error(服务器内部错误)。 -
res.statusMessage: 设置 HTTP 响应状态消息(比如OK,Not Found)。通常它会根据statusCode自动匹配。 -
res.setHeader(name, value): 设置一个响应头。你可以多次调用来设置多个头。比如res.setHeader('Content-Type', 'application/json'); -
res.writeHead(statusCode[, statusMessage][, headers]): 一个更高效的方法,可以一次性设置状态码、状态消息和多个响应头。注意: 调用此方法后,你就不能再使用res.statusCode或res.setHeader了,因为它已经开始发送响应头了。 -
res.write(chunk[, encoding][, callback]): 向响应体写入数据块。可以多次调用,每次写一点点数据。 -
res.end([data][, encoding][, callback]): 非常非常重要! 结束响应。你必须调用此方法,否则客户端会一直等待,请求永远不会完成!如果提供了data参数,它会作为最后一块数据写入并立即结束响应。这就像你上完菜,必须说一声“您的菜上齐了,请慢用”,不然客人会一直坐在那儿等!
10.3 处理不同的 HTTP 方法 (GET, POST) 和路径 — ‘餐馆’的‘菜单’和‘点餐规则’!
在 http.createServer 的回调函数中,你可以根据 req.method(客人要干啥)和 req.url(客人要啥)来决定如何“路由”这个请求,并发送不同的响应。这就像你的餐馆里有“菜单”和“点餐规则”!
const http = require('http');
const url = require('url'); // Node.js 内置模块,用于解析 URL,把 URL 字符串拆解成对象
const server = http.createServer((req, res) => {
// 使用 url 模块解析请求的 URL,第二个参数 true 表示解析查询字符串
const parsedUrl = url.parse(req.url, true);
const path = parsedUrl.pathname; // URL 路径,比如 /users, /about
const query = parsedUrl.query; // 查询参数对象,比如 { id: '123' }
console.log(`\n--- 新请求 ---`);
console.log(`请求方法: ${req.method}, 路径: ${path}, 查询参数:`, query);
// 默认响应头,告诉客户端是 HTML 内容
res.setHeader('Content-Type', 'text/html; charset=utf-8');
// 根据请求方法和路径进行“路由”判断
if (req.method === 'GET') { // 处理 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 http 模块搭建的服务器。</p>');
} else if (path === '/api/users') {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json'); // 咱们要返回 JSON 数据了
const users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
];
res.end(JSON.stringify(users)); // 把 JavaScript 对象转换成 JSON 字符串发送
} else if (path === '/greet') {
const name = query.name || '陌生人'; // 获取查询参数 name,如果没有就用“陌生人”
res.statusCode = 200;
res.end(`<h1>你好, ${name}!</h1><p>欢迎你!</p>`);
} else {
// 404 Not Found: 没找到请求的资源
res.statusCode = 404;
res.end('<h1>404 Not Found</h1><p>您访问的页面不存在或资源已删除。</p>');
}
} else if (req.method === 'POST') { // 处理 POST 请求,通常用于提交数据
if (path === '/submit') {
let body = '';
// 监听请求体的 'data' 事件,收集数据块
req.on('data', (chunk) => {
body += chunk.toString(); // chunk 是 Buffer,需要 toString() 转换
});
// 监听请求体的 'end' 事件,表示所有数据都已接收完毕
req.on('end', () => {
console.log('接收到 POST 请求体数据:', body);
res.statusCode = 200;
res.end(`<h1>POST 请求成功!</h1><p>你发送的数据是: ${body}</p>`);
});
// 监听请求体的 'error' 事件
req.on('error', (err) => {
console.error('POST 请求数据接收错误:', err.message);
res.statusCode = 500;
res.end('<h1>500 Internal Server Error</h1><p>数据接收失败。</p>');
});
} else {
res.statusCode = 404;
res.end('<h1>404 Not Found</h1><p>POST 请求的路径不存在。</p>');
}
} else {
// 处理其他 HTTP 方法(PUT, DELETE 等)
res.statusCode = 405; // 405 Method Not Allowed
res.setHeader('Allow', 'GET, POST'); // 告诉客户端只允许 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('请打开浏览器访问,或使用 curl 命令测试:');
console.log(' GET 请求示例:');
console.log(' curl http://localhost:3000/');
console.log(' curl http://localhost:3000/about');
console.log(' curl http://localhost:3000/api/users');
console.log(' curl http://localhost:3000/greet?name=隔壁老王');
console.log(' POST 请求示例 (注意,需要在另一个终端执行):');
console.log(' curl -X POST -d "mydata=这是我发送的数据" http://localhost:3000/submit');
console.log(' curl -X POST -H "Content-Type: application/json" -d \'{"username": "小芳", "password": "123"}\' http://localhost:3000/submit');
});
测试 POST 请求(使用 curl 或 Postman/Insomnia):
-
在第一个终端中运行
node server.js启动服务器。 -
打开另一个终端,使用
curl命令发送 POST 请求:-
发送普通表单数据:
curl -X POST -d "username=testuser&password=123" http://localhost:3000/submit -
发送 JSON 数据(注意
-H "Content-Type: application/json"和单引号\'):curl -X POST -H "Content-Type: application/json" -d '{"username": "jsonUser", "email": "json@example.com"}' http://localhost:3000/submit
-
注意:
-
当你发送 POST 请求时,
req.on('data')会被多次触发,每次接收一小块数据(chunk)。你需要把这些chunk拼接起来,直到req.on('end')触发,才表示所有数据都接收完毕了。 -
chunk通常是Buffer对象,需要调用.toString()转换为字符串。
本节总结:
好了,同学们,通过本节的学习,你已经掌握了 Node.js 事件驱动编程的基石 EventEmitter,知道如何“发令”和“监听”各种事件,以及最重要的——如何正确处理 error 事件,避免你的程序“原地爆炸”!
同时,你还学会了如何使用 Node.js 内置的 http 模块从零开始构建一个简单的 Web 服务器,理解了请求 (req) 和响应 (res) 对象的基本操作,以及如何根据 HTTP 方法和路径进行简单的“路由”!
虽然这个“原生”服务器功能还比较有限,但它可是所有 Node.js Web 框架的“老祖宗”!理解它,能让你对 Web 服务器的底层运作有更深刻的认识。
下节预告:
手写服务器有点累?别担心!下节课,咱们就要引入 Node.js Web 开发的“神器”——Express.js!它会极大地简化 Web 应用的开发,让你从繁琐的底层细节中解脱出来,专注于你的业务逻辑!准备好了吗?咱们下节课,不见不散!
好的,同学们,欢迎回来!
上节课咱们用 Node.js 原生的 http 模块手撸了一个最简单的 Web 服务器,是不是感觉有点“硬核”?每次都要手动解析 URL、判断方法、拼接请求体,写起来是不是有点“劝退”?
别急!今天,咱们就要迎来 Node.js Web 开发的“救世主”——Express.js!它就像给你把拖拉机换成了跑车,让你从繁琐的底层细节中解脱出来,专注于业务逻辑,开发效率瞬间起飞!
第三阶段:Node.js Web 开发:Express.js (约6节) — 告别手摇拖拉机,开上跑车!
学习目标:
在本阶段,你将从零开始学习 Express.js 框架,掌握其核心概念和使用方法。你将学会如何配置路由、使用强大的中间件、处理各种请求参数和请求体,并最终能够利用 Express.js 构建一个快速、可扩展的 Web 应用程序和 RESTful API。
第 11 节:Express.js 入门:初次见面,请多指教!(Web 开发效率飙升!)
各位老铁,同学们好啊! 还记得咱们上节课用 Node.js 内置 http 模块写服务器的“痛苦”吗?解析 URL、判断请求方法、处理请求体流、手动设置响应头和状态码…… 即使是几个简单的 URL 路径,代码也变得又臭又长,而且还容易出错。这正是像 Express.js 这样的 Web 框架出现的原因!
11.1 为什么选择 Express.js?它的作用。
Express.js 是什么?
Express.js 是一个基于 Node.js 平台的快速、开放、极简的 Web 框架。它不是一个“大而全”的框架,而是一个“小而精”的框架,只提供了 Web 应用所需的核心功能,其他功能都通过中间件按需添加。它的设计哲学就是“极简主义”,给你最大的自由度。
为什么选择 Express.js?(它的魔力在哪里?)
-
简化路由:
- 还记得原生
http模块里,我们是怎么根据req.url和req.method写一堆if/else来判断路由的吗?Express.js 简直是“天神下凡”!它提供了超级简洁的 API,比如app.get(),app.post(),app.put(),app.delete()。你只需要指定路径和对应的处理函数,Express 就会自动帮你搞定路由匹配!爽不爽?
- 还记得原生
-
中间件 (Middleware) 机制:
-
这是 Express.js 最最最强大的特性之一! 中间件函数就像一条流水线上的一个个“工人”,它们可以访问请求对象 (
req)、响应对象 (res),还能把活儿交给流水线上的下一个“工人” (next函数)。 -
它们能干啥?日志记录、身份验证、解析请求体(把你麻烦的请求体流解析成直接可用的对象)、处理会话、压缩响应数据等等。通过中间件,你的代码变得高度模块化、可复用性极高。这就像搭积木一样,功能想加就加,想拆就拆!
-
-
模板引擎集成:
- 如果你需要渲染动态 HTML 页面(比如传统的服务端渲染),Express.js 可以方便地与各种流行的模板引擎(如 Pug/Jade, EJS, Handlebars)集成,让你轻松生成动态网页。
-
错误处理:
- Express.js 提供了统一的错误处理机制。当你的业务逻辑中发生错误时,你只需简单地
next(error)一下,Express 就会自动帮你把错误传递给专门的错误处理中间件,实现集中式的错误管理。再也不用担心错误没处理导致程序“原地爆炸”了!
- Express.js 提供了统一的错误处理机制。当你的业务逻辑中发生错误时,你只需简单地
-
社区和生态系统:
- 作为 Node.js 最流行、使用最广泛的 Web 框架,Express.js 拥有一个庞大而活跃的社区。这意味着你遇到任何问题,都能轻松找到大量的文档、教程和解决方案。而且,NPM 上有海量的第三方 Express 中间件和模块,你可以轻松找到并集成各种功能,加速你的开发进程!
-
性能:
- 别看 Express.js 功能强大,但它本身非常轻量和快速!因为它只提供了 Web 应用所需的核心功能,其他的功能都是通过中间件“按需加载”的。所以,它既强大又不臃肿!
总结: Express.js 的作用,就是提供一个结构化、高效且易于使用的框架,来简化 Node.js Web 应用程序和 API 的开发。它帮你搞定了那些繁琐的底层 HTTP 细节,让你能够把精力集中在真正的业务逻辑上!
11.2 安装 Express.js — 简单粗暴,一步到位!
Express.js 是一个第三方模块,所以它需要通过我们熟悉的 NPM 来进行安装。
-
创建一个新的项目目录并初始化 npm:
-
找个你喜欢的地儿,创建一个新文件夹,比如叫
my-express-app。 -
打开你的终端(命令行工具),进入这个新文件夹:
mkdir my-express-app cd my-express-app npm init -y # -y 会跳过所有提问,直接生成一个默认的 package.json 文件。方便快捷!
-
-
安装 Express.js:
-
现在,咱们把 Express.js “请”到项目里来!
npm install express -
这个命令会做几件事:
-
把 Express.js 的代码下载到你项目目录下的
node_modules文件夹里。 -
在你的
package.json文件里,自动把express这个依赖添加到dependencies字段中(通常会带^前缀)。 -
同时会生成或更新
package-lock.json文件,记录精确的版本信息。
-
-
11.3 创建第一个 Express.js 应用:基本路由设置 — Hello Express World!
现在,咱们来创建你的第一个 Express.js 应用!就像咱们之前手写 HTTP 服务器一样,但你会发现,这回简单多了!
创建一个名为 app.js 的文件(通常 Express 应用的主文件就叫这个)。
// app.js
// 1. 引入 Express 模块 (请它进来)
const express = require('express');
// 2. 创建 Express 应用实例 (这就是你的 Web 应用程序的核心对象!)
const app = express();
// 3. 定义端口号 (你的应用要跑在哪个“门牌号”上)
const PORT = 3000;
// 4. 设置基本路由 (定义你的“网站地图”和“响应规则”!)
// app.get() 方法:专门用来处理客户端发送的 GET 请求
// 它的第一个参数是请求的路径(URL),第二个参数是一个回调函数 (req, res)。
// 这个 req 和 res 对象,比原生 http 模块的要“好用”得多!
app.get('/', (req, res) => { // 当客户端访问根路径 '/' 时
// res.send() 方法:Express 提供的一个超级方便的方法!
// 它可以发送各种类型的响应:字符串、对象、数组等等。
// 最重要的是,它会自动帮你设置好 Content-Type 和 Content-Length,并且自动结束响应(不用再手动 res.end() 了!)
res.send('Hello from Express.js! This is the homepage. Welcome!');
});
// 再来一个 GET 路由,访问 /about 路径
app.get('/about', (req, res) => {
res.send('<h1>关于我们</h1><p>这是一个简单的 Express.js 应用程序。</p><p>由资深讲师手把手教学!</p>');
});
// 5. 启动服务器并监听指定端口 (让你的应用“开门营业”!)
app.listen(PORT, () => {
// 服务器成功启动后,会执行这个回调函数
console.log(`Express 服务器运行在 http://localhost:${PORT}`);
console.log('请在浏览器中访问此地址,或尝试访问 /about 页面。');
});
运行和测试:
-
在终端中,确保你已经进入到
my-express-app目录。 -
运行命令:
node app.js -
打开你的浏览器,分别访问:
-
http://localhost:3000/ -
http://localhost:3000/about
-
是不是感觉比手写 http 模块简单多了?路由设置清晰明了,res.send() 用起来简直不要太爽!
11.4 中间件 (Middleware) 概念与使用 (app.use()) — Express 的‘黑科技’!
同学们,中间件,这是 Express.js 最最最核心,也是最最最强大的特性之一! 搞懂了它,你就掌握了 Express..js 的“灵魂”!
什么是中间件?
中间件函数是 Express.js 应用程序中处理请求的“工作站”。你可以把它想象成一条流水线上的一个个“工人”。当一个 HTTP 请求进入 Express 应用时,它会像流水线上的产品一样,依次经过你定义好的一个个中间件函数。
每个中间件函数都可以访问以下三个东西:
-
请求对象 (
req):从客户端来的请求信息,你可以在这里读取、修改它。 -
响应对象 (
res):你将要发回给客户端的响应,你可以在这里写入、修改它。 -
下一个中间件函数 (
next):这是一个特殊的函数!
中间件可以执行以下任务:
-
执行任何代码:比如你想统计请求数量,或者在请求到达前做一些初始化工作。
-
对请求 (
req) 和响应 (res) 对象进行更改:比如在req上添加一些属性(认证后的用户信息),或者在res上设置一些默认的响应头。 -
结束请求-响应循环(即发送响应):如果一个中间件处理完了请求,并给客户端发送了响应(比如调用了
res.send()或res.end()),那么整个请求-响应的流程就结束了,后面的中间件和路由处理函数就不会再执行了。 -
调用堆栈中的下一个中间件函数:这是关键!如果当前中间件函数没有结束请求-响应循环,它就必须调用
next()函数,将控制权传递给流水线上的下一个中间件函数!否则,请求就会在这里“卡住”,客户端会一直等待,最终导致请求超时!
使用 app.use():
app.use() 方法是用来“挂载”中间件函数的。你可以把它想象成“把工人放到流水线上”!
-
app.use(middlewareFunction): 如果app.use()没有指定路径参数,这意味着这个中间件会应用于所有进入 Express 应用的请求(无论什么路径,什么 HTTP 方法)。 -
app.use('/path', middlewareFunction): 如果指定了路径参数,表示这个中间件只应用于以/path开头的所有请求。只有请求的 URL 路径匹配或以/path开头时,这个中间件才会被执行。
示例:咱们来加一个简单的日志中间件!
// app.js (在现有代码基础上修改)
const express = require('express');
const app = express();
const PORT = 3000;
// 1. 定义一个简单的日志中间件 (应用级,对所有请求生效)
// 它会在每个请求到达 Express 应用时,打印请求信息
app.use((req, res, next) => { // req, res, next 这三个参数是中间件的“标配”
const timestamp = new Date().toISOString(); // 获取当前时间
console.log(`[${timestamp}] 收到请求: ${req.method} ${req.url}`);
next(); // 敲黑板!必须调用 next()!否则请求会在这里“卡住”!
// next() 告诉 Express:这个中间件处理完了,请把控制权交给下一个中间件或路由处理函数。
});
// 2. 定义另一个中间件,只对以 /api 开头的路径生效 (带路径的中间件)
app.use('/api', (req, res, next) => {
console.log('[API 专用中间件] 这是一个针对 /api 路径的请求。');
next(); // 别忘了 next()!
});
// 3. 设置基本路由 (与之前相同)
app.get('/', (req, res) => {
res.send('Hello from Express.js! This is the homepage.');
});
app.get('/about', (req, res) => {
res.send('<h1>关于我们</h1><p>这是一个简单的 Express.js 应用程序。</p>');
});
// 新增一个 API 路由,用来测试 /api 路径的中间件
app.get('/api/data', (req, res) => {
// res.json() 方法:Express 提供的,专门用来发送 JSON 数据,它会自动设置 Content-Type 为 application/json
res.json({ message: '数据来自 API 接口', timestamp: new Date() });
});
// 4. 启动服务器
app.listen(PORT, () => {
console.log(`Express 服务器运行在 http://localhost:${PORT}`);
});
测试中间件:
-
确保
node app.js正在运行。 -
打开浏览器访问:
-
http://localhost:3000/:你会看到控制台输出[时间戳] 收到请求: GET /。 -
http://localhost:3000/about:你会看到控制台输出[时间戳] 收到请求: GET /about。 -
http://localhost:3000/api/data:你会看到控制台输出[时间戳] 收到请求: GET /api/data,紧接着还会输出[API 专用中间件] 这是一个针对 /api 路径的请求。。因为/api中间件匹配到了这个请求!
-
中间件的顺序很重要:
同学们,中间件就像流水线上的工位,它是严格按照它们被 app.use() 或路由方法(如 app.get())定义的顺序来执行的! 如果一个中间件在处理请求后没有调用 next(),那么后续的中间件和路由处理函数将不会被执行。所以,通常像日志记录、请求体解析这种需要先执行的中间件,会放在最前面。
本节总结:
好了,同学们,通过本节课,你已经正式踏入了 Express.js 的大门!
-
你理解了为什么 Express.js 是 Node.js Web 开发的“救世主”,它如何通过简化路由、引入中间件等机制,大大提高开发效率。
-
你学会了安装 Express.js,并创建了你的第一个 Express 应用,感受到了
res.send()的方便。 -
最重要的是,你掌握了 Express.js 的**“灵魂”——中间件**的概念,以及如何使用
app.use()来挂载中间件,理解了next()函数的重要性,并且知道中间件的执行顺序是按照它们定义的顺序来的。
中间件是 Express.js 强大和灵活的关键所在,理解并善用它,你就能构建出强大且可扩展的 Web 应用!
下节预告:
下节课,咱们要继续深入 Express.js 的路由和请求处理!我们将学习如何从 URL 中获取数据(路由参数和查询字符串),以及如何优雅地处理客户端发送的 POST 请求体!这些都是构建 RESTful API 的核心技能!准备好了吗?咱们下节课,不见不散!
好的,同学们,欢迎回来!
上节课咱们正式进入了 Express.js 的世界,学会了怎么搭建第一个 Express 应用,并深入理解了 Express 的“灵魂”——中间件。是不是感觉开发效率噌噌往上涨?
今天,咱们要继续深入 Express.js 的“点餐系统”!一个 Web 应用,光能响应首页和关于页面可不够,还得能根据用户请求的不同路径、不同方式,提供精准的服务。所以,这节课,咱们就来学习 Express.js 的路由与请求处理!这就像你的餐厅有了更精细的“菜单”和“点餐规则”,能够识别客人点的是“招牌菜”,还是“私人定制”的“大餐”!
第 12 节:Express.js 路由与请求处理 — 精准打击你的请求!
学习目标:
本节课,你将掌握 Express.js 中获取请求参数的两种常见方式:路由参数 (req.params) 和查询字符串 (req.query)。你还将学会如何优雅地处理客户端发送的 POST、PUT 等请求中的请求体数据。
12.1 路由参数 (Route Params) 和查询字符串 (Query Strings) — 从 URL 里‘挖’出你要的数据!
各位老铁,同学们好啊! 咱们平时访问网站,或者调用 API 的时候,经常会看到这样的 URL:
-
http://example.com/users/123(获取 ID 为 123 的用户) -
http://example.com/products/apple-watch-s8/reviews/latest(获取 Apple Watch Series 8 的最新评论) -
http://example.com/search?q=nodejs&category=web(搜索关键词 Node.js,分类是 Web)
这些 URL 里的 123、apple-watch-s8、latest、nodejs、web,都是客户端想要告诉服务器的一些信息。在 Express.js 里,咱们有两种主要的方式来获取这些信息:路由参数和查询字符串。
1. 路由参数 (Route Parameters) — URL 路径里的‘占位符’!
路由参数用于捕获 URL 中特定位置的值。它们在定义路由路径时,使用冒号 : 来表示一个“占位符”。
-
定义: 在你的路由路径里,用
:后面跟着一个参数名,比如/users/:id。 -
访问: 在你的路由处理函数里,通过
req.params对象来访问。参数名就是你:后面定义的那个。
示例:
// app.js (在你的 Express 应用里添加这些路由)
const express = require('express');
const app = express();
// ... (其他中间件和端口定义,为了演示这里省略了)
const PORT = 3000;
// 获取单个用户信息的路由:/users/123, /users/abc
// ':userId' 就是一个路由参数的占位符
app.get('/users/:userId', (req, res) => {
const userId = req.params.userId; // 通过 req.params.参数名 来获取值
res.send(`你请求的用户 ID 是: <b>${userId}</b>`);
});
// 获取特定产品评论的路由:/products/123/reviews/456
// 这里有两个路由参数::productId 和 :reviewId
app.get('/products/:productId/reviews/:reviewId', (req, res) => {
const productId = req.params.productId;
const reviewId = req.params.reviewId;
res.send(`你请求的产品 ID 是 <b>${productId}</b> 的评论 ID 是 <b>${reviewId}</b>`);
});
// ... (省略 app.listen 部分)
app.listen(PORT, () => {
console.log(`Express 服务器运行在 http://localhost:${PORT}`);
console.log('请尝试访问:');
console.log(' http://localhost:3000/users/john_doe');
console.log(' http://localhost:3000/products/iphone-15/reviews/10086');
});
测试:
-
访问
http://localhost:3000/users/123,你会看到“你请求的用户 ID 是: 123”。 -
访问
http://localhost:3000/products/apple-watch-s8/reviews/latest,你会看到“你请求的产品 ID 是 apple-watch-s8 的评论 ID 是 latest”。
2. 查询字符串 (Query Strings) — URL 问号后的‘额外信息’!
查询字符串是 URL 中 ? 后面跟着的一系列 key=value 对,用 & 连接。它们通常用于传递可选参数、过滤条件、排序方式、分页信息等。
-
格式:
key1=value1&key2=value2 -
访问: Express.js 会自动把这些
key=value对解析成一个对象,并放在req.query中。
示例:
// app.js (在你的 Express 应用里添加这个路由)
// 搜索商品的路由:/search?q=nodejs&category=web&page=1
app.get('/search', (req, res) => {
const searchTerm = req.query.q; // 获取查询参数 'q'
const category = req.query.category; // 获取查询参数 'category'
const page = req.query.page || 1; // 获取查询参数 'page',如果没有则默认是 1
if (searchTerm) {
res.send(`你正在搜索: "<b>${searchTerm}</b>" ${category ? `在分类 <b>${category}</b> 中` : ''} (第 <b>${page}</b> 页)`);
} else {
res.send('请提供搜索关键词,例如: <a href="/search?q=express">/search?q=express</a>');
}
});
// ... (省略 app.listen 部分)
测试:
-
访问
http://localhost:3000/search?q=express -
访问
http://localhost:3000/search?q=javascript&category=backend -
访问
http://localhost:3000/search?q=database&page=5
总结一下:
-
路由参数 (
req.params): 放在 URL 路径中,通常用于识别特定资源。比如users/:id。是路径的一部分,通常是必填的。 -
查询字符串 (
req.query): 放在 URL 问号?之后,通常用于传递额外、可选的过滤条件或配置信息。比如search?q=...。
12.2 处理 POST 请求:body-parser 或 Express 内置中间件 — 接收客人的‘订单’!
当客户端发送 POST、PUT 或 PATCH 等请求时,它们通常会把数据放在请求体 (Request Body) 中发送给服务器。
在咱们原生 http 模块里,处理请求体数据可是个麻烦事儿:你需要监听 req 对象的 data 事件来收集数据块,再监听 end 事件,最后手动拼接和解析这些数据。
Express.js 早就把这个麻烦事儿给搞定了! 它通过中间件来自动解析不同格式的请求体数据,然后把解析后的数据直接放在 req.body 对象上,让你用起来就像访问普通 JavaScript 对象一样方便!
好消息是:现代 Express.js (4.16.0 及以上版本) 已经内置了 body-parser 的核心功能! 你不再需要单独安装 body-parser 这个第三方模块了。
使用 Express 内置中间件(直接启用,省心省力!):
-
express.json():-
作用: 用来解析
Content-Type: application/json格式的请求体。这是最常见的 API 请求体格式。 -
使用场景: 你的前端应用通常会把数据以 JSON 格式发送给你的 RESTful API。
-
-
express.urlencoded({ extended: true }):-
作用: 用来解析
Content-Type: application/x-www-form-urlencoded格式的请求体(这通常是 HTML 表单提交数据时用的默认编码方式)。 -
extended: true的含义: 允许解析更复杂的嵌套对象和数组(比如user[name]=Alice这种格式)。如果设置为false,则只能解析简单的键值对。通常建议设为true。
-
示例:
// app.js (在你的 Express 应用里添加这些中间件和路由)
const express = require('express');
const app = express();
const PORT = 3000;
// ... (其他中间件,比如日志中间件,应该放在这两个解析器之前)
// 启用 Express 内置的 JSON 解析中间件
// 任何 Content-Type 为 application/json 的请求体都会被它解析到 req.body 上
app.use(express.json());
// 启用 Express 内置的 URL-encoded 解析中间件
// 任何 Content-Type 为 application/x-www-form-urlencoded 的请求体都会被它解析到 req.body 上
app.use(express.urlencoded({ extended: true }));
// 处理 POST 请求的路由
app.post('/submit-form', (req, res) => {
// 解析后的请求体数据会直接存储在 req.body 中!
const formData = req.body;
console.log('接收到 POST 请求体数据:', formData); // 打印到服务器控制台
// 根据 req.body 的内容发送响应
res.send(`
<h1>表单提交成功!</h1>
<p>你提交的数据是: <code>${JSON.stringify(formData)}</code></p>
<p>用户名: <b>${formData.username || '未提供'}</b></p>
<p>邮箱: <b>${formData.email || '未提供'}</b></p>
<p>密码: <b>${formData.password ? '已收到(但不推荐直接传输)' : '未提供'}</b></p>
`);
});
// ... (省略 app.listen 部分)
app.listen(PORT, () => {
console.log(`Express 服务器运行在 http://localhost:${PORT}`);
console.log('请使用 Postman/Insomnia 或 curl 命令测试 POST 请求:');
console.log(' 测试 JSON 数据:');
console.log(' curl -X POST -H "Content-Type: application/json" -d \'{"username": "testuser", "email": "test@example.com", "password": "pass"}\' http://localhost:3000/submit-form');
console.log(' 测试 URL-encoded 数据:');
console.log(' curl -X POST -H "Content-Type: application/x-www-form-urlencoded" -d "username=anotheruser&email=another@example.com&password=abc" http://localhost:3000/submit-form');
});
运行和测试 POST 请求:
由于浏览器直接访问通常是 GET 请求,所以你需要使用专门的工具来测试 POST 请求:
-
Postman / Insomnia (这是两个非常流行的图形化 API 测试工具,强烈推荐安装!)
-
curl 命令 (命令行工具,适合快速测试)
使用 curl 测试 JSON 数据:
# 注意 -H 设置 Content-Type,-d 指定数据
# JSON 数据通常用单引号包起来,防止 shell 解析
curl -X POST -H "Content-Type: application/json" -d '{"username": "jsonUser", "email": "json@example.com", "password": "jsonpass"}' http://localhost:3000/submit-form
使用 curl 测试 URL-encoded 数据:
# 注意 -H 设置 Content-Type,-d 指定数据
curl -X POST -H "Content-Type: application/x-www-form-urlencoded" -d "username=encodedUser&email=encoded@example.com&password=encpass" http://localhost:3000/submit-form
你会发现,无论你发送哪种格式的请求体,req.body 都能自动帮你解析成一个 JavaScript 对象,是不是很方便?这正是 express.json() 和 express.urlencoded() 中间件的功劳!
12.3 HTTP 请求方法(GET, POST, PUT, DELETE)— RESTful API 的‘动词’!
在咱们前面讲 RESTful API 设计原则的时候,提到了要使用标准的 HTTP 方法来对资源执行操作。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 — 搭建一个简单的用户管理系统!
咱们来搭建一个最简单的用户管理系统 API,用数组模拟数据库存储数据。
// app.js (在你的 Express 应用里添加这些路由)
const express = require('express');
const app = express();
const PORT = 3000;
// ... (确保你已经启用了 express.json() 和 express.urlencoded() 中间件!)
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 假设我们有一个简单的用户数据存储(实际项目中会用数据库!)
let users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
];
let nextUserId = 3; // 用来给新用户分配 ID
// --- 用户 API 路由 ---
// GET /api/users - 获取所有用户
app.get('/api/users', (req, res) => {
res.json(users); // res.json() 等同于 res.send(JSON.stringify(obj)),并自动设置 Content-Type
});
// 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); // 200 OK
} else {
res.status(404).json({ message: '用户未找到' }); // 404 Not Found
}
});
// 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) {
// 客户端发送的数据有问题,返回 400 Bad Request
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, // 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); // 200 OK,返回更新后的用户
} else {
res.status(404).json({ message: '用户未找到' }); // 404 Not Found
}
});
// 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: '用户未找到' }); // 404 Not Found
}
});
// ... (省略 app.listen 部分)
app.listen(PORT, () => {
console.log(`Express 服务器运行在 http://localhost:${PORT}`);
console.log('请使用 Postman/Insomnia 或 curl 命令测试这些 API:');
console.log(' GET http://localhost:3000/api/users');
console.log(' GET http://localhost:3000/api/users/1');
console.log(' POST http://localhost:3000/api/users (Body: {"name": "Charlie", "email": "charlie@example.com"})');
console.log(' PUT http://localhost:3000/api/users/1 (Body: {"name": "Alice Smith", "email": "alice.s@example.com"})');
console.log(' DELETE http://localhost:3000/api/users/2');
});
测试这些 API (使用 curl 或 Postman/Insomnia):
-
运行
node app.js启动服务器。 -
用你喜欢的工具(Postman、Insomnia 或 curl)测试:
-
GET http://localhost:3000/api/users(获取所有用户,开始是 Alice, Bob) -
POST http://localhost:3000/api/users(Body:{"name": "Charlie", "email": "charlie@example.com"},创建一个新用户) -
再次
GET http://localhost:3000/api/users(看看 Charlie 是不是加进来了,ID 可能是 3) -
GET http://localhost:3000/api/users/1(获取 ID 为 1 的用户) -
PUT http://localhost:3000/api/users/1(Body:{"name": "Alice Smith", "email": "alice.s@example.com"},更新 ID 为 1 的用户) -
DELETE http://localhost:3000/api/users/2(删除 ID 为 2 的用户 Bob) -
再
GET http://localhost:3000/api/users看看剩余的用户列表。
-
注意:
-
在
PUT和POST请求中,你需要确保客户端发送的Content-Type头与你的 Express 中间件(express.json()或express.urlencoded())相匹配,这样req.body才能正确解析。 -
实际项目中,咱们的“数据库”可不是一个简单的数组,而是真正的数据库,比如咱们下节课要讲的 MongoDB!
本节总结:
好了,同学们,通过本节的学习,你已经掌握了 Express.js 的核心用法,包括:
-
路由参数 (
req.params) 和 查询字符串 (req.query) 的使用,能够灵活地从 URL 中获取数据。 -
学会了如何利用 Express 内置中间件 (
express.json(),express.urlencoded()) 优雅地处理 POST、PUT 等请求中的请求体数据,让你能够轻松访问req.body。 -
理解了 HTTP 请求方法(GET, POST, PUT, DELETE, PATCH)在 RESTful API 中的作用,并能够使用 Express.js 为它们定义对应的路由处理函数。
-
最重要的是,你已经能够使用这些知识,搭建一个简单的 RESTful 用户管理 API,实现了最核心的 CRUD(创建、读取、更新、删除)功能!
这些都是构建任何 Express.js Web 应用和 API 的基础!掌握了它们,你离全栈开发者又近了一步!
下节预告:
接下来,咱们要继续深入 Express.js,聊聊更高级的中间件用法,并正式把 Node.js 应用和数据库(MongoDB) 连接起来!让你的应用不再是“临时记忆”,而是拥有“永久记忆”!准备好了吗?咱们下节课,不见不散!
好的,同学们,欢迎回来!
上节课咱们搞定了 Express.js 的路由和请求处理,学会了从 URL 里“挖”数据,也能优雅地处理 POST 请求体,甚至还搭建了一个简单的 RESTful 用户管理 API。是不是感觉自己的 Web 开发能力又上了一个台阶?
今天,咱们要继续深入 Express.js 的“内功心法”——中间件!它可是 Express.js 最强大、最灵活的“武器”!同时,咱们还要正式把你的 Node.js 应用和数据库连接起来,让你的应用拥有“永久记忆”!没有数据库的应用,就像没有大脑的人,啥都记不住!
第 13 节:Express.js 中间件深入 — 你的 Web 应用‘变形金刚’!
学习目标:
在本节课中,你将深入理解 Express.js 中间件的各种类型(应用级、路由级、错误处理),学会如何编写自定义中间件来实现特定的功能,并认识一些常用的第三方中间件,让你的 Express 应用变得更加强大、安全和易于管理。
13.1 应用级中间件、路由级中间件、错误处理中间件 — 中间件的‘兵种分类’!
各位老铁,同学们好啊! 还记得咱们在 Express.js 入门时,提到中间件就像流水线上的“工人”吗?实际上,这些“工人”还分不同的“兵种”和“级别”,它们在流水线上承担着不同的职责和作用范围。
-
应用级中间件 (Application-level Middleware):
-
作用范围: 这是最常见的中间件!使用
app.use()或app.METHOD()(比如app.app.get(),app.post()) 绑定到app这个 Express 应用实例上。 -
执行时机:
-
如果使用
app.use()且没有指定路径:那么这个中间件将应用于所有进入 Express 应用的请求(无论什么路径,什么 HTTP 方法)。它就像你公司门口的保安,每个进公司的人都要过他那关。 -
如果使用
app.use('/path', callback):这个中间件只应用于以该path开头的所有请求。比如app.use('/api', ...),那么只有api开头的请求才会经过它。 -
app.METHOD(path, callback):这些也是应用级中间件,只不过它们绑定到了特定的 HTTP 方法和路径。它们通常是请求处理链的终点(也就是处理完就直接响应了,不用next()了)。
-
-
示例:
const express = require('express'); const app = express(); const PORT = 3000; // 应用级中间件 1:对所有请求生效的日志记录器 // 就像公司大门口的打卡机,每个人进来都要打卡 app.use((req, res, next) => { console.log(`[应用级中间件] 收到请求: ${req.method} ${req.url} at ${new Date().toISOString()}`); next(); // 必须调用 next(),否则请求会在这里停止! }); // 应用级中间件 2:只对 /api 路径生效的中间件 // 就像公司的“API 部门”的门禁,只有去 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 部门的数据' }); }); app.listen(PORT, () => console.log(`服务器运行在 http://localhost:${PORT}`));
-
-
路由级中间件 (Router-level Middleware):
-
作用范围: 它不是直接绑定到
app实例,而是绑定到express.Router()实例。你可以把express.Router()想象成 Express.js 应用里的小型“子应用程序”或者“模块化的路由器”。 -
目的: 当你的应用变得庞大,路由逻辑复杂时,你可以把相关的路由和中间件组织到一个独立的路由模块里,提高代码的模块化和可维护性。
-
示例:
// usersRoutes.js (一个新的文件,专门管理用户相关的路由和中间件) const express = require('express'); const router = express.Router(); // 创建一个路由实例 // 路由级中间件:只对通过此 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}`); }); module.exports = router; // 导出这个路由实例 // app.js (在你的主应用里引入并使用这个路由模块) const express = require('express'); const app = express(); const usersRoutes = require('./usersRoutes'); // 引入用户路由模块 const PORT = 3000; // ... (其他应用级中间件,比如日志中间件) // 将用户路由模块挂载到应用程序的 /users 路径下 // 任何访问 /users/xx 的请求都会先经过 usersRoutes 里的中间件 app.use('/users', usersRoutes); app.get('/', (req, res) => res.send('首页')); app.listen(PORT, () => console.log(`服务器运行在 http://localhost:${PORT}`));测试: 访问
http://localhost:3000/users/123,你会看到应用级和路由级中间件的日志都打印出来了。
-
-
错误处理中间件 (Error-handling Middleware):
-
特点: 这是最特殊的中间件!它有四个参数:
(err, req, res, next)。其他中间件只有三个参数。 -
执行时机: 它们必须定义在所有其他路由和常规中间件的后面!当你的任何路由或中间件中发生错误(例如,你调用了
next(err)并传入一个错误对象)时,Express 会非常智能地跳过所有常规中间件,直接把控制权交给这个错误处理中间件!它就像你公司的“急诊室”,专门处理各种突发状况。 -
示例:
const express = require('express'); const app = express(); const PORT = 3000; // ... (省略常规中间件和路由) 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><p>您访问的页面不存在。</p>'); }); // 错误处理中间件 (必须有四个参数:err, req, res, next) // 且必须放在所有路由和常规中间件的最后面! app.use((err, req, res, next) => { console.error('哎呀!捕获到错误了:', err.stack); // 打印错误堆栈到服务器控制台,方便调试 const statusCode = err.statusCode || 500; // 如果错误有自定义状态码就用,否则默认为 500 res.status(statusCode).send(` <h1>${statusCode} - 服务器出错了!</h1> <p>${err.message}</p> <pre>${process.env.NODE_ENV === 'development' ? err.stack : ''}</pre> <p>生产环境不会显示堆栈信息,怕泄露隐私!</p> `); // 注意:错误处理中间件里通常不需要调用 next(),因为它是处理请求的终点 }); app.listen(PORT, () => console.log(`服务器运行在 http://localhost:${PORT}`));测试:
-
访问
http://localhost:3000/error-test,你会看到自定义的错误页面。 -
访问
http://localhost:3000/nonexistent-path,你会看到 404 页面。
-
-
13.2 自定义中间件的编写 — 打造你自己的‘特种兵’!
编写自定义中间件非常简单,你只需要定义一个函数,并确保它的参数是 (req, res, next) 就行了。然后,在这个函数里写你的业务逻辑,最后别忘了调用 next() 或发送响应来结束请求。
示例:身份验证中间件
这个中间件可以用来检查用户是否登录,或者是否有权限访问某个资源。
// authMiddleware.js (一个新的文件,专门放你的自定义中间件)
function authenticate(req, res, next) {
// 假设我们从请求头中获取一个 API Key 来进行简单认证
const apiKey = req.headers['x-api-key'];
if (apiKey === 'MY_SECRET_API_KEY_123') { // 假设这是你的秘密 API Key
// 认证成功!可以在 req 对象上添加用户信息,方便后续路由使用
req.user = { id: 101, name: '认证用户_张三', role: 'admin' };
console.log('[认证中间件] 认证成功!用户:', req.user.name);
next(); // 认证通过,继续处理请求,交给下一个中间件或路由
} else {
// 认证失败!直接发送 401 Unauthorized 响应,并结束请求
console.log('[认证中间件] 认证失败!无效的 API Key。');
res.status(401).send('未授权: 请提供有效的 API Key!');
// 这里没有调用 next(),因为已经发送了响应,请求流程结束
}
}
module.exports = authenticate; // 导出这个中间件函数
// app.js (在你的主应用里使用这个自定义中间件)
const express = require('express');
const app = express();
const authenticate = require('./authMiddleware'); // 引入自定义中间件
const PORT = 3000;
app.use(express.json()); // 用于解析请求体
// 应用到所有以 /secure 开头的路径的请求
// 只有带了正确的 API Key 的请求才能进入 /secure 内部的路由
app.use('/secure', authenticate); // 放在 /secure 路由定义之前
// 受保护的路由
app.get('/secure/data', (req, res) => {
// 只有通过 authenticate 中间件的请求才能到达这里
res.json({ message: `欢迎, ${req.user.name}! 这是受保护的秘密数据。`, data: '这是只有 VIP 才能看的信息!' });
});
// 公开的路由,不需要认证
app.get('/public/data', (req, res) => {
res.send('这是公开数据,无需认证,任何人都可以访问。');
});
app.listen(PORT, () => console.log(`服务器运行在 http://localhost:${PORT}`));
测试:
-
GET http://localhost:3000/public/data(成功) -
GET http://localhost:3000/secure/data(你会得到 401 未授权错误,因为没带 API Key) -
GET http://localhost:3000/secure/data(用 Postman 或 curl,添加请求头X-API-Key: MY_SECRET_API_KEY_123,你会成功获取数据!)
13.3 常用的第三方中间件介绍 — NPM 上的‘宝藏’!
Express.js 的强大之处,很大一部分要归功于其庞大的中间件生态系统。NPM 上有无数的第三方中间件,它们帮你解决了各种常见的问题,让你不用“重复造轮子”。
这里介绍几个你在实际项目中肯定会用到的“明星中间件”:
-
morgan(HTTP 请求日志):-
作用: 自动帮你记录 HTTP 请求的详细信息,比如请求方法、URL、状态码、响应时间等等。非常适合开发时调试和生产环境监控。
-
安装:
npm install morgan -
使用:
const morgan = require('morgan'); // ... app.use(morgan('dev')); // 'dev' 是一种预定义的日志格式,它会输出简洁的带颜色的日志到控制台 // 还有 'tiny', 'short', 'common', 'combined' 等多种格式 // 你也可以自定义格式: app.use(morgan(':method :url :status :response-time ms - :res[content-length]'));运行后,每次请求都会在你的服务器控制台打印一行日志,清晰明了!
-
-
cors(跨域资源共享):-
作用: 当你的前端应用和后端 API 不在同一个域名下时,就会遇到跨域问题(CORS 错误)。
cors中间件可以帮你轻松地设置 CORS 策略,允许或限制来自不同源的 Web 应用程序访问你的服务器资源。 -
安装:
npm install cors -
使用:
const cors = require('cors'); // ... app.use(cors()); // 最简单粗暴的方式:允许所有来源的跨域请求 (开发环境常用,生产环境慎用!) // 生产环境通常需要配置特定的来源,更安全: // app.use(cors({ // origin: 'http://your-frontend-domain.com', // 只允许来自这个域名的请求 // methods: ['GET', 'POST', 'PUT', 'DELETE'], // 允许的 HTTP 方法 // allowedHeaders: ['Content-Type', 'Authorization'] // 允许的请求头 // }));
-
-
helmet(安全):-
作用: “安全帽”中间件!它通过设置各种 HTTP 响应头,来帮助你的 Express 应用程序抵御一些常见的 Web 漏洞和攻击。比如 XSS (跨站脚本攻击)、Clickjacking (点击劫持) 等。
-
安装:
npm install helmet -
使用:
const helmet = require('helmet'); // ... app.use(helmet()); // 启用所有默认的 Helmet 中间件(通常就够用了) // 你也可以选择性地启用或禁用其中的某些模块,比如: // app.use(helmet.contentSecurityPolicy()); // 内容安全策略 // app.use(helmet.xssFilter()); // XSS 过滤
-
-
express-session(会话管理):-
作用: 如果你不想用 JWT,或者需要传统的基于 Session 的会话管理,
express-session提供了强大的会话管理功能,允许你在用户请求之间存储用户特定的数据(比如用户是否登录,购物车里有啥)。 -
安装:
npm install express-session -
使用: (需要一个
secret字符串来签名会话 ID 的 Cookie,这个secret必须保密!)const session = require('express-session'); // ... app.use(session({ secret: 'your_secret_key_for_session_security', // 必须提供一个秘密字符串,用于签名会话 ID resave: false, // 强制会话保存,即使它在请求期间没有被修改(通常设置为 false) saveUninitialized: true, // 强制未初始化的会话保存到存储(通常设置为 true) cookie: { secure: false } // 在生产环境中,如果使用 HTTPS,应设置为 true! })); app.get('/set-session', (req, res) => { // req.session 对象就是存储会话数据的地方 req.session.views = (req.session.views || 0) + 1; // 统计访问次数 res.send(`你访问了此页面 ${req.session.views} 次`); });
-