Node.js Shell 脚本开发指南(上)

第一部分:关于本书

原文:exploringjs.com/nodejs-shell-scripting/pt_about.html

译者:飞龙

协议:CC BY-NC-SA 4.0

下一步:1 关于本书

一、本书简介

原文:exploringjs.com/nodejs-shell-scripting/ch_about-book.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 1.1 为什么我应该阅读这本书?

  • 1.2 阅读本书需要什么知识?

  • 1.3 购买和预览这本书

    • 1.3.1 我怎样才能购买这本书?

    • 1.3.2 我怎样才能预览这本书?

  • 1.4 关于作者

  • 1.5 致谢


本章将帮助您决定这本书是否适合您。

1.1 为什么我应该阅读这本书?

这本书是关于使用 Node.js 进行 shell 脚本编程的。您将学到:

  • Node.js 的工作原理:

    • 它的基础:它的架构,它的事件循环等。

    • 它的 API:如何使用它的全局变量和模块。

    • npm 包*(JavaScript 包的事实标准)是什么。
  • 如何使用npm(与 Node.js 捆绑的包管理器)来:

    • 安装和管理包。

    • 创建和发布包。

  • 如何编写用于运行开发任务(如生成构件和运行测试)的跨平台包脚本

  • 如何利用前述知识创建和部署跨平台 shell 脚本。

1.2 阅读本书需要什么知识?

您应该熟悉 JavaScript - 尤其是:

  • ECMAScript 模块:导入和导出值等。

  • 异步 JavaScript:Promises,async 函数等。

我的 JavaScript 书籍,“JavaScript for impatient programmers”可以免费在线阅读:

  • 它有一个关于模块的章节。

  • 它涵盖了一系列关于异步 JavaScript 的章节,从“JavaScript 中的异步编程”开始。

1.3 购买和预览这本书

1.3.1 我怎样才能购买这本书?

您可以购买包含电子书的套装。它们以这些格式提供(全部不带 DRM):

  • PDF

  • HTML

  • EPUB

  • MOBI

1.3.2 我怎样才能预览这本书?
  • HTML 版本可以免费在线阅读。

  • 在本书的主页上,有关于本书所有电子书版本的详细预览。

1.4 关于作者

Axel Rauschmayer 博士专注于 JavaScript 和 Web 开发。他自 1995 年以来一直在开发 Web 应用程序。1999 年,他是德国互联网初创公司的技术经理,后来扩展到国际市场。2006 年,他首次就 Ajax 发表了演讲。2010 年,他从慕尼黑大学获得了信息学博士学位。

自 2009 年以来,他一直在 2ality.com 上撰写关于 Web 开发的博客,并撰写了几本关于 JavaScript 的书籍。他曾为 eBay、美国银行和 O’Reilly Media 等公司进行培训和演讲。

他住在德国慕尼黑。

1.5 致谢

  • 封面:六角形抽象 由 CreativeMagic 提供

评论

二、说明

原文:exploringjs.com/nodejs-shell-scripting/ch_instructions.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 2.1?如何阅读本书

  • 2.2?本书中断言的使用方式


本章包含在阅读本书时有用的信息。

2.1?如何阅读本书

您可以以两种方式阅读本书:

  • 就像一本指南:从头开始阅读并继续阅读。

  • 就像一本参考书:只阅读你感兴趣的章节,跳过其余部分。

本书考虑了这两种方式,因此跳过内容不应该是问题。如果在任何时候书中有相关信息,我会指出它。

2.2?本书中断言的使用方式

始终假定已经进行了以下导入(类似于在 Node.js REPL 中可用非严格的assert):

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

这个模块实现了断言 - 这在本书的示例中经常使用。它们看起来像这样:

// Comparing primitive values:
assert.equal(3 + 4, 7);
assert.equal('abc'.toUpperCase(), 'ABC');

// Comparing objects:
assert.notEqual({prop: 1}, {prop: 1}); // shallow comparison
assert.deepEqual({prop: 1}, {prop: 1}); // deep comparison
assert.notDeepEqual({prop: 1}, {prop: 2}); // deep comparison

评论

第二部分:基础

原文:exploringjs.com/nodejs-shell-scripting/pt_foundations.html

译者:飞龙

协议:CC BY-NC-SA 4.0

下一步:3 开始使用 Node.js

三、开始使用 Node.js

原文:exploringjs.com/nodejs-shell-scripting/ch_getting-started-with-nodejs.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 3.1?获取 Node.js 的帮助

  • 3.2?安装 Node.js 和 npm

  • 3.3?运行 Node.js 代码

    • 3.3.1?在 Node.js REPL 中评估代码

    • 3.3.2?快速打印 JavaScript 表达式的结果

    • 3.3.3?使用 Node.js 代码运行模块

    • 3.3.4?运行剪贴板中的 Node.js 代码


本章介绍了 Node.js 的第一步。

3.1?获取 Node.js 的帮助

  • 在线:

    • 在线文档概述

    • API 文档

    • 命令行选项

  • 命令行:

    • 在线帮助:node -h

    • 打印 Node.js 的版本:node -v

    • 各种 Node.js 组件的打印版本:

      • npm version

      • node -p process.versions

3.2?安装 Node.js 和 npm

Node.js 的安装程序还安装了包管理器 npm。它可以从Node.js 主页下载,并适用于许多操作系统。

3.3?运行 Node.js 代码

3.3.1?在 Node.js REPL 中评估代码

Node.js REPL(读取-求值-打印循环)是一个命令行,我们可以交互式地评估 Node.js 代码。

我们可以在JavaScript 严格模式下启动 Node.js REPL(默认情况下,对于 ESM 模块中的代码,它更安全且已打开):

node --use_strict

如果我们运行node而不带任何参数,Node.js REPL 将不使用严格模式:

node

这是使用 Node.js REPL 的样子(%是 Unix shell 提示,>是 Node.js REPL 提示):

% node
Welcome to Node.js v18.9.0.
Type ".help" for more information.
> path.join('dir', 'sub', 'file.txt')
'dir/sub/file.txt'
>

所有 Node 的内置模块都可以通过 REPL 中的全局变量访问:assertpathfsutil等。

3.3.2?快速打印 JavaScript 表达式的结果

我们可以使用带有选项--print(缩写:-p)的 shell 命令node来打印评估 JavaScript 表达式的结果。类似于 REPL,所有内置模块都可以通过全局变量访问。例如,以下命令打印主目录的路径,并且在 Unix 和 Windows 上都有效:

node -p "os.homedir()"

有关此命令行选项的更多信息,请参见§15.7.7“node --evalnode --print”。

3.3.3?使用 Node.js 代码运行模块

例如,以下模块:

// my-module.mjs
import * as os from 'node:os';
console.log(os.userInfo());

我们可以通过 shell 来运行它:

node my-module.mjs
3.3.4?运行剪贴板中的 Node.js 代码

我们还可以运行我们从剪贴板复制的 Node.js 代码。例如,我们可以从上一节复制my-module.mjs的代码,然后在 macOS 上像这样运行它:

pbpaste | node --input-type=module

选项--input-type=module告诉 Node.js 将从标准输入接收的代码解释为模块。除其他外,这使我们可以使用import

macOS shell 命令pbpaste将剪贴板的内容发送到标准输出。其他操作系统也有类似的 shell 命令:

  • Windows 命令行:powershell get-clipboard

  • Windows PowerShell:get-clipboard

  • Linux:xclip

评论

四、Node.js 概述:架构、API、事件循环、并发性

原文:exploringjs.com/nodejs-shell-scripting/ch_nodejs-overview.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 4.1 Node.js 平台

    • 4.1.1 全局 Node.js 变量

    • 4.1.2 内置 Node.js 模块

    • 4.1.3 Node.js 函数的不同风格

  • 4.2 Node.js 事件循环

    • 4.2.1 运行到完成使代码更简单

    • 4.2.2 为什么 Node.js 代码在单线程中运行?

    • 4.2.3 真实的事件循环有多个阶段

    • 4.2.4 Next-tick 任务和微任务

    • 4.2.5 比较直接调度任务的不同方式

    • 4.2.6 Node.js 应用程序何时退出?

  • 4.3 libuv:处理 Node.js 异步 I/O(以及更多)的跨平台库

    • 4.3.1 libuv 如何处理异步 I/O

    • 4.3.2 libuv 如何处理阻塞 I/O

    • 4.3.3 libuv 处理异步 I/O 之外的功能

  • 4.4 通过用户代码逃离主线程

    • 4.4.1 工作线程

    • 4.4.2 集群

    • 4.4.3 子进程

  • 4.5 本章的来源

    • 4.5.1 致谢

本章概述了 Node.js 的工作原理:

  • 它的架构是什么样的。

  • 它的 API 是如何结构化的。

    • 全局变量和内置模块的一些亮点。
  • 它如何通过事件循环在单线程中运行 JavaScript。

  • 在这个平台上并发 JavaScript 的选项。

4.1 Node.js 平台

以下图表概述了 Node.js 的结构:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Node.js 应用程序可用的 API 包括:

  • ECMAScript 标准库(它是语言的一部分)

  • Node.js 的 API(不是语言本身的一部分):

    • 一些 API 是通过全局变量提供的:

      • 特别是跨平台的 web API,比如fetchCompressionStream属于这一类别。

      • 但是一些仅适用于 Node.js 的 API 也是全局的,例如process

    • 其余的 Node.js API 是通过内置模块提供的 - 例如,'node:path'(处理文件系统路径的函数和常量)和'node:fs'(与文件系统相关的功能)。

Node.js 的 API 部分是用 JavaScript 实现的,部分是用 C++实现的。后者是为了与操作系统进行接口。

Node.js 通过嵌入的 V8 JavaScript 引擎运行 JavaScript(与 Google 的 Chrome 浏览器使用的相同引擎)。

4.1.1 全局 Node.js 变量

这些是Node 的全局变量的一些亮点:

  • crypto让我们可以访问与 web 兼容的crypto API。

  • console与浏览器中的全局变量(console.log()等)有很多重叠。

  • fetch()让我们可以使用Fetch 浏览器 API。

  • process包含一个类Process的实例,并且让我们访问命令行参数、标准输入、标准输出等。

  • structuredClone()是一个兼容浏览器的用于克隆对象的函数。

  • URL是一个处理 URL 的兼容浏览器的类。

在本章中还提到了更多的全局变量。

4.1.1.1?使用模块而不是全局变量

以下内置模块提供了全局变量的替代方案:

  • 'node:console'是全局变量console的替代方案:

    console.log('Hello!');
    
    import {log} from 'node:console';
    log('Hello!');
    
  • 'node:process'是全局变量process的替代方案:

    console.log(process.argv);
    
    import {argv} from 'node:process';
    console.log(process.argv);
    

原则上,使用模块比使用全局变量更清晰。然而,使用全局变量consoleprocess是已经建立的模式,偏离这些模式也有缺点。

4.1.2?内置的 Node.js 模块

