使用 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

返回从 sourcePathdestinationPath 的相对路径

> 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 的路径。并且 .base.name 加上 .ext

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

这就是 path.parse() 在 Windows 上的工作方式

> 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():提取路径的 base

path.basename(path, ext?)

返回 path 的 base

> 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,因为这是它在其他 Web 平台上的使用方式。但它也可以被导入

import {URL} from 'node:url';
7.11.1.1 URI 与相对引用

URL 是 URI 的一个子集。RFC 3986 是 URI 的标准,它区分了 两种*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://exploring.javascript.ac.cn')
});
assert.equal(
  jsonStr, '{"pageUrl":"https://exploring.javascript.ac.cn"}'
);
7.11.1.6 获取 URL 属性

URL 实例的属性不是自己的数据属性,它们是通过 getter 和 setter 实现的。在下一个示例中,我们使用实用函数 pickProps()(其代码显示在末尾)将这些 getter 返回的值复制到一个普通对象中

const props = pickProps(
  new URL('https://jane:[email protected]: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:[email protected]:80/news.html?date=today#misc'
  }
);
function pickProps(input, ...keys) {
  const output = {};
  for (const key of keys) {
    output[key] = input[key];
  }
  return output;
}

唉,路径名是一个单一的原子单元。也就是说,我们不能使用类 URL 来访问它的各个部分(base、扩展名等)。

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.pathnameURL 实例 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 的用例:检测当前模块是否是“main”(应用程序入口点)

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(如前面小节中所述)