使用 Node.js 進行 Shell 指令碼編寫
您可以購買這本書的離線版本(HTML、PDF、EPUB、MOBI),並支持免費的線上版本。
(廣告,請不要封鎖。)

8 使用 Node.js 處理檔案系統



本章包含

由於本書的重點在於 shell 指令碼,我們只處理文字資料。

8.1 Node 檔案系統 API 的概念、模式和慣例

8.1.1 存取檔案的方式

  1. 我們可以透過字串讀取或寫入檔案的全部內容。
  2. 我們可以開啟一個串流來讀取或寫入,並一次處理檔案較小的部分。串流只允許順序存取。
  3. 我們可以使用檔案描述符或檔案控制代碼,並透過與串流類似(但較寬鬆)的 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 使用檔案描述符。基於 Promise 的 API 有更好的抽象,類別 FileHandle,它基於檔案描述符。執行個體透過 fsPromises.open() 建立。各種操作透過方法(而非函式)提供
      • fileHandle.close()
      • fileHandle.chmod(mode)
      • fileHandle.chown(uid, gid)
      • 等等

請注意,我們在本章節中不使用 (3) – (1) 和 (2) 足以符合我們的目的。

8.1.2 函式名稱前綴

名稱以「l」開頭的函式通常用於符號連結

8.1.2.2 前綴「f」:檔案描述符

名稱以「f」開頭的函式通常用於管理檔案描述符

8.1.3 重要的類別

有幾個類別在 Node 的檔案系統 API 中扮演重要的角色。

8.1.3.1 URL:字串中檔案系統路徑的替代方案

每當 Node.js 函式接受字串中的檔案系統路徑(A 行)時,通常也會接受 URL 的實例(B 行)

assert.equal(
  fs.readFileSync(
    '/tmp/text-file.txt', {encoding: 'utf-8'}), // (A)
  'Text content'
);
assert.equal(
  fs.readFileSync(
    new URL('file:///tmp/text-file.txt'), {encoding: 'utf-8'}), // (B)
  'Text content'
);

在路徑和 file: URL 之間手動轉換看似容易,但卻有許多令人驚訝的陷阱:百分比編碼或解碼、Windows 磁碟機代號等等。相反地,最好使用以下兩個函式

我們在本章節中不使用檔案 URL。它們的用例說明於 §7.11.1「類別 URL

8.1.3.2 緩衝區

類別 Buffer 代表 Node.js 上的固定長度位元組序列。它是 Uint8Array(一個 TypedArray)的子類別。緩衝區主要用於處理二進位檔案,因此在這本書中較不重要。

每當 Node.js 接受緩衝區時,它也會接受 Uint8Array。因此,由於 Uint8Arrays 是跨平台的,而緩衝區不是,因此前者較佳。

緩衝區可以執行 Uint8Arrays 無法執行的動作:使用各種編碼對文字進行編碼和解碼。如果我們需要在 Uint8Arrays 中編碼或解碼 UTF-8,可以使用類別 TextEncoder 或類別 TextDecoder。這些類別可在大多數 JavaScript 平台上使用

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

有些函式會接受或傳回原生 Node.js 串流

現在,我們可以在 Node.js 上使用跨平台的網路串流,而不是原生串流。說明請見 §10「在 Node.js 上使用網路串流」

8.2 讀寫檔案

8.2.1 同步讀取檔案到單一字串(選用:拆分成多行)

fs.readFileSync(filePath, options?)filePath 中的檔案讀取到單一字串

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

此方法的優缺點(相較於使用串流)

接下來,我們將探討如何將已讀取的字串拆分成多行。

8.2.1.1 拆分多行而不包含行終止符

下列程式碼會將字串拆分成多行,同時移除行終止符。它可以處理 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',例如前一個範例中的第一個)。更多資訊,請見 §8.3「處理跨平台的行終止符」

8.2.1.2 拆分多行並包含行終止符

下列程式碼會將字串拆分成多行,同時包含行終止符。它可以處理 Unix 和 Windows 的行終止符(「EOL」代表「行尾」)

const RE_SPLIT_AFTER_EOL = /(?<=\r?\n)/; // (A)
function splitLinesWithEols(str) {
  return str.split(RE_SPLIT_AFTER_EOL);
}

assert.deepEqual(
  splitLinesWithEols('there\r\nare\nmultiple\nlines'),
  ['there\r\n', 'are\n', 'multiple\n', 'lines']
);
assert.deepEqual(
  splitLinesWithEols('first\n\nthird'),
  ['first\n', '\n', 'third']
);
assert.deepEqual(
  splitLinesWithEols('EOL at the end\n'),
  ['EOL at the end\n']
);
assert.deepEqual(
  splitLinesWithEols(''),
  ['']
);