Node 的大多数 API 都是通过模块提供的。以下是一些经常使用的模块(按字母顺序排列):

  • 'node:assert/strict':断言是检查条件是否满足并在不满足时报告错误的函数。它们可以用于应用程序代码和单元测试。这是使用此 API 的一个例子:

    import * as assert from 'node:assert/strict';
    assert.equal(3 + 4, 7);
    assert.equal('abc'.toUpperCase(), 'ABC');
    
    assert.deepEqual({prop: true}, {prop: true}); // deep comparison
    assert.notEqual({prop: true}, {prop: true}); // shallow comparison
    
  • 'node:child_process' 用于同步或在单独的进程中运行本机命令。该模块在§12“在子进程中运行 shell 命令”中有描述。

  • 'node:fs' 提供了文件系统操作,如读取、写入、复制和删除文件和目录。更多信息,请参见§8“在 Node.js 上处理文件系统”。

  • 'node:os' 包含特定于操作系统的常量和实用函数。其中一些在§7“在 Node.js 上处理文件系统路径和文件 URL”中有解释。

  • 'node:path' 是一个用于处理文件系统路径的跨平台 API。它在§7“在 Node.js 上处理文件系统路径和文件 URL”中有描述。

  • 'node:stream' 包含了一个特定于 Node.js 的流 API,这些流在§9“原生 Node.js 流”中有解释。

    • Node.js 还支持跨平台的 Web 流 API,这是§10“在 Node.js 上使用 Web 流”的主题。
  • 'node:util' 包含各种实用函数。

    • 函数util.parseArgs()在§16“使用util.parseArgs()解析命令行参数”中有描述。

模块'node:module'包含函数builtinModules(),它返回一个包含所有内置模块的规范符号的数组:

import * as assert from 'node:assert/strict';
import {builtinModules} from 'node:module';
// Remove internal modules (whose names start with underscores)
const modules = builtinModules.filter(m => !m.startsWith('_'));
modules.sort();
assert.deepEqual(
 modules.slice(0, 5),
 [
 'assert',
 'assert/strict',
 'async_hooks',
 'buffer',
 'child_process',
 ]
);
4.1.3?Node.js 函数的不同风格

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

import * as fs from 'node:fs';

Node 的函数有三种不同的风格。让我们以内置模块'node:fs'为例:

  • 使用普通函数的同步风格 - 例如:

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

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

4.1.3.1?同步函数

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

try {
 const result = fs.readFileSync('/etc/passwd', {encoding: 'utf-8'});
 console.log(result);
} catch (err) {
 console.error(err);
}
4.1.3.2?基于 Promise 的函数

基于 Promise 的函数返回用结果实现的 Promise,并用错误拒绝:

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

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

注意第 A 行中的模块说明符:基于 Promise 的 API 位于不同的模块中。

有关 Promise 的更多详细信息,请参阅“JavaScript for impatient programmers”。

4.1.3.3?基于回调的函数

基于回调的函数将结果和错误传递给它们的最后一个参数:

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

这种风格在Node.js 文档中有更详细的解释。

4.2?Node.js 事件循环

默认情况下,Node.js 在单个线程中执行所有 JavaScript,即主线程。主线程不断运行事件循环 - 一个执行 JavaScript 块的循环。每个块都是一个回调,可以被视为一个合作调度的任务。第一个任务包含我们使用的代码(来自模块或标准输入)启动 Node.js。其他任务通常稍后添加,原因是:

  • 手动添加任务的代码

  • 与文件系统进行 I/O(输入或输出),与网络套接字等。

  • 等等。

事件循环的第一个近似值如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

也就是说,主线程运行类似于以下代码:

while (true) { // event loop
 const task = taskQueue.dequeue(); // blocks
 task();
}

事件循环从任务队列中取出回调并在主线程中执行它们。如果任务队列为空,则出队阻塞(暂停主线程)。

稍后我们将探讨两个主题:

  • 如何从事件循环中退出。

  • 如何绕过 JavaScript 在单个线程中运行的限制。

为什么这个循环被称为事件循环?许多任务是响应事件添加的,例如操作系统发送的事件,当输入数据准备好被处理时。

回调是如何添加到任务队列中的?这些是常见的可能性:

  • JavaScript 代码可以将任务添加到队列中,以便稍后执行。

  • 事件发射器(事件源)触发事件时,事件监听器的调用将被添加到任务队列中。

  • Node.js API 中的基于回调的异步操作遵循这种模式:

    • 我们请求某些东西,并给 Node.js 一个回调函数,它可以用来向我们报告结果。

    • 最终,操作要么在主线程中运行,要么在外部线程中运行(稍后详细介绍)。

    • 完成后,回调的调用将被添加到任务队列中。

以下代码显示了异步回调操作的实际操作。它从文件系统中读取文本文件:

import * as fs from 'node:fs';

function handleResult(err, result) {
 if (err) {
 console.error(err);
 return;
 }
 console.log(result); // (A)
}
fs.readFile('reminder.txt', 'utf-8',
 handleResult
);
console.log('AFTER'); // (B)

这是输出:

AFTER
Don’t forget!

fs.readFile()在另一个线程中执行读取文件的代码。在这种情况下,代码成功并将此回调添加到任务队列中:

() => handleResult(null, 'Don’t forget!')
4.2.1?运行到完成使代码更简单

Node.js 运行 JavaScript 代码的一个重要规则是:每个任务在其他任务运行之前都会完成(“运行到完成”)。我们可以在上一个示例中看到这一点:'AFTER’在 B 行之前被记录,因为初始任务在调用handleResult()的任务运行之前完成。

运行到完成意味着任务的生命周期不重叠,我们不必担心共享数据在后台被更改。这简化了 Node.js 代码。下一个示例演示了这一点。它实现了一个简单的 HTTP 服务器:

// server.mjs
import * as http from 'node:http';

let requestCount = 1;
const server = http.createServer(
 (_req, res) => { // (A)
 res.writeHead(200);
 res.end('This is request number ' + requestCount); // (B)
 requestCount++; // (C)
 }
);
server.listen(8080);

我们通过node server.mjs运行此代码。之后,代码启动并等待 HTTP 请求。我们可以通过使用 Web 浏览器访问http://localhost:8080来发送请求。每次重新加载该 HTTP 资源时,Node.js 会调用从 A 行开始的回调函数。它提供了变量requestCount当前值的消息(B 行),并增加它(C 行)。

回调函数的每次调用都是一个新任务,变量requestCount在任务之间共享。由于运行到完成,它很容易读取和更新。不需要与其他同时运行的任务同步,因为没有其他任务。

4.2.2?为什么 Node.js 代码在单个线程中运行?

为什么 Node.js 代码默认在单个线程(带有事件循环)中运行?这有两个好处:

  • 正如我们已经看到的,如果只有一个线程,任务之间共享数据会更简单。

  • 在传统的多线程代码中,需要较长时间才能完成的操作会阻塞当前线程,直到操作完成。此类操作的示例包括读取文件或处理 HTTP 请求。执行许多此类操作是昂贵的,因为每次都必须创建一个新线程。使用事件循环,每次操作的成本更低,特别是如果每个操作都不做太多。这就是为什么基于事件循环的 Web 服务器可以处理比基于线程的服务器更高的负载。

鉴于 Node 的一些异步操作在主线程之外的线程中运行(稍后会详细介绍),并通过任务队列向 JavaScript 报告,Node.js 实际上并不是单线程的。相反,我们使用单个线程来协调并发和异步运行的操作(在主线程中)。

这就是我们对事件循环的第一次了解。如果您只需要一个表面的解释,可以随意跳过本节的其余部分。继续阅读以了解更多细节。

4.2.3?真实的事件循环有多个阶段

真实的事件循环有多个任务队列,它从中读取多个阶段(您可以在 GitHub 存储库nodejs/node中查看一些 JavaScript 代码)。以下图表显示了其中最重要的阶段:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图表中显示的事件循环阶段的作用是什么?

  • “定时器”阶段调用了定时任务,这些任务是通过以下方式添加到其队列中的:

    • setTimeout(task, delay=1)会在delay毫秒后运行回调函数task

    • setInterval(task, delay=1)会重复运行回调函数task,每次暂停持续delay毫秒。

  • “轮询”阶段检索和处理 I/O 事件,并从其队列中运行与 I/O 相关的任务。

  • “检查”阶段(“立即”阶段)执行通过以下方式安排的任务:

    • setImmediate(task)会尽快运行回调函数task(“轮询”阶段之后“立即”)。

每个阶段运行直到其队列为空,或者直到处理了最大数量的任务。除了“轮询”阶段外,每个阶段在处理其运行期间添加的任务之前会等待其下一个轮次。

4.2.3.1?“轮询”阶段
  • 如果轮询队列不为空,轮询阶段将遍历并运行其任务。

  • 一旦轮询队列为空:

    • 如果有setImmediate()任务,处理将进入“检查”阶段。

    • 如果有准备好的定时器任务,处理将进入“定时器”阶段。

    • 否则,此阶段将阻塞整个主线程,并等待直到将新任务添加到轮询队列(或直到此阶段结束,见下文)。这些任务会立即处理。

如果此阶段花费的时间超过系统相关的时间限制,它将结束并运行下一个阶段。

4.2.4?下一个任务和微任务

在每次调用任务后,会运行一个“子循环”,其中包括两个阶段:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

子阶段处理:

  • 通过 process.nextTick()排队的 next-tick 任务。

  • Microtasks,通过 queueMicrotask()、Promise reactions 等方式加入队列。

Next-tick 任务是 Node.js 特有的,Microtasks 是跨平台的 Web 标准(参见MDN 的支持表)。

这个子循环一直运行,直到两个队列都为空。在其运行期间添加的任务会立即处理 - 子循环不会等到下一轮才执行。

4.2.5 比较直接调度任务的不同方式

我们可以使用以下函数和方法将回调添加到其中一个任务队列中:

  • 定时任务(“timers”阶段)

    • setTimeout()(Web 标准)

    • setInterval()(Web 标准)

  • 非定时任务(“check”阶段)

    • setImmediate()(Node.js 特有)
  • 在当前任务之后立即运行的任务:

    • process.nextTick()(Node.js 特有)

    • queueMicrotask():(Web 标准)

需要注意的是,通过延迟计划任务时,我们指定了任务将运行的最早可能时间。Node.js 并不总是能够在准确的预定时间运行它们,因为它只能在任务之间检查是否有定时任务到期。因此,长时间运行的任务可能会导致定时任务延迟。

4.2.5.1 Next-tick 任务和 microtasks vs. normal tasks

考虑以下代码:

function enqueueTasks() {
 Promise.resolve().then(() => console.log('Promise reaction 1'));
 queueMicrotask(() => console.log('queueMicrotask 1'));
 process.nextTick(() => console.log('nextTick 1'));
 setImmediate(() => console.log('setImmediate 1')); // (A)
 setTimeout(() => console.log('setTimeout 1'), 0);

 Promise.resolve().then(() => console.log('Promise reaction 2'));
 queueMicrotask(() => console.log('queueMicrotask 2'));
 process.nextTick(() => console.log('nextTick 2'));
 setImmediate(() => console.log('setImmediate 2')); // (B)
 setTimeout(() => console.log('setTimeout 2'), 0);
}

