在Node.js上使用文件系统API的教程

395 阅读16分钟

这篇博文解释了如何使用Node.js的文件系统API。它的重点是shell脚本,这就是为什么我们只处理文本数据的原因。

这篇文章更像是一个参考。没有必要阅读所有内容,你可以挑选与你相关的部分。


Node的文件系统API的概念、模式和惯例

在本节中,我们使用了以下的导入:

import * as fs from 'node:fs';
import * as fsPromises from 'node:fs/promises';

不同风格的函数

Node的文件系统API有三种不同的风格:

  • 带有普通函数的同步风格--比如说:
  • 两种异步的风格:
    • 带有基于回调的函数的异步风格
    • 基于Promise的异步风格的函数

我们刚才看到的三个例子,展示了具有类似功能的函数的命名规则:

  • 一个基于回调的函数有一个基本名称:fs.readFile()
  • 它的基于承诺的版本有同样的名字,但在不同的模块中:fsPromises.readFile()
  • 它的同步版本的名称是基本名称加上后缀 "Sync":fs.readFileSync()

让我们仔细看看这三种风格是如何工作的。

同步函数

同步函数是最简单的--它们立即返回值并将错误作为异常抛出。

import * as fs from 'node:fs';

try {
  const result = fs.readFileSync('/etc/passwd', {encoding: 'utf-8'});
  console.log(result);
} catch (err) {
  console.error(err);
}

这是我们在这篇博文中主要使用的风格,因为它很方便,很适合shell脚本。

基于承诺的函数

基于承诺的函数返回的承诺是以结果来实现的,以错误来拒绝。

注意A行中的模块指定符:基于承诺的API位于不同的模块中。

基于回调的函数

基于回调的函数将结果和错误传递给作为其最后参数的回调。

import * as fs from 'node:fs';

fs.readFile('/etc/passwd', {encoding: 'utf-8'},
  (err, result) => {
    if (err) {
      console.error(err);
      return;
    }
    console.log(result);
  }
);

在这篇博文中,我们不会使用这种风格。它在Node.js文档中有解释。

访问文件的方式

  1. 我们可以通过字符串读取或写入一个文件的全部内容。
  2. 我们可以打开一个用于读取的流或一个用于写入的流,并将一个文件分成小块,一次一个地处理。流只允许顺序访问。
  3. 我们可以使用文件描述符或FileHandles,通过与流松散相似的API,获得顺序和随机访问。
    • 文件描述符是代表文件的整数。它们通过这些函数进行管理(只显示了同步的名称,也有基于回调的版本--fs.open() 等)。
      • fs.openSync(path, flags?, mode?) 在给定的路径上为一个文件打开一个新的文件描述符,并返回它。
      • fs.closeSync(fd) 关闭一个文件描述符。
      • fs.fchmodSync(fd, mode)
      • fs.fchownSync(fd, uid, gid)
      • fs.fdatasyncSync(fd)
      • fs.fstatSync(fd, options?)
      • fs.fsyncSync(fd)
      • fs.ftruncateSync(fd, len?)
      • fs.futimesSync(fd, atime, mtime)
    • 只有同步的API和基于回调的API使用文件描述符。基于承诺的API有一个更好的抽象,即 FileHandle ,它是基于文件描述符的。实例是通过fsPromises.open() 创建的。各种操作是通过方法(而不是通过函数)提供的。
      • fileHandle.close()
      • fileHandle.chmod(mode)
      • fileHandle.chown(uid, gid)
      • 等等。

注意,在这篇博文中我们不使用(3)--(1)和(2)对我们的目的来说已经足够了。

函数名称的前缀

名称以 "l "开头的函数通常对符号链接进行操作:

  • fs.lchmodSync(),fs.lchmod(),fsPromises.lchmod()
  • fs.lchownSync(),fs.lchown()fsPromises.lchown()
  • fs.lutimesSync(),fs.lutimes()fsPromises.lutimes()
  • 等等。

前缀 "f":文件描述符

名称以 "f "开头的函数通常管理文件描述符:

  • fs.fchmodSync(),fs.fchmod()
  • fs.fchownSync(),fs.fchown()
  • fs.fstatSync(),fs.fstat()
  • 等等。

重要的类

有几个类在Node的文件系统API中扮演着重要的角色。

URLs:字符串中文件系统路径的替代品

每当Node.js函数接受字符串中的文件系统路径时(A行),它通常也接受一个URL (B行)的实例。

在路径和file: URLs之间手动转换似乎很容易,但有令人惊讶的许多陷阱:百分比编码或解码,Windows驱动器字母,等等。相反,最好使用以下两个函数。