A 行包含一個正規表示式,其中有 後向肯定斷言。它會在符合 \r?\n 模式的前方位置配對,但不會擷取任何內容。因此,它不會移除輸入字串拆分後字串片段之間的任何內容。

在不支援後向肯定斷言的引擎中(請見此表格),我們可以使用下列解決方案

function splitLinesWithEols(str) {
  if (str.length === 0) return [''];
  const lines = [];
  let prevEnd = 0;
  while (prevEnd < str.length) {
    // Searching for '\n' means we’ll also find '\r\n'
    const newlineIndex = str.indexOf('\n', prevEnd);
    // If there is a newline, it’s included in the line
    const end = newlineIndex < 0 ? str.length : newlineIndex+1;
    lines.push(str.slice(prevEnd, end));
    prevEnd = end;
  }
  return lines;
}

此解決方案很簡單,但較為冗長。

splitLinesWithEols() 的兩個版本中,我們再次接受 Unix 行終止符 ('\n') 和 Windows 行終止符 ('\r\n')。更多資訊,請見 §8.3「處理跨平台的行終止符」

8.2.2 使用串流逐行讀取檔案

我們也可以使用串流讀取文字檔案

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);
}

// Output:
// 'there\r\n'
// 'are\n'
// 'multiple\n'
// 'lines'

我們使用了下列外部功能

網頁串流是 非同步可疊代 的,這就是我們可以使用 for-await-of 迴圈疊代行的原因。

如果我們對文字行沒有興趣,那麼我們不需要 ChunksToLinesStream,可以疊代 webReadableStream 並取得任意長度的區塊。

更多資訊

此方法的優缺點(相較於讀取單一字串)

8.2.3 同步將單一字串寫入檔案

fs.writeFileSync(filePath, str, options?)str 寫入 filePath 的檔案中。如果該路徑中已存在檔案,則會覆寫該檔案。

下列程式碼顯示如何使用此函式

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

有關行終止符號的資訊,請參閱 §8.3「跨平台處理行終止符號」

優缺點(相較於使用串流)

8.2.4 附加單一字串至檔案(同步)

下列程式碼將一行文字附加至現有檔案

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

我們也可以使用 fs.writeFileSync() 來執行此任務

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

此程式碼幾乎與我們用來覆寫現有內容的程式碼相同(有關更多資訊,請參閱前一章節)。唯一的差別是我們新增了選項 .flag:值 'a' 表示我們附加資料。其他可能值(例如如果檔案尚未存在則擲回錯誤)在 Node.js 文件 中說明。

注意:在某些函式中,此選項稱為 .flag,在其他函式中則稱為 .flags

8.2.5 透過串流將多個字串寫入檔案

下列程式碼使用串流將多個字串寫入檔案

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()
}

我們使用了下列函式

更多資訊

優缺點(與寫入單一字串相比)

8.2.6 透過串流附加多個字串到檔案(非同步)

下列程式碼使用串流將文字附加到現有檔案

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

8.3 處理跨平台的行終止符

唉,並非所有平台都有相同的行終止符字元來標示行尾(EOL)

若要以適用於所有平台的方式處理 EOL,我們可以使用多種策略。

8.3.1 讀取行終止符

在讀取文字時,最好辨識兩種 EOL。

在將文字分割成多行時,這可能會是什麼樣子?我們可以在結尾包含 EOL(任何格式)。如果我們修改這些行並將它們寫入檔案,這讓我們可以盡可能少做修改。

在處理帶有 EOL 的行時,有時會想要移除它們,例如透過下列函式

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'
);

8.3.2 寫入行終止符

在寫入行終止符時,我們有兩個選項

8.4 瀏覽和建立目錄

8.4.1 瀏覽目錄

下列函式瀏覽目錄並列出其所有後代(其子目錄、其子目錄的子目錄等)

import * as path from 'node:path';

function* traverseDirectory(dirPath) {
  const dirEntries = fs.readdirSync(dirPath, {withFileTypes: true});
  // Sort the entries to keep things more deterministic
  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);
    }
  }
}

我們使用了此功能

以下程式碼展示 traverseDirectory() 的實際運作

for (const filePath of traverseDirectory('dir')) {
  console.log(filePath);
}

// Output:
// 'dir/dir-file.txt'
// 'dir/subdir'
// 'dir/subdir/subdir-file1.txt'
// 'dir/subdir/subdir-file2.csv'

8.4.2 建立目錄 (mkdirmkdir -p)

我們可以使用 以下函式 來建立目錄

fs.mkdirSync(thePath, options?): undefined | string

