跳到主要内容

文件 IO✅

本主题涵盖 Node.js 中文件系统操作的核心概念,包括文件读写、流式处理、路径操作、Buffer 以及错误处理等。

什么是 Stream ,有什么作用?

答案

核心概念:

  • Stream 是按块增量处理数据的抽象,避免单次读取大量文件导致的内存峰值暴涨,通过字节流来实现高效处理。
  • 流的类型
    • Readable 只读流,用于单纯消费场景,例如 HTTP 请求体、文件读取等
    • Writable 只写流,用于单纯写入场景,例如 HTTP 响应体、文件写入等
    • Duplex 可读可写流,例如 TCP 套接字
    • Transform 可在流动中转换数据(如压缩、转码)
  • 背压(Backpressure)用于协调生产与消费速率,避免内存暴涨;Node 提供 highWaterMark、pause/resume、drain 等机制
  • 管道(pipe、pipeline) 内置了对流的组合与错误处理支持,使得多个流可以方便地连接在一起。默认支持背压。

示例说明:

延伸阅读

什么是 Buffer?为什么在 Node.js 中需要它?

答案

核心概念

  1. 定义Buffer 是 Node.js 提供的一个全局类,用于处理二进制数据。它是一块在 V8 堆外部分配的固定大小的原始内存区域。
  2. 原因:JavaScript 语言本身没有高效处理二进制数据的机制。在 Node.js 中,处理 TCP 流、文件系统 I/O 等场景时,必须直接操作二进制数据流。Buffer 的出现正是为了弥补这一不足。
  3. 特性
    • Buffer 实例类似于整数数组,但其大小在创建后不能更改。
    • 与字符编码紧密相关,可以在 Buffer 和字符串之间进行转换(如 Buffer.from('hello', 'utf8'))。
    • Buffer API 与 Uint8Array 兼容,可以互相转换。
提示

注意 Buffer 使用的是 原始内存 不受 V8 垃圾回收机制的管理,因此在使用时需要注意内存的分配和释放。

示例说明

// 1. 从字符串创建 Buffer
const buf1 = Buffer.from('Hello, Buffer!', 'utf8')
console.log(buf1) // <Buffer 48 65 6c 6c 6f 2c 20 42 75 66 66 65 72 21>
console.log(buf1.toString('hex')) // 48656c6c6f2c2042756666657221
console.log(buf1.toString('utf8')) // Hello, Buffer!

// 2. 分配指定大小的 Buffer
const buf2 = Buffer.alloc(10) // 创建一个 10 字节并用 0 填充的 Buffer
console.log(buf2) // <Buffer 00 00 00 00 00 00 00 00 00 00>

// 3. 写入和读取 Buffer
buf2.write('abc')
console.log(buf2.toString('utf8')) // abc

// 4. Buffer 合并
const buf3 = Buffer.from('Node')
const buf4 = Buffer.from('.js')
const combinedBuf = Buffer.concat([buf3, buf4])
console.log(combinedBuf.toString()) // Node.js

延伸阅读

好的,完全理解。为了确保能够全面地考察候选人对 Node.js 文件系统的深入理解,我们需要补充一些涉及更底层操作和更复杂场景的问题。

以下是两个新增的、更具深度的问题,它们专注于文件描述符、状态信息和资源管理,并紧密结合实际应用场景。


在什么场景下需要直接使用文件描述符?如何确保句柄正确关闭?

答案

核心概念

  • 文件描述符 (File Descriptor):在操作系统层面,当一个文件被打开时,系统会返回一个非负整数作为该文件的引用,即文件描述符。后续的读、写、关闭等操作都通过这个描述符进行,比直接操作文件路径更高效。
  • 句柄 (Handle):在 Node.js 中提供了三种典型的获取句柄方式
    • fs.openSync():同步打开文件,返回一个文件描述符。
    • fs.open():异步回调模式打开文件,返回一个文件描述符。
    • fsPromises.open() promise 对象, 也可直接通过 import { open } from 'node:fs/promises' 引用 open
  • 资源管理:文件描述符是有限的系统资源(可通过 ulimit -n 查看)。如果打开后不关闭,会导致句柄泄漏 (Handle Leak),最终耗尽资源使应用崩溃。因此,确保句柄被正确关闭至关重要。