setImmediate(enqueueTasks);

我们使用 setImmediate()来避免 ESM 模块的一个特殊情况:它们在 microtasks 中执行,这意味着如果我们在 ESM 模块的顶层排队 microtasks,它们会在 next-tick 任务之前运行。正如我们将在接下来看到的,在大多数其他情境中是不同的。

这是前面代码的输出:

nextTick 1
nextTick 2
Promise reaction 1
queueMicrotask 1
Promise reaction 2
queueMicrotask 2
setTimeout 1
setTimeout 2
setImmediate 1
setImmediate 2

观察:

  • 所有 next-tick 任务都会在 enqueueTasks()之后立即执行。

  • 它们后面是所有的 microtasks,包括 Promise reactions。

  • “timers”阶段在 immediate 阶段之后。这时定时任务被执行。

  • 我们在 immediate(“check”)阶段(A 行和 B 行)添加了 immediate 任务。它们出现在输出的最后,这意味着它们不是在当前阶段执行的,而是在下一个 immediate 阶段执行的。

4.2.5.2 在它们的阶段排队 next-tick 任务和 microtasks

下一个代码检查了如果我们在 next-tick 阶段排队 next-tick 任务,以及在 microtask 阶段排队 microtask 会发生什么:

setImmediate(() => {
 setImmediate(() => console.log('setImmediate 1'));
 setTimeout(() => console.log('setTimeout 1'), 0);

 process.nextTick(() => {
 console.log('nextTick 1');
 process.nextTick(() => console.log('nextTick 2'));
 });

 queueMicrotask(() => {
 console.log('queueMicrotask 1');
 queueMicrotask(() => console.log('queueMicrotask 2'));
 process.nextTick(() => console.log('nextTick 3'));
 });
});

这是输出:

nextTick 1
nextTick 2
queueMicrotask 1
queueMicrotask 2
nextTick 3
setTimeout 1
setImmediate 1

观察:

  • Next-tick 任务会首先执行。

  • “nextTick 2”在 next-tick 阶段排队并立即执行。只有在 next-tick 队列为空时,执行才会继续。

  • 对于 microtasks 也是如此。

  • 我们在 microtask 阶段排队了“nextTick 3”,执行循环回到了 next-tick 阶段。这些子阶段会重复,直到它们的队列都为空。然后执行才会移动到下一个全局阶段:首先是“timers”阶段(“setTimeout 1”)。然后是 immediate 阶段(“setImmediate 1”)。

4.2.5.3 饿死事件循环阶段

以下代码探讨了哪种类型的任务可以通过无限递归饿死事件循环阶段(阻止它们运行):

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

function timers() { // OK
 setTimeout(() => timers(), 0);
}
function immediate() { // OK
 setImmediate(() => immediate());
}

function nextTick() { // starves I/O
 process.nextTick(() => nextTick());
}

function microtasks() { // starves I/O
 queueMicrotask(() => microtasks());
}

timers();
console.log('AFTER'); // always logged
console.log(await fs.readFile('./file.txt', 'utf-8'));

“timers”阶段和 immediate 阶段不会执行在它们的阶段排队的任务。这就是为什么 timers()和 immediate()不会饿死 fs.readFile(),后者在“poll”阶段报告回来(这里也有一个 Promise reaction,但我们在这里忽略它)。

由于 next-tick 任务和 microtasks 的调度方式,nextTick()和 microtasks()都会阻止最后一行的输出。

4.2.6 Node.js 应用何时退出?

在事件循环的每次迭代结束时,Node.js 都会检查是否是退出的时候。它会保持待处理的超时(定时任务)的引用计数:

  • 通过 setImmediate()、setInterval()或 setTimeout()调度定时任务会增加引用计数。

  • 运行定时任务会减少引用计数。

如果引用计数在事件循环迭代结束时为零,Node.js 会退出。

我们可以看到在以下示例中:

function timeout(ms) {
 return new Promise(
 (resolve, _reject) => {
 setTimeout(resolve, ms); // (A)
 }
 );
}
await timeout(3_000);

Node.js 等待timeout()返回的 Promise 被实现。为什么?因为我们在 A 行安排的任务使事件循环保持活动状态。

相比之下,创建 Promise 不会增加引用计数:

function foreverPending() {
 return new Promise(
 (_resolve, _reject) => {}
 );
}
await foreverPending(); // (A)

在这种情况下,在 A 行的await期间,执行暂时离开了这个(主)任务。在事件循环结束时,引用计数为零,Node.js 退出。但是,退出不成功。也就是说,退出代码不是 0,而是 13(“未完成的顶级等待”)。

我们可以手动控制超时是否保持事件循环活动:默认情况下,通过setImmediate()setInterval()setTimeout()安排的任务在挂起状态时会保持事件循环活动。这些函数返回class Timeout的实例,其方法.unref()更改了默认设置,使得活动的超时不会阻止 Node.js 退出。方法.ref()恢复默认设置。

Tim Perry 提到了.unref()的一个用例:他的库使用setInterval()重复运行后台任务。该任务阻止应用程序退出。他通过.unref()解决了这个问题。

4.3 libuv:处理 Node.js 异步 I/O(以及更多)的跨平台库

libuv 是用 C 编写的库,支持许多平台(Windows,macOS,Linux 等)。Node.js 使用它来处理 I/O 和更多内容。

4.3.1 libuv 如何处理异步 I/O

网络 I/O 是异步的,不会阻塞当前线程。这种 I/O 包括:

  • TCP

  • UDP

  • 终端 I/O

  • 管道(Unix 域套接字,Windows 命名管道等)

为了处理异步 I/O,libuv 使用本机内核 API 并订阅 I/O 事件(Linux 上的 epoll;BSD Unix 包括 macOS 上的 kqueue;SunOS 上的事件端口;Windows 上的 IOCP)。然后在发生时得到通知。所有这些活动,包括 I/O 本身,都发生在主线程上。

4.3.2 libuv 如何处理阻塞 I/O

一些本机 I/O API 是阻塞的(不是异步的)-例如,文件 I/O 和一些 DNS 服务。libuv 从线程池中的线程调用这些 API(所谓的“工作池”)。这使得主线程可以异步使用这些 API。

4.3.3 libuv 功能超出 I/O

libuv 不仅帮助 Node.js 处理 I/O。其他功能包括:

  • 在线程池中运行任务

  • 信号处理

  • 高分辨率时钟

  • 线程和同步原语

另外,libuv 有自己的事件循环,你可以在 GitHub 存储库libuv/libuv中查看其源代码(函数uv_run())。

4.4 通过用户代码逃离主线程

如果我们想让 Node.js 对 I/O 保持响应,我们应该避免在主线程任务中执行长时间运行的计算。有两种选择:

  • 分区:我们可以将计算分成较小的部分,并通过setImmediate()运行每个部分。这使得事件循环能够在这些部分之间执行 I/O。

    • 一个优点是我们可以在每个部分执行 I/O。

    • 一个缺点是我们仍然减慢了事件循环。

  • 卸载:我们可以在不同的线程或进程中执行我们的计算。

    • 缺点是我们不能从主线程以外的线程执行 I/O,与外部代码的通信变得更加复杂。

    • 优点是我们不会减慢事件循环,我们可以更好地利用多个处理器核心,并且其他线程中的错误不会影响主线程。

下面的小节涵盖了一些卸载的选项。

4.4.1 Worker 线程

Worker Threads实现了跨平台 Web Workers API,但有一些区别-例如:

  • 必须从模块导入 Worker Threads,通过全局变量访问 Web Workers。

  • 在工作线程中,通过浏览器全局对象的方法来监听消息和发布消息。在 Node.js 中,我们使用parentPort进行导入。

  • 我们可以从工作线程中使用大多数 Node.js API。在浏览器中,我们的选择更有限(无法使用 DOM 等)。

  • 在 Node.js 中,可以传输更多的对象(所有类扩展内部类JSTransferable的对象)比在浏览器中。

一方面,Worker Threads 确实是线程:它们比进程更轻量,并在同一进程中运行。

另一方面:

  • 每个工作线程都运行自己的事件循环。

  • 每个工作线程都有自己的 JavaScript 引擎实例和自己的 Node.js 实例 - 包括单独的全局变量。

    • (具体来说,每个工作线程都是一个V8 隔离,它有自己的 JavaScript 堆,但与其他线程共享操作系统堆。)
  • 线程之间共享数据是有限的:

    • 我们可以通过 SharedArrayBuffers 共享二进制数据/数字。

    • Atomics提供原子操作和同步原语,有助于使用 SharedArrayBuffers 时。

    • 通道消息 API允许我们通过双向通道发送数据(“消息”)。数据可以是克隆(复制)或传输(移动)。后者更有效,并且仅受少数数据结构支持。

更多信息,请参阅worker threads 的 Node.js 文档。

4.4.2 集群

Cluster是一个 Node.js 特定的 API。它允许我们运行 Node.js 进程的集群,我们可以用来分发工作负载。这些进程是完全隔离的,但共享服务器端口。它们可以通过通道传递 JSON 数据进行通信。

如果我们不需要进程隔离,可以使用更轻量的 Worker Threads。

4.4.3 子进程

Child process是另一个 Node.js 特定的 API。它允许我们生成运行本机命令(通常通过本机 shell)的新进程。此 API 在§12“在子进程中运行 shell 命令”中有介绍。

4.5 本章的来源

Node.js 事件循环:

  • Node.js 文档:“Node.js 事件循环,定时器和process.nextTick()

  • “要真正理解 Node.js 事件循环,你应该知道的事情” by Daniel Khan

  • “Node.js 如何决定是退出事件循环还是再次运行?” by Mark Meyer

事件循环的视频(刷新了本章所需的一些背景知识):

  • “Node 的事件循环从内部到外部”(由 Sam Roberts)解释了为什么操作系统增加了对异步 I/O 的支持;哪些操作是异步的,哪些不是(必须在线程池中运行)等。

  • “Node.js 事件循环:并非单线程”(由 Bryan Hughes)包含了多任务处理的简要历史(协作多任务处理,抢占式多任务处理,对称多线程,异步多任务处理);进程与线程;同步运行 I/O 与在线程池中运行等。

libuv:

  • libuv 文档:

    • “设计概述”

    • “libuv 基础知识”

  • “深入了解 libuv” 由 Saúl Ibarra Corretgé

  • “I/O 多路复用(select vs. poll vs. epoll/kqueue)-问题和算法” 由 Nima Aghdaii

  • “开发人员启动 I/O 操作。接下来会发生什么,你将不会相信。” 由 Colin J. Ihrig

    • 跟踪 JavaScript 函数调用,从 JavaScript 到 Node 的核心再到 libuv,然后返回。

JavaScript 并发:

  • 在 Node.js 文档中“不要阻塞事件循环(或工作线程池)”中的部分“不阻塞事件循环的复杂计算”

  • “理解 Node.js 中的工作线程” 由 Liz Parody

  • “2021 年 Web Workers 的现状” 由 Surma

  • 视频“Node.js:通往工作线程的道路” 由 Anna Henningsen

