Node.js 的 shell 指令碼
你可以購買此書的離線版本(HTML、PDF、EPUB、MOBI),並支援免費的線上版本。
(廣告,請不要封鎖。)

7 在 Node.js 上處理檔案系統路徑和檔案 URL



在本章中,我們將學習如何在 Node.js 上使用檔案系統路徑和檔案 URL。

在本章中,我們將探討 Node.js 上與路徑相關的功能

7.1.1 存取 'node:path' API 的三種方式

模組 'node:path' 通常會以以下方式匯入

import * as path from 'node:path';

在本章中,這個匯入陳述式偶爾會被省略。我們也會省略以下匯入

import * as assert from 'node:assert/strict';

我們可以透過三種方式存取 Node 的路徑 API

讓我們看看函式 path.parse() 如何解析檔案系統路徑,它在兩個平台上有所不同

> path.win32.parse(String.raw`C:\Users\jane\file.txt`)
{
  dir: 'C:\\Users\\jane',
  root: 'C:\\',
  base: 'file.txt',
  name: 'file',
  ext: '.txt',
}
> path.posix.parse(String.raw`C:\Users\jane\file.txt`)
{
  dir: '',
  root: '',
  base: 'C:\\Users\\jane\\file.txt',
  name: 'C:\\Users\\jane\\file',
  ext: '.txt',
}

我們解析一個 Windows 路徑 – 首先透過 path.win32 API 正確解析,然後透過 path.posix API 解析。我們可以看到在後者的情況下,路徑沒有正確地分割成它的部分 – 例如,檔案的基本檔名應該是 file.txt(稍後會詳細說明其他屬性的意義)。

7.2 基礎路徑概念及其 API 支援

7.2.1 路徑區段、路徑分隔符號、路徑分隔符號

術語

如果我們檢查 PATH shell 變數(其中包含作業系統在 shell 中輸入命令時尋找可執行檔的路徑),我們可以看到路徑分隔符號和路徑分隔符號。

以下是 macOS PATH(shell 變數 $PATH)的範例

> process.env.PATH.split(/(?<=:)/)
[
  '/opt/homebrew/bin:',
  '/opt/homebrew/sbin:',
  '/usr/local/bin:',
  '/usr/bin:',
  '/bin:',
  '/usr/sbin:',
  '/sbin',
]

分割分隔符號的長度為零,因為回顧斷言 (?<=:) 匹配給定位置前面是否有冒號,但不會擷取任何內容。因此,路徑分隔符號 ':' 包含在前一個路徑中。

以下是 Windows PATH(shell 變數 %Path%)的範例

> process.env.Path.split(/(?<=;)/)
[
  'C:\\Windows\\system32;',
  'C:\\Windows;',
  'C:\\Windows\\System32\\Wbem;',
  'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\;',
  'C:\\Windows\\System32\\OpenSSH\\;',
  'C:\\ProgramData\\chocolatey\\bin;',
  'C:\\Program Files\\nodejs\\',
]

7.2.2 目前工作目錄

許多 shell 都具有目前工作目錄 (CWD) 的概念,也就是「我目前所在的目錄」

process 是全域 Node.js 變數。它提供取得和設定 CWD 的方法

Node.js 使用 CWD 填入路徑不是完全限定(完整)時的遺失部分。這讓我們可以使用部分限定路徑搭配各種函式,例如 fs.readFileSync()

7.2.2.1 Unix 上的目前工作目錄

以下程式碼示範 Unix 上的 process.chdir()process.cwd()

process.chdir('/home/jane');
assert.equal(
  process.cwd(), '/home/jane'
);
7.2.2.2 Windows 上的目前工作目錄

到目前為止,我們已在 Unix 上使用目前工作目錄。Windows 的運作方式不同

我們可以使用 path.chdir() 同時設定兩者

process.chdir('C:\\Windows');
process.chdir('Z:\\tmp');

當我們重新拜訪一個磁碟時,Node.js 會記住該磁碟之前目前的目錄

assert.equal(
  process.cwd(), 'Z:\\tmp'
);
process.chdir('C:');
assert.equal(
  process.cwd(), 'C:\\Windows'
);

7.2.3 完整與部分限定路徑,解析路徑

7.2.3.1 Unix 上的完整和部分限定路徑

Unix 僅知道兩種路徑

讓我們使用 path.resolve()(在 後面 會有更詳細的說明)來解析相對路徑與絕對路徑。結果是絕對路徑

