当前位置: 首页 > news >正文

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、核心概念

  1. 文件结构
    • 文件名:文件的标识(如 document.txt),通常包含扩展名(如 .txt.jpg)指示文件类型。
    • 元数据:文件的描述信息,如创建时间、修改时间、大小、权限等。
    • 数据内容:文件实际存储的数据。
  2. 文件系统
    • 文件通常组织在目录(文件夹) 中,形成层次结构(如 C:/Users/Documents/)。
    • 常见文件系统包括:NTFS(Windows)、ext4(Linux)、APFS(macOS)。
  3. 文件类型
    • 文本文件:存储人类可读的文本(如 .txt.html.js)。
    • 二进制文件:存储非文本数据(如 .jpg.exe.mp3)。
    • 特殊文件:在类 Unix 系统中,还包括设备文件、管道文件等。

2、文件操作

​ 编程语言通常提供文件操作的 API,例如:在 Node.js 中的实现(fs 模块)通过 fs 模块提供文件操作能力,支持同步、异步和流式三种模式。

二、同步文件与异步文件

​ 普通文件处理(同步)和异步文件处理是两种不同的 I/O 操作模式,主要区别在于程序执行流程和资源利用效率。

​ Node.js 基于单线程事件循环机制设计,主线程负责处理所有同步代码、事件回调和异步操作的调度。

1、同步文件处理(Blocking I/O)

  1. 核心特点
  • 阻塞事件循环:同步操作会暂停 Node.js 主线程,直到 I/O 操作完成。
  • 代码顺序执行:操作按代码顺序同步执行,结果可立即获取。
  • API 命名含 Sync:如 fs.readFileSync()fs.writeFileSync()

“Sync” 是 “Synchronization”(同步)的缩写

  1. 示例代码
const fs = require('fs');try {const data = fs.readFileSync('file.txt', 'utf8'); // 阻塞主线程,直到读取完成console.log('文件内容:', data);
} catch (err) {console.error('读取失败:', err);
}console.log('同步操作完成后执行'); // 此代码会在文件读取完成后执行
  1. 优点

  2. 代码逻辑简单:无需处理回调或Promise,适合线性执行的简单场景。

  3. 结果可立即获取:同步操作返回值即为结果,便于调试和理解。

  4. 避免回调地狱:无需嵌套回调,代码结构更扁平。

  5. 缺点

  • 阻塞主线程:若操作耗时较长(如大文件读取、网络请求),会导致整个应用无响应。

  • 无法处理异步流程:难以与其他异步操作(如定时器、网络请求)配合使用。

  1. 适用场景
  • 初始化配置:应用启动时读取配置文件(仅需执行一次,且需保证配置提前加载)。
  • 简单测试场景:开发阶段临时读取少量数据。

2、异步文件处理(Non-blocking I/O)

  1. 核心特点
  • 非阻塞事件循环:操作发起后立即返回,主线程继续执行后续代码,I/O 完成后通过回调或Promise通知结果。
  • 基于回调 / Promise:API 支持回调函数(如 fs.readFile())或 Promise(如 fs.promises.readFile)。
  1. 示例代码
  • 回调风格
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('异步操作发起后立即执行');
  1. 优点
  • 非阻塞主线程:

    • 支持同时处理多个 I/O 操作(如并发读取多个文件),充分利用 Node.js 的事件驱动特性。
    • 适合高并发场景(如 Web 服务器处理多个请求)。
  • 灵活的异步流程控制:

    • 可通过 Promise.all()async/await 等方式组合多个异步操作,实现复杂流程(如并行读取文件后合并数据)。
  • 与异步生态兼容:无缝集成 Node.js 的异步 API

  1. 缺点

  2. 回调嵌套问题:多层回调可能导致代码可读性下降(“回调地狱”),需通过 Promise 或 async/await 优化。

  3. 错误处理需谨慎:异步操作的错误需通过回调函数的第一个参数或 try/catch(搭配 async/await)捕获,否则可能导致未处理拒绝(Unhandled Rejection)。

  4. 结果非立即获取:需通过回调或 await 获取结果,代码逻辑相对同步更复杂。

  5. 适用场景

  • 大文件处理:使用流(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)核心概念

  1. 单线程执行:JavaScript 主线程一次只能执行一个任务。
  2. 非阻塞 I/O:通过底层线程池(如 libuv)处理 I/O 操作,不阻塞主线程。
  3. 事件循环:不断检查任务队列,当操作完成时触发回调,将待处理的回调函数放入主线程执行。

(2)事件循环的工作流程

  1. 发起异步操作: 当调用 fs.readFile()setTimeout() 等异步 API 时,Node.js 将任务交给底层线程池(如磁盘 I/O)或其他系统组件(如定时器)处理。
  2. 回调入队: 当异步操作完成时(如文件读取完毕),对应的回调函数被放入任务队列(Task Queue)。
  3. 事件循环处理队列: 主线程执行完当前任务后(空闲时),事件循环不断从队列中取出回调函数并执行,重复此过程。

