14 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
访问文件的方式
- 可以通过字符串的方式读写文件
- 可以打开读取流或写入流,并将文件分成更小的块,一次一个。流只允许顺序访问
- 可以使用文件描述符或文件句柄,通过一个类似流的 API 获得顺序或是随机访问
- 文件描述符 (File descriptors) 是一个用于表示文件的整数,Nodejs 中有很多函数可以管理文件描述符
- 只有同步风格和异步回调风格的 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'))
手动转换文件路径看起来很容易,但是实际上隐藏着许多坑,所以更加推荐以下函数对路径进行转换:
url.pathToFileURL()
url.fileURLToPath()
Buffers:在 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.readFile
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 (ChunksToLinesStream
、ChunksToLinesTransformer
),类的代码太长这里就不贴出来了,这两个类也是实现 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.writeFile
和stream
的差异
fs.writeFile
使用这种办法写入时,如果文件已经存在于路径上,那么会被覆盖掉
import * as fs from 'node:fs';
fs.writeFileSync(
'existing-file.txt',
'Appended line\n',
{ encoding: 'utf-8' }
);
如果并不希望已经存在的文件被覆盖掉,那么需要在 fs.createReadStream
的 options
参数中添加一个属性: 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.writeFile
相同:如果路径上依旧存在文件,那么写入的内容会将原文件的内容覆盖掉,如果需要追加而不是覆盖,操作和 fs.writeFile
是相同的,在 fs.createReadStream
的 options
参数中添加一个属性 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 参数中的 withFileTypes
为 true
时,它会返回一个可迭代的 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)
,它可以重命名或者移动文件或文件夹从 oldPath
到 newPath
,对于目录而言,该函数仅能实现重命名,而对于文件则可以重命名或是移动
移除 (Remove) 文件或是目录
fs.rmSync(thePath,options)
在 thePath
上删除一个文件或是目录,就像是 rm, rm -rf
options
中一些有意思的参数:
recursive (default: false)
:只有该参数为true
时,才会删除目录(包含空目录)force (default: false)
:如果该参数为false
,thePath
上不存在文件或是目录时会抛出异常
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)
修改文件的时间戳
使用链接 (links)
Nodejs 文件系统(File System) 还提供了一些用于链接的 API
硬链接 (hard links) API
fs.linkSync(existingPath, newPath)
创建一个硬链接
fs.unlinkSync(path)
移除一个硬链接以及它指向的文件(如果这是指向那个文件的最后一个硬链接)
符号链接(symbolic links) API
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)