我们在这篇博文中不使用文件URLs。在未来的一篇博文中,我们将看到它们的使用情况。

缓冲器

Buffer代表Node.js上的固定长度的字节序列。它是Uint8ArrayTypedArray)的一个子类。缓冲器主要是在处理二进制文件时使用,因此在本博文中兴趣不大。

每当Node.js接受一个Buffer,它也接受一个Uint8Array。因此,鉴于Uint8Arrays是跨平台的,而Buffers不是,前者更可取。

Buffers有一个关键的功能,但Uint8Arrays没有,那就是对文本进行编码和解码。对于UTF-8,我们可以使用类 TextEncoderTextDecoder这两个类在大多数JavaScript平台上都有。

> new TextEncoder().encode('café')
Uint8Array.of(99, 97, 102, 195, 169)
> new TextDecoder().decode(Uint8Array.of(99, 97, 102, 195, 169))
'café'

Node.js流

一些函数接受或返回本地Node.js流:

  • stream.Readable 是Node的类,用于可读流。模块 node:fs 使用fs.ReadStream ,这是一个子类。
  • stream.Writable 是Node的可写流的类。模块 node:fs使用fs.WriteStream ,它是一个子类。

我们现在可以在Node.js上使用跨平台的网络流,而不是本地流。博文 "在Node.js上使用网络流 "解释了如何使用。

读取和写入文件

将一个文件同步读成一个字符串(可选:分割成行)。

fs.readFileSync(filePath, options?)filePath ,将文件读成一个字符串。

import * as fs from 'node:fs';
assert.equal(
  fs.readFileSync('text-file.txt', {encoding: 'utf-8'}),
  'there\r\nare\nmultiple\nlines'
);

这种方法的优点和缺点(与使用流相比):

  • 优点:容易使用,而且是同步的。对许多用例来说足够好。
  • 缺点:对于大文件来说不是一个好的选择。
    • 在我们处理数据之前,我们必须完整地读取它。

接下来,我们将研究如何将我们读取的字符串分割成行。

不包括行结束符的拆分行

下面的代码将一个字符串分割成几行,同时去掉行的结束符。它适用于Unix和Windows的行结束符。

const RE_SPLIT_EOL = /\r?\n/;
function splitLines(str) {
  return str.split(RE_SPLIT_EOL);
}
assert.deepEqual(
  splitLines('there\r\nare\nmultiple\nlines');
  ['there', 'are', 'multiple', 'lines']
);

"EOL "是指 "行结束"。我们既接受Unix的行结束符('\n'),也接受Windows的行结束符('\r\n' ,像前面例子中的第一个)。欲了解更多信息,请参见"跨平台处理行结束符 "一节。

在包括行结束符的情况下拆分行

下面的代码将一个字符串分割成几行,同时包括行结束符。它适用于Unix和Windows的行结束符("EOL "代表 "行结束")。

A行包含一个带有后视断言的正则表达式。它在模式\r?\n 前面的位置进行匹配,但它没有捕获任何东西。因此,它没有删除输入字符串被分割成的字符串片段之间的任何内容。

在不支持lookbehind断言的引擎上(见此表),我们可以使用以下解决方案。

这个解决方案很简单,但比较啰嗦。

splitLinesWithEols() 的两个版本中,我们再次接受Unix的行结束符('\n' )和Windows的行结束符('\r\n' )。更多信息,见"跨平台处理行结束符 "一节。

通过流读取文件,逐行读取

我们还可以通过流来读取文本文件。

我们使用了以下的外部功能。

网络流是可以异步迭代的,这就是为什么我们可以使用for-await-of 循环来迭代行。

如果我们对文本行不感兴趣,那么我们就不需要ChunksToLinesStream ,可以迭代webReadableStream ,得到任意长度的块。

这种方法的优点和缺点(与读取单个字符串相比):

  • 优点。对大文件有很好的作用。
    • 我们可以逐步处理数据,分成小块,不必等待所有的数据被读取。
  • 缺点:使用起来更复杂,而且不是同步的。

同步写一个字符串到一个文件中

fs.writeFileSync(filePath, str, options?)str 写到一个位于filePath 的文件中。如果在这个路径上已经有一个文件存在,它将被覆盖。

下面的代码显示了如何使用这个函数:

import * as fs from 'node:fs';
fs.writeFileSync(
  'new-file.txt',
  'First line\nSecond line\n',
  {encoding: 'utf-8'}
);

优点和缺点(与使用流相比):

  • 优点。容易使用,而且是同步的。适用于许多使用情况。
  • 缺点:不适合大文件。