4.5.1?致谢
  • 我非常感谢Dominic Elm审阅本章并提供重要反馈。

评论

五、包:JavaScript 的软件分发单元

原文:exploringjs.com/nodejs-shell-scripting/ch_packages.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 5.1?什么是包?

    • 5.1.1?发布包:包注册表,包管理器,包名称
  • 5.2?包的文件系统布局

    • 5.2.1?package.json

    • 5.2.2?package.json"dependencies"属性

    • 5.2.3?package.json"bin"属性

    • 5.2.4?package.json"license"属性

  • 5.3?归档和安装包

    • 5.3.1?从 git 安装包

    • 5.3.2?创建新包并安装依赖项

  • 5.4?通过规范引用模块

    • 5.4.1?模块规范中的文件扩展名
  • 5.5?Node.js 中的模块规范

    • 5.5.1?在 Node.js 中解析模块规范

    • 5.5.2?包导出:控制其他包看到的内容

    • 5.5.3?包导入

    • 5.5.4?node:协议导入


本章解释了 npm 包是什么以及它们如何与 ESM 模块交互。

**必需的知识:**我假设您对 ECMAScript 模块的语法略有了解。如果没有,您可以阅读“JavaScript for impatient programmers”中的章节“modules”。

5.1?什么是包?

在 JavaScript 生态系统中,是组织软件项目的一种方式:它是一个具有标准布局的目录。包可以包含各种文件 - 例如:

  • 用 JavaScript 编写的 Web 应用程序,部署在服务器上

  • JavaScript 库(用于 Node.js,浏览器,所有 JavaScript 平台等)

  • 除 JavaScript 之外的其他编程语言的库:TypeScript,Rust 等

  • 单元测试(例如包中的库)

  • Bin scripts – 基于 Node.js 的 shell 脚本 – 例如,开发工具,如编译器,测试运行器和文档生成器

  • 许多其他类型的工件

包可以依赖于其他包(称为依赖项),其中包含:

  • 包的 JavaScript 代码所需的库

  • 开发过程中使用的 shell 脚本

  • 等等。

包的依赖项安装在该包内部(我们很快就会看到)。

包之间的一个常见区别是:

  • 已发布的包可以由我们安装:

    • 全局安装:我们可以全局安装它们,以便它们的 bin 脚本在命令行中可用。

    • 本地安装:我们可以将它们作为依赖项安装到我们自己的包中。它们的 bin 脚本可以在本地使用(我们很快就会看到)。

  • 未发布的包永远不会成为其他包的依赖项,但它们本身有依赖项。例如,部署到服务器的 Web 应用程序。

下一小节将解释如何发布包。

5.1.1?发布包:包注册表,包管理器,包名称

发布包的主要方式是将其上传到包注册表 - 一个在线软件仓库。事实上的标准是npm 注册表,但这不是唯一的选择。例如,公司可以托管自己的内部注册表。

包管理器是一个命令行工具,它从注册表(或其他来源)下载包并在本地或全局安装它们。如果一个包包含 bin 脚本,它也会在本地或全局提供这些脚本。

最流行的包管理器称为npm,并与 Node.js 捆绑在一起。它的名称最初代表“Node Package Manager”。后来,当 npm 和 npm 注册表不仅用于 Node.js 包时,定义被更改为“npm 不是一个包管理器”(来源)。

还有其他流行的包管理器,如 yarn 和 pnpm。所有这些包管理器默认使用 npm 注册表。

npm 注册表中的每个包都有一个名称。有两种名称:

  • 全局名称在整个注册表中是唯一的。这是两个例子:

    minimatch
    mocha
    
  • 作用域名称由两部分组成:作用域和名称。作用域是全局唯一的,名称在作用域内是唯一的。这是两个例子:

    @babel/core
    @rauschma/iterable
    

    范围从@符号开始,并用斜杠与名称分隔。

5.2?包的文件系统布局

一旦包my-package完全安装,它几乎总是看起来像这样:

my-package/
  package.json
  node_modules/
  [More files]

这些文件系统条目的目的是什么?

  • package.json是每个包都必须拥有的文件:

    • 它包含描述包的元数据(名称、版本、作者等)。

    • 它列出了包的依赖项:它所需的其他包,如库和工具。对于每个依赖项,我们记录:

      • 一系列版本号。不指定特定版本允许升级和依赖项之间的代码共享。

      • 默认情况下,依赖项来自 npm 注册表。但我们也可以指定其他来源:本地目录,GZIP 文件,指向 GZIP 文件的 URL,不同于 npm 的注册表,git 存储库等。

  • node_modules/是包的依赖项安装的目录。每个依赖项也有一个带有其依赖项等的node_modules文件夹。结果是一个依赖项树。

一些包还有文件package-lock.json,它位于package.json旁边:它记录了安装的依赖项的确切版本,并且如果我们通过 npm 添加更多依赖项,它会保持更新。

5.2.1?package.json

这是一个可以通过 npm 创建的起始package.json

{
 "name": "my-package",
 "version": "1.0.0",
 "description": "",
 "main": "index.js",
 "scripts": {
 "test": "echo "Error: no test specified" && exit 1"
 },
 "keywords": [],
 "author": "",
 "license": "ISC"
}

这些属性的目的是什么?

  • 一些属性对于公共包(发布在 npm 注册表上)是必需的:

    • name指定了这个包的名称。

    • version用于版本管理,并遵循语义化版本,由三个用点分隔的数字组成:

      • 主要版本在不兼容的 API 更改时递增。

      • 次要版本在向后兼容的方式下添加功能时递增。

      • 补丁版本在进行了不会真正改变功能的小更改时递增。

  • 公共包的其他属性是可选的:

    • descriptionkeywordsauthor是可选的,使找到包变得更容易。

    • license澄清了这个包如何被使用。如果包在任何方面是公共的,提供这个值是有意义的。“选择一个开源许可证”可以帮助做出这个选择。

  • main是用于包含库代码的包的属性。它指定了“是”包的模块(在本章后面解释)。

  • scripts是用于设置package scripts的属性——开发时 shell 命令的缩写。这些可以通过npm run执行。例如,脚本test可以通过npm run test执行。有关此主题的更多信息,请参阅§15“通过 npm 软件包脚本运行跨平台任务”。

其他有用的属性:

  • dependencies列出了软件包的依赖关系。其格式很快就会解释。

  • devDependencies是仅在开发过程中需要的依赖关系。

  • 以下设置意味着所有具有扩展名.js的文件都被解释为 ECMAScript 模块。除非我们处理旧代码,否则添加它是有意义的:

    "type": "module"
    
  • bin列出了 npm 将其安装为 shell 脚本的软件包内的 Node.js 模块的bin scripts。其格式很快就会解释。

  • license指定软件包的许可证。其格式很快就会解释。

  • 通常,nameversion属性是必需的,如果缺少它们,npm 会发出警告。但是,我们可以通过以下设置更改:

    "private": true
    

    这可以防止软件包意外发布,并允许我们省略名称和版本。

有关package.json的更多信息,请参阅npm 文档。

5.2.2 package.json的属性"dependencies"

这是package.json文件中的依赖关系的样子:

"dependencies": {
 "minimatch": "?.1.0",
 "mocha": "1?.0.0"
}

属性记录了软件包的名称和其版本的约束。

版本本身遵循语义化版本标准。它们由点分隔的最多三个数字组成(第二个和第三个数字是可选的,默认为零):

  1. 主要版本:当软件包以不兼容的方式更改时,此数字会更改。

  2. 次要版本:当以向后兼容的方式添加功能时,此数字会更改。

  3. 补丁版本:当进行向后兼容的错误修复时,此数字会更改。

有关 Node 的版本范围,请参阅semver 存储库。示例包括:

  • 没有任何额外字符的特定版本意味着安装的版本必须完全匹配:

    "pkg1": "2.0.1",
    
  • major.minor.xmajor.x表示数字组件必须匹配,x或省略的组件可以具有任何值:

    "pkg2": "2.x",
    "pkg3": "3.3.x",
    
  • *匹配任何版本:

    "pkg4": "*",
    
  • >=version表示安装的版本必须是version或更高:

    "pkg5": ">=1.0.2",
    
  • <=version表示安装的版本必须是version或更低:

    "pkg6": "<=2.3.4",
    
  • version1-version2>=version1 <=version2相同:

    "pkg7": "1.0.0 - 2.9999.9999",
    
  • ^version(如前面的示例中使用的)是一个caret range,意味着安装的版本可以是version或更高,但不得引入破坏性更改。也就是说,主要版本必须相同:

    "pkg8": "?.17.21",
    
5.2.3 package.json的属性"bin"

这是我们告诉 npm 将模块安装为 shell 脚本的方法:

"bin": {
 "my-shell-script": "./src/shell/my-shell-script.mjs",
 "another-script": "./src/shell/another-script.mjs"
}

如果我们使用全局安装具有此"bin"值的软件包,Node.js 会确保命令my-shell-scriptanother-script在命令行上可用。

如果我们在本地安装软件包,可以在软件包脚本中使用这两个命令,或者通过npx 命令使用。

"bin"的值也可以是字符串:

{
 "name": "my-package",
 "bin": "./src/main.mjs"
}

这是对的缩写:

{
 "name": "my-package",
 "bin": {
 "my-package": "./src/main.mjs"
 }
}
5.2.4 package.json的属性"license"

属性"license"的值始终是一个带有 SPDX 许可证 ID 的字符串。例如,以下值拒绝其他人以任何条款使用软件包(如果软件包未发布,则这很有用):

"license": "UNLICENSED"

SPDX 网站列出了所有可用的许可证 ID。如果您发现很难选择一个,“选择开源许可证”网站可以帮助您——例如,如果您“希望它简单和宽松”,这是建议:

MIT 许可证简短而直接。它允许人们几乎可以为项目做任何他们想做的事情,比如制作和分发闭源版本。

Babel、.NET 和 Rails 使用 MIT 许可证。

你可以像这样使用许可证:

"license": "MIT"

5.3 存档和安装包

npm 注册表中的包通常以两种不同的方式存档:

  • 在开发过程中,它们存储在 git 仓库中。

  • 为了使它们可以通过 npm 安装,它们被上传到 npm 注册表。

无论哪种方式,包都会被存档,不包括它的依赖项 - 我们必须在使用之前安装它们。

如果一个包存储在 git 仓库中:

  • 通常情况下,我们希望每次安装包时都使用相同的依赖树。

    • 这就是为什么通常会包含package-lock.json
  • 我们可以从其他工件中重新生成工件 - 例如,将 TypeScript 文件编译为 JavaScript 文件。

如果一个包发布到 npm 注册表:

  • 它应该灵活地处理其依赖关系,以便升级依赖关系并在依赖树中共享包成为可能。

    • 这就是为什么package-lock.json永远不会上传到 npm 注册表的原因。
  • 它通常包含生成的工件 - 例如,从 TypeScript 文件编译的 JavaScript 文件被包含在内,这样只使用 JavaScript 的人就不必安装 TypeScript 编译器。

开发依赖项(package.json中的devDependencies属性)只在开发过程中安装,而不是在我们从 npm 注册表安装包时安装。

