Non-blocking File Ninja: 异步文件忍者
文章目录
- 一、文件概念
- 1、核心概念
- 2、文件操作
- 二、同步文件与异步文件
- 1、同步文件处理(Blocking I/O)
- 2、异步文件处理(Non-blocking I/O)
- 3、对比表
- 三、异步文件的实现原理
- 1、I/O操作模式
- (1)基础
- (2)非阻塞 I/O与异步I/O区别
- 2、事件循环机制
- (1)核心概念
- (2)事件循环的工作流程
- (3)任务队列的分类
- (4)事件循环的执行顺序
- (5)示例
- 3、线程池(如 Node.js 的 `libuv`)
- (1)核心概念
- (2)线程池的工作流程
- (3)示例
- 4、事件循环和线程池区别
- (1)对比表
- (2)工作流程对比
- 事件循环的工作流程
- 线程池的工作流程
- (3)协作关系
- (4)常见误解
- 四、异步文件的操作实现
- 1、定义
- 2、对比表
- 3、API举例
- (1)`readFile(path[, options])`
- (2)`appendFile(file, data[, options])`
- (3)`mkdir(path[, options])`
- 4、常见错误码
- 5、文件操作的核心原则
- 五、流操作
- 1、文件流
- (1)文件流的定义
- (2)文件流的分类
- (3)核心用法示例
- 可读流
- 1. 两种读取模式
- 2. 关键事件
- 3.示例
- 可写流
- 1.读取模式
- 2.关键事件
- 3.示例
- (4)流操作的优势与场景
- (5)注意事项
- 2、文件与文件流的对比
- 3、Promise 封装流操作
- (1)核心目标
- (2)实现方式
- (3)主要优点
- (4)常用函数
- 4、高级
- (1)背压(Backpressure)
- 背压的定义
- 1. 核心概念
- 2. 类比理解
- Node.js 中的背压实现
- 1. 缓冲区与水位线
- 2. 背压触发条件
- 3. 关键事件与方法
- 背压处理示例
- 背压处理的最佳方法
- 1. 优先使用 `pipe()`
- 2. 调整缓冲区大小
- (2)管道(Pipe)
- 管道机制的定义
- 1. 核心概念
- 2. 基本语法
- 管道的核心特性
- 1. 自动数据传输
- 2. 背压自动处理
- 3. 链式管道
- 4. 错误传播
- 管道的工作流程
- **数据流动**:
- **背压处理**:
- 管道的常用场景
- 1. 文件复制
- 2. 数据转换
- 3. 压缩 / 解压缩
- 管道的注意事项
- 1. 错误处理
- 2. 流的关闭控制
- 3. 内存优化
- 4. 避免重复管道
- 与手动处理流的对比
- 总结
一、文件概念
在计算机科学中,文件(File) 是存储在存储设备(如硬盘、固态硬盘、光盘等)上的一组相关数据的集合,通常具有名称和目录路径。在操作系统中,文件通过**文件描述符(File Descriptor,FD)**管理,它是一个非负整数,用于标识打开的文件(类似 “文件句柄”)。文件是操作系统组织和管理数据的基本单位,可包含文本、图像、程序代码、音频、视频等各种类型的数据。
1、核心概念
- 文件结构
- 文件名:文件的标识(如
document.txt
),通常包含扩展名(如.txt
、.jpg
)指示文件类型。 - 元数据:文件的描述信息,如创建时间、修改时间、大小、权限等。
- 数据内容:文件实际存储的数据。
- 文件名:文件的标识(如
- 文件系统
- 文件通常组织在目录(文件夹) 中,形成层次结构(如
C:/Users/Documents/
)。 - 常见文件系统包括:NTFS(Windows)、ext4(Linux)、APFS(macOS)。
- 文件通常组织在目录(文件夹) 中,形成层次结构(如
- 文件类型
- 文本文件:存储人类可读的文本(如
.txt
、.html
、.js
)。 - 二进制文件:存储非文本数据(如
.jpg
、.exe
、.mp3
)。 - 特殊文件:在类 Unix 系统中,还包括设备文件、管道文件等。
- 文本文件:存储人类可读的文本(如
2、文件操作
编程语言通常提供文件操作的 API,例如:在 Node.js 中的实现(fs
模块)通过 fs
模块提供文件操作能力,支持同步、异步和流式三种模式。
二、同步文件与异步文件
普通文件处理(同步)和异步文件处理是两种不同的 I/O 操作模式,主要区别在于程序执行流程和资源利用效率。
Node.js 基于单线程事件循环机制设计,主线程负责处理所有同步代码、事件回调和异步操作的调度。
1、同步文件处理(Blocking I/O)
- 核心特点
- 阻塞事件循环:同步操作会暂停 Node.js 主线程,直到 I/O 操作完成。
- 代码顺序执行:操作按代码顺序同步执行,结果可立即获取。
- API 命名含
Sync
:如fs.readFileSync()
、fs.writeFileSync()
。
“Sync” 是 “Synchronization”(同步)的缩写
- 示例代码
const fs = require('fs');try {const data = fs.readFileSync('file.txt', 'utf8'); // 阻塞主线程,直到读取完成console.log('文件内容:', data);
} catch (err) {console.error('读取失败:', err);
}console.log('同步操作完成后执行'); // 此代码会在文件读取完成后执行
-
优点
-
代码逻辑简单:无需处理回调或Promise,适合线性执行的简单场景。
-
结果可立即获取:同步操作返回值即为结果,便于调试和理解。
-
避免回调地狱:无需嵌套回调,代码结构更扁平。
-
缺点
-
阻塞主线程:若操作耗时较长(如大文件读取、网络请求),会导致整个应用无响应。
-
无法处理异步流程:难以与其他异步操作(如定时器、网络请求)配合使用。
- 适用场景
- 初始化配置:应用启动时读取配置文件(仅需执行一次,且需保证配置提前加载)。
- 简单测试场景:开发阶段临时读取少量数据。
2、异步文件处理(Non-blocking I/O)
- 核心特点
- 非阻塞事件循环:操作发起后立即返回,主线程继续执行后续代码,I/O 完成后通过回调或Promise通知结果。
- 基于回调 / Promise:API 支持回调函数(如
fs.readFile()
)或 Promise(如fs.promises.readFile
)。
- 示例代码
- 回调风格
const fs = require('fs');fs.readFile('file.txt', 'utf8', (err, data) => {if (err) {console.error('读取失败:', err);return;}console.log('文件内容:', data); // I/O 完成后执行
});console.log('异步操作发起后立即执行'); // 此代码会在读取操作发起后立即执行
- Promise 风格(
fs/promises
)
const fs = require('fs').promises;
// 所有方法都返回 Promise,可通过 then()/catch() 或 async/await 处理结果。async function readFileAsync() {try {const data = await fs.readFile('file.txt', 'utf8'); // 非阻塞,等待 Promise resolveconsole.log('文件内容:', data);} catch (err) {console.error('读取失败:', err);}
}readFileAsync();
console.log('异步操作发起后立即执行');
- 优点
-
非阻塞主线程:
- 支持同时处理多个 I/O 操作(如并发读取多个文件),充分利用 Node.js 的事件驱动特性。
- 适合高并发场景(如 Web 服务器处理多个请求)。
-
灵活的异步流程控制:
- 可通过
Promise.all()
、async/await
等方式组合多个异步操作,实现复杂流程(如并行读取文件后合并数据)。
- 可通过
-
与异步生态兼容:无缝集成 Node.js 的异步 API
-
缺点
-
回调嵌套问题:多层回调可能导致代码可读性下降(“回调地狱”),需通过 Promise 或
async/await
优化。 -
错误处理需谨慎:异步操作的错误需通过回调函数的第一个参数或
try/catch
(搭配async/await
)捕获,否则可能导致未处理拒绝(Unhandled Rejection)。 -
结果非立即获取:需通过回调或
await
获取结果,代码逻辑相对同步更复杂。 -
适用场景
- 大文件处理:使用流(Stream)或分块操作(如
fs.createReadStream
)处理大文件,避免内存占用过高。 - 高并发场景:同时处理多个文件操作、网络请求或定时任务。
3、对比表
维度 | 同步文件处理 | 异步文件处理 |
---|---|---|
阻塞性 | 阻塞主线程,等待 I/O 完成 | 非阻塞,立即返回并继续执行 |
API 形式 | 函数名含 Sync (如 readFileSync ) | 回调函数或 Promise(如 readFile /fs.promises ) |
代码复杂度 | 简单(线性执行) | 较高(需处理回调 / Promise) |
错误处理 | try/catch | 回调参数或 try/catch (搭配 async/await ) |
适用场景 | 短时间脚本、初始化配置 | 高并发应用、大文件操作、Web 服务 |
性能影响 | 可能导致应用无响应 | 不阻塞主线程,性能更优 |
三、异步文件的实现原理
1、I/O操作模式
在计算机系统中,I/O(输入 / 输出)操作是数据在内存与外部设备(如硬盘、网络、键盘)之间传输的过程。不同的 I/O 操作模式直接影响系统的性能和资源利用率。
(1)基础
-
阻塞 vs 非阻塞 vs 异步:
- 阻塞 I/O:程序调用 I/O 操作后,主线程被挂起(进入睡眠状态),直到 I/O 完成才继续执行后续代码。
- 非阻塞 I/O:程序调用 I/O 操作后,系统立即返回(无论操作是否完成),主线程可继续执行其他任务。应用程序循环调用 I/O 操作(轮询)。若数据未就绪,返回错误(如
EWOULDBLOCK
);若就绪,则进行数据复制。 - 异步I/O:应用程序发起 I/O 请求,立即返回继续执行。在后台完成数据读写,并在完成后通过信号或回调通知应用程序,无需轮询或阻塞线程。
非阻塞是异步的必要条件,但非充分条件。
(2)非阻塞 I/O与异步I/O区别
特性 | 非阻塞 I/O | 异步 I/O |
---|---|---|
轮询机制 | 需要程序主动轮询(Polling)检查状态 | 无需轮询,通过回调或通知机制(如事件循环)得知操作完成 |
线程占用 | 主线程仍需参与检查状态,未完全释放 | 主线程完全不参与,由操作系统或线程池处理 I/O |
实现复杂度 | 中等(需手动处理轮询逻辑) | 高(依赖平台 API 或库) |
典型场景 | 网络编程(如套接字非阻塞模式) | 文件系统操作(如 Node.js 的 fs.readFile) |
2、事件循环机制
事件循环(Event Loop)是 Node.js 和 JavaScript 实现异步编程的核心机制,它负责处理非阻塞 I/O 操作的回调函数,使单线程的 JavaScript 能够高效处理高并发场景。
(1)核心概念
- 单线程执行:JavaScript 主线程一次只能执行一个任务。
- 非阻塞 I/O:通过底层线程池(如
libuv
)处理 I/O 操作,不阻塞主线程。 - 事件循环:不断检查任务队列,当操作完成时触发回调,将待处理的回调函数放入主线程执行。
(2)事件循环的工作流程
- 发起异步操作: 当调用
fs.readFile()
、setTimeout()
等异步 API 时,Node.js 将任务交给底层线程池(如磁盘 I/O)或其他系统组件(如定时器)处理。 - 回调入队: 当异步操作完成时(如文件读取完毕),对应的回调函数被放入任务队列(Task Queue)。
- 事件循环处理队列: 主线程执行完当前任务后(空闲时),事件循环不断从队列中取出回调函数并执行,重复此过程。
(3)任务队列的分类
Node.js 中的任务队列分为两类:
- 宏任务队列(Macrotask Queue):
- 定时器(
setTimeout
、setInterval
) - I/O 回调(如文件读取完成回调)
setImmediate
- 主程序代码
- 定时器(
- 微任务队列(Microtask Queue):
- Promise 回调(
.then()
、.catch()
) process.nextTick()
queueMicrotask()
(微任务中的微任务)
- Promise 回调(
(4)事件循环的执行顺序
每次事件循环迭代(Tick)遵循以下顺序:
- 执行当前任务:主线程执行同步代码。
- 清空微任务队列:执行所有微任务,直到队列为空。
- 检查定时器:执行到期的定时器回调。
- 处理 I/O 回调:执行已完成的 I/O 操作的回调。(实际执行顺序取决于文件读取完成时间)
- 执行
setImmediate
:如果存在,执行setImmediate
回调。 - 重复循环:回到步骤 1。
任务 | 执行 | 作用 |
---|---|---|
timers(定时器) | 执行到期的 setTimeout/setInterval 回调 | 检查定时器(timer )是否超时,若超时则将回调加入事件循环执行队列 |
pending callbacks(I/O 回调) | 处理网络、文件等 I/O 操作的回调 | 执行上一轮事件循环中未执行完的 I/O 操作回调 |
idle, prepare(空闲 / 准备) | 内部使用,用户代码通常不涉及 | 内部使用阶段,用于 Node.js 引擎的内部逻辑(如清理定时器句柄、准备 poll 阶段等) |
poll(轮询) | 检索新的 I/O 事件;执行 I/O 回调 | 核心阶段,负责处理新的 I/O 事件(如读取文件内容、接收网络数据)。 执行与 I/O 相关的回调函数(如 fs.readFile 的成功回调)。 |
check(检查) | 执行 setImmediate() 回调 | 专门用于执行通过 setImmediate() 调度的回调函数。 |
close callbacks(关闭回调) | 执行关闭事件的回调(如 socket.on(‘close’)) | 执行与资源关闭事件相关的回调函数。 |
- poll阶段:
- 若存在未到期的
setTimeout
/setInterval
,且当前poll
队列为空,事件循环会提前离开poll
阶段,进入timers
阶段等待定时器到期。- 若存在
setImmediate
回调,且poll
队列为空,事件循环会进入check
阶段执行setImmediate
。- check阶段:
- 是
setImmediate
回调的唯一执行时机,确保其在poll
阶段之后、下一轮timers
阶段之前执行。- 需要在当前 I/O 操作完成后异步执行的任务(如分解长任务、优化 I/O 密集型操作)。
关键特性
- 非阻塞主线程: 事件循环使主线程无需等待I/O操作完成,可继续处理其他任务。
- 微任务优先执行: 微任务队列在每次宏任务执行后都会被清空,确保高优先级任务优先处理。
- 处理并发而非并行: 单线程的事件循环通过时间片轮转处理多个任务,适合 I/O 密集型场景,但不适合 CPU 密集型任务。
(5)示例
console.log('1. 开始执行');// 外层定时器(延迟200ms)
setTimeout(() => {console.log('2. 外层定时器回调执行');// 内层定时器(延迟100ms)setTimeout(() => {console.log('3. 内层定时器回调执行');}, 100);// setImmediatesetImmediate(() => {console.log('4. setImmediate回调执行');});// 微任务(Promise)Promise.resolve().then(() => {console.log('5. Promise微任务执行');});console.log('6. 外层定时器回调结束');
}, 200);// 主模块中的setImmediate
setImmediate(() => {console.log('7. 主模块setImmediate执行');
});console.log('8. 同步代码结束');
- 执行流程分析:
-
同步代码执行:
- 主线程依次执行同步代码,调度外层
setTimeout(200ms)
和主模块setImmediate
,然后进入事件循环。
- 主线程依次执行同步代码,调度外层
-
事件循环第一轮迭代:
- timers 阶段:检查是否有到期定时器(≥200ms)。此时无到期定时器,跳过。
- pending callbacks 阶段:无未完成的 I/O 回调,跳过。
- idle/prepare 阶段:内部处理,跳过。
- poll 阶段:队列为空,检查是否存在未到期定时器或
setImmediate
。存在主模块setImmediate
,进入check阶段
。 - check 阶段:执行主模块
setImmediate
回调,输出:7. 主模块setImmediate执行
- close callbacks 阶段:无关闭事件,跳过。
-
事件循环第二轮迭代:
- timers 阶段:外层setTimeout(200ms)到期,执行回调:
2. 外层定时器回调执行
- 回调中调度:内层
setTimeout(100ms)
→ 实际延迟为 200ms(当前时间) + 100ms = 300ms。setImmediate
→ 进入check阶段
队列。Promise.then()
→ 微任务队列(优先级高于setImmediate
)。继续执行回调剩余代码,输出:6. 外层定时器回调结束
- 关键点:微任务队列在当前阶段结束后立即执行,因此输出:
5. Promise微任务执行
- 回调中调度:内层
- pending callbacks 阶段:无任务,跳过。
- idle/prepare 阶段:内部处理,跳过。
- poll 阶段:队列为空,检查未到期定时器(内层
setTimeout
将于 300ms 到期)和setImmediate
。存在setImmediate
,进入check阶段
。 - check 阶段:执行setImmediate回调,输出:
4. setImmediate回调执行
- timers 阶段:外层setTimeout(200ms)到期,执行回调:
-
事件循环第三轮迭代:
- timers 阶段:内层setTimeout(100ms)到期(总延迟 300ms),执行回调,输出:
3. 内层定时器回调执行
- 后续阶段:无其他任务,事件循环进入休眠状态(等待新事件)。
- timers 阶段:内层setTimeout(100ms)到期(总延迟 300ms),执行回调,输出:
- 关键点总结:
- 定时器时间计算:
- 内层
setTimeout(100ms)
从外层回调执行时(200ms)开始计时,实际触发时间为 300ms。
- 内层
- 微任务优先级:
Promise.then()
属于微任务,在当前阶段(timers)结束后、下一阶段(pending callbacks)开始前执行,因此优先于setImmediate
。
- setImmediate 执行时机:
- 外层回调中的
setImmediate
在当前poll阶段
结束后,立即在check阶段
执行,早于内层定时器(需等待到 300ms)。
- 外层回调中的
- 嵌套回调的影响:
- 回调中的异步操作(如定时器、
setImmediate
)会被加入事件循环的后续迭代,而非立即执行。
- 回调中的异步操作(如定时器、
1. 开始执行
8. 同步代码结束
7. 主模块setImmediate执行
2. 外层定时器回调执行
6. 外层定时器回调结束
5. Promise微任务执行
4. setImmediate回调执行
3. 内层定时器回调执行
3、线程池(如 Node.js 的 libuv
)
线程池(如 Node.js 的 libuv
):用于处理无法通过操作系统直接异步化的操作(如磁盘 I/O),使用后台线程执行,避免阻塞主线程。
为什么需要线程池?
1. 操作系统的限制
- 磁盘 I/O 的同步特性:
大多数操作系统的磁盘 I/O API 是同步的(如 Linux 的read()
/write()
),无法直接在事件循环中异步执行。- CPU 密集型操作的阻塞风险:
加密、压缩等计算任务若在主线程执行,会阻塞事件循环,导致应用无响应。2. Node.js 的解决方案
- 线程池:创建一组后台线程(默认 4 个),将同步操作委托给这些线程执行,主线程通过事件循环监听操作完成事件。
- 核心目标:将同步操作异步化,避免阻塞主线程,维持 Node.js 的非阻塞特性。
线程池的工作流程
1. 关键组件
- 主线程(Main Thread):负责事件循环、处理异步回调、调度线程池任务。
- 后台线程(Worker Threads):
- 默认数量:4 个(可通过环境变量
UV_THREADPOOL_SIZE
调整,范围:1~128)。- 执行同步操作(如文件读取),完成后通知主线程。
- 任务队列(Task Queue):主线程将待执行的任务(如
fs.readFile
)加入队列,后台线程按顺序取出执行。
(1)核心概念
- 线程池:预先创建一组线程(默认 4 个,可配置),用于执行耗时操作,避免阻塞主线程。
- libuv:Node.js 的底层 C 库,负责处理异步 I/O 和线程池管理。
- 适用场景:
- 磁盘 I/O(如
fs.readFile
) - 加密操作(如
crypto
模块) - DNS 查询(
dns.lookup
) - 用户自定义的耗时操作(通过
worker_threads
)
- 磁盘 I/O(如
(2)线程池的工作流程
- 主线程接收到异步操作: 当调用
fs.readFile()
等 API 时,Node.js 将任务封装并发送给 libuv 线程池。 - 线程池分配线程执行任务: 线程池从空闲线程中取出一个线程,执行实际的 I/O 操作(如读取文件)。
- 主线程继续执行: 主线程无需等待,继续执行后续代码。
- 任务完成通知: 当线程完成操作后,通过事件循环通知主线程,并将回调函数放入任务队列。
- 主线程执行回调: 事件循环在下一轮迭代中执行该回调函数。
(3)示例
const fs = require('fs');fs.readFile('data.txt', (err, data) => {// 回调在主线程执行console.log('文件读取完成');
});
- 主线程发起请求:调用
fs.readFile
时,主线程将文件读取任务封装为uv_fs_t
请求,加入线程池任务队列。 - 后台线程执行同步 I/O:某个空闲的后台线程从队列中取出任务,调用操作系统同步 API读取文件。
- 完成通知:后台线程完成读取后,将结果通过 IPC(进程间通信)返回主线程。
- 主线程处理回调:事件循环将回调加入
poll
阶段队列,执行console.log('文件读取完成')
。
4、事件循环和线程池区别
(1)对比表
特性 | 事件循环 | 线程池 |
---|---|---|
定义 | 单线程的执行模型,负责调度和执行回调函数 | 多线程的执行模型,负责执行耗时操作 |
作用 | 处理异步操作的结果(回调),避免阻塞主线程 | 执行阻塞操作(如磁盘 I/O),将结果通知事件循环 |
线程数量 | 单线程(JavaScript 主线程) | 多线程(默认 4 个,可配置) |
适用场景 | 非阻塞 I/O(如网络请求)、定时器、微任务 | 阻塞 I/O(如磁盘读写) |
典型实现 | Node.js 的事件循环机制 | Node.js 的 libuv 线程池 |
优势 | 高效处理非阻塞操作,单线程无锁开销 | 支持阻塞操作,利用多核并行,适用于I/O 密集型 |
劣势 | 无法处理 CPU 密集型任务 | 线程创建和切换有开销,不适用于CPU 密集型操作 |
(2)工作流程对比
事件循环的工作流程
- 发起异步操作:主线程调用异步 API(如
fs.readFile
)。 - 操作交给底层:将任务交给操作系统或线程池处理。
- 继续执行主线程:主线程不等待,继续执行后续代码。
- 回调入队:异步操作完成后,回调函数被放入任务队列。
- 循环处理队列:事件循环不断从队列中取出回调并执行。
线程池的工作流程
- 接收阻塞任务:主线程将阻塞操作(如磁盘读取)交给线程池。
- 分配线程执行:线程池从空闲线程中取出一个执行任务。
- 主线程继续执行:主线程不受影响,继续处理其他任务。
- 任务完成通知:线程完成操作后,通过事件循环通知主线程。
- 执行回调:事件循环将回调放入队列并执行。
(3)协作关系
事件循环和线程池通常协同工作:
- 事件循环负责调度和执行回调,是异步编程的 “大脑”。
- 线程池负责执行阻塞操作,是异步编程的 “苦力”。
(4)常见误解
- Node.js 是单线程的,因此无法利用多核:
- 错误。Node.js 主线程是单线程的,但线程池可以利用多核 CPU 执行阻塞操作。
- 所有异步操作都通过线程池实现:
- 错误。只有不支持异步的操作(如磁盘 I/O)才使用线程池;网络 I/O 直接通过操作系统的非阻塞机制实现。
- 线程池越大越好:
- 错误。过多线程会导致上下文切换开销增加,反而降低性能。
四、异步文件的操作实现
1、定义
在 TypeScript中进行文件操作时,本质上仍依赖 Node.js 的 fs
模块,但 TypeScript 提供了更严格的类型定义和类型安全支持。
特性 | CommonJS(require) | ES6 模块化(import) |
---|---|---|
加载时机 | 运行时加载(动态) | 编译时静态分析(静态) |
导出方式 | module.exports/exports | export/export default |
返回值 | 模块导出对象(值拷贝) | 只读引用(动态绑定) |
顶级 this | 指向模块对象(module) | 指向 undefined |
兼容性 | Node.js 原生支持 | Node.js 需配置 type: module |
-
- CommonJS(
require
)是 Node.js 原生支持的模块化系统,直接返回模块导出对象。 - ES6 模块化(
import
)需在 Node.js 中通过配置(package.json
中"type": "module"
)启用,语法更接近浏览器规范。
- CommonJS(
场景 | CommonJS(require) | ES6 模块化(import) |
---|---|---|
导入内置模块(如 fs) | const fs = require('fs'); | import * as fs from 'fs'; |
导入本地模块 | const myModule = require('./myModule'); | import myModule from './myModule.js'; |
动态导入 | const module = require(path); | const module = await import(path); |
const fs = require('fs');
const fs = require('fs').promises;import * as fs from 'fs';
import * as fs from 'fs/promises';// // 直接导入 Promise 版本的模块
两者**promises
** 作用相同,都是为了获取 Promise 风格的文件系统(FS)API。
2、对比表
传统回调 API | Promise 版本(.promises) | 功能描述 |
---|---|---|
fs.readFile(path, callback) | fs.promises.readFile(path) | 读取文件内容 |
fs.writeFile(path, data, callback) | fs.promises.writeFile(path, data) | 写入文件内容 |
fs.appendFile(path, data, callback) | fs.promises.appendFile(path, data) | 追加内容到文件 |
fs.unlink(path, callback) | fs.promises.unlink(path) | 删除文件 |
fs.mkdir(path, callback) | fs.promises.mkdir(path) | 创建目录 |
fs.rmdir(path, callback) | fs.promises.rmdir(path) | 删除目录 |
fs.rename(oldPath, newPath, callback) | fs.promises.rename(oldPath, newPath) | 重命名文件 / 目录 |
fs.stat(path, callback) | fs.promises.stat(path) | 获取文件 / 目录状态信息 |
fs.readdir(path, callback) | fs.promises.readdir(path) | 读取目录内容 |
3、API举例
(1)readFile(path[, options])
- 含义:读取文件内容。
- 参数:
path
:文件路径。options
(可选):encoding
:编码格式(如'utf8'
),默认返回Buffer
。flag
:文件标志(如'r'
只读)。
- 返回值:Promise 解析为文件内容。
import * as fs from 'fs/promises';
async function readFile() {try {const data: string = await fs.readFile('abc.txt', 'utf8');console.log(data.toUpperCase());} catch (error: any) {console.error('failed' + error);}
}async function main() {try {await readFile();console.log('Success');} catch (error) {console.error('Has error' + error);}
}main();
(2)appendFile(file, data[, options])
- 含义:追加数据到文件末尾。
- 参数:同
writeFile
。
import * as fs from 'fs/promises';
async function appendFile() {try {await fs.appendFile('abc.txt', 'HTMLCSSJSTSsgfesgfd', { encoding: 'utf-8' });} catch (error: any) {console.error('failed' + error);}
}async function main() {try {await appendFile();console.log('Success');} catch (error) {console.error('Has error' + error);}
}main();
(3)mkdir(path[, options])
- 含义:创建目录。
- 参数:
path
:目录路径。options
(可选):recursive
:是否递归创建父目录(如{ recursive: true }
)。mode
:目录权限(如0o777
)。
rmdir(path[, options])
- 含义:删除目录(必须为空,除非
recursive: true
)。 - 参数:同
mkdir
。
- 含义:删除目录(必须为空,除非
import * as fs from 'fs/promises';
// 创建单层目录(父目录必须存在)
async function createSimpleDir() {await fs.mkdir('new-dir'); // 若父目录不存在,抛出 ENOENT
}// 递归创建多级目录(等价于 mkdir -p)
async function createRecursiveDir() {await fs.mkdir('a/b/c', { recursive: true }); // 自动创建 a、b、c 目录
}// 创建目录并指定权限(如 0o755 = rwxr-xr-x)
async function createDirWithMode() {await fs.mkdir('secure-dir', { mode: 0o755 });
}async function main() {try {await createDirWithMode();await createSimpleDir();await createRecursiveDir();console.log('Success');} catch (error) {console.error('Has error' + error);}
}main();// 删除空目录
async function removeEmptyDir() {await fs.rmdir('empty-dir'); // 目录非空时抛出 ENOTEMPTY
}// 递归删除非空目录(Node.js 12.10.0+)
async function removeNonEmptyDir() {await fs.rmdir('full-dir', { recursive: true }); // 谨慎使用!
}
4、常见错误码
错误代码 | 含义 | 场景示例 |
---|---|---|
ENOENT | 文件或目录不存在 | 读取不存在的文件 |
EACCES | 权限不足 | 写入只读文件 |
EISDIR | 操作的是目录而非文件 | 尝试读取目录内容为文件 |
ENOTDIR | 路径不是目录 | 尝试读取文件路径为目录 |
EBUSY | 文件被占用 | 删除被其他程序打开的文件 |
5、文件操作的核心原则
- 优先使用异步方法:避免阻塞事件循环,提升应用吞吐量。
- 完善错误处理:针对不同错误代码(如
ENOENT
、EACCES
)编写特定处理逻辑。 - 大文件使用流:通过
createReadStream
和createWriteStream
处理 GB 级文件。 - 避免同步方法:仅在初始化阶段(如读取配置)使用同步方法。
五、流操作
1、文件流
(1)文件流的定义
文件流是一种逐块读取或写入文件数据的机制,而非一次性加载整个文件到内存。它通过 Node.js 的 stream
模块实现,核心思想是流式处理(Streaming),适用于处理大文件或需要渐进式操作的场景。
核心特性:
- 内存高效:逐块处理数据,避免大文件导致的内存溢出。
- 异步非阻塞:基于事件驱动,不会阻塞主线程。
- 可组合性:支持管道操作(
pipe()
),实现数据的链式处理(如压缩 → 加密 → 写入)。
流的工作原理:
- 非阻塞读取:
- 流基于事件驱动,读取操作不会阻塞主线程。
- Node.js 后台通过 libuv 调用操作系统的异步 I/O 接口。
- 数据分块:
- 文件被分成多个 数据块(chunks),每个块默认大小约为 64KB(可通过
highWaterMark
选项调整)。 - 每次
data
事件触发时,chunk
携带一个数据块。
- 文件被分成多个 数据块(chunks),每个块默认大小约为 64KB(可通过
- 背压(Backpressure):
- 当数据处理速度慢于读取速度时,流会自动调节读取速率,防止内存溢出。
(2)文件流的分类
Node.js 提供四种类型的文件流,均继承自 stream
模块:
类型 | 作用 | 核心事件 / 方法 |
---|---|---|
可读流(Readable Stream) | 从文件读取数据(fs.createReadStream ) | data (数据块)、end (读取完成) |
可写流(Writable Stream) | 向文件写入数据(fs.createWriteStream ) | drain (可继续写入)、finish (写入完成) |
双向流(Duplex Stream) | 同时支持读写(如网络套接字) | 继承可读流和可写流特性 |
转换流(Transform Stream) | 处理数据转换(如压缩、编码) | transform (数据转换逻辑) |
(3)核心用法示例
可读流
1. 两种读取模式
- 流动模式(Flowing Mode)
- 数据自动从底层系统读取,并通过
data
事件推送。 - 触发方式:监听
data
事件或调用resume()
。 - 适用场景:实时数据流(如网络请求、传感器数据)。
- 数据自动从底层系统读取,并通过
- 暂停模式(Paused Mode)
- 数据需主动调用
read()
方法获取。 - 触发方式:创建流后默认处于暂停模式,调用
read()
读取数据。 - 适用场景:精确控制数据读取时机(如内存敏感场景)。
- 数据需主动调用
- 模式切换
resume()
:从暂停模式切换到流动模式。pause()
:从流动模式切换到暂停模式。
2. 关键事件
事件名称 | 触发时机 | 回调参数 | 典型用途 |
---|---|---|---|
data | 流有新数据块可读时触发。数据块可能是 Buffer (默认)或字符串(指定 encoding 时)。 | chunk :当前读取的数据块。 | 逐块处理数据(如解析大文件、实时数据处理)。 |
end | 流中没有更多数据可读时触发,表明已读取到文件末尾。 | 无 | 数据读取完成后的收尾工作(如统计、关闭连接)。 |
error | 读取过程中发生错误(如文件不存在、权限不足)时触发。 | err :错误对象,包含错误码(如 ENOENT )和描述。 | 捕获并处理读取异常,避免程序崩溃。 |
close | 底层资源(如文件描述符)关闭时触发。并非所有流都会触发此事件。 | 无 | 清理资源(如释放文件句柄、关闭数据库连接)。 |
readable | 流有数据可读或可能有更多数据可读时触发。可用于手动控制读取(配合 read() 方法)。 | 无 | 精细控制读取时机(如需要调整缓冲区大小)。 |
pause | 流因背压或手动调用 pause() 方法而暂停读取时触发。 | 无 | 监听流状态变化,实现流量控制。 |
resume | 流因手动调用 resume() 方法而恢复读取时触发。 | 无 | 监听流状态变化,实现流量控制。 |
drain | 可写流的缓冲区已清空,可继续写入数据时触发(通常在 write() 返回 false 后)。 | 无 | 处理背压,避免内存溢出(见下文示例)。 |
3.示例
const fs = require('fs');// 创建可读流
const readStream = fs.createReadStream('large-file.txt', 'utf8');// 监听数据事件(逐块读取)
readStream.on('data', (chunk) => {console.log('读取数据块:', chunk.length, '字节');}).on('end', () => {console.log('读取完成');}).on('error', (err) => {console.error('读取失败:', err);}).on('close', () => {console.log('流已关闭');});
可写流
// 低效:一次性写入(适合小文件)
fs.writeFile('large.txt', largeData, (err) => { ... });// 高效:流写入(适合大文件)
const stream = fs.createWriteStream('large.txt');
stream.write(chunk1);
stream.write(chunk2);const fs = require('fs');// 创建可写流(默认参数)
const writeStream = fs.createWriteStream('output.txt');// 带参数创建(示例:设置高水位线和编码)
const writeStream = fs.createWriteStream('output.txt', {highWaterMark: 1024 * 1024, // 缓冲区大小 1MB(默认 16KB)encoding: 'utf8' // 字符编码
});
1.读取模式
模式 | 含义 | 行为说明 |
---|---|---|
'r' | 读取模式(默认)。 | 打开文件用于读取。若文件不存在,抛出错误。 |
'r+' | 读写模式。 | 打开文件用于读写。若文件不存在,抛出错误。 |
'rs' | 同步读取模式(慎用)。 | 同步模式读取,强制从磁盘读取,不使用缓存。可能影响性能,仅特殊场景使用。 |
'rs+' | 同步读写模式(慎用)。 | 同步模式读写,强制同步到磁盘。可能影响性能,仅特殊场景使用。 |
'w' | 写入模式。 | 打开文件用于写入。若文件不存在则创建;若存在则截断(清空)。 |
'wx' | 排他写入模式。 | 类似 'w' ,但文件必须不存在,否则抛出错误。用于避免覆盖已有文件。 |
'w+' | 读写模式(创建 / 截断)。 | 打开文件用于读写。若文件不存在则创建;若存在则截断。 |
'wx+' | 排他读写模式。 | 类似 'w+' ,但文件必须不存在,否则抛出错误。 |
'a' | 追加模式。 | 打开文件用于追加。若文件不存在则创建;写入数据追加到文件末尾。 |
'ax' | 排他追加模式。 | 类似 'a' ,但文件必须不存在,否则抛出错误。 |
'a+' | 读取 + 追加模式。 | 打开文件用于读取和追加。若文件不存在则创建;写入数据追加到文件末尾。 |
'ax+' | 排他读取 + 追加模式。 | 类似 'a+' ,但文件必须不存在,否则抛出错误。 |
2.关键事件
事件名称 | 触发时机 | 参数 | 常见用途 |
---|---|---|---|
drain | 当内部缓冲区中的数据已全部写入目标(如磁盘、网络),可继续写入新数据时触发。 | 无 | 处理背压(Backpressure),避免内存溢出。 |
finish | 调用 end() 方法且所有数据已写入完成(包括缓冲区数据)时触发。 | 无 | 标记写入操作彻底完成,可执行后续清理或通知操作。 |
pipe | 当有可读流(Readable )通过 pipe() 方法连接到当前可写流时触发。 | src :源可读流对象 | 监听流连接,例如记录数据来源或初始化资源。 |
unpipe | 当可读流通过 unpipe() 方法断开与当前可写流的连接时触发。 | src :源可读流对象 | 清理资源或记录断开连接事件。 |
error | 写入过程中发生错误(如文件权限不足、磁盘满、网络中断)时触发。 | err :错误对象 | 捕获并处理写入异常,避免程序崩溃。 |
close | 底层资源(如文件描述符、网络套接字)关闭时触发。注意:并非所有可写流都会触发此事件(如 HTTP 响应流不会触发)。 | 无 | 释放资源或记录日志。 |
3.示例
const fs = require('fs');// 创建可写流
const writeStream = fs.createWriteStream('output.txt');// 写入数据(可分多次调用 write())
writeStream.write('Hello, ');
writeStream.write('Stream!\n');
// write() 方法将数据写入内存缓冲区,而非直接写入磁盘。
// 缓冲区满时(默认 16KB),流会自动暂停接受新数据(触发 drain 事件)。// 结束写入
writeStream.end();
// 调用 end() 表示 “不再写入数据”,流会在缓冲区数据全部写入后触发 finish 事件。// 监听写入完成
writeStream.on('finish', () => {console.log('数据写入完成');
});
(4)流操作的优势与场景
- 优势:
- 内存优化:处理 GB 级文件时,内存占用稳定(仅与块大小相关)。
- 实时处理:边读取边处理数据(如实时日志分析、视频流处理)。
- 高并发支持:配合事件循环,可同时处理多个流操作。
- 典型场景:
- 大文件处理:视频转码、日志压缩。
- 实时数据处理:服务器实时日志分析。
- 网络传输:通过流分块发送 / 接收数据(如 HTTP 分块传输)。
(5)注意事项
-
背压(Backpressure):
当可读流速度超过可写流处理能力时,需通过stream.pause()
和stream.resume()
控制流速,避免数据丢失。通过drain
事件或pipe()
自动管理。 -
编码处理:
创建流时指定编码(如'utf8'
),否则默认返回Buffer
对象。 -
错误处理:
必须监听流的error
事件,避免未捕获异常导致进程崩溃。 -
Promise 封装:
可简化流的异步控制,但需使用
stream/promises
模块。
2、文件与文件流的对比
维度 | 文件(直接操作) | 文件流(流式操作) |
---|---|---|
数据加载方式 | 一次性读取 / 写入(内存占用大) | 逐块处理(内存占用小) |
适用场景 | 小文件、简单操作 | 大文件、需要流式处理或管道操作 |
阻塞性 | 同步操作阻塞主线程,异步操作非阻塞 | 异步非阻塞 |
典型 API | readFile/writeFile | createReadStream/createWriteStream |
性能 | 小文件高效,大文件可能内存溢出 | 大文件性能更优,支持背压和流式处理 |
3、Promise 封装流操作
(1)核心目标
将传统的基于事件的流操作(如监听 data
、end
、error
事件)转换为基于 Promise 的接口,使代码更符合现代异步编程范式。
(2)实现方式
-
手动封装:通过 Promise 包装流事件。
function streamToPromise(stream) {return new Promise((resolve, reject) => {stream.on('finish', resolve);stream.on('end', resolve);stream.on('error', reject);}); }
-
使用内置工具:Node.js 提供的
stream/promises
模块。const { finished, pipeline } = require('stream/promises');
(3)主要优点
- 代码更简洁
-
传统事件方式:
stream.on('end', () => {console.log('流完成'); }).on('error', (err) => {console.error('流出错:', err); });
-
Promise 方式:
await finished(stream); // 简洁的单行处理 // 1. 成功完成:当流触发 `finish` 事件(可写流)或 `end` 事件(可读流)时,Promise 会成功 resolve。 // 2. 发生错误:当流触发 `error` 事件时,Promise 会拒绝(reject),并将错误对象作为拒绝原因。 // // finished() 内部已经监听了流的 error 事件,并将其转换为 Promise 拒绝。等价于手动监听每个流的 error 事件并处理
- 更好的错误处理
-
流错误自动转换为 Promise 拒绝,可通过
try/catch
统一处理:try {await finished(stream); } catch (err) {console.error('流操作失败:', err); // 捕获所有错误 }
- 支持异步流程控制
-
可与
Promise.all()、Promise.race()
等组合使用:// 并行处理多个流 await Promise.all([processStream(stream1),processStream(stream2) ]);
- 更符合现代 JS 风格
- 与 ES6+ 的
async/await
语法结合,提高代码可读性。
async function copyFile() {const readStream = fs.createReadStream('abc.txt');const writeStream = fs.createWriteStream('abcd.txt');readStream.pipe(writeStream);await finished(writeStream); // 等待流完成console.log('复制完成');
}
(4)常用函数
finished(stream[, options])
-
作用:监听流的
finish
(可写流)或end
(可读流)事件,并返回 Promise。 -
示例:
const { finished } = require('stream/promises'); const fs = require('fs');async function writeFile() {const stream = fs.createWriteStream('output.txt');stream.write('Hello, World!');stream.end();await finished(stream); // 等待写入完成console.log('写入成功'); }
pipeline(...streams)
-
作用:连接多个流(如读取 → 转换 → 写入),并在所有流完成后 resolve。
-
优势:
- 自动处理背压。
- 任一环节出错时自动销毁所有流。
-
示例:
const { pipeline } = require('stream/promises'); const fs = require('fs'); const { createGzip } = require('zlib');async function compressFile() {await pipeline(fs.createReadStream('input.txt'),createGzip(), // 转换流:压缩数据fs.createWriteStream('output.txt.gz'));console.log('压缩完成'); }
- 手动封装其他流操作
-
示例 1:读取流到字符串:
async function streamToString(stream) {const chunks = [];for await (const chunk of stream) {chunks.push(chunk);}return Buffer.concat(chunks).toString('utf8'); }
-
示例 2:写入字符串到流:
async function stringToStream(str, stream) {return new Promise((resolve, reject) => {stream.write(str, (err) => {if (err) reject(err);else resolve();});}); }
4、高级
(1)背压(Backpressure)
在 Node.js 流操作中,背压(Backpressure) 是一个核心概念,用于描述当数据生产速度超过消费速度时的流量控制机制。它确保系统不会因内存溢出或资源耗尽而崩溃,是构建高性能、稳定应用的关键。
背压的定义
1. 核心概念
当数据生产者(如可读流)生成数据的速度快于消费者(如可写流)处理数据的速度时,数据会在缓冲区堆积。背压机制通过暂停生产者或反馈压力信号来防止缓冲区无限增长。
2. 类比理解
- 水管系统:当出水口排水速度慢于入水口进水速度时,水管内压力增大,需通过阀门(背压机制)调节进水速度。
- 高速公路:当车辆进入高速的速度超过出口放行速度时,入口需限流(如收费站)。
Node.js 中的背压实现
1. 缓冲区与水位线
- 缓冲区(Buffer):流内部用于临时存储数据的内存区域。
- 高水位线(High Water Mark):缓冲区的最大容量(通过
highWaterMark
选项设置,默认 16KB 或 64KB)。
2. 背压触发条件
当写入流的缓冲区达到高水位线时,write()
方法返回 false
,表示需暂停写入。
3. 关键事件与方法
drain
事件:当可写流缓冲区清空到低于高水位线时触发,表示可以继续写入。write()
返回值:true
:缓冲区未满,可继续写入。false
:缓冲区已满,需等待drain
事件。
背压处理示例
- 手动处理背压
const writeStream = fs.createWriteStream('output.txt', {highWaterMark: 1024 * 10 // 10KB 缓冲区// 当写入数据超过此容量时,write() 返回 false
});let i = 0;
const totalChunks = 1000;function writeNextChunk() {while (i < totalChunks) {const data = `Chunk ${i}\n`;const canWrite = writeStream.write(data);if (!canWrite) {// 缓冲区已满,暂停写入,监听 drain 事件writeStream.once('drain', writeNextChunk);// once() 确保只监听一次 drain 事件。当缓冲区数据被写入底层资源(如磁盘)后触发,表示可以继续写入新数据return;// 退出循环,暂停写入}i++;}// 所有数据已写入缓冲区,结束流writeStream.end();
}
背压处理的最佳方法
1. 优先使用 pipe()
pipe()
方法内置了完整的背压处理逻辑,避免手动管理 pause()
/resume()
。
2. 调整缓冲区大小
根据应用场景调整 highWaterMark
:
- 大文件 / 高吞吐量:增大缓冲区(如 1MB)减少事件触发频率。
- 内存敏感场景:减小缓冲区(如 4KB)降低内存占用。
(2)管道(Pipe)
在 Node.js 中,管道(Pipe)机制是连接可读流(Readable)和可写流(Writable)的核心工具,它提供了一种高效、自动的方式来处理连续数据的传输。以下是对管道机制的详细解析:
管道机制的定义
1. 核心概念
管道是一种将可读流的输出直接连接到可写流的输入的机制,数据会自动从源头(可读流)流向目标(可写流),无需手动管理每个数据块的传输。
2. 基本语法
readableStream.pipe(writableStream[, options]);
- 参数:
writableStream
:目标可写流。options
(可选):end
:默认为true
,表示可读流结束时自动结束可写流。
管道的核心特性
1. 自动数据传输
管道会自动从可读流读取数据块,并写入到可写流,无需手动监听data
事件:
const readStream = fs.createReadStream('input.txt');
const writeStream = fs.createWriteStream('output.txt');readStream.pipe(writeStream); // 自动完成数据传输
2. 背压自动处理
当可写流缓冲区满时,管道会自动暂停可读流的读取,避免内存溢出:
// 当 writeStream 缓冲区满时,readStream 会自动暂停
// 缓冲区清空后,readStream 会自动恢复
readStream.pipe(writeStream);
3. 链式管道
可连接多个流形成处理管道(如读取 → 转换 → 写入):
const { createGzip } = require('zlib');fs.createReadStream('input.txt').pipe(createGzip()) // 压缩数据.pipe(fs.createWriteStream('output.txt.gz'));
4. 错误传播
任何流发生错误时,错误会传播到整个管道链,触发error
事件:
readStream.on('error', (err) => {console.error('读取错误:', err);// 错误会自动传播到 writeStream
});
管道的工作流程
-
启动传输:
- 调用
pipe()
后,可读流开始读取数据并触发data
事件。
- 调用
-
数据流动:
- 每个数据块被读取后,立即写入到可写流的缓冲区。
-
背压处理:
- 若可写流缓冲区达到
highWaterMark
,write()
返回false
,触发:- 可读流暂停读取(调用
pause()
)。 - 当缓冲区清空时,可写流触发
drain
事件,可读流恢复读取(调用resume()
)。
- 可读流暂停读取(调用
- 若可写流缓冲区达到
-
结束处理:
- 可读流结束时(触发
end
事件),若options.end
为true
(默认),则自动结束可写流。
- 可读流结束时(触发
管道的常用场景
1. 文件复制
fs.createReadStream('source.txt').pipe(fs.createWriteStream('destination.txt'));
2. 数据转换
const { Transform } = require('stream');const upperCaseTransform = new Transform({transform(chunk, encoding, callback) {this.push(chunk.toString().toUpperCase());callback();}
});fs.createReadStream('input.txt').pipe(upperCaseTransform).pipe(fs.createWriteStream('output.txt'));
3. 压缩 / 解压缩
const { createGzip, createGunzip } = require('zlib');// 压缩文件
fs.createReadStream('data.txt').pipe(createGzip()).pipe(fs.createWriteStream('data.txt.gz'));// 解压文件
fs.createReadStream('data.txt.gz').pipe(createGunzip()).pipe(fs.createWriteStream('extracted.txt'));
管道的注意事项
1. 错误处理
必须监听error
事件,否则未处理的错误会导致程序崩溃:
readStream.pipe(writeStream).on('error', (err) => {console.error('管道错误:', err);});
2. 流的关闭控制
默认情况下,可读流结束时会自动结束可写流。若需保持可写流打开:
readStream.pipe(writeStream, { end: false }); // 保持 writeStream 打开
3. 内存优化
通过调整highWaterMark
控制缓冲区大小:
const readStream = fs.createReadStream('input.txt', { highWaterMark: 1024 * 1024 }); // 1MB 缓冲区
4. 避免重复管道
同一可读流不能同时 pipe 到多个可写流,可能导致数据竞争:
// 错误:可能导致数据问题
readStream.pipe(writeStream1);
readStream.pipe(writeStream2);// 正确:复制可读流
const stream1 = fs.createReadStream('input.txt');
const stream2 = fs.createReadStream('input.txt');
stream1.pipe(writeStream1);
stream2.pipe(writeStream2);
与手动处理流的对比
特性 | 手动处理 | 管道机制 |
---|---|---|
代码复杂度 | 高(需手动监听事件、处理背压) | 低(一行代码完成) |
背压处理 | 需要手动管理 pause() /resume() | 自动处理 |
错误传播 | 需要为每个流单独监听 error | 自动传播到整个管道链 |
资源管理 | 需要手动关闭所有流 | 自动关闭(可配置) |
总结
管道机制是 Node.js 处理连续数据的核心工具,其核心优势在于:
- 简洁高效:一行代码实现复杂的数据传输。
- 自动背压:智能控制数据流速,避免内存溢出。
- 链式处理:轻松构建多环节的数据处理管道。
- 错误安全:统一的错误传播机制。
在处理大文件、网络数据传输、实时数据流等场景时,管道机制是首选方案。