> const abs = '/home/john/proj';

> path.resolve(abs, '.')
'/home/john/proj'
> path.resolve(abs, '..')
'/home/john'
> path.resolve(abs, 'dir')
'/home/john/proj/dir'
> path.resolve(abs, './dir')
'/home/john/proj/dir'
> path.resolve(abs, '../dir')
'/home/john/dir'
> path.resolve(abs, '../../dir/subdir')
'/home/dir/subdir'
7.2.3.2 Windows 上的完整和部分限定路徑

Windows 區分四種類型的路徑(有關更多資訊,請參閱 Microsoft 的文件

帶有磁碟機代號的絕對路徑是完整限定的。所有其他路徑都是部分限定的。

解析沒有磁碟機代號的絕對路徑針對完整限定路徑 full,會選取 full 的磁碟機代號

> const full = 'C:\\Users\\jane\\proj';

> path.resolve(full, '\\Windows')
'C:\\Windows'

解析沒有磁碟機代號的相對路徑針對完整限定路徑,可以視為更新後者

> const full = 'C:\\Users\\jane\\proj';

> path.resolve(full, '.')
'C:\\Users\\jane\\proj'
> path.resolve(full, '..')
'C:\\Users\\jane'
> path.resolve(full, 'dir')
'C:\\Users\\jane\\proj\\dir'
> path.resolve(full, '.\\dir')
'C:\\Users\\jane\\proj\\dir'
> path.resolve(full, '..\\dir')
'C:\\Users\\jane\\dir'
> path.resolve(full, '..\\..\\dir')
'C:\\Users\\dir'

解析帶有磁碟機代號的相對路徑 rel針對完整限定路徑 full 取決於 rel 的磁碟機代號

如下所示

// Configure current directories for C: and Z:
process.chdir('C:\\Windows\\System');
process.chdir('Z:\\tmp');

const full = 'C:\\Users\\jane\\proj';

// Same drive letter
assert.equal(
  path.resolve(full, 'C:dir'),
  'C:\\Users\\jane\\proj\\dir'
);
assert.equal(
  path.resolve(full, 'C:'),
  'C:\\Users\\jane\\proj'
);

// Different drive letter
assert.equal(
  path.resolve(full, 'Z:dir'),
  'Z:\\tmp\\dir'
);
assert.equal(
  path.resolve(full, 'Z:'),
  'Z:\\tmp'
);

7.3 透過模組 'node:os' 取得標準目錄的路徑

模組 'node:os' 提供我們兩個重要目錄的路徑

7.4 串接路徑

有兩個用於串接路徑的函式

7.4.1 path.resolve():串接路徑以建立完整限定路徑

path.resolve(...paths: Array<string>): string

串接 paths 並傳回完整限定路徑。它使用下列演算法

若沒有參數,path.resolve() 會傳回目前工作目錄的路徑

> process.cwd()
'/usr/local'
> path.resolve()
'/usr/local'

一個或多個相對路徑會用於解析,從目前工作目錄開始

> path.resolve('.')
'/usr/local'
> path.resolve('..')
'/usr'
> path.resolve('bin')
'/usr/local/bin'
> path.resolve('./bin', 'sub')
'/usr/local/bin/sub'
> path.resolve('../lib', 'log')
'/usr/lib/log'

任何完全限定的路徑會取代前一個結果

> path.resolve('bin', '/home')
'/home'

這讓我們得以針對完全限定的路徑解析部分限定的路徑

> path.resolve('/home/john', 'proj', 'src')
'/home/john/proj/src'

7.4.2 path.join():串接路徑,同時保留相對路徑

path.join(...paths: Array<string>): string

paths[0] 開始,並將剩餘路徑解譯為上行或下行的指令。與 path.resolve() 相反,此函數會保留部分限定的路徑:如果 paths[0] 是部分限定的,結果也會是部分限定的。如果它是完全限定的,結果也會是完全限定的。

下行範例

> path.posix.join('/usr/local', 'sub', 'subsub')
'/usr/local/sub/subsub'
> path.posix.join('relative/dir', 'sub', 'subsub')
'relative/dir/sub/subsub'

兩個點表示上行

> path.posix.join('/usr/local', '..')
'/usr'
> path.posix.join('relative/dir', '..')
'relative'

一個點不執行任何動作

> path.posix.join('/usr/local', '.')
'/usr/local'
> path.posix.join('relative/dir', '.')
'relative/dir'

如果第一個參數之後的參數是完全限定的路徑,它們會被解譯為相對路徑

> path.posix.join('dir', '/tmp')
'dir/tmp'
> path.win32.join('dir', 'C:\\Users')
'dir\\C:\\Users'

使用超過兩個參數

> path.posix.join('/usr/local', '../lib', '.', 'log')
'/usr/lib/log'

7.5 確保路徑已正規化、完全限定或相對

7.5.1 path.normalize():確保路徑已正規化

path.normalize(path: string): string

在 Unix 上,path.normalize()

例如

// Fully qualified path
assert.equal(
  path.posix.normalize('/home/./john/lib/../photos///pet'),
  '/home/john/photos/pet'
);

// Partially qualified path
assert.equal(
  path.posix.normalize('./john/lib/../photos///pet'),
  'john/photos/pet'
);

在 Windows 上,path.normalize()

例如

// Fully qualified path
assert.equal(
  path.win32.normalize('C:\\Users/jane\\doc\\..\\proj\\\\src'),
  'C:\\Users\\jane\\proj\\src'
);

// Partially qualified path
assert.equal(
  path.win32.normalize('.\\jane\\doc\\..\\proj\\\\src'),
  'jane\\proj\\src'
);

請注意,具有單一參數的 path.join() 也會正規化,且與 path.normalize() 的運作方式相同

> path.posix.normalize('/home/./john/lib/../photos///pet')
'/home/john/photos/pet'
> path.posix.join('/home/./john/lib/../photos///pet')
'/home/john/photos/pet'

> path.posix.normalize('./john/lib/../photos///pet')
'john/photos/pet'
> path.posix.join('./john/lib/../photos///pet')
'john/photos/pet'

7.5.2 path.resolve()(一個參數):確保路徑已正規化且完全限定

我們已經遇到 path.resolve()。如果呼叫此函數時只有一個參數,它會同時正規化路徑,並確保它們完全限定。

在 Unix 上使用 path.resolve()

> process.cwd()
'/usr/local'

> path.resolve('/home/./john/lib/../photos///pet')
'/home/john/photos/pet'
> path.resolve('./john/lib/../photos///pet')
'/usr/local/john/photos/pet'

在 Windows 上使用 path.resolve()

> process.cwd()
'C:\\Windows\\System'

> path.resolve('C:\\Users/jane\\doc\\..\\proj\\\\src')
'C:\\Users\\jane\\proj\\src'
> path.resolve('.\\jane\\doc\\..\\proj\\\\src')
'C:\\Windows\\System\\jane\\proj\\src'

7.5.3 path.relative():建立相對路徑

path.relative(sourcePath: string, destinationPath: string): string

傳回一個相對路徑,讓我們從 sourcePath 到達 destinationPath

> path.posix.relative('/home/john/', '/home/john/proj/my-lib/README.md')
'proj/my-lib/README.md'
> path.posix.relative('/tmp/proj/my-lib/', '/tmp/doc/zsh.txt')
'../../doc/zsh.txt'

在 Windows 上,如果 sourcePathdestinationPath 在不同的磁碟機上,我們會取得一個完全限定路徑

> path.win32.relative('Z:\\tmp\\', 'C:\\Users\\Jane\\')
'C:\\Users\\Jane'

此函式也可以用於相對路徑

> path.posix.relative('proj/my-lib/', 'doc/zsh.txt')
'../../doc/zsh.txt'

7.6 剖析路徑:擷取路徑的各個部分(檔案名稱副檔名等)

7.6.1 path.parse():建立一個包含路徑部分的物件

type PathObject = {
  dir: string,
    root: string,
  base: string,
    name: string,
    ext: string,
};
path.parse(path: string): PathObject

擷取 path 的各個部分,並在一個物件中傳回,物件包含下列屬性

稍後,我們會看到 函式 path.format(),它是 path.parse() 的反函式:它會將包含路徑部分的物件轉換成一個路徑。

7.6.1.1 範例:在 Unix 上使用 path.parse()

以下是在 Unix 上使用 path.parse() 的範例

> path.posix.parse('/home/jane/file.txt')
{
  dir: '/home/jane',
  root: '/',
  base: 'file.txt',
  name: 'file',
  ext: '.txt',
}

下列圖示視覺化各個部分的範圍

  /      home/jane / file   .txt
| root |           | name | ext  |
| dir              | base        |

例如,我們可以看到 .dir 是沒有基底的路徑。而 .base.name 加上 .ext

7.6.1.2 範例:在 Windows 上使用 path.parse()

以下是在 Windows 上使用 path.parse() 的範例

> path.win32.parse(String.raw`C:\Users\john\file.txt`)
{
  dir: 'C:\\Users\\john',
  root: 'C:\\',
  base: 'file.txt',
  name: 'file',
  ext: '.txt',
}

這是結果的圖示

  C:\    Users\john \ file   .txt
| root |            | name | ext  |
| dir               | base        |

7.6.2 path.basename():擷取路徑的基底

path.basename(path, ext?)

傳回 path 的基底

> path.basename('/home/jane/file.txt')
'file.txt'

此函式也可以移除後綴字元(選擇性)

> path.basename('/home/jane/file.txt', '.txt')
'file'
> path.basename('/home/jane/file.txt', 'txt')
'file.'
> path.basename('/home/jane/file.txt', 'xt')
'file.t'

移除副檔名時會區分大小寫 – 即使是在 Windows 上!

> path.win32.basename(String.raw`C:\Users\john\file.txt`, '.txt')
'file'
> path.win32.basename(String.raw`C:\Users\john\file.txt`, '.TXT')
'file.txt'

7.6.3 path.dirname():擷取路徑的父目錄

path.dirname(path)

傳回 path 中檔案或目錄的父目錄

> path.win32.dirname(String.raw`C:\Users\john\file.txt`)
'C:\\Users\\john'
> path.win32.dirname('C:\\Users\\john\\dir\\')
'C:\\Users\\john'

> path.posix.dirname('/home/jane/file.txt')
'/home/jane'
> path.posix.dirname('/home/jane/dir/')
'/home/jane'

7.6.4 path.extname():擷取路徑的副檔名

path.extname(path)

傳回 path 的副檔名

> path.extname('/home/jane/file.txt')
'.txt'
> path.extname('/home/jane/file.')
'.'
> path.extname('/home/jane/file')
''
> path.extname('/home/jane/')
''
> path.extname('/home/jane')
''

7.7 分類路徑

7.7.1 path.isAbsolute():給定的路徑是否為絕對路徑?

path.isAbsolute(path: string): boolean

如果 path 是絕對路徑,傳回 true,否則傳回 false

在 Unix 上的結果很簡單

> path.posix.isAbsolute('/home/john')
true
> path.posix.isAbsolute('john')
false

在 Windows 上,「絕對」並不一定代表「完全限定」(只有第一個路徑是完全限定的)

> path.win32.isAbsolute('C:\\Users\\jane')
true
> path.win32.isAbsolute('\\Users\\jane')
true
> path.win32.isAbsolute('C:jane')
false
> path.win32.isAbsolute('jane')
false

7.8 path.format():從部分建立路徑

type PathObject = {
  dir: string,
    root: string,
  base: string,
    name: string,
    ext: string,
};
path.format(pathObject: PathObject): string

從路徑物件建立路徑

> path.format({dir: '/home/jane', base: 'file.txt'})
'/home/jane/file.txt'

7.8.1 範例:變更檔案名稱副檔名

我們可以使用 path.format() 來變更路徑的副檔名

function changeFilenameExtension(pathStr, newExtension) {
  if (!newExtension.startsWith('.')) {
    throw new Error(
      'Extension must start with a dot: '
      + JSON.stringify(newExtension)
    );
  }
  const parts = path.parse(pathStr);
  return path.format({
    ...parts,
    base: undefined, // prevent .base from overriding .name and .ext
    ext: newExtension,
  });
}

assert.equal(
  changeFilenameExtension('/tmp/file.md', '.html'),
  '/tmp/file.html'
);
assert.equal(
  changeFilenameExtension('/tmp/file', '.html'),
  '/tmp/file.html'
);
assert.equal(
  changeFilenameExtension('/tmp/file/', '.html'),
  '/tmp/file.html'
);

如果我們知道原始檔案名稱副檔名,我們也可以使用正規表示式來變更檔案名稱副檔名

> '/tmp/file.md'.replace(/\.md$/i, '.html')
'/tmp/file.html'
> '/tmp/file.MD'.replace(/\.md$/i, '.html')
'/tmp/file.html'

7.9 在不同平台上使用相同路徑

有時候我們希望在不同平台上使用相同路徑。這時候我們會遇到兩個問題

舉例來說,考慮一個在有資料目錄中執行的 Node.js 應用程式。假設應用程式可以透過兩種路徑進行設定

由於上述問題

7.9.1 相對平台非相依路徑

相對平台非相依路徑可以儲存為路徑區段陣列,並轉換成完全限定的平台特定路徑,如下所示

const universalRelativePath = ['static', 'img', 'logo.jpg'];

const dataDirUnix = '/home/john/data-dir';
assert.equal(
  path.posix.resolve(dataDirUnix, ...universalRelativePath),
  '/home/john/data-dir/static/img/logo.jpg'
);

const dataDirWindows = 'C:\\Users\\jane\\data-dir';
assert.equal(
  path.win32.resolve(dataDirWindows, ...universalRelativePath),
  'C:\\Users\\jane\\data-dir\\static\\img\\logo.jpg'
);

若要建立相對平台特定路徑,我們可以使用

const dataDir = '/home/john/data-dir';
const pathInDataDir = '/home/john/data-dir/static/img/logo.jpg';
assert.equal(
  path.relative(dataDir, pathInDataDir),
  'static/img/logo.jpg'
);

下列函式將相對平台特定路徑轉換成平台非相依路徑

import * as path from 'node:path';

function splitRelativePathIntoSegments(relPath) {
  if (path.isAbsolute(relPath)) {
    throw new Error('Path isn’t relative: ' + relPath);
  }
  relPath = path.normalize(relPath);
  const result = [];
  while (true) {
    const base = path.basename(relPath);
    if (base.length === 0) break;
    result.unshift(base);
    const dir = path.dirname(relPath);
    if (dir === '.') break;
    relPath = dir;
  }
  return result;
}

在 Unix 上使用 splitRelativePathIntoSegments()

> splitRelativePathIntoSegments('static/img/logo.jpg')
[ 'static', 'img', 'logo.jpg' ]
> splitRelativePathIntoSegments('file.txt')
[ 'file.txt' ]

在 Windows 上使用 splitRelativePathIntoSegments()

> splitRelativePathIntoSegments('static/img/logo.jpg')
[ 'static', 'img', 'logo.jpg' ]
> splitRelativePathIntoSegments('C:static/img/logo.jpg')
[ 'static', 'img', 'logo.jpg' ]

> splitRelativePathIntoSegments('file.txt')
[ 'file.txt' ]
> splitRelativePathIntoSegments('C:file.txt')
[ 'file.txt' ]

7.10 使用函式庫透過 glob 比對路徑

npm 模組 'minimatch' 讓我們可以比對路徑與稱為 glob 表達式glob 模式glob 的樣式

import minimatch from 'minimatch';
assert.equal(
  minimatch('/dir/sub/file.txt', '/dir/sub/*.txt'), true
);
assert.equal(
  minimatch('/dir/sub/file.txt', '/**/file.txt'), true
);

glob 的使用案例

更多 glob 函式庫

7.10.1 Minimatch API

Minimatch 的完整 API 記錄在 專案的自述檔案 中。在此小節中,我們將探討最重要的功能。

Minimatch 將 glob 編譯成 JavaScript RegExp 物件,並使用它們進行比對。

7.10.1.1 minimatch():編譯並比對一次
minimatch(path: string, glob: string, options?: MinimatchOptions): boolean

如果 globpath 相符,則傳回 true,否則傳回 false

兩個有趣的選項

7.10.1.2 new minimatch.Minimatch():編譯一次,比對多次

類別 minimatch.Minimatch 讓我們僅將 glob 編譯成正規表示式一次,並比對多次

new Minimatch(pattern: string, options?: MinimatchOptions)

以下是此類別的使用方式

import minimatch from 'minimatch';
const {Minimatch} = minimatch;
const glob = new Minimatch('/dir/sub/*.txt');
assert.equal(
  glob.match('/dir/sub/file.txt'), true
);
assert.equal(
  glob.match('/dir/sub/notes.txt'), true
);

7.10.2 glob 表達式的語法

此小節涵蓋語法的基本要素。但還有更多功能。這些功能在此處有記錄

7.10.2.1 比對 Windows 路徑

即使在 Windows 上,glob 區段也以斜線分隔,但它們會比對反斜線和斜線(這是 Windows 上合法的路徑分隔符號)

> minimatch('dir\\sub/file.txt', 'dir/sub/file.txt')
true
7.10.2.2 Minimatch 沒有正規化路徑

Minimatch 沒有為我們正規化路徑

> minimatch('./file.txt', './file.txt')
true
> minimatch('./file.txt', 'file.txt')
false
> minimatch('file.txt', './file.txt')
false

因此,如果我們沒有自己建立路徑,就必須正規化路徑

> path.normalize('./file.txt')
'file.txt'
7.10.2.3 沒有萬用字元的模式:路徑分隔符號必須對齊

沒有萬用字元(可以更靈活地比對)的模式必須完全相符。特別是路徑分隔符號必須對齊

> minimatch('/dir/file.txt', '/dir/file.txt')
true
> minimatch('dir/file.txt', 'dir/file.txt')
true
> minimatch('/dir/file.txt', 'dir/file.txt')
false

> minimatch('/dir/file.txt', 'file.txt')
false

也就是說,我們必須決定使用絕對路徑或相對路徑。

使用選項 .matchBase,我們可以比對路徑基本名稱中不含斜線的樣式

> minimatch('/dir/file.txt', 'file.txt', {matchBase: true})
true
7.10.2.4 星號 (*) 比對任何 (單一區段的) 部分

萬用字元符號 星號 (*) 比對任何路徑區段或任何區段的一部分

> minimatch('/dir/file.txt', '/*/file.txt')
true
> minimatch('/tmp/file.txt', '/*/file.txt')
true

> minimatch('/dir/file.txt', '/dir/*.txt')
true
> minimatch('/dir/data.txt', '/dir/*.txt')
true

星號不會比對名稱以點開頭的「隱藏檔案」。如果我們想要比對這些檔案,我們必須在星號前面加上一個點

> minimatch('file.txt', '*')
true
> minimatch('.gitignore', '*')
false
> minimatch('.gitignore', '.*')
true
> minimatch('/tmp/.log/events.txt', '/tmp/*/events.txt')
false

選項 .dot 讓我們可以關閉此行為

> minimatch('.gitignore', '*', {dot: true})
true
> minimatch('/tmp/.log/events.txt', '/tmp/*/events.txt', {dot: true})
true
7.10.2.5 雙星號 (**) 比對零個或多個區段

´**/ 比對零個或多個區段

> minimatch('/file.txt', '/**/file.txt')
true
> minimatch('/dir/file.txt', '/**/file.txt')
true
> minimatch('/dir/sub/file.txt', '/**/file.txt')
true

如果我們想要比對相對路徑,樣式仍然不能以路徑分隔符號開頭

> minimatch('file.txt', '/**/file.txt')
false

雙星號不會比對名稱以點開頭的「隱藏」路徑區段

> minimatch('/usr/local/.tmp/data.json', '/usr/**/data.json')
false

我們可以使用選項 .dot 關閉此行為

> minimatch('/usr/local/.tmp/data.json', '/usr/**/data.json', {dot: true})
true
7.10.2.6 否定 glob

如果我們以驚嘆號開頭,glob 會比對驚嘆號後的樣式不比對的情況

> minimatch('file.txt', '!**/*.txt')
false
> minimatch('file.js', '!**/*.txt')
true
7.10.2.7 替代樣式

大括號內的逗號分隔樣式會在其中一個樣式比對時比對

> minimatch('file.txt', 'file.{txt,js}')
true
> minimatch('file.js', 'file.{txt,js}')
true
7.10.2.8 整數範圍

一對以雙點分隔的整數定義一個整數範圍,並在任何元素比對時比對

> minimatch('file1.txt', 'file{1..3}.txt')
true
> minimatch('file2.txt', 'file{1..3}.txt')
true
> minimatch('file3.txt', 'file{1..3}.txt')
true
> minimatch('file4.txt', 'file{1..3}.txt')
false

也支援以零填充

> minimatch('file1.txt', 'file{01..12}.txt')
false
> minimatch('file01.txt', 'file{01..12}.txt')
true
> minimatch('file02.txt', 'file{01..12}.txt')
true
> minimatch('file12.txt', 'file{01..15}.txt')
true

7.11 使用 file: URL 參照檔案

在 Node.js 中有兩種常見的方式可以參照檔案

例如

assert.equal(
  fs.readFileSync(
    '/tmp/data.txt', {encoding: 'utf-8'}),
  'Content'
);
assert.equal(
  fs.readFileSync(
    new URL('file:///tmp/data.txt'), {encoding: 'utf-8'}),
  'Content'
);

7.11.1 類別 URL

在本節中,我們將仔細探討類別 URL。有關此類別的更多資訊

在本章中,我們透過全域變數存取類別 URL,因為這是其他網路平台使用的方式。但也可以匯入

import {URL} from 'node:url';
7.11.1.1 URI 與相對參照

URL 是 URI 的子集。URI 標準 RFC 3986 區分 兩種URI 參照

7.11.1.2 URL 的建構函式

類別 URL 可用兩種方式實例化

這裡我們可以看到類別的實際應用

// If there is only one argument, it must be a proper URI
assert.equal(
  new URL('https://example.com/public/page.html').toString(),
  'https://example.com/public/page.html'
);
assert.throws(
  () => new URL('../book/toc.html'),
  /^TypeError \[ERR_INVALID_URL\]: Invalid URL$/
);

// Resolve a relative reference against a base URI 
assert.equal(
  new URL(
    '../book/toc.html',
    'https://example.com/public/page.html'
  ).toString(),
  'https://example.com/book/toc.html'
);
7.11.1.3 解析相對於 URL 實例的相對參照

讓我們重新檢視 URL 建構函式的這個變體

new URL(uriRef: string, baseUri: string)

參數 baseUri 會強制轉換為字串。因此,任何物件都可以使用,只要強制轉換為字串時會變成有效的 URL 即可

const obj = { toString() {return 'https://example.com'} };
assert.equal(
  new URL('index.html', obj).href,
  'https://example.com/index.html'
);

這讓我們可以解析相對於 URL 實例的相對參照

const url = new URL('https://example.com/dir/file1.html');
assert.equal(
  new URL('../file2.html', url).href,
  'https://example.com/file2.html'
);

以這種方式使用時,建構函式與 path.resolve() 非常類似。

7.11.1.4 URL 實例的屬性

URL 實例具有下列屬性

type URL = {
  protocol: string,
  username: string,
  password: string,
  hostname: string,
  port: string,
  host: string,
  readonly origin: string,
  
  pathname: string,
  
  search: string,
  readonly searchParams: URLSearchParams,
  hash: string,

  href: string,
  toString(): string,
  toJSON(): string,
}
7.11.1.5 將 URL 轉換為字串

有三個常見的方式可以將 URL 轉換為字串

const url = new URL('https://example.com/about.html');

assert.equal(
  url.toString(),
  'https://example.com/about.html'
);
assert.equal(
  url.href,
  'https://example.com/about.html'
);
assert.equal(
  url.toJSON(),
  'https://example.com/about.html'
);

方法 .toJSON() 讓我們可以在 JSON 資料中使用 URL

const jsonStr = JSON.stringify({
  pageUrl: new URL('https://exploringjs.dev.org.tw')
});
assert.equal(
  jsonStr, '{"pageUrl":"https://exploringjs.dev.org.tw"}'
);
7.11.1.6 取得 URL 屬性

URL 實例的屬性不是自己的資料屬性,它們是透過 getter 和 setter 實作的。在以下範例中,我們使用工具函式 pickProps() (其程式碼顯示在最後),將這些 getter 回傳的值複製到一個純粹的物件中

const props = pickProps(
  new URL('https://jane:pw@example.com:80/news.html?date=today#misc'),
  'protocol', 'username', 'password', 'hostname', 'port', 'host',
  'origin', 'pathname', 'search', 'hash', 'href'
);
assert.deepEqual(
  props,
  {
    protocol: 'https:',
    username: 'jane',
    password: 'pw',
    hostname: 'example.com',
    port: '80',
    host: 'example.com:80',
    origin: 'https://example.com:80',
    pathname: '/news.html',
    search: '?date=today',
    hash: '#misc',
    href: 'https://jane:pw@example.com:80/news.html?date=today#misc'
  }
);
function pickProps(input, ...keys) {
  const output = {};
  for (const key of keys) {
    output[key] = input[key];
  }
  return output;
}

唉,路徑名稱是一個單一的原子單位。也就是說,我們無法使用類別 URL 存取其部分 (基礎、副檔名等)。

7.11.1.7 設定 URL 的部分

我們也可以透過設定屬性 (例如 .hostname) 來變更 URL 的部分

const url = new URL('https://example.com');
url.hostname = '2ality.com';
assert.equal(
  url.href, 'https://2ality.com/'
);

我們可以使用 setter 從部分建立 URL (Haroen Viaene 的點子)

// Object.assign() invokes setters when transferring property values
const urlFromParts = (parts) => Object.assign(
  new URL('https://example.com'), // minimal dummy URL
  parts // assigned to the dummy
);

const url = urlFromParts({
  protocol: 'https:',
  hostname: '2ality.com',
  pathname: '/p/about.html',
});
assert.equal(
  url.href, 'https://2ality.com/p/about.html'
);
7.11.1.8 透過 .searchParams 管理搜尋參數

我們可以使用屬性 .searchParams 來管理 URL 的搜尋參數。它的值是 URLSearchParams 的實例。

我們可以使用它來讀取搜尋參數

const url = new URL('https://example.com/?topic=js');
assert.equal(
  url.searchParams.get('topic'), 'js'
);
assert.equal(
  url.searchParams.has('topic'), true
);

我們也可以透過它變更搜尋參數

url.searchParams.append('page', '5');
assert.equal(
  url.href, 'https://example.com/?topic=js&page=5'
);

url.searchParams.set('topic', 'css');
assert.equal(
  url.href, 'https://example.com/?topic=css&page=5'
);

7.11.2 在 URL 和檔案路徑之間轉換

手動在檔案路徑和 URL 之間進行轉換很誘人。例如,我們可以嘗試透過 `myUrl.pathname` 將 `URL` 執行個體 `myUrl` 轉換為檔案路徑。然而,這並不總是可行的,最好使用 此函式

url.fileURLToPath(url: URL | string): string

以下程式碼比較該函式的結果與 `pathname` 的值

import * as url from 'node:url';

//::::: Unix :::::

const url1 = new URL('file:///tmp/with%20space.txt');
assert.equal(
  url1.pathname, '/tmp/with%20space.txt');
assert.equal(
  url.fileURLToPath(url1), '/tmp/with space.txt');

const url2 = new URL('file:///home/thor/Mj%C3%B6lnir.txt');
assert.equal(
  url2.pathname, '/home/thor/Mj%C3%B6lnir.txt');
assert.equal(
  url.fileURLToPath(url2), '/home/thor/Mjölnir.txt');

//::::: Windows :::::

const url3 = new URL('file:///C:/dir/');
assert.equal(
  url3.pathname, '/C:/dir/');
assert.equal(
  url.fileURLToPath(url3), 'C:\\dir\\');

此函式 是 `url.fileURLToPath()` 的反函式

url.pathToFileURL(path: string): URL

它將 `path` 轉換為檔案 URL

> url.pathToFileURL('/home/john/Work Files').href
'file:///home/john/Work%20Files'

7.11.3 URL 的使用案例:存取與目前模組相關的檔案

URL 的一個重要使用案例是存取與目前模組同層的檔案

function readData() {
  const url = new URL('data.txt', import.meta.url);
  return fs.readFileSync(url, {encoding: 'UTF-8'});
}

此函式使用 `import.meta.url`,其中包含目前模組的 URL(在 Node.js 上通常是 `file:` URL)。

使用 `fetch()` 會讓先前的程式碼更跨平台。然而,截至 Node.js 18.9.0,`fetch()` 尚未支援 `file:` URL

> await fetch('file:///tmp/file.txt')
TypeError: fetch failed
  cause: Error: not implemented... yet...

7.11.4 URL 的使用案例:偵測目前模組是否為「主程式」(應用程式進入點)

ESM 模組可以用兩種方式使用

  1. 它可以用作函式庫,其他模組可以從中匯入值。
  2. 它可以用作我們透過 Node.js 執行的指令碼,例如從命令列執行。在這種情況下,它稱為主模組

如果我們希望模組以這兩種方式使用,我們需要一種方法來檢查目前模組是否為主模組,因為只有在這種情況下,我們才會執行指令碼功能。在本章中,我們將學習如何執行該檢查。

7.11.4.1 判斷 CommonJS 模組是否為主模組

使用 CommonJS,我們可以使用以下模式來偵測目前模組是否為進入點(來源:Node.js 文件

if (require.main === module) {
  // Main CommonJS module
}
7.11.4.2 判斷 ESM 模組是否為主模組

截至目前,ESM 模組沒有簡單的內建方式來檢查模組是否為主模組。相反地,我們必須使用以下解決方法(基於 Rich Harris 的推文

import * as url from 'node:url';

if (import.meta.url.startsWith('file:')) { // (A)
  const modulePath = url.fileURLToPath(import.meta.url);
  if (process.argv[1] === modulePath) { // (B)
    // Main ESM module
  }
}

說明

7.11.5 路徑與 file: URL

當 shell 腳本接收檔案的參考或匯出檔案的參考(例如,將它們記錄在螢幕上)時,它們幾乎總是路徑。但是,有兩種情況我們需要 URL(如前小節所述)