请注意,git 仓库中未发布的包在开发过程中与已发布的包类似处理。

5.3.1 从 git 安装包

要安装一个名为pkg的包,我们克隆它的存储库并:

cd pkg/
npm install

然后执行以下步骤:

  • node_modules被创建并安装依赖项。安装一个依赖项也意味着下载该依赖项并安装它的依赖项(等等)。

  • 有时会执行额外的设置步骤。可以通过package.json配置这些步骤。

如果根包没有package-lock.json文件,则在安装过程中会创建该文件(如前所述,依赖项没有此文件)。

在依赖树中,相同的依赖项可能存在多次,可能是不同的版本。有一些方法可以最小化重复,但这超出了本章的范围。

5.3.1.1 重新安装一个包

这是一种(略显粗糙)修复依赖树中问题的方法:

cd pkg/
rm -rf node_modules/
rm package-lock.json
npm install

请注意,这可能导致安装不同的、更新的包。我们可以通过不删除package-lock.json来避免这种情况。

5.3.2 创建一个新的包并安装依赖项

有许多工具和技术可以设置新的包。这是一个简单的方法:

mkdir my-package
cd my-package/
npm init --yes

之后,目录看起来像这样:

my-package/
  package.json

package.json中包含了我们已经看到的起始内容。

5.3.2.1 安装依赖项

现在,my-package没有任何依赖项。假设我们想要使用库lodash-es。这是我们将其安装到我们的包中的方法:

npm install lodash-es

该命令执行以下步骤:

  • 该包被下载到my-package/node_modules/lodash-es中。

  • 也会安装它的依赖项。然后是它的依赖项的依赖项。等等。

  • package.json中添加了一个新属性:

    "dependencies": {
     "lodash-es": "?.17.21"
    }
    
  • package-lock.json会更新为安装的确切版本。

5.4 通过标识符引用模块

ECMAScript 模块中的代码通过import语句(A 行和 B 行)访问:

// Static import
import {namedExport} from 'https://example.com/some-module.js'; // (A)
console.log(namedExport);

// Dynamic import
import('https://example.com/some-module.js') // (B)
.then((moduleNamespace) => {
 console.log(moduleNamespace.namedExport);
});

静态导入和动态导入都使用模块标识符来引用模块:

  • A 行中from后面的字符串。

  • B 行中的字符串参数。

有三种类型的模块标识符:

  • 绝对标识符是完整的 URL - 例如:

    'https://www.unpkg.com/browse/[email protected]/browser.mjs'
    'file:///opt/nodejs/config.mjs'
    

    绝对标识符主要用于访问直接托管在网络上的库。

  • 相对标识符是相对 URL(以'/''./''../'开头) - 例如:

    './sibling-module.js'
    '../module-in-parent-dir.mjs'
    '../../dir/other-module.js'
    

    每个模块都有一个 URL,其协议取决于其位置(file:https:等)。如果它使用相对标识符,JavaScript 会通过将其解析为模块的 URL 来将该标识符转换为完整的 URL。

    相对标识符主要用于访问同一代码库中的其他模块。

  • 裸 specifier是以包的名称开头的路径(没有协议和域)。这些名称可以选择后跟子路径

    'some-package'
    'some-package/sync'
    'some-package/util/files/path-tools.js'
    

    裸 specifier 也可以指向具有作用域名称的包:

    '@some-scope/scoped-name'
    '@some-scope/scoped-name/async'
    '@some-scope/scoped-name/dir/some-module.mjs'
    

    每个裸 specifier 都指向包内的一个模块;如果没有子路径,则指向其包的指定“主”模块。裸 specifier 永远不会直接使用,而是总是解析 - 转换为绝对 specifier。解析的工作方式取决于平台。我们很快就会了解更多。

5.4.1 模块 specifier 中的文件扩展名
  • 绝对 specifier 和相对 specifier 总是带有文件扩展名-通常是.js.mjs

  • 有三种裸 specifier 的样式:

    • 样式 1:没有子路径

    • 样式 2:没有文件扩展名的子路径。在这种情况下,子路径的作用类似于包名称的修饰符:

      'my-parser/sync'
      'my-parser/async'
      
      'assertions'
      'assertions/strict'
      
    • 样式 3:带有文件扩展名的子路径。在这种情况下,包被视为模块的集合,子路径指向其中一个:

      'large-package/misc/util.js'
      'large-package/main/parsing.js'
      'large-package/main/printing.js'
      

裸 specifier 样式 3 的注意事项:文件扩展名的解释取决于依赖项,可能与导入包不同。例如,导入包可能对 ESM 模块使用.mjs,对 CommonJS 模块使用.js,而依赖项导出的 ESM 模块可能具有带有文件扩展名.js的裸路径。

5.5 Node.js 中的模块 specifier

让我们看看 Node.js 中模块 specifier 的工作原理。

5.5.1 Node.js 中解析模块 specifier

Node.js 解析算法的工作如下:

  • 参数:

    • 导入模块的 URL

    • 模块 specifier

  • 结果:模块 specifier 的解析 URL

这是算法:

  • 如果 specifier 是绝对的,解析已经完成。三个协议最常见:

    • file:用于本地文件

    • https:用于远程文件

    • node:用于内置模块(稍后讨论)

  • 如果 specifier 是相对的,它将根据导入模块的 URL 进行解析。

  • 如果一个 specifier 是裸的:

    • 如果以'#'开头,则通过在包导入中查找它并解析结果来解析它(稍后将解释)。

    • 否则,它是一个具有以下格式之一的裸 specifier(子路径是可选的):

      • ?package?/sub/path

      • @?scope?/?scoped-package?/sub/path

      解析算法遍历当前目录及其祖先,直到找到一个具有与裸 specifier 开头匹配的子目录node_modules,即:

      • node_modules/?package?/

      • node_modules/@?scope?/?scoped-package?/

      该目录是包的目录。默认情况下,包 ID 后的(可能为空的)子路径被解释为相对于包目录。默认值可以通过下面将要解释的包出口来覆盖。

解析算法的结果必须指向一个文件。这就解释了为什么绝对 specifier 和相对 specifier 总是带有文件扩展名。裸 specifier 大多数情况下没有,因为它们是在包出口中查找的缩写。

模块文件通常具有这些文件扩展名:

  • 如果文件的扩展名为.mjs,它总是一个 ES 模块。

  • 如果文件的扩展名为.js,则最接近的package.json具有此条目,则它是一个 ES 模块:

    • "type": "module"

如果 Node.js 执行通过 stdin、--eval--print提供的代码,我们使用以下命令行选项以便它被解释为 ES 模块:

--input-type=module
5.5.2 包出口:控制其他包看到什么

在本小节中,我们正在处理具有以下文件布局的包:

my-lib/
  dist/
    src/
      main.js
      util/
        errors.js
      internal/
        internal-module.js
    test/

包出口通过package.json中的"exports"属性指定,并支持两个重要功能:

  • 隐藏包的内部:

    • 没有"exports"属性,包my-lib中的每个模块都可以在包名后使用相对路径访问 - 例如:

      'my-lib/dist/src/internal/internal-module.js'
      
    • 一旦属性存在,只能使用其中列出的指定符。其他所有内容都对外部隐藏。

  • 更好的模块指定符:包出口让我们为较短和/或名称更好的模块定义裸指定符子路径。

回想一下裸指定符的三种样式:

  • 样式 1:没有子路径的裸指定符

  • 样式 2:没有扩展名的裸指定符

  • 样式 3:带有扩展名的裸指定符子路径

包出口帮助我们处理所有三种样式

5.5.2.1 样式 1:配置哪个文件代表(包的裸指定符)

package.json

{
 "main": "./dist/src/main.js",
 "exports": {
 ".": "./dist/src/main.js"
 }
}

我们只提供"main"是为了向后兼容(与旧的捆绑器和 Node.js 12 及更旧版本)。否则,"."的条目就足够了。

有了这些包出口,我们现在可以这样从my-lib导入。

import {someFunction} from 'my-lib';

这导入了someFunction()从这个文件:

my-lib/dist/src/main.js
5.5.2.2 样式 2:将不带扩展名的子路径映射到模块文件

package.json

{
 "exports": {
 "./util/errors": "./dist/src/util/errors.js"
 }
}

我们将指定符子路径'util/errors'映射到一个模块文件。这使得以下导入成为可能:

import {UserError} from 'my-lib/util/errors';
5.5.2.3 样式 2:更好的不带扩展名的子路径为子树

前一小节解释了如何为不带扩展名的子路径创建单个映射。还有一种方法可以通过单个条目创建多个这样的映射:

package.json

{
 "exports": {
 "./lib/*": "./dist/src/*.js"
 }
}

任何位于./dist/src/下的文件现在都可以在不带文件扩展名的情况下导入:

import {someFunction} from 'my-lib/lib/main';
import {UserError}    from 'my-lib/lib/util/errors';

请注意这个"exports"条目中的星号:

"./lib/*": "./dist/src/*.js"

这些更多的指令是如何将子路径映射到实际路径,而不是匹配文件路径片段的通配符。

5.5.2.4 样式 3:将带有扩展名的子路径映射到模块文件

package.json

{
 "exports": {
 "./util/errors.js": "./dist/src/util/errors.js"
 }
}

我们将指定符子路径'util/errors.js'映射到一个模块文件。这使得以下导入成为可能:

import {UserError} from 'my-lib/util/errors.js';
5.5.2.5 样式 3:更好的带有扩展名的子路径为子树

package.json

{
 "exports": {
 "./*": "./dist/src/*"
 }
}

在这里,我们缩短了my-package/dist/src下整个子树的模块指定符:

import {InternalError} from 'my-package/util/errors.js';

没有出口,导入语句将是:

import {InternalError} from 'my-package/dist/src/util/errors.js';

请注意这个"exports"条目中的星号:

"./*": "./dist/src/*"

这些不是文件系统通配符,而是如何将外部模块指定符映射到内部模块指定符的指令。

5.5.2.6 暴露子树同时隐藏其中的部分

通过以下技巧,我们暴露了my-package/dist/src/目录中的所有内容,但除了my-package/dist/src/internal/

"exports": {
 "./*": "./dist/src/*",
 "./internal/*": null
}

请注意,这个技巧在不带文件名扩展名的情况下导出子树时也适用。

5.5.2.7 条件包出口

我们还可以使出口条件:然后给定的路径根据包在其中使用的上下文而映射到不同的值。

Node.js vs. 浏览器。 例如,我们可以为 Node.js 和浏览器提供不同的实现:

"exports": {
 ".": {
 "node": "./main-node.js",
 "browser": "./main-browser.js",
 "default": "./main-browser.js"
 }
}

"default"条件在没有其他键匹配时匹配,并且必须放在最后。每当我们区分平台时,建议使用它,因为它负责新的和/或未知的平台。

开发 vs. 生产。 条件包出口的另一个用例是在“开发”和“生产”环境之间切换:

"exports": {
 ".": {
 "development": "./main-development.js",
 "production": "./main-production.js",
 }
}

在 Node.js 中,我们可以这样指定环境:

node --conditions development app.mjs
5.5.3 包导入

包导入让一个包为模块指定符定义缩写,它可以在内部自己使用(其中包出口为其他包定义了缩写)。这是一个例子:

package.json

{
 "imports": {
 "#some-pkg": {
 "node": "some-pkg-node-native",
 "default": "./polyfills/some-pkg-polyfill.js"
 }
 },
 "dependencies": {
 "some-pkg-node-native": "1.2.3"
 }
}

包导入#条件的(具有与条件包出口相同的功能):

  • 如果当前包在 Node.js 上使用,则模块指定符'#some-pkg'指的是包some-pkg-node-native

  • 在其他地方,'#some-pkg'指的是当前包内的./polyfills/some-pkg-polyfill.js文件。

(只有包引入可以引用外部包,包导出不能这样做。)

包引入的用例是什么?

  • 通过相同的模块标识符引用不同的特定于平台的实现模块(如上所示)。

  • 当前包内部模块的别名 - 避免使用相对路径(在嵌套目录中可能会变得复杂)。

在使用打包工具时要小心包引入:这个功能相对较新,你的打包工具可能不支持它。

5.5.4 node:协议导入

Node.js 有许多内置模块,比如’path’和’fs’。它们都可以作为 ES 模块和 CommonJS 模块使用。它们的一个问题是它们可能会被安装在node_modules中的模块覆盖,这既是安全风险(如果意外发生)也是一个问题,如果 Node.js 想要在未来引入新的内置模块并且它们的名称已经被 npm 包占用。

我们可以使用node:协议来明确表示我们想要导入一个内置模块。例如,以下两个导入语句在大多数情况下是等效的(如果没有安装名为’fs’的 npm 模块):

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

使用node:协议的另一个好处是我们立即看到导入的模块是内置的。考虑到有多少内置模块,这在阅读代码时很有帮助。

由于node:标识符具有协议,它们被认为是绝对的。这就是为什么它们不会在node_modules中查找的原因。

评论

六、npm 概述(JavaScript 包管理器)

原文:exploringjs.com/nodejs-shell-scripting/ch_npm-overview.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 6.1?npm 包管理器

  • 6.2?npm 获取帮助

    • 6.2.1?在命令行上获取帮助

    • 6.2.2?在线获取帮助

  • 6.3?常见的 npm 命令

  • 6.4?npm 命令的缩写


6.1?npm 包管理器

npm 注册表是托管 JavaScript 包的事实标准。这些包具有特定的格式,称为npm 包

因此,在 JavaScript 生态系统中,包管理器是一个用于安装 npm 包的命令行工具,可以从 npm 注册表或其他来源获取。

最受欢迎的包管理器称为npm,并与 Node.js 捆绑在一起。它的名称最初代表“Node Package Manager”。后来,当 npm 和 npm 注册表不仅用于 Node.js 包时,定义被更改为“npm 不是一个包管理器”(来源)。

还有其他流行的包管理器,如 yarn 和 pnpm。所有这些包管理器默认都使用 npm 注册表。

我们通过 shell 命令npm使用 npm,它提供了多个子命令,如npm install

6.2?获取 npm 帮助

6.2.1?在命令行上获取帮助

我们可以使用npm命令来解释自身:一方面,有-h选项,可以在npm和 npm 命令之后使用。它提供简要的解释:

npm -h        # brief explanation of `npm`
npm <cmd> -h  # brief explanation of `npm <cmd>`

另一方面,有npm help命令提供更长的解释:

npm help         # brief explanation of `npm` (same as `npm -h`)
npm help npm     # longer explanation of `npm`
npm help <cmd>   # longer explanation of `npm <cmd>`
npm help <topic> # longer explanation of <topic>

帮助主题包括:

  • folders

  • npmrc

  • package.json

6.2.2?在线获取帮助

官方 npm 文档也可以在线获取。

6.3?常见的 npm 命令

这是一些常见命令:

  • npm init “初始化”当前目录为一个包。也就是说,它在其中创建package.json文件。这个命令在§14.3.1 “设置包目录”中有解释。

  • npm install 全局或本地安装 npm 包。在§13 “安装 npm 包和运行 bin 脚本”中有解释。

  • npm publish 将包发布到注册表:它可以创建新包或更新现有包。在§14.5.3 “npm publish: 将包上传到 npm 注册表”中有解释。

  • npm run(简写为npm run-script)执行包脚本。包脚本在§15 “通过 npm 包脚本运行跨平台任务”中有解释。

  • npm uninstall 移除全局或本地安装的包。

  • npm version 打印记录 Node.js 和 npm 各个组件版本的process.versions对象:

    {
     'my-package': '1.0.0', // current package
     npm: '8.15.0',
     node: '18.7.0',
     v8: '10.2.154.13-node.9',
     uv: '1.43.0', // libuv
     ···
     tz: '2022a', // version of tz database
     unicode: '14.0', // version of Unicode standard
     ···
    }
    
  • npx允许我们在不安装它们的情况下运行包中的 bin 脚本。在§13.4 “npx: 在 npm 包中运行 bin 脚本而不安装它们”中有描述。

npm 文档中有所有 npm 命令的列表。

6.4?npm 命令的缩写

许多 npm 命令都有缩写,例如:

npm i npm install
npm rm npm uninstall
npm run npm run-script

对于每个 npm 命令,npm 文档还列出了所有的别名(包括缩写)。

评论

第三部分:Node.js 核心功能

原文:exploringjs.com/nodejs-shell-scripting/pt_nodejs-core.html

译者:飞龙

协议:CC BY-NC-SA 4.0

下一步:7?在 Node.js 上使用文件系统路径和文件 URL

七、使用 Node.js 上的文件系统路径和文件 URL

原文:exploringjs.com/nodejs-shell-scripting/ch_nodejs-path.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 7.1 在 Node.js 上与路径相关的功能

    • 7.1.1 访问'node:path' API 的三种方式
  • 7.2 基本路径概念及其 API 支持

    • 7.2.1 路径段、路径分隔符、路径分隔符

    • 7.2.2 当前工作目录

    • 7.2.3 完全 vs.部分合格路径,解析路径

  • 7.3 通过模块'node:os'获取标准目录的路径

  • 7.4 连接路径

    • 7.4.1 path.resolve(): 连接路径以创建完全合格的路径

    • 7.4.2 path.join(): 连接路径并保留相对路径

  • 7.5 确保路径被规范化、完全合格或相对

    • 7.5.1 path.normalize(): 确保路径被规范化

    • 7.5.2 path.resolve()(一个参数):确保路径被规范化和完全合格

    • 7.5.3 path.relative(): 创建相对路径

  • 7.6 解析路径:提取路径的各个部分(文件名扩展名等)

    • 7.6.1 path.parse(): 创建具有路径部分的对象

    • 7.6.2 path.basename(): 提取路径的基本部分

    • 7.6.3 path.dirname(): 提取路径的父目录

    • 7.6.4 path.extname(): 提取路径的扩展名

  • 7.7 对路径进行分类

    • 7.7.1 path.isAbsolute(): 给定路径是否绝对?
  • 7.8 path.format(): 从部分创建路径

    • 7.8.1 示例:更改文件名扩展名
  • 7.9 在不同平台上使用相同的路径

    • 7.9.1 相对平台无关的路径
  • 7.10 使用库通过globs匹配路径

    • 7.10.1 minimatch API

    • 7.10.2 glob 表达式的语法

  • 7.11 使用file: URL 引用文件

    • 7.11.1 Class URL

    • 7.11.2 在 URL 和文件路径之间转换

    • 7.11.3 URL 的用例:访问相对于当前模块的文件

    • 7.11.4 URL 的用例:检测当前模块是否为“main”(应用程序入口点)

    • 7.11.5 路径 vs. file: URL


在本章中,我们将学习如何在 Node.js 上处理文件系统路径和文件 URL。

7.1 在 Node.js 上与路径相关的功能

在本章中,我们将探索 Node.js 上与路径相关的功能:

  • 大多数与路径相关的功能都在模块 'node:path' 中。

  • 全局变量 process 有用于改变当前工作目录的方法(这是什么,很快就会解释)。

  • 模块 'node:os' 有返回重要目录路径的函数。

7.1.1 访问 'node:path' API 的三种方式

模块 'node:path' 经常被导入如下:

import * as path from 'node:path';

在本章中,有时会省略此导入语句。我们还省略了以下导入:

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

我们可以通过三种方式访问 Node 的路径 API:

  • 我们可以访问特定于平台的 API 版本:

    • path.posix 支持包括 macOS 在内的 Unix 系统。

    • path.win32 支持 Windows。

  • path 本身始终支持当前平台。例如,这是 macOS 上 REPL 交互的一个示例:

    > path.parse === path.posix.parse
    true
    

让我们看看函数 path.parse() 如何在两个平台上解析文件系统路径的不同之处:

> path.win32.parse(String.raw`C:Usersjanefile.txt`)
{
 dir: 'C:\Users\jane',
 root: 'C:\',
 base: 'file.txt',
 name: 'file',
 ext: '.txt',
}
> path.posix.parse(String.raw`C:Usersjanefile.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.sep 包含当前平台的路径分隔符:

    assert.equal(
     path.posix.sep, '/' // Path separator on Unix
    );
    assert.equal(
     path.win32.sep, '\' // Path separator on Windows
    );
    
  • 路径分隔符 用于分隔路径列表中的元素。path.delimiter 包含当前平台的路径分隔符:

    assert.equal(
     path.posix.delimiter, ':' // Path delimiter on Unix
    );
    assert.equal(
     path.win32.delimiter, ';' // Path delimiter on Windows
    );
    

如果我们检查 PATH 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)的概念 - “我当前所在的目录”:

  • 如果我们使用部分合格的路径执行命令,该路径将相对于当前工作目录进行解析。

  • 如果我们在命令期望路径时省略路径,将使用当前工作目录。

  • 在 Unix 和 Windows 上,改变当前工作目录的命令是 cd

process 是一个全局的 Node.js 变量。它为我们提供了获取和设置当前工作目录的方法:

  • process.cwd() 返回当前工作目录。

  • process.chdir(dirPath) 将当前工作目录更改为 dirPath

    • dirPath 必须存在一个目录。

    • 这种更改不会影响 shell,只会影响当前正在运行的 Node.js 进程。

Node.js 使用当前工作目录来填充缺失的部分,每当路径不是完全合格时。这使我们能够在各种函数中使用部分合格的路径,例如 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 只知道两种路径:

  • 绝对路径是完全合格的,并以斜杠开头:

    /home/john/proj
    
  • 相对路径是部分合格的,以文件名或点开头:

    .   (current directory)
    ..  (parent directory)
    dir
    ./dir
    ../dir
    ../../dir/subdir
    

让我们使用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'

解析带有驱动器号的相对路径与完全合格路径full取决于rel的驱动器号:

  • full相同的驱动器号?将rel解析为full

  • full不同的驱动器号?将rel解析为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'为我们提供了两个重要目录的路径:

  • os.homedir()返回当前用户的主目录路径,例如:

    > os.homedir() // macOS
    '/Users/rauschma'
    > os.homedir() // Windows
    'C:\Users\axel'
    
  • os.tmpdir()返回操作系统用于临时文件的目录路径,例如:

    > os.tmpdir() // macOS
    '/var/folders/ph/sz0384m11vxf5byk12fzjms40000gn/T'
    > os.tmpdir() // Windows
    'C:\Users\axel\AppData\Local\Temp'
    

7.4 连接路径

有两个用于连接路径的函数:

  • path.resolve()总是返回完全合格的路径

  • path.join() 保留相对路径

7.4.1 path.resolve(): 连接路径以创建完全合格的路径
path.resolve(...paths: Array<string>): string

连接paths并返回完全合格的路径。它使用以下算法:

  • 从当前工作目录开始。

  • path[0]解析为先前的结果。

  • path[1]解析为先前的结果。

  • 对所有剩余的路径执行相同的操作。

  • 返回最终结果。

没有参数,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的各个部分,并以具有以下属性的对象返回它们:

  • .base:路径的最后一部分

    • .ext:基本的文件扩展名

    • .name:没有扩展名的基本部分。这部分也被称为路径的stem

  • .root:路径的开始(第一个段之前)

  • .dir:基本所在的目录-没有基本的路径

稍后,我们将看到函数path.format(),它是path.parse()的反函数:它将具有路径部分的对象转换为路径。

7.6.1.1 path.parse()在 Unix 上的示例

这是在 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 path.parse()在 Windows 上的示例

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

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

这是结果的图表:

 C:    Usersjohn  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:Usersjohnfile.txt`, '.txt')