(3)任务队列的分类

Node.js 中的任务队列分为两类:

  1. 宏任务队列(Macrotask Queue):
    1. 定时器(setTimeoutsetInterval
    2. I/O 回调(如文件读取完成回调)
    3. setImmediate
    4. 主程序代码
  2. 微任务队列(Microtask Queue):
    1. Promise 回调(.then().catch()
    2. process.nextTick()
    3. queueMicrotask()(微任务中的微任务)

(4)事件循环的执行顺序

每次事件循环迭代(Tick)遵循以下顺序:

  1. 执行当前任务:主线程执行同步代码。
  2. 清空微任务队列:执行所有微任务,直到队列为空。
  3. 检查定时器:执行到期的定时器回调。
  4. 处理 I/O 回调:执行已完成的 I/O 操作的回调。(实际执行顺序取决于文件读取完成时间)
  5. 执行 setImmediate:如果存在,执行 setImmediate 回调。
  6. 重复循环:回到步骤 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 密集型操作)。

关键特性

  1. 非阻塞主线程: 事件循环使主线程无需等待I/O操作完成,可继续处理其他任务。
  2. 微任务优先执行: 微任务队列在每次宏任务执行后都会被清空,确保高优先级任务优先处理。
  3. 处理并发而非并行: 单线程的事件循环通过时间片轮转处理多个任务,适合 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. 同步代码结束');
  1. 执行流程分析:
  • 同步代码执行

    • 主线程依次执行同步代码,调度外层 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 = 300mssetImmediate → 进入 check阶段 队列。Promise.then() → 微任务队列(优先级高于 setImmediate)。继续执行回调剩余代码,输出:6. 外层定时器回调结束
      • 关键点:微任务队列在当前阶段结束后立即执行,因此输出:5. Promise微任务执行
    • pending callbacks 阶段:无任务,跳过。
    • idle/prepare 阶段:内部处理,跳过。
    • poll 阶段:队列为空,检查未到期定时器(内层 setTimeout 将于 300ms 到期)和 setImmediate。存在 setImmediate,进入 check阶段
    • check 阶段:执行setImmediate回调,输出:4. setImmediate回调执行
  • 事件循环第三轮迭代

    • timers 阶段:内层setTimeout(100ms)到期(总延迟 300ms),执行回调,输出:3. 内层定时器回调执行
    • 后续阶段:无其他任务,事件循环进入休眠状态(等待新事件)。
  1. 关键点总结:
  • 定时器时间计算
    • 内层 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

(2)线程池的工作流程

  1. 主线程接收到异步操作: 当调用 fs.readFile() 等 API 时,Node.js 将任务封装并发送给 libuv 线程池。
  2. 线程池分配线程执行任务: 线程池从空闲线程中取出一个线程,执行实际的 I/O 操作(如读取文件)。
  3. 主线程继续执行: 主线程无需等待,继续执行后续代码。
  4. 任务完成通知: 当线程完成操作后,通过事件循环通知主线程,并将回调函数放入任务队列。
  5. 主线程执行回调: 事件循环在下一轮迭代中执行该回调函数。

(3)示例

const fs = require('fs');fs.readFile('data.txt', (err, data) => {// 回调在主线程执行console.log('文件读取完成');
});
  1. 主线程发起请求:调用 fs.readFile 时,主线程将文件读取任务封装为 uv_fs_t 请求,加入线程池任务队列。
  2. 后台线程执行同步 I/O:某个空闲的后台线程从队列中取出任务,调用操作系统同步 API读取文件。
  3. 完成通知:后台线程完成读取后,将结果通过 IPC(进程间通信)返回主线程。
  4. 主线程处理回调:事件循环将回调加入 poll 阶段队列,执行 console.log('文件读取完成')

4、事件循环和线程池区别

(1)对比表

特性事件循环线程池
定义单线程的执行模型,负责调度和执行回调函数多线程的执行模型,负责执行耗时操作
作用处理异步操作的结果(回调),避免阻塞主线程执行阻塞操作(如磁盘 I/O),将结果通知事件循环
线程数量单线程(JavaScript 主线程)多线程(默认 4 个,可配置)
适用场景非阻塞 I/O(如网络请求)、定时器、微任务阻塞 I/O(如磁盘读写)
典型实现Node.js 的事件循环机制Node.js 的 libuv 线程池
优势高效处理非阻塞操作,单线程无锁开销支持阻塞操作,利用多核并行,适用于I/O 密集型
劣势无法处理 CPU 密集型任务线程创建和切换有开销,不适用于CPU 密集型操作

(2)工作流程对比

事件循环的工作流程
  1. 发起异步操作:主线程调用异步 API(如 fs.readFile)。
  2. 操作交给底层:将任务交给操作系统或线程池处理。
  3. 继续执行主线程:主线程不等待,继续执行后续代码。
  4. 回调入队:异步操作完成后,回调函数被放入任务队列。
  5. 循环处理队列:事件循环不断从队列中取出回调并执行。
线程池的工作流程
  1. 接收阻塞任务:主线程将阻塞操作(如磁盘读取)交给线程池。
  2. 分配线程执行:线程池从空闲线程中取出一个执行任务。
  3. 主线程继续执行:主线程不受影响,继续处理其他任务。
  4. 任务完成通知:线程完成操作后,通过事件循环通知主线程。
  5. 执行回调:事件循环将回调放入队列并执行。

(3)协作关系

事件循环和线程池通常协同工作:

  • 事件循环负责调度和执行回调,是异步编程的 “大脑”。
  • 线程池负责执行阻塞操作,是异步编程的 “苦力”。

(4)常见误解

  1. Node.js 是单线程的,因此无法利用多核:
    1. 错误。Node.js 主线程是单线程的,但线程池可以利用多核 CPU 执行阻塞操作。
  2. 所有异步操作都通过线程池实现:
    1. 错误。只有不支持异步的操作(如磁盘 I/O)才使用线程池;网络 I/O 直接通过操作系统的非阻塞机制实现。
  3. 线程池越大越好:
    1. 错误。过多线程会导致上下文切换开销增加,反而降低性能。

四、异步文件的操作实现

1、定义

​ 在 TypeScript中进行文件操作时,本质上仍依赖 Node.js 的 fs 模块,但 TypeScript 提供了更严格的类型定义和类型安全支持。

特性CommonJS(require)ES6 模块化(import)
加载时机运行时加载(动态)编译时静态分析(静态)
导出方式module.exports/exportsexport/export default
返回值模块导出对象(值拷贝)只读引用(动态绑定)
顶级 this指向模块对象(module)指向 undefined
兼容性Node.js 原生支持Node.js 需配置 type: module
    • CommonJS(require)是 Node.js 原生支持的模块化系统,直接返回模块导出对象。
    • ES6 模块化(import)需在 Node.js 中通过配置(package.json"type": "module")启用,语法更接近浏览器规范。
场景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、对比表

传统回调 APIPromise 版本(.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、文件操作的核心原则

  1. 优先使用异步方法:避免阻塞事件循环,提升应用吞吐量。
  2. 完善错误处理:针对不同错误代码(如 ENOENTEACCES)编写特定处理逻辑。
  3. 大文件使用流:通过 createReadStreamcreateWriteStream 处理 GB 级文件。
  4. 避免同步方法:仅在初始化阶段(如读取配置)使用同步方法。

五、流操作

1、文件流

(1)文件流的定义

文件流是一种逐块读取或写入文件数据的机制,而非一次性加载整个文件到内存。它通过 Node.js 的 stream 模块实现,核心思想是流式处理(Streaming),适用于处理大文件或需要渐进式操作的场景。

核心特性

  1. 内存高效:逐块处理数据,避免大文件导致的内存溢出。
  2. 异步非阻塞:基于事件驱动,不会阻塞主线程。
  3. 可组合性:支持管道操作(pipe()),实现数据的链式处理(如压缩 → 加密 → 写入)。

流的工作原理

  1. 非阻塞读取:
    • 流基于事件驱动,读取操作不会阻塞主线程。
    • Node.js 后台通过 libuv 调用操作系统的异步 I/O 接口。
  2. 数据分块:
    • 文件被分成多个 数据块(chunks),每个块默认大小约为 64KB(可通过 highWaterMark 选项调整)。
    • 每次 data 事件触发时,chunk 携带一个数据块。
  3. 背压(Backpressure):
    • 当数据处理速度慢于读取速度时,流会自动调节读取速率,防止内存溢出。

(2)文件流的分类

Node.js 提供四种类型的文件流,均继承自 stream 模块:

类型作用核心事件 / 方法
可读流(Readable Stream)从文件读取数据(fs.createReadStreamdata(数据块)、end(读取完成)
可写流(Writable Stream)向文件写入数据(fs.createWriteStreamdrain(可继续写入)、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)流操作的优势与场景

  1. 优势
  • 内存优化:处理 GB 级文件时,内存占用稳定(仅与块大小相关)。
  • 实时处理:边读取边处理数据(如实时日志分析、视频流处理)。
  • 高并发支持:配合事件循环,可同时处理多个流操作。
  1. 典型场景
  • 大文件处理:视频转码、日志压缩。
  • 实时数据处理:服务器实时日志分析。
  • 网络传输:通过流分块发送 / 接收数据(如 HTTP 分块传输)。

(5)注意事项

  1. 背压(Backpressure)
    当可读流速度超过可写流处理能力时,需通过 stream.pause()stream.resume() 控制流速,避免数据丢失。通过 drain 事件或 pipe() 自动管理。

  2. 编码处理
    创建流时指定编码(如 'utf8'),否则默认返回 Buffer 对象。

  3. 错误处理
    必须监听流的 error 事件,避免未捕获异常导致进程崩溃。

  4. Promise 封装

    可简化流的异步控制,但需使用 stream/promises 模块。

2、文件与文件流的对比

维度文件(直接操作)文件流(流式操作)
数据加载方式一次性读取 / 写入(内存占用大)逐块处理(内存占用小)
适用场景小文件、简单操作大文件、需要流式处理或管道操作
阻塞性同步操作阻塞主线程,异步操作非阻塞异步非阻塞
典型 APIreadFile/writeFilecreateReadStream/createWriteStream
性能小文件高效,大文件可能内存溢出大文件性能更优,支持背压和流式处理

3、Promise 封装流操作

(1)核心目标

将传统的基于事件的流操作(如监听 dataenderror 事件)转换为基于 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)主要优点

  1. 代码更简洁
  • 传统事件方式:

    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 事件并处理
    
  1. 更好的错误处理
  • 流错误自动转换为 Promise 拒绝,可通过try/catch统一处理:

    try {await finished(stream);
    } catch (err) {console.error('流操作失败:', err); // 捕获所有错误
    }
    
  1. 支持异步流程控制
  • 可与Promise.all()、Promise.race()等组合使用:

    // 并行处理多个流
    await Promise.all([processStream(stream1),processStream(stream2)
    ]);
    
  1. 更符合现代 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)常用函数

  1. 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('写入成功');
    }
    
  1. 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. 手动封装其他流操作
  • 示例 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 事件。