将一个字符串追加到一个文件中(同步)

下面的代码将一行文本追加到一个现有文件中:

import * as fs from 'node:fs';
fs.writeFileSync(
  'existing-file.txt',
  'Appended line\n',
  {encoding: 'utf-8', flag: 'a'}
);

这段代码与我们用来覆盖现有内容的代码几乎相同(更多信息见上一节)。唯一的区别是,我们添加了选项.flag :数值'a' 意味着我们附加了数据。其他可能的值(例如,如果一个文件还不存在,则抛出一个错误)在Node.js文档中解释。

请注意:在一些函数中,这个选项被命名为.flag ,在其他函数中被命名为.flags

通过流将多个字符串写入一个文件

下面的代码使用流将多个字符串写到一个文件中。

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

const nodeWritable = fs.createWriteStream(
  'new-file.txt', {encoding: 'utf-8'});
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()
}

优点和缺点(与写单个字符串相比):

  • 优点:在处理大文件时效果很好,因为我们可以以小块的方式递增地写入数据。
  • 缺点:使用起来比较复杂,而且不是同步的。

通过一个流将多个字符串追加到一个文件中(异步)

下面的代码使用一个流将文本追加到一个现有的文件中。

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

const nodeWritable = fs.createWriteStream(
  'existing-file.txt', {encoding: 'utf-8', flags: 'a'});
const webWritableStream = Writable.toWeb(nodeWritable);

const writer = webWritableStream.getWriter();
try {
  await writer.write('First appended line\n');
  await writer.write('Second appended line\n');
  await writer.close();
} finally {
  writer.releaseLock()
}

这段代码与我们用来覆盖现有内容的代码几乎相同(更多信息见上一节)。唯一不同的是,我们添加了选项.flags :数值'a' 意味着我们追加了数据。其他可能的值(例如,如果一个文件还不存在,则抛出一个错误)在Node.js文档中解释。

请注意。在一些函数中,这个选项被命名为.flag ,在其他函数中被命名为.flags

处理跨平台的行结束符

唉,不是所有的平台都有相同的结束符来标记行的结束(EOL):

  • 在Windows上,EOL是'\r\n'
  • 在Unix(包括macOS)上,EOL是'\n'

为了以适用于所有平台的方式处理EOL,我们可以使用几种策略。

读取行终止符

在阅读文本时,最好能识别两个EOL。

当把一个文本分割成行时,可能会是什么样子呢?我们可以将EOLs(无论是哪种格式)包含在两端。这使我们在修改这些行并将其写入文件时,能够尽可能少地改变。

在处理带有EOLs的行时,有时删除它们是很有用的--例如,通过以下函数:

const RE_EOL_REMOVE = /\r?\n$/;
function removeEol(line) {
  const match = RE_EOL_REMOVE.exec(line);
  if (!match) return line;
  return line.slice(0, match.index);
}

assert.equal(
  removeEol('Windows EOL\r\n'),
  'Windows EOL'
);
assert.equal(
  removeEol('Unix EOL\n'),
  'Unix EOL'
);
assert.equal(
  removeEol('No EOL'),
  'No EOL'
);

写入行终止符

当涉及到写行结束符时,我们有两个选择:

  • 'node:os' 模块中的常数EOL ,包含了当前平台的EOL。
  • 我们可以检测一个输入文件的EOL格式,并在我们改变该文件时使用该格式。

遍历和创建目录

遍历一个目录

下面的函数遍历了一个目录,并列出了它所有的子孙(它的子孙,它的子孙,等等)。

我们使用了这个功能:

  • fs.readdirSync(thePath, options?)返回目录的子目录,地址是thePath
    • 如果选项.withFileTypestrue ,该函数返回目录条目、实例的 fs.Dirent.这些有一些属性,如:
      • dirent.name
      • dirent.isDirectory()
      • dirent.isFile()
      • dirent.isSymbolicLink()
    • 如果选项.withFileTypesfalse 或缺失,该函数返回带有文件名的字符串。

下面的代码显示了traverseDirectory() 的运行情况。

创建一个目录(mkdir,mkdir -p)

我们可以使用下面的函数来创建目录。

options.recursive 决定了该函数如何在thePath 创建目录。

  • 如果.recursive 丢失或falsemkdirSync() 返回undefined ,如果出现异常,则抛出一个异常。

    • 一个目录(或文件)已经存在于thePath
    • thePath 的父目录不存在。
  • 如果.recursivetrue

    • 如果在thePath 已经有一个目录,那就可以了。
    • thePath 的祖先目录会根据需要被创建。
    • mkdirSync() 返回第一个新创建的目录的路径。

