1
0
Fork 0
Obsidian 管理的个人笔记仓库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

16 KiB

这篇文章是 Working with the file system on Node.js 学习过程中的个人总结和笔记,大部分内容实际来自于原文章,如果你对 Nodejs 中的文件系统感兴趣,我更建议你直接去看原文章。

Node.js 文件系统 APIs 具有多种不同的风格

  • 同步风格的函数调用 fs.readFileSync(path, options?): string | Buffer
  • 异步风格的函数调用
    • 异步回调 fs.readFile(path, options?, callback): void
    • 异步 Promise fsPromises.readFile(path, options?): Promise<string|Buffer>

它们在名称上很容易区分:

  • 异步回调的函数名,如 fs.readFile()
  • 异步 Promise 的函数名和异步回调的函数名相同,但是在不同的模块中,如 fsPromises.readFile()
  • 对于同步的函数名,则是在函数结尾加上 Sync 字样,如 fs.readFileSync

访问文件的方式

  1. 可以通过字符串的方式读写文件
  2. 可以打开读取流或写入流,并将文件分成更小的块,一次一个。流只允许顺序访问
  3. 可以使用文件描述符或文件句柄,通过一个类似流的 API 获得顺序或是随机访问
    • 文件描述符 (File descriptors) 是一个用于表示文件的整数,Nodejs 中有很多函数可以管理文件描述符
  4. 只有同步风格和异步回调风格的 API 使用文件描述符,对于异步 Promise 风格的 API,则有着更加抽象的类实现:文件句柄类 (class FileHandle),它基于文件描述符

文件系统中的一些重要类 (Important classes)

URLs:字符串文件系统路径的替代方案

当 Nodejs 的一个文件系统 API 支持字符串形式的路径参数,那么它通常也会支持 URL 的实例

fs.readFileSync('/tmp/text-file.txt')
fs.readFileSync(new URL('file:///tmp/text-file.txt'))

手动转换文件路径看起来很容易,但是实际上隐藏着许多坑,所以更加推荐以下函数对路径进行转换:

Buffer:在 Nodejs 中表示固定长度的字节序列,它是 Uint8Array 的子类,更多的在二进制文件中使用

由于 Buffer 实际上是 Uint8Array 的子类,和 URL 与 String 的关系相同,当 Nodejs 接受一个 Buffer 的同时,它也接受 Uint8Array,考虑到 Uint8Array 具有跨平台(cross-platform) 的特性,很多时候可能会更加合适

Buffers 可以做一件 Uint8Array 不能做到的事:以各种编码方式对文本进行编码和解码,如果我们需要在 Uint8Array 中编码或解码 UTF-8,我们可以使用 class TextEncoder 或是 class TextDecoder

文件系统中的文件读和写

读取文件并将其拆分为行

这里将会用两个读取文件并按行拆分的例子演示 fs.readFile 和 stream 读取的差异

fs.readFileSync(filePath, options?)

import * as fs from 'node:fs';
fs.readFileSync('text-file.txt', {encoding: 'utf-8'})

这种方法的优缺点:

  • 优点:使用简单,在大多数情况下可以满足需求
  • 缺点:对大文件而言并不是一个好的选择,因为它需要完整的读取完数据

按行拆分

// 拆分不包含换行符
const RE_SPLIT_EOL = /\r?\n/;
function splitLines(str) {
	return str.split(RE_SPLIT_EOL);
}
// ['there', 'are', 'multiple', 'lines']
splitLines('there\r\nare\nmultiple\nlines');

// 拆分包含换行符
const RE_SPLIT_AFTER_EOL = /(?<=\r?\n)/;
function splitLinesWithEols(str) {
	return str.split(RE_SPLIT_AFTER_EOL);
}
// ['there\r\n', 'are\n', 'multiple\n', 'lines']
splitLinesWithEols('there\r\nare\nmultiple\nlines')

stream

原文章中使用的是跨平台的 web stream,且声明了两个 class (ChunksToLinesStreamChunksToLinesTransformer),类的代码太长这里就不贴出来了,这两个类也是实现 web stream 逐行读取的关键。

import * as fs from "node:fs";
import { Readable } from "node:stream";

const nodeReadable = fs.createReadStream(
	"text-file.txt",
	{ encoding: 'utf-8' }
);
const webReadableStream = Readable.toWeb(nodeReadable);
// 逐行读取
// const lineStream = webReadableStream.pipeThrough(
// 	new ChunksToLinesStream();
// )
for await (const line of lineStream) {
	console.log(line);
}

web stream 是异步可迭代的,这也是为什么上述代码中使用 for-await-of

这种方法的优缺点:

  • 优点:对大文件友好,因为我们并不需要等到所有数据读取完
  • 缺点:使用复杂且都是异步操作