背压处理示例
  1. 手动处理背压
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
});
管道的工作流程
  1. 启动传输

    • 调用 pipe() 后,可读流开始读取数据并触发 data 事件。
  2. 数据流动
    • 每个数据块被读取后,立即写入到可写流的缓冲区。
  3. 背压处理
    • 若可写流缓冲区达到highWaterMarkwrite()返回false,触发:
      • 可读流暂停读取(调用 pause())。
      • 当缓冲区清空时,可写流触发 drain 事件,可读流恢复读取(调用 resume())。
  4. 结束处理

    • 可读流结束时(触发 end 事件),若 options.endtrue(默认),则自动结束可写流。
管道的常用场景
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 处理连续数据的核心工具,其核心优势在于:

  • 简洁高效:一行代码实现复杂的数据传输。
  • 自动背压:智能控制数据流速,避免内存溢出。
  • 链式处理:轻松构建多环节的数据处理管道。
  • 错误安全:统一的错误传播机制。

在处理大文件、网络数据传输、实时数据流等场景时,管道机制是首选方案。

http://www.xdnf.cn/news/625951.html

相关文章:

  • 人形机器人通过观看视频学习人类动作的技术可行性与前景展望
  • 《AVL树完全解析:平衡之道与C++实现》
  • 如何保证 Kafka 数据实时同步到 Elasticsearch?
  • NHANES指标推荐:PHDI
  • RT Thread Nano V4.1.1 rtconfig.h 注释 Configuration Wizard 格式
  • 【TCP/IP协议族详解】
  • Docker安装MySQL集群(主从复制)
  • 关于gt的gt_data_valid_in信号
  • LeetCode-贪心-买卖股票的最佳时机
  • 【算法】力扣体系分类
  • QML学习05MouseArea
  • 51、c# 请列举出6个集合类及用途
  • VLLM推理可以分配不同显存限制给两张卡吗?
  • MongoDB 备份与恢复策略全面指南:保障数据安全的完整方案
  • springboot中redis的事务的研究
  • 深入理解nvidia container toolkit核心组件与流程
  • 10大Python知识图谱开源项目全解析
  • 【Linux 学习计划】-- Linux调试工具 - gdb cgdb
  • 怎么开发一个网络协议模块(C语言框架)之(二) 数据结构设计
  • RabbitMQ核心特性——重试、TTL、死信队列
  • python项目和依赖管理工具uv简介
  • OpenLayers 加载鼠标位置控件
  • git常用操作命令
  • 用本地大模型解析智能家居语音指令:构建一个离线可用的文本控制助手
  • vitepress | 文档:展示与说明只写一次,使用vitepress-deme-preview插件
  • 力扣HOT100之回溯:46. 全排列
  • juc面试题
  • LumaDot (亮度可调的屏幕圆点)
  • 分布式消息中间件基础
  • 网络协议与通信安全