这就是mkdirSync() 的作用。

import * as fs from 'node:fs';

assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
  ]
);
fs.mkdirSync('dir/sub/subsub', {recursive: true});
assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
    'dir/sub',
    'dir/sub/subsub',
  ]
);

函数traverseDirectory(dirPath)列出了dirPath 的目录的所有子孙。

确保一个父目录的存在

如果我们想按需设置一个嵌套文件结构,当我们创建一个新文件时,我们不可能总是确定祖先目录是否存在。那么下面的函数就能帮上忙。

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

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

这里我们可以看到ensureParentDirectory() (A行)的作用。

创建一个临时目录

fs.mkdtempSync(pathPrefix, options?)创建一个临时目录。它将6个随机字符附加到pathPrefix ,在新的路径上创建一个目录并返回该路径。

pathPrefix 不应该以大写的 "X "结尾,因为有些平台会用随机字符替换尾部的X。

如果我们想在一个操作系统特定的全局临时目录内创建我们的临时目录,我们可以使用函数os.tmpdir()

值得注意的是,当Node.js脚本终止时,临时目录不会被自动删除。我们要么自己删除它,要么依靠操作系统定期清理其全局临时目录(它可能会做,也可能不会)。

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

复制文件或目录

fs.cpSync(srcPath, destPath, options?)复制:将一个文件或目录从srcPath 复制到destPath 。有趣的选项:

  • .recursive (默认: false)。目录(包括空目录)只有在该选项为true 时才会被复制。
  • .force (默认:true )。如果true ,现有的文件被覆盖。如果是false ,则保留现有的文件。
    • 在后一种情况下,如果文件路径发生冲突,将.errorOnExist 设置为true 会导致错误。
  • .filter 是一个函数,让我们控制哪些文件被复制。
  • .preserveTimestamps (默认:false )。如果true ,那么destPath 中的副本就会得到与 srcPath中的原件相同的时间戳。

这就是该函数的作用:

import * as fs from 'node:fs';

assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir-orig',
    'dir-orig/some-file.txt',
  ]
);
fs.cpSync('dir-orig', 'dir-copy', {recursive: true});
assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir-copy',
    'dir-copy/some-file.txt',
    'dir-orig',
    'dir-orig/some-file.txt',
  ]
);

函数traverseDirectory(dirPath)列出了目录在dirPath 的所有子孙。

重命名或移动文件或目录

fs.renameSync(oldPath, newPath)重命名或移动一个文件或目录,从oldPathnewPath

让我们用这个函数来重命名一个目录。

import * as fs from 'node:fs';

assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'old-dir-name',
    'old-dir-name/some-file.txt',
  ]
);
fs.renameSync('old-dir-name', 'new-dir-name');
assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'new-dir-name',
    'new-dir-name/some-file.txt',
  ]
);

这里我们使用该函数来移动一个文件。

assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
    'dir/subdir',
    'dir/subdir/some-file.txt',
  ]
);
fs.renameSync('dir/subdir/some-file.txt', 'some-file.txt');
assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
    'dir/subdir',
    'some-file.txt',
  ]
);

函数traverseDirectory(dirPath)列出了目录的所有后代,地址是dirPath

移除文件或目录

删除文件和任意目录 (shell:rm,rm -r)

fs.rmSync(thePath, options?)删除一个位于thePath 的文件或目录。有趣的选项:

  • .recursive (默认:false )。目录(包括空目录)只有在这个选项是true 时才会被删除。
  • .force (默认:false )。如果是false ,如果在thePath ,没有文件或目录,将抛出一个异常。

让我们使用fs.rmSync() 来删除一个文件:

import * as fs from 'node:fs';

assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
    'dir/some-file.txt',
  ]
);
fs.rmSync('dir/some-file.txt');
assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
  ]
);

这里我们使用fs.rmSync() 来递归地删除一个非空的目录。

import * as fs from 'node:fs';

assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
    'dir/subdir',
    'dir/subdir/some-file.txt',
  ]
);
fs.rmSync('dir/subdir', {recursive: true});
assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
  ]
);

函数traverseDirectory(dirPath)列出了目录在dirPath 的所有后代。

删除一个空目录 (shell:rmdir)

fs.rmdirSync(thePath, options?)删除一个空的目录(如果一个目录不是空的,会产生一个异常)。

下面的代码显示了这个函数是如何工作的。

import * as fs from 'node:fs';

assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
    'dir/subdir',
  ]
);
fs.rmdirSync('dir/subdir');
assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
  ]
);