同步的将字符串写入到文件中

和上面相同,这里也会使用两个例子演示 fs.writeFilestream 的差异

fs.writeFileSync(filePath, str, options?)

使用这种办法写入时,如果文件已经存在于路径上,那么会被覆盖掉

import * as fs from 'node:fs';

fs.writeFileSync(
	'existing-file.txt',
	'Appended line\n',
	{ encoding: 'utf-8' }
);

如果并不希望已经存在的文件被覆盖掉,那么需要在 fs.writeFileSyncoptions 参数中添加一个属性: flag: "a",这个属性表示我们需要追加而不是覆盖文件,

ps: 在有些 fs 函数中,这个属性名叫做 flag,但是另外一些函数中会被叫做 flags

stream

import * as fs from "node:fs";
import { Writable } from "node:stream";

// 使用 fs.createWriteStream 创建一个 Nodejs srteam
const nodeWritable = fs.createWriteStream("text-file.txt", {
	encoding: "utf-8",
});
// 转换为 web stream
const webWritableStream = Writable.toWeb(nodeWritable);
const writer = webWritableStream.getWriter();

try {
	await writer.write("First line\n");
	await writer.write("Second line\n");
	await writer.close();
} finally {
	writer.releaseLock();
}

以上代码和 fs.writeFileSync 相同:如果路径上依旧存在文件,那么写入的内容会将原文件的内容覆盖掉,如果需要追加而不是覆盖,操作和 fs.writeFile 是相同的,在 fs.createReadStreamoptions 参数中添加一个属性 flag: "a"

遍历和创建目录

遍历目录

import * as fs from "node:fs";
import * as path from "node:path";

function* traverseDirectory(dirPath: string): any {
	const dirEntries = fs.readdirSync(
		dirPath,
		{ withFileTypes: true }
	);
	dirEntries.sort((a, b) => a.name.localeCompare(b.name, "en"));
	for (const dirEntry of dirEntries) {
		const fileName = dirEntry.name;
		const pathName = path.join(dirPath, fileName);
		yield pathName;
		
		if (dirEntry.isDirectory()) {
			yield* traverseDirectory(pathName);
		}
	}
}

for (const filePath of traverseDirectory("../../../")) {
	console.log(filePath);
}

在以上代码中我们用到了 fs.readdirSync 用来返回子目录路径,其中当 option 参数中的 withFileTypestrue 时,它会返回一个可迭代的 fs.Dirent 实例 (directory entries),如果 withFileTypes 为 false 或者不传,那么会以字符串类型返回字符串

创建目录

我们可以使用 fs.mkdirSync(thePath, options?) 来创建一个目录,其中 options.recursive 参数用于确定如何在 thePath 上创建目录:

  • 如果 recursive 参数缺失或者为 false,那么 fs.mkidrSync 会返回 undefined,且在一些情况下会抛出异常:
    • 目录已经存在于 thePath
    • thePath 的父目录不存在
  • 如果 recursive 为 true
    • 即使目录已经存在于 thePath 上也不会抛出错误
    • 如果父目录不存在,那么会一起创建父目录,并返回第一个创建成功的目录 path

确保父目录存在

如果我们希望按需设置嵌套文件结构 (nested file structure),我们就没法总是在创建新文件时确保其祖先目录存在,以下代码就是为了解决这个问题

import * as fs from "node:fs";
import * as path from "node:path";

const ensureParentDirectory = (filePath: string) => {
	const parentDir = path.dirname(filePath);
	if (!fs.existsSync(parentDir)) {
		fs.mkdirSync(parentDir, { recursive: true });
	}
};

创建临时目录

在有些场景,我们需要创建一个临时目录,那么这时我们可以使用 fs.mkdtempSync(pathPrefix, options?),它会在 pathPrefix 目录下创建一个随机6位字符串作为名称的目录,并返回路径

如果我们需要在系统的全局临时目录中创建临时目录,我们可以借助 os.tmpdir()

import * as os from "node:os";
import * as fs from "node:fs";
import * as path from "node:path";

const pathPrefix = path.resolve(os.tmpdir(), "my-app");
const tmpPath = fs.mkdtempSync(pathPrefix);

需要注意的是,创建的临时目录并不会在 Nodejs 脚本结束后被删除,我们需要自己手动的去删除它,或是依赖系统自己的定期清除(不太可靠)

复制、重命名、移动文件或是目录

复制目录结构

fs.cpSync(srcPath, destPath, options?) 可以帮助我们同步地将整个目录结构从src 复制到 dest,包括子目录和文件,而其中的 options 有如下一些有意思的参数:

  • recursive (default: false):如果该参数为 true 则递归复制目录(包括空目录),ps:在我的测试中,这个参数如果为 false 是一定会抛出异常的
  • force (default: true):为 true 时则覆盖已经有文件