实际应用场景

当你需要对文件进行更精细化的控制时,readFile/writeFile 这种高级抽象就不够用了。以下场景需要使用文件描述符:

  1. 大文件分块读写:需要从文件的特定位置读取或写入指定长度的数据,例如实现断点续传或编辑大型二进制文件。
  2. 保证写入原子性:在多进程或多线程环境下,需要确保对文件的写入操作不被其他操作中断。虽然 Node.js 单线程模型避免了大部分并发问题,但在与外部系统交互或使用 worker threads 时仍需考虑。
  3. 文件锁定:在写入文件前锁定它,防止其他进程同时写入,确保数据一致性。(注意:Node.js 核心 fs 模块没有内置文件锁定,通常需要借助第三方库如 proper-lockfile,但其底层原理仍依赖于文件描述符)。
  4. 持续写入日志:对于一个长期运行的服务,打开一个日志文件并持续向其追加(append)内容,避免了每次写入都重新打开文件的开销。

示例说明:可靠地向文件追加内容

使用 fs.promises.opentry...finally 结构是现代 Node.js 中管理文件句柄的最佳实践。finally 代码块可以保证无论 try 块中是否发生错误,handle.close() 都会被执行。

延伸阅读

如何获取文件元信息?fs.statfs.access 的区别是什么?

答案
  • fs.stat:用于获取一个路径的详细元信息(metadata)。如果路径不存在,它会抛出错误。
  • fs.access:用于检查当前进程对一个路径是否具有特定的操作权限(如读、写、执行)。它不返回元信息,如果检查失败(如权限不足或路径不存在),会抛出错误。
API目的返回值 (成功时)核心场景常见陷阱
fs.stat(path)获取文件/目录的状态信息一个 fs.Stats 对象,包含大小、创建/修改时间、是否为文件/目录等。读取文件属性(如 mtime 判断缓存是否过期),遍历目录时区分文件类型。如果路径不存在,会抛出异常。
fs.lstat(path)类似 fs.stat,但用于符号链接 (symlink) 本身一个 fs.Stats 对象,描述的是符号链接的信息,而不是它指向的目标。需要处理符号链接的场景,避免穿透到目标文件。fs.stat
fs.access(path, mode)检查访问权限undefined (仅表示操作成功)在执行敏感操作前预检权限。存在 TOCTOU 竞争条件 (见下文),不推荐用于 open/read/write 之前。

关键区别:TOCTOU 竞争条件

不应该使用 fs.access() 来检查文件的可读写性,然后再调用 fs.open()fs.readFile()。这会产生一个典型的“检查时-使用时”(Time-of-check to time-of-use, TOCTOU) 的竞态条件。也就是说,在你检查权限和实际操作文件之间的短暂间隙,文件状态可能已经发生了改变(例如被另一个进程删除或修改了权限)。

正确做法是:直接尝试操作,并通过 try...catch 处理可能出现的错误。

示例说明

import { promises as fs } from 'node:fs'
import path from 'node:path'

async function logFileStats (filePath) {
try {
const stats = await fs.stat(filePath)
console.log(`Stats for ${filePath}:`)
console.log(` - Is it a file? ${stats.isFile()}`)
console.log(` - Is it a directory? ${stats.isDirectory()}`)
console.log(` - Size: ${stats.size} bytes`)
console.log(` - Last modified: ${stats.mtime.toISOString()}`)
} catch (error) {
if (error.code === 'ENOENT') {
console.error(`File not found: ${filePath}`)
} else {
console.error('An error occurred:', error)
}
}
}

// 准备一个文件
fs.writeFile('./my-file.txt', 'hello world')
logFileStats('./my-file.txt')
logFileStats('./non-existent-file.txt')

延伸阅读

readFilereadFileSyncpromises.readFile 有何区别?