函数traverseDirectory(dirPath)列出了目录的所有子孙,地址是dirPath

清除目录

一个将输出保存到目录dir 的脚本,经常需要在开始之前清除 dir 。删除dir 中的每个文件,使其为空。下面的函数可以做到这一点。

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

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

我们使用了两个文件系统函数:

这是一个使用clearDirectory() 的例子。

assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
    'dir/dir-file.txt',
    'dir/subdir',
    'dir/subdir/subdir-file.txt'
  ]
);
clearDirectory('dir');
assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
  ]
);

捣毁文件或目录

trash,将文件和文件夹移到垃圾桶中。它可以在macOS、Windows和Linux(支持有限,希望得到帮助)上工作。这是它的readme文件中的一个例子。

import trash from 'trash';

await trash(['*.png', '!rainbow.png']);

trash() 接受一个字符串数组或一个字符串作为其第一个参数。任何字符串都可以是一个glob模式(带有星号和其他元字符)。

读取和改变文件系统条目

检查一个文件或目录是否存在

fs.existsSync(thePath)如果有文件或目录存在于thePath ,则返回true

import * as fs from 'node:fs';

assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
    'dir/some-file.txt',
  ]
);
assert.equal(
  fs.existsSync('dir'), true
);
assert.equal(
  fs.existsSync('dir/some-file.txt'), true
);
assert.equal(
  fs.existsSync('dir/non-existent-file.txt'), false
);

函数traverseDirectory(dirPath)列出了目录的所有后代,地址是dirPath

检查一个文件的统计资料。它是一个目录吗?它是什么时候创建的?等等。

fs.statSync(thePath, options?)返回一个fs.Stats 的实例,该实例包含thePath 的文件或目录的信息。

有趣的是options

  • .throwIfNoEntry (默认值:true )。如果在path ,没有实体会发生什么?

    • 如果这个选项是true ,会抛出一个异常。
    • 如果是false ,则返回undefined
  • .bigint (默认:false )。如果 true,该函数对数字值使用bigints(如时间戳,见下文)。

  • 它是什么样的文件系统条目?

    • stats.isFile()
    • stats.isDirectory()
    • stats.isSymbolicLink()
  • stats.size 是以字节为单位的大小

  • 时间戳。

    • 有三种类型的时间戳:
      • stats.atime: 最后一次访问的时间
      • stats.mtime: 最后一次修改的时间
      • stats.birthtime: 创建时间
    • 每种时间戳都可以用三种不同的单位来指定--例如,atime
      • stats.atime的实例Date
      • stats.atimeMS: 自POSIX Epoch以来的毫秒数
      • stats.atimeNs: 自POSIX Epoch以来的纳秒数(需要选项.bigint

在下面的例子中,我们用fs.statSync() 来实现一个函数isDirectory()

import * as fs from 'node:fs';

function isDirectory(thePath) {
  const stats = fs.statSync(thePath, {throwIfNoEntry: false});
  return stats !== undefined && stats.isDirectory();
}

assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
    'dir/some-file.txt',
  ]
);

assert.equal(
  isDirectory('dir'), true
);
assert.equal(
  isDirectory('dir/some-file.txt'), false
);
assert.equal(
  isDirectory('non-existent-dir'), false
);

函数traverseDirectory(dirPath)列出了位于dirPath 的目录的所有子孙。

影响符号链接处理方式的函数选项。

  • fs.cpSync(src, dest, options?):
    • .dereference (默认: false)。如果 true ,复制符号链接所指向的文件,而不是符号链接本身。
    • .verbatimSymlinks (默认值:false )。如果是 false,复制的符号链接的目标将被更新,以便它仍然指向同一位置。如果是 true ,则目标不会被改变

获取和设置当前工作目录

在许多shell中,我们总是处于一个所谓的当前工作目录(CWD)中。

  • 如果我们使用相对路径来创建文件、列出文件等,该路径被解释为与CWD相对。
  • 我们可以通过命令cd 来改变CWD。

在Node.js中,我们可以通过以下方式读取shell的CWD process.cwd()(process 是一个全局变量)。这个值会影响到相对路径--例如,在解析路径时:

import * as path from 'node:path';

assert.equal(
  process.cwd(), '/tmp'
);
assert.equal(
  path.resolve('dir/file.txt'),
  '/tmp/dir/file.txt'
);

我们还可以通过以下方式改变CWD process.chdir().然而,这种改变并不影响shell,只影响当前运行的Node.js代码。

import * as path from 'node:path';

process.chdir('/home/jane');
assert.equal(
  path.resolve('dir/file.txt'),
  '/home/jane/dir/file.txt'
);