'file'
> path.win32.basename(String.raw`C:Usersjohnfile.txt`, '.TXT')
'file.txt'
7.6.3 path.dirname(): 提取路径的父目录
path.dirname(path)

返回path中文件或目录的父目录:

> path.win32.dirname(String.raw`C:Usersjohnfile.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/https://gitcode.net/OpenDocCN/exploringjs-zh/-/raw/master/docs/sh-scp-node/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/https://gitcode.net/OpenDocCN/exploringjs-zh/-/raw/master/docs/sh-scp-node/img/logo.jpg';
assert.equal(
 path.relative(dataDir, pathInDataDir),
 'static/https://gitcode.net/OpenDocCN/exploringjs-zh/-/raw/master/docs/sh-scp-node/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/https://gitcode.net/OpenDocCN/exploringjs-zh/-/raw/master/docs/sh-scp-node/img/logo.jpg')
[ 'static', 'img', 'logo.jpg' ]
> splitRelativePathIntoSegments('file.txt')
[ 'file.txt' ]

在 Windows 上使用splitRelativePathIntoSegments()

> splitRelativePathIntoSegments('static/https://gitcode.net/OpenDocCN/exploringjs-zh/-/raw/master/docs/sh-scp-node/img/logo.jpg')
[ 'static', 'img', 'logo.jpg' ]
> splitRelativePathIntoSegments('C:static/https://gitcode.net/OpenDocCN/exploringjs-zh/-/raw/master/docs/sh-scp-node/img/logo.jpg')
[ 'static', 'img', 'logo.jpg' ]

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

7.10 使用库通过globs匹配路径

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

通配符的用例:

  • 指定目录中应由脚本处理的文件。

  • 指定要忽略哪些文件。

更多的通配符库:

  • multimatch扩展了 minimatch,支持多个模式。

  • micromatch是 minimatch 和 multimatch 的替代品,具有类似的 API。

  • globby是基于fast-glob的库,添加了便利功能。

7.10.1 minimatch API

minimatch 的整个 API 在项目的自述文件中有文档。在本小节中,我们将重点关注最重要的功能。

Minimatch 将通配符编译为 JavaScript RegExp对象,并使用它们进行匹配。

7.10.1.1 minimatch(): 编译和匹配一次
minimatch(path: string, glob: string, options?: MinimatchOptions): boolean

如果glob匹配path,则返回true,否则返回false

两个有趣的选项:

  • .dot: boolean(默认值:false

    如果为true,通配符符号如***将匹配“不可见”的路径段(其名称以点开头):

    > minimatch('/usr/local/.tmp/data.json', '/usr/**/data.json')
    false
    > minimatch('/usr/local/.tmp/data.json', '/usr/**/data.json', {dot: true})
    true
    
    > minimatch('/tmp/.log/events.txt', '/tmp/*/events.txt')
    false
    > minimatch('/tmp/.log/events.txt', '/tmp/*/events.txt', {dot: true})
    true
    
  • .matchBase: boolean(默认值:false

    如果为true,不带斜杠的模式将与路径的基本名称匹配:

    > minimatch('/dir/file.txt', 'file.txt')
    false
    > minimatch('/dir/file.txt', 'file.txt', {matchBase: true})
    true
    
7.10.1.2 new minimatch.Minimatch(): 编译一次,多次匹配

minimatch.Minimatch使我们只需将通配符编译为正则表达式一次,就可以多次进行匹配:

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 通配符表达式的语法

本小节涵盖了语法的基本要点。但还有更多功能。这些在这里记录:

  • Minimatch 的单元测试有许多通配符的示例。

  • Bash 参考手册有关于文件名扩展的部分。

7.10.2.1 匹配 Windows 路径

即使在 Windows 上,通配符段也是由斜杠分隔的-但它们匹配反斜杠和斜杠(这些是 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 否定通配符

如果我们以感叹号开头的通配符,它将匹配感叹号后的模式不匹配的情况:

> 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 中有两种常见的引用文件的方式:

  • 字符串中的路径

  • 具有协议file:URL实例

例如:

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类。有关此类的更多信息:

  • Node.js 文档:部分“WHATWG URL API”

  • WHATWG URL 标准的“API”部分

在本章中,我们通过全局变量访问URL类,因为这是在其他 Web 平台上使用的方式。但它也可以被导入:

import {URL} from 'node:url';
7.11.1.1 URIs vs. 相对引用

URL 是 URI 的一个子集。URI 的标准 RFC 3986 区分了两种URI 引用

  • URI以方案开头,后跟冒号分隔符。

  • 所有其他 URI 引用都是相对引用

7.11.1.2 URL的构造函数

URL类可以通过两种方式实例化:

  • new URL(uri: string)

    uri必须是一个 URI。它指定了新实例的 URI。

  • new URL(uriRef: string, baseUri: string)

    baseUri必须是一个 URI。如果uriRef是相对引用,它将根据baseUri解析,并且结果将成为新实例的 URI。

    如果uriRef是一个 URI,则它完全替换baseUri作为实例所基于的数据。

在这里我们可以看到类的实际应用:

// 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() loosly 类似。

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.com')
});
assert.equal(
 jsonStr, '{"pageUrl":"https://exploringjs.com"}'
);
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类来访问其部分(基础,扩展名等)。

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

解释:

  • import.meta.url包含当前执行的 ESM 模块的 URL。

  • 如果我们确定我们的代码始终在本地运行(这在将来可能变得不太常见),我们可以省略 A 行中的检查。如果我们这样做,而代码没有在本地运行,至少我们会得到一个异常(而不是静默失败)- 这要归功于url.fileURLToPath()(见下一项)。

  • 我们使用url.fileURLToPath()将 URL 转换为本地路径。如果协议不是file:,此函数会抛出异常。

  • process.argv[1]包含初始模块的路径。B 行中的比较有效,因为这个值始终是绝对路径 - Node.js 设置如下(源代码):

    process.argv[1] = path.resolve(process.argv[1]);
    
7.11.5?路径 vs. file: URL

当 shell 脚本接收到文件的引用或导出文件的引用(例如在屏幕上记录它们)时,它们几乎总是路径。但是,有两种情况我们需要 URL(如前面的小节中所讨论的):

  • 访问相对于当前模块的文件

  • 检测当前模块是否作为脚本运行

评论

八、在 Node.js 上处理文件系统

原文:exploringjs.com/nodejs-shell-scripting/ch_nodejs-file-system.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 8.1 Node 文件系统 API 的概念、模式和约定

    • 8.1.1 访问文件的方式

    • 8.1.2 函数名称前缀

    • 8.1.3 重要类

  • 8.2 读写文件

    • 8.2.1 同步将文件读入单个字符串(可选:拆分为行)

    • 8.2.2 通过流逐行读取文件

    • 8.2.3 同步将单个字符串写入文件

    • 8.2.4 将单个字符串追加到文件(同步)

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

    • 8.2.6 通过流异步追加多个字符串到文件

  • 8.3 跨平台处理行终止符

    • 8.3.1 读取行终止符

    • 8.3.2 写入行终止符

  • 8.4 遍历和创建目录

    • 8.4.1 遍历目录

    • 8.4.2 创建目录(mkdirmkdir -p

    • 8.4.3 确保父目录存在

    • 8.4.4 创建临时目录

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

    • 8.5.1 复制文件或目录

    • 8.5.2 重命名或移动文件或目录

  • 8.6 删除文件或目录

    • 8.6.1 删除文件和任意目录(shell:rmrm -r

    • 8.6.2 删除空目录(shell:rmdir

    • 8.6.3 清空目录

    • 8.6.4 删除文件或目录

  • 8.7 读取和更改文件系统条目

    • 8.7.1 检查文件或目录是否存在

    • 8.7.2 检查文件的统计信息:它是目录吗?它是何时创建的?等等。

    • 8.7.3 更改文件属性:权限、所有者、组、时间戳

  • 8.8 处理链接

  • 8.9 进一步阅读


本章包括:

  • Node 的文件系统 API 的不同部分概述。

  • Recipes(代码片段)用于通过这些 API 执行各种任务。

鉴于本书的重点是 shell 脚本,我们只处理文本数据。

8.1?Node 的文件系统 API 的概念、模式和约定

8.1.1?访问文件的方式
  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 使用文件描述符。基于 Promise 的 API 有更好的抽象,class FileHandle,它基于文件描述符。实例是通过 fsPromises.open() 创建的。各种操作通过方法提供(而不是通过函数):

      • fileHandle.close()

      • fileHandle.chmod(mode)

      • fileHandle.chown(uid, gid)

      • 等等。

请注意,在本章中我们不使用(3)-(1)和(2)对我们的目的足够了。

8.1.2?函数名前缀
8.1.2.1?前缀“l”:符号链接

以“l”开头的函数通常操作符号链接:

  • fs.lchmodSync(), fs.lchmod(), fsPromises.lchmod()

  • fs.lchownSync(), fs.lchown(), fsPromises.lchown()

  • fs.lutimesSync(), fs.lutimes(), fsPromises.lutimes()

  • 等等。

8.1.2.2?前缀“f”:文件描述符

以“f”开头的函数通常管理文件描述符:

  • fs.fchmodSync(), fs.fchmod()

  • fs.fchownSync(), fs.fchown()

  • fs.fstatSync(), fs.fstat()

  • 等等。

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.pathToFileURL()

  • url.fileURLToPath()

在本章中我们不使用文件 URL。它们的用例在§7.11.1 “Class URL”中有描述。

8.1.3.2?缓冲区

Buffer表示 Node.js 上的固定长度字节序列。它是 Uint8Array 的子类(TypedArray)。缓冲区在处理二进制文件时大多被使用,因此在本书中不太感兴趣。

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

缓冲区可以做 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 流:

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

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

现在我们可以在 Node.js 上使用跨平台的 web 流,具体方法在§10 “在 Node.js 上使用 web 流”中有解释。

8.2?读取和写入文件

8.2.1?同步读取文件为单个字符串(可选:拆分成行)

fs.readFileSync(filePath, options?) 将文件在 filePath 处同步读取为单个字符串:

assert.equal(
 fs.readFileSync('text-file.txt', {encoding: 'utf-8'}),
 'there
are
multiple
lines'
);

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

  • 优点:易于使用和同步。对于许多用例来说已经足够好了。

  • 缺点:不适合大文件。

    • 在我们可以处理数据之前,我们必须将其完全读取。

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

8.2.1.1?不包括行终止符拆分行

以下代码将一个字符串拆分成行,同时删除行终止符。它适用于 Unix 和 Windows 行终止符:

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

“EOL”代表“行结束”。我们接受 Unix 行终止符('
'
)和 Windows 行终止符('
'
,就像前面示例中的第一个)。更多信息,请参见§8.3 “跨平台处理行终止符”。

8.2.1.2?包括行终止符拆分行

以下代码将一个字符串拆分成行,同时包括行终止符。它适用于 Unix 和 Windows 行终止符(“EOL”代表“行结束”):

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

assert.deepEqual(
 splitLinesWithEols('there
are
multiple
lines'),
 ['there
', 'are
', 'multiple
', 'lines']
);
assert.deepEqual(
 splitLinesWithEols('first

third'),
 ['first
', '
', 'third']
);
assert.deepEqual(
 splitLinesWithEols('EOL at the end
'),
 ['EOL at the end
']
);
assert.deepEqual(
 splitLinesWithEols(''),
 ['']
);

行 A 包含一个带有后行断言的正则表达式。它匹配前面有
?
模式的位置,但它不捕获任何内容。因此,它不会删除输入字符串被拆分成的字符串片段之间的任何内容。

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

function splitLinesWithEols(str) {
 if (str.length === 0) return [''];
 const lines = [];
 let prevEnd = 0;
 while (prevEnd < str.length) {
 // Searching for '
' means we’ll also find '
'
 const newlineIndex = str.indexOf('
', 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 行终止符('
'
)和 Windows 行终止符('
'
)。更多信息,请参见§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
'
// 'are
'
// 'multiple
'
// 'lines'

我们使用了以下外部功能:

  • fs.createReadStream(filePath, options?) 创建了一个 Node.js 流(一个 stream.Readable 实例)。

  • stream.Readable.toWeb(streamReadable) 将一个可读的 Node.js 流转换为 web 流(一个 ReadableStream 实例)。

  • TransformStream 类 ChunksToLinesStream 在§10.7.1 “示例:将任意块的流转换为行流”中有解释。是流产生的数据片段。如果我们有一个流,其块是具有任意长度的字符串,并将其通过 ChunksToLinesStream,那么我们得到的流的块就是行。

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

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

更多信息:

  • Web 流在§10 “在 Node.js 上使用 web 流”中有介绍。

  • 行终止符在§8.3“跨平台处理行终止符”中有介绍。

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

  • 优点:对于大文件效果很好。

    • 我们可以逐步处理数据,分成较小的片段,而不必等待所有内容被读取。
  • 缺点:使用起来更复杂,且不同步。

8.2.3 同步地向文件写入单个字符串

fs.writeFileSync(filePath, str, options?)str写入到filePath的文件中。如果该路径下已经存在文件,则会被覆盖。

以下代码显示了如何使用此函数:

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

有关行终止符的信息,请参见§8.3“跨平台处理行终止符”。

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

  • 优点:易于使用,且同步。适用于许多用例。

  • 缺点:不适用于大文件。

8.2.4 同步地向文件追加单个字符串

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

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

我们也可以使用fs.writeFileSync()来执行此任务:

fs.writeFileSync(
 'existing-file.txt',
 'Appended line
',
 {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
');
 await writer.write('Second line
');
 await writer.close();
} finally {
 writer.releaseLock()
}

我们使用了以下函数:

  • fs.createWriteStream(path, options?)创建一个 Node.js 流(stream.Writable的实例)。

  • stream.Writable.toWeb(streamWritable)将可写的 Node.js 流转换为 Web 流(WritableStream的实例)。

更多信息:

  • 可写流和写入器在§10“在 Node.js 上使用 Web 流”中有介绍。

  • 行终止符在§8.3“跨平台处理行终止符”中有介绍。

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

  • 优点:对于大文件效果很好,因为我们可以逐步写入数据,分成较小的片段。

  • 缺点:使用起来更复杂,且不同步。

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
');
 await writer.write('Second appended line
');
 await writer.close();
} finally {
 writer.releaseLock()
}

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

注意:在某些函数中,此选项称为.flag,在其他函数中称为.flags

8.3 处理跨平台的行终止符

遗憾的是,并非所有平台都具有标记行终止符(EOL)的相同行终止符字符:

  • 在 Windows 上,EOL 是'
    '

  • 在 Unix(包括 macOS)上,EOL 是'
    '

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

8.3.1 读取行终止符

在读取文本时,最好能够识别两种 EOL。

当将文本拆分成行时,可能会是什么样子?我们可以在行尾包含 EOL(以任何格式)。这样,如果我们修改这些行并将其写入文件,我们可以尽可能少地进行更改。

在处理带有 EOL 的行时,有时将它们移除是有用的,例如通过以下函数:

const RE_EOL_REMOVE = /
?
$/;
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
'),
 'Windows EOL'
);
assert.equal(
 removeEol('Unix EOL
'),
 'Unix EOL'
);
assert.equal(
 removeEol('No EOL'),
 'No EOL'
);
8.3.2?写入行终止符

在写入行终止符时,我们有两个选项:

  • 模块'node:os'中的常量EOL(https://nodejs.org/api/os.html#oseol)包含当前平台的 EOL。

  • 我们可以检测输入文件的 EOL 格式,并在更改该文件时使用它。

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

我们使用了这个功能:

  • fs.readdirSync(thePath, options?)返回thePath处目录的子目录。

    • 如果选项.withFileTypestrue,函数返回directory entries,即fs.Dirent的实例。这些具有属性,如:

      • dirent.name

      • dirent.isDirectory()

      • dirent.isFile()

      • dirent.isSymbolicLink()

    • 如果选项.withFileTypesfalse或缺失,函数返回文件名的字符串。

以下代码展示了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?创建目录(mkdir, mkdir -p

我们可以使用以下函数来创建目录:

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

options.recursive决定函数如何创建thePath处的目录:

  • 如果.recursive缺失或为falsemkdirSync()返回undefined,并且如果:

    • thePath处已经存在一个目录(或文件)。

    • thePath的父目录不存在。

  • 如果.recursivetrue

    • 如果thePath处已经有一个目录,那没关系。

    • thePath的祖先目录将根据需要创建。

    • mkdirSync()返回第一个新创建目录的路径。

这是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?)创建一个临时目录:它在pathPrefix后附加 6 个随机字符,创建一个新路径的目录并返回该路径。

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。有趣的选项:

  • .recursive(默认:false):只有在此选项为true时,目录(包括空目录)才会被复制。

  • .force(默认:true):如果为true,则覆盖现有文件。如果为false,则保留现有文件。

    • 在后一种情况下,将.errorOnExist设置为true会导致如果文件路径冲突则抛出错误。
  • .filter是一个函数,让我们控制哪些文件被复制。

  • .preserveTimestamps(默认:false):如果为truedestPath中的复制品将获得与srcPath中原始文件相同的时间戳。

这是函数的操作:

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上的文件或目录。有趣的选项:

  • .recursive(默认:false):只有在此选项为true时,才会删除目录(包括空目录)。

  • .force(默认:false):如果为false,则如果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});
 }
}

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

  • fs.readdirSync(dirPath)返回dirPath目录中所有子项的名称。在§8.4.1“遍历目录”中有解释。

  • fs.rmSync(pathName, options?)删除文件和目录(包括非空目录)。在§8.6.1“删除文件和任意目录(shell:rmrm -r)”中有解释。

这是使用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(在 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

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

    • 如果此选项为true,则会抛出异常。

    • 如果为false,则返回undefined

  • .bigint(默认:false):如果为true,则此函数将使用 bigints 作为数值(例如时间戳,请参见下文)。

fs.Stats实例的属性:

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

    • stats.isFile()

    • stats.isDirectory()

    • stats.isSymbolicLink()

  • stats.size 是以字节为单位的大小

  • 时间戳:

    • 有三种时间戳:

      • stats.atime:上次访问时间

      • stats.mtime:上次修改时间

      • stats.birthtime:创建时间

    • 这些时间戳中的每一个都可以用三种不同的单位指定,例如atime

      • stats.atimeDate的实例

      • stats.atimeMS:自 POSIX 纪元以来的毫秒数

      • stats.atimeNs:自 POSIX 纪元以来的纳秒数(需要选项.bigint

在以下示例中,我们使用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?更改文件属性:权限、所有者、组、时间戳

让我们简要地看一下更改文件属性的函数:

  • fs.chmodSync(path, mode) 改变文件的权限。

  • fs.chownSync(path, uid, gid) 改变文件的所有者和组。

  • fs.utimesSync(path, atime, mtime) 改变文件的时间戳:

    • atime:上次访问时间

    • mtime:上次修改时间

8.8?处理链接

用于处理硬链接的函数:

  • fs.linkSync(existingPath, newPath) 创建一个硬链接。

  • fs.unlinkSync(path) 删除一个硬链接,可能也会删除它指向的文件(如果它是指向该文件的最后一个硬链接)。

用于处理符号链接的函数:

  • fs.symlinkSync(target, path, type?)pathtarget创建一个符号链接。

  • fs.readlinkSync(path, options?) 返回path处符号链接的目标。

以下函数在不解除引用符号链接的情况下操作符号链接(注意名称前缀“l”):

  • fs.lchmodSync(path, mode) 改变path处符号链接的权限。

  • fs.lchownSync(path, uid, gid) 改变path处符号链接的用户和组。

  • fs.lutimesSync(path, atime, mtime) 改变path处符号链接的时间戳。

  • fs.lstatSync(path, options?) 返回path处符号链接的统计信息(时间戳等)。

其他有用的函数:

  • fs.realpathSync(path, options?) 通过解析点(.)、双点(..)和符号链接来计算规范路径名。

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

  • fs.cpSync(src, dest, options?)

    • .dereference(默认:false):如果为true,则复制符号链接指向的文件,而不是符号链接本身。

    • .verbatimSymlinks(默认:false):如果为false,则复制的符号链接的目标将被更新,以便仍然指向相同的位置。如果为true,目标不会改变。

8.9?进一步阅读

  • “JavaScript 快速编程者”有几章关于编写异步代码:

    • “JavaScript 中的异步编程基础”

    • “用于异步编程的 Promise”

    • “异步函数”

    • “异步迭代”

评论