重命名或移动文件或文件夹

fs.renameSync(oldPath, newPath) 它可以重命名或者移动文件或文件夹从 oldPathnewPath,对于目录而言,该函数仅能实现重命名,而对于文件则可以重命名或是移动

移除 (Remove) 文件或是目录

fs.rmSync(thePath, options?)thePath 上删除一个文件或是目录,就像是 rm, rm -rf options 中一些有意思的参数:

  • recursive (default: false):只有该参数为 true 时,才会删除目录(包含空目录)
  • force (default: false):如果该参数为 falsethePath 上不存在文件或是目录时会抛出异常

fs.rmdirSync(thePath, options?) 则用来专门删除空的目录,如果目录并不是空的,则会抛出异常

在有些场景中,一些脚本执行之前需要清理它们的输出(output)目录的所有文件,这时可以用到一个简单的工具(utils)函数:

const clearDirectory = (dirPath: string) => {
	for (const fileName of fs.readdirSync(dirPath)) {
		const pathName = path.join(dirPath, fileName);
		fs.rmSync(pathName, { recursive: true });
	}
};

以上代码中我们用到了 fs.readdirSync() 函数,然后遍历其返回值并调用 fs,rmSync() 递归删除目录

读取和更改文件系统条目 (entries)

检查文件或者目录是否存在

fs.existsSync(thePath),早在之前的章节中我们就已经使用过这个 API 了,如果 thePath 文件或者目录已经存在则会返回 true

检查文件详细信息,如创建时间

fs.statSync(thePath, options?) 会返回一个 fs.stats 的实例,thePath 上文件或者目录的详细信息,一些有意思的 options

  • throwIfNoEntry (default: true):该参数用于控制 thePath 上不存在目录或文件的时候的行为
    • 如果参数为 true,则会抛出异常
    • 如果参数为 false,则会返回 undefined
  • bigint (default: false):如果该参数为 true,函数将用 bigints 表示数值,如时间戳

fs.Stats 实例的一些属性:

  • 系统条目(system entry) 属于哪种类型
    • stats.isFile()
    • stats.isDirectory()
    • stats.isSymbolicLink()
  • stats.size 按字节为单位显示大小
  • 时间戳 (Timestamps)
    • 总共有三种时间戳:
      • stats.atime 最后存取时间
      • stats.mtime 最后修改时间
      • stats.birthtime 创建时间
    • 这三种时间戳都有三种不同的单位表示,以 atime 为例
      • stats.atime Date 的实例
      • stats.atimeMS 自 POSIX Epoch 起的毫秒数
      • stats.atimeNs 纳秒自 POSIX,需要 bigint 参数为 true

修改文件属性:权限、所有者、组、时间戳

fs.chmodSync(path, mode) 用于修改文件权限 fs.chownSync(path, uid, gid) 用于修改文件的所有者和组 fs.utimesSync(path, atime, mtime) 修改文件的时间戳

Nodejs 文件系统(File System) 还提供了一些用于链接的 API

fs.linkSync(existingPath, newPath) 创建一个硬链接 fs.unlinkSync(path) 移除一个硬链接以及它指向的文件(如果这是指向那个文件的最后一个硬链接)

fs.symlinkSync(target, path, type?) 创建一个符号链接 fs.readlinkSync(path, options?) 返回位于 path 的符号链接的目标

还有一些函数用于在不解除符号链接引用的情况下进行操作(它们的函数名实际上就是普通文件的操作 API 前用 “l” 开头)

fs.lchmodSync(path, mode) 更改 path 上符号链接的权限 fs.lchownSync(path, uid, gid) 更改 path 上符号链接的用户和组 fs.lutimesSync(path, atime, mtime) 更改 path 上符号链接的时间戳 fs.lstatSync(path, options?) 返回 path 上符号链接的 stats

还有一些有用的函数: fs.realpathSync(path, options?) 通过解析(.)、(..) 和 符号链接计算并返回规范路径名 (canonical pathname)

关于 existsSync 的一些补充

这些补充来自原文章的评论区,在作者的文章之外做了一些有意思的补充

existsSync 是一个比较特殊的 API,因为 exists 已经被废弃,但是 existsSync 却没有,并且在 fsPromise 中也没有相应的实现,有的只是 fsPromises.access:如果文件不可访问则抛出错误

因为 Nodejs 团队认为,在使用文件或目录之前检查是否存在是一个反模式 (anti-pattern),它会使用户暴露在 TOCTOU 的风险当中,相应的,Nodejs 团队推荐直接尝试访问、读/写文件,并在目录不存在时处理错误

详情请参阅 fs.access 关于推荐和不推荐("recommended" "not recommended")的部分