options.recursive 決定函式如何建立 thePath 中的目錄

這是 mkdirSync() 的實際運作

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 中目錄的所有後代。

8.4.3 確保父目錄存在

如果我們想要依需求設定巢狀檔案結構,我們無法在建立新檔案時,總是確定祖先目錄存在。以下函式可以協助處理這個問題

import * as path from 'node:path';

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

這裡我們可以看到 ensureParentDirectory() 的實際運作 (A 行)

assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
  ]
);
const filePath = 'dir/sub/subsub/new-file.txt';
ensureParentDirectory(filePath); // (A)
fs.writeFileSync(filePath, 'content', {encoding: 'utf-8'});
assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
    'dir/sub',
    'dir/sub/subsub',
    'dir/sub/subsub/new-file.txt',
  ]
);

8.4.4 建立暫時目錄

fs.mkdtempSync(pathPrefix, options?) 會建立一個暫時目錄:它會將 6 個隨機字元附加到 pathPrefix,在新的路徑中建立一個目錄,並傳回該路徑。

pathPrefix 不應以大寫「X」結尾,因為某些平台會以隨機字元取代尾端的 X。

如果我們想要在作業系統特定的全域暫時目錄中建立暫時目錄,我們可以使用 函式 os.tmpdir()

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

const pathPrefix = path.resolve(os.tmpdir(), 'my-app');
  // e.g. '/var/folders/ph/sz0384m11vxf/T/my-app'

const tmpPath = fs.mkdtempSync(pathPrefix);
  // e.g. '/var/folders/ph/sz0384m11vxf/T/my-app1QXOXP'

請務必注意,當 Node.js 腳本終止時,暫時目錄不會自動移除。我們必須自行刪除,或依賴作業系統定期清除其全域暫時目錄 (它可能會或可能不會執行此動作)。

8.5 複製、重新命名、移動檔案或目錄

8.5.1 複製檔案或目錄

fs.cpSync(srcPath, destPath, options?):將檔案或目錄從 srcPath 複製到 destPath。有趣的選項

這是此函式的實際運作狀況

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 中目錄的所有後代。

8.5.2 重新命名或移動檔案或目錄

fs.renameSync(oldPath, newPath) 會將檔案或目錄從 oldPath 重新命名或移動到 newPath

讓我們使用此函式重新命名目錄

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 中目錄的所有後代。

8.6 移除檔案或目錄

8.6.1 移除檔案和任意目錄(shell:rmrm -r

fs.rmSync(thePath, options?) 會移除 thePath 中的檔案或目錄。有趣的選項

讓我們使用 fs.rmSync() 移除檔案

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

我們在此使用 fs.rmSync() 遞迴移除非空的目錄。

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 中目錄的所有後代。

8.6.2 移除空的目錄(shell:rmdir

fs.rmdirSync(thePath, options?) 會移除空的目錄(如果目錄不為空,會擲回例外狀況)。

以下程式碼顯示此函式的運作方式

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

函式 traverseDirectory(dirPath) 會列出 dirPath 中目錄的所有後代。

8.6.3 清除目錄

一個將其輸出儲存到目錄 dir 的指令碼,通常需要在開始前清除 dir:移除 dir 中的每個檔案,使其為空。以下函式會執行此動作。

import * as path from 'node:path';

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',
  ]
);

8.6.4 將檔案或目錄移至垃圾桶

函式庫 trash 將檔案和資料夾移至垃圾桶。它可在 macOS、Windows 和 Linux 上執行(支援有限,需要協助)。這是其自述檔案中的範例

import trash from 'trash';

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

trash() 接受陣列字串或字串作為其第一個參數。任何字串都可以是 glob 模式(包含星號和其他元字元)。

8.7 讀取和變更檔案系統項目

8.7.1 檢查檔案或目錄是否存在

fs.existsSync(thePath) 如果檔案或目錄存在於 thePath,則傳回 true

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 中目錄的所有後代。

8.7.2 檢查檔案的統計資料:它是目錄嗎?何時建立?等等。

fs.statSync(thePath, options?) 傳回 fs.Stats 的執行個體,其中包含 thePath 處檔案或目錄的資訊。

有趣的 options

fs.Stats 執行個體的屬性

在以下範例中,我們使用 fs.statSync() 來實作函式 isDirectory()

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 中目錄的所有後代。

8.7.3 變更檔案屬性:權限、擁有者、群組、時間戳記

讓我們簡要看看用於變更檔案屬性的函式

使用硬連結的函式

使用符號連結的函式

下列函數在符號連結上運作而不取消參照它們(注意名稱前綴「l」)

其他有用的函數

影響符號連結處理方式的函數選項

8.9 進一步閱讀