答案
APII/O 模型执行方式错误处理适用场景
readFileSync阻塞 I/O同步执行,阻塞事件循环try...catch命令行工具、应用启动时读取配置文件
readFile非阻塞 I/O异步执行,不阻塞事件循环回调函数第一个参数 (err)高并发、I/O 密集型应用
promises.readFile非阻塞 I/O返回 Promise,异步执行Promise.catchasync/await 中的 try...catch现代异步编程,推荐使用

核心概念

  • 同步 vs 异步readFileSync 会阻塞 Node.js 的事件循环,直到文件读取完成,这在高并发场景下会严重影响性能。而 readFilepromises.readFile 是异步的,它们会将 I/O 操作交给底层系统处理,不会阻塞事件循环。
  • 回调 vs PromisereadFile 是 Node.js 传统的异步回调风格(CPS,Continuation-Passing Style)。而 fs/promises 模块提供了基于 Promise 的 API,更易于与 async/await 结合使用,避免了回调地狱,是现代 Node.js 开发的首选。

示例说明

用过 readline 吗, 解决什么问题?

Buffer alloc 和 allocUnsafe 有什么区别?

答案

核心概念:

  • Buffer.alloc 会将新分配内存清零(全 0),更安全但略慢;适合默认场景
  • Buffer.allocUnsafe 返回未初始化的内存块,速度更快,但读取前必须先完全写满或手动 fill
  • 未初始化意味着可能读到旧内存残留数据,存在信息泄露与逻辑不确定性风险
  • 若需大块且避开内部池,也可用 allocUnsafeSlow;通常很少需要

示例说明:

延伸阅读:

path.joinpath.resolve 有什么区别?

答案
方法功能描述关键行为
path.join([...paths])将所有给定的路径片段连接在一起,然后规范化生成的路径。遇到 .. 会解析上一级目录,但不会生成绝对路径(除非传入的片段本身就能构成绝对路径)。
path.resolve([...paths])将路径或路径片段的序列解析为绝对路径从右到左处理路径序列,直到构造出绝对路径。如果处理完所有路径还未生成绝对路径,则使用当前工作目录。

核心概念

  • path.join 主要用于拼接路径,它像 cd 命令一样,会处理 ...,但最终结果是相对路径还是绝对路径取决于输入。在不同系统下统一使用 path.join 来拼接避免路径分隔符不一致的问题。
  • path.resolve 主要用于解析得到一个绝对路径。它总是返回一个从根目录开始的完整路径,非常适合用于定位文件。存在多个目录时,path.resolve 会优先考虑从最右侧开始的路径结合,如果遇到绝对路径,会忽略之前的所有路径。

示例说明

import path from 'node:path'

// --- path.join ---
// 拼接普通路径
console.log('join 1:', path.join('/foo', 'bar', 'baz/asdf', 'quux', '..'))
// 输出: /foo/bar/baz/asdf

// 拼接相对路径
console.log('join 2:', path.join('foo', {}, 'bar'))
// 抛出 TypeError: Path must be a string. Received {}

// --- path.resolve ---
// 不带参数,返回当前工作目录
console.log('resolve 1:', path.resolve())
// 输出: /path/to/current/working/directory

// 带相对路径
console.log('resolve 2:', path.resolve('tmp', 'file.txt'))
// 输出: /path/to/current/working/directory/tmp/file.txt

// 如果遇到绝对路径,会忽略之前的所有路径
console.log('resolve 3:', path.resolve('/foo/bar', './baz'))
// 输出: /foo/bar/baz
console.log('resolve 4:', path.resolve('/foo/bar', '/tmp/file/'))
// 输出: /tmp/file

面试官视角

这是一个非常高频的实践性问题,用于考察面试者在处理文件路径时的严谨性和对常用工具的熟悉度。

  • :能说出 join 是拼接,resolve 是生成绝对路径。
  • :能清晰地解释两者在处理 /(根路径)和 .. 时的不同行为,并能给出在什么场景下应该使用哪一个(例如,拼接项目内相对路径用 join,需要文件完整绝对路径时用 resolve)。

延伸阅读