第一部分:关于本书
原文:
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 中可用非严格的
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 Welcome to Node.js v18.9.0. Type ".help" for more information. > path.join('dir', 'sub', 'file.txt') 'dir/sub/file.txt' >
所有 Node 的内置模块都可以通过 REPL 中的全局变量访问:
3.3.2?快速打印 JavaScript 表达式的结果
我们可以使用带有选项
node -p "os.homedir()"
有关此命令行选项的更多信息,请参见§15.7.7“
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 代码。例如,我们可以从上一节复制
pbpaste | node --input-type=module
选项
macOS 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,比如
fetch 和CompressionStream 属于这一类别。 -
但是一些仅适用于 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);
原则上,使用模块比使用全局变量更清晰。然而,使用全局变量
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() 解析命令行参数”中有描述。
- 函数
模块
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 的函数有三种不同的风格。让我们以内置模块
-
使用普通函数的同步风格 - 例如:
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!
() => handleResult(null, 'Don’t forget!')
4.2.1?运行到完成使代码更简单
Node.js 运行 JavaScript 代码的一个重要规则是:每个任务在其他任务运行之前都会完成(“运行到完成”)。我们可以在上一个示例中看到这一点:'AFTER’在 B 行之前被记录,因为初始任务在调用
运行到完成意味着任务的生命周期不重叠,我们不必担心共享数据在后台被更改。这简化了 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);
我们通过
回调函数的每次调用都是一个新任务,变量
4.2.2?为什么 Node.js 代码在单个线程中运行?
为什么 Node.js 代码默认在单个线程(带有事件循环)中运行?这有两个好处:
-
正如我们已经看到的,如果只有一个线程,任务之间共享数据会更简单。
-
在传统的多线程代码中,需要较长时间才能完成的操作会阻塞当前线程,直到操作完成。此类操作的示例包括读取文件或处理 HTTP 请求。执行许多此类操作是昂贵的,因为每次都必须创建一个新线程。使用事件循环,每次操作的成本更低,特别是如果每个操作都不做太多。这就是为什么基于事件循环的 Web 服务器可以处理比基于线程的服务器更高的负载。
鉴于 Node 的一些异步操作在主线程之外的线程中运行(稍后会详细介绍),并通过任务队列向 JavaScript 报告,Node.js 实际上并不是单线程的。相反,我们使用单个线程来协调并发和异步运行的操作(在主线程中)。
这就是我们对事件循环的第一次了解。如果您只需要一个表面的解释,可以随意跳过本节的其余部分。继续阅读以了解更多细节。
4.2.3?真实的事件循环有多个阶段
真实的事件循环有多个任务队列,它从中读取多个阶段(您可以在 GitHub 存储库
图表中显示的事件循环阶段的作用是什么?
-
“定时器”阶段调用了定时任务,这些任务是通过以下方式添加到其队列中的:
-
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 等待
相比之下,创建 Promise 不会增加引用计数:
function foreverPending() { return new Promise( (_resolve, _reject) => {} ); } await foreverPending(); // (A)
在这种情况下,在 A 行的
我们可以手动控制超时是否保持事件循环活动:默认情况下,通过
Tim Perry 提到了
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 存储库
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/ package.json node_modules/ [More files]
这些文件系统条目的目的是什么?
-
package.json 是每个包都必须拥有的文件:-
它包含描述包的元数据(名称、版本、作者等)。
-
它列出了包的依赖项:它所需的其他包,如库和工具。对于每个依赖项,我们记录:
-
一系列版本号。不指定特定版本允许升级和依赖项之间的代码共享。
-
默认情况下,依赖项来自 npm 注册表。但我们也可以指定其他来源:本地目录,GZIP 文件,指向 GZIP 文件的 URL,不同于 npm 的注册表,git 存储库等。
-
-
-
node_modules/ 是包的依赖项安装的目录。每个依赖项也有一个带有其依赖项等的node_modules 文件夹。结果是一个依赖项树。
一些包还有文件
5.2.1?package.json
这是一个可以通过 npm 创建的起始
{ "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 更改时递增。
-
次要版本在向后兼容的方式下添加功能时递增。
-
补丁版本在进行了不会真正改变功能的小更改时递增。
-
-
-
公共包的其他属性是可选的:
-
description 、keywords 、author 是可选的,使找到包变得更容易。 -
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 指定软件包的许可证。其格式很快就会解释。 -
通常,
name 和version 属性是必需的,如果缺少它们,npm 会发出警告。但是,我们可以通过以下设置更改:"private": true
这可以防止软件包意外发布,并允许我们省略名称和版本。
有关
5.2.2 package.json 的属性"dependencies"
这是
"dependencies": { "minimatch": "?.1.0", "mocha": "1?.0.0" }
属性记录了软件包的名称和其版本的约束。
版本本身遵循语义化版本标准。它们由点分隔的最多三个数字组成(第二个和第三个数字是可选的,默认为零):
-
主要版本:当软件包以不兼容的方式更改时,此数字会更改。
-
次要版本:当以向后兼容的方式添加功能时,此数字会更改。
-
补丁版本:当进行向后兼容的错误修复时,此数字会更改。
有关 Node 的版本范围,请参阅semver 存储库。示例包括:
-
没有任何额外字符的特定版本意味着安装的版本必须完全匹配:
"pkg1": "2.0.1",
-
major.minor.x 或major.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" }
如果我们使用全局安装具有此
如果我们在本地安装软件包,可以在软件包脚本中使用这两个命令,或者通过npx 命令使用。
{ "name": "my-package", "bin": "./src/main.mjs" }
这是对的缩写:
{ "name": "my-package", "bin": { "my-package": "./src/main.mjs" } }
5.2.4 package.json 的属性"license"
属性
"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 编译器。
开发依赖项(
请注意,git 仓库中未发布的包在开发过程中与已发布的包类似处理。
5.3.1 从 git 安装包
要安装一个名为
cd pkg/ npm install
然后执行以下步骤:
-
node_modules 被创建并安装依赖项。安装一个依赖项也意味着下载该依赖项并安装它的依赖项(等等)。 -
有时会执行额外的设置步骤。可以通过
package.json 配置这些步骤。
如果根包没有
在依赖树中,相同的依赖项可能存在多次,可能是不同的版本。有一些方法可以最小化重复,但这超出了本章的范围。
5.3.1.1 重新安装一个包
这是一种(略显粗糙)修复依赖树中问题的方法:
cd pkg/ rm -rf node_modules/ rm package-lock.json npm install
请注意,这可能导致安装不同的、更新的包。我们可以通过不删除
5.3.2 创建一个新的包并安装依赖项
有许多工具和技术可以设置新的包。这是一个简单的方法:
mkdir my-package cd my-package/ npm init --yes
之后,目录看起来像这样:
my-package/ package.json
5.3.2.1 安装依赖项
现在,
npm install lodash-es
该命令执行以下步骤:
-
该包被下载到
my-package/node_modules/lodash-es 中。 -
也会安装它的依赖项。然后是它的依赖项的依赖项。等等。
-
package.json 中添加了一个新属性:"dependencies": { "lodash-es": "?.17.21" }
-
package-lock.json 会更新为安装的确切版本。
5.4 通过标识符引用模块
ECMAScript 模块中的代码通过
// 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 模块使用
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、
--input-type=module
5.5.2 包出口:控制其他包看到什么
在本小节中,我们正在处理具有以下文件布局的包:
my-lib/ dist/ src/ main.js util/ errors.js internal/ internal-module.js test/
包出口通过
-
隐藏包的内部:
-
没有
"exports" 属性,包my-lib 中的每个模块都可以在包名后使用相对路径访问 - 例如:'my-lib/dist/src/internal/internal-module.js'
-
一旦属性存在,只能使用其中列出的指定符。其他所有内容都对外部隐藏。
-
-
更好的模块指定符:包出口让我们为较短和/或名称更好的模块定义裸指定符子路径。
回想一下裸指定符的三种样式:
-
样式 1:没有子路径的裸指定符
-
样式 2:没有扩展名的裸指定符
-
样式 3:带有扩展名的裸指定符子路径
包出口帮助我们处理所有三种样式
5.5.2.1 样式 1:配置哪个文件代表(包的裸指定符)
{ "main": "./dist/src/main.js", "exports": { ".": "./dist/src/main.js" } }
我们只提供
有了这些包出口,我们现在可以这样从
import {someFunction} from 'my-lib';
这导入了
my-lib/dist/src/main.js
5.5.2.2 样式 2:将不带扩展名的子路径映射到模块文件
{ "exports": { "./util/errors": "./dist/src/util/errors.js" } }
我们将指定符子路径
import {UserError} from 'my-lib/util/errors';
5.5.2.3 样式 2:更好的不带扩展名的子路径为子树
前一小节解释了如何为不带扩展名的子路径创建单个映射。还有一种方法可以通过单个条目创建多个这样的映射:
{ "exports": { "./lib/*": "./dist/src/*.js" } }
任何位于
import {someFunction} from 'my-lib/lib/main'; import {UserError} from 'my-lib/lib/util/errors';
请注意这个
"./lib/*": "./dist/src/*.js"
这些更多的指令是如何将子路径映射到实际路径,而不是匹配文件路径片段的通配符。
5.5.2.4 样式 3:将带有扩展名的子路径映射到模块文件
{ "exports": { "./util/errors.js": "./dist/src/util/errors.js" } }
我们将指定符子路径
import {UserError} from 'my-lib/util/errors.js';
5.5.2.5 样式 3:更好的带有扩展名的子路径为子树
{ "exports": { "./*": "./dist/src/*" } }
在这里,我们缩短了
import {InternalError} from 'my-package/util/errors.js';
没有出口,导入语句将是:
import {InternalError} from 'my-package/dist/src/util/errors.js';
请注意这个
"./*": "./dist/src/*"
这些不是文件系统通配符,而是如何将外部模块指定符映射到内部模块指定符的指令。
5.5.2.6 暴露子树同时隐藏其中的部分
通过以下技巧,我们暴露了
"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" } }
开发 vs. 生产。 条件包出口的另一个用例是在“开发”和“生产”环境之间切换:
"exports": { ".": { "development": "./main-development.js", "production": "./main-production.js", } }
在 Node.js 中,我们可以这样指定环境:
node --conditions development app.mjs
5.5.3 包导入
包导入让一个包为模块指定符定义缩写,它可以在内部自己使用(其中包出口为其他包定义了缩写)。这是一个例子:
{ "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:协议来明确表示我们想要导入一个内置模块。例如,以下两个导入语句在大多数情况下是等效的(如果没有安装名为’fs’的 npm 模块):
import * as fs from 'node:fs/promises'; import * as fs from 'fs/promises';
使用
由于
评论
六、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 命令
6.2?获取 npm 帮助
6.2.1?在命令行上获取帮助
我们可以使用
npm -h # brief explanation of `npm` npm <cmd> -h # brief explanation of `npm <cmd>`
另一方面,有
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 命令,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.1.1 访问
-
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.7.1
-
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 的三种方式
模块
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.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 路径 - 首先通过
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 变量
> process.env.PATH.split(/(?<=:)/) [ '/opt/homebrew/bin:', '/opt/homebrew/sbin:', '/usr/local/bin:', '/usr/bin:', '/bin:', '/usr/sbin:', '/sbin', ]
分隔符的长度为零,因为回顾断言
这是 Windows PATH 的一个示例(shell 变量
> 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.cwd() 返回当前工作目录。 -
process.chdir(dirPath) 将当前工作目录更改为dirPath 。-
dirPath 必须存在一个目录。 -
这种更改不会影响 shell,只会影响当前正在运行的 Node.js 进程。
-
Node.js 使用当前工作目录来填充缺失的部分,每当路径不是完全合格时。这使我们能够在各种函数中使用部分合格的路径,例如
7.2.2.1 Unix 上的当前工作目录
以下代码演示了在 Unix 上使用
process.chdir('/home/jane'); assert.equal( process.cwd(), '/home/jane' );
7.2.2.2 Windows 上的当前工作目录
到目前为止,我们已经在 Unix 上使用了当前工作目录。Windows 的工作方式不同:
-
每个驱动器都有一个当前目录。
-
有一个当前驱动器。
我们可以使用
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
让我们使用
> 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 的文档):
-
有绝对路径和相对路径。
-
这两种路径都可以有驱动器号(“卷标”)或者没有。
带有驱动器号的绝对路径是完全合格的。所有其他路径都是部分合格的。
解析没有驱动器号的绝对路径与完全合格路径
> 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 。 -
与
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' 获取标准目录的路径
模块
-
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
连接
-
从当前工作目录开始。
-
将
path[0] 解析为先前的结果。 -
将
path[1] 解析为先前的结果。 -
对所有剩余的路径执行相同的操作。
-
返回最终结果。
没有参数,
> 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
从
下降的例子:
> 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 上,
-
删除单个点(
。 )的路径段。 -
解析双点(
.. )的路径段。 -
将多个路径分隔符转换为单个路径分隔符。
例如:
// 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 上,
-
删除单点(
. )的路径段。 -
解析双点(
.. )的路径段。 -
将每个路径分隔符斜杠(
/ )转换为首选路径分隔符()。 -
将多个路径分隔符序列转换为单个反斜杠。
例如:
// 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.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() (一个参数):确保路径被规范化和完全合格
我们已经遇到了
在 Unix 上使用
> 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 上使用
> 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
返回一个相对路径,使我们从
> 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 上,如果
> 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
提取
-
.base :路径的最后一部分-
.ext :基本的文件扩展名 -
.name :没有扩展名的基本部分。这部分也被称为路径的stem。
-
-
.root :路径的开始(第一个段之前) -
.dir :基本所在的目录-没有基本的路径
稍后,我们将看到函数
7.6.1.1 path.parse() 在 Unix 上的示例
这是在 Unix 上使用
> 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 |
例如,我们可以看到
7.6.1.2 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.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.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.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
如果
在 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 示例:更改文件扩展名
我们可以使用
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('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('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 模块
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
7.10.1.1 minimatch() : 编译和匹配一次
minimatch(path: string, glob: string, options?: MinimatchOptions): boolean
如果
两个有趣的选项:
-
.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() : 编译一次,多次匹配
类
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
也就是说,我们必须决定是绝对路径还是相对路径。
使用
> 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
选项
> 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
我们可以通过选项
> 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 类
在本节中,我们更详细地了解了
-
Node.js 文档:部分“WHATWG URL API”
-
WHATWG URL 标准的“API”部分
在本章中,我们通过全局变量访问
import {URL} from 'node:url';
7.11.1.1 URIs vs. 相对引用
URL 是 URI 的一个子集。URI 的标准 RFC 3986 区分了两种URI 引用:
-
URI以方案开头,后跟冒号分隔符。
-
所有其他 URI 引用都是相对引用。
7.11.1.2 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 实例
让我们重新访问
new URL(uriRef: string, baseUri: string)
参数
const obj = { toString() {return 'https://example.com'} }; assert.equal( new URL('index.html', obj).href, 'https://example.com/index.html' );
这使我们能够相对于
const url = new URL('https://example.com/dir/file1.html'); assert.equal( new URL('../file2.html', url).href, 'https://example.com/file2.html' );
以这种方式使用构造函数,它与
7.11.1.4 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' );
方法
const jsonStr = JSON.stringify({ pageUrl: new URL('https://exploringjs.com') }); assert.equal( jsonStr, '{"pageUrl":"https://exploringjs.com"}' );
7.11.1.6 获取URL 属性
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; }
遗憾的是,路径名是一个单一的原子单位。也就是说,我们不能使用
7.11.1.7 设置 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 管理搜索参数
我们可以使用属性
我们可以用它来读取搜索参数:
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 之间进行转换是很诱人的。例如,我们可以尝试通过
url.fileURLToPath(url: URL | string): string
以下代码将该函数的结果与
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.pathToFileURL(path: string): 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'}); }
这个函数使用
使用
> await fetch('file:///tmp/file.txt') TypeError: fetch failed cause: Error: not implemented... yet...
7.11.4?URL 的用例:检测当前模块是否为“main”(应用程序入口点)
ESM 模块可以以两种方式使用:
-
它可以作为其他模块可以导入值的库使用。
-
它可以作为我们通过 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 创建目录(
mkdir ,mkdir -p ) -
8.4.3 确保父目录存在
-
8.4.4 创建临时目录
-
-
8.5 复制、重命名、移动文件或目录
-
8.5.1 复制文件或目录
-
8.5.2 重命名或移动文件或目录
-
-
8.6 删除文件或目录
-
8.6.1 删除文件和任意目录(shell:
rm ,rm -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?访问文件的方式
-
我们可以通过字符串读取或写入文件的整个内容。
-
我们可以打开一个用于读取或写入的流,并逐个处理文件的较小部分。流只允许顺序访问。
-
我们可以使用文件描述符或 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)时,它通常也接受一个
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' );
手动在路径和
-
url.pathToFileURL() -
url.fileURLToPath()
在本章中我们不使用文件 URL。它们的用例在§7.11.1 “Class
8.1.3.2?缓冲区
类
每当 Node.js 接受一个缓冲区时,它也接受一个 Uint8Array。因此,鉴于 Uint8Arrays 是跨平台的,而 Buffers 不是,前者更可取。
缓冲区可以做 Uint8Arrays 无法做的一件事:在各种编码中编码和解码文本。如果我们需要在 Uint8Arrays 中编码或解码 UTF-8,我们可以使用类
> 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?同步读取文件为单个字符串(可选:拆分成行)
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 行终止符(
'
'
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; }
这个解决方案很简单,但更冗长。
在
'
'
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 流是异步可迭代的,这就是为什么我们可以使用
如果我们对文本行不感兴趣,那么我们不需要
更多信息:
-
Web 流在§10 “在 Node.js 上使用 web 流”中有介绍。
-
行终止符在§8.3“跨平台处理行终止符”中有介绍。
这种方法的优缺点(与读取单个字符串相比):
-
优点:对于大文件效果很好。
- 我们可以逐步处理数据,分成较小的片段,而不必等待所有内容被读取。
-
缺点:使用起来更复杂,且不同步。
8.2.3 同步地向文件写入单个字符串
以下代码显示了如何使用此函数:
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( 'existing-file.txt', 'Appended line ', {encoding: 'utf-8', flag: 'a'} );
这段代码几乎与我们用来覆盖现有内容的代码相同(有关更多信息,请参见前一节)。唯一的区别是我们添加了选项
注意:在某些函数中,此选项称为
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() }
这段代码几乎与我们用来覆盖现有内容的代码相同(有关更多信息,请参见前一节)。唯一的区别是我们添加了选项
注意:在某些函数中,此选项称为
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 处目录的子目录。-
如果选项
.withFileTypes 是true ,函数返回directory entries,即fs.Dirent 的实例。这些具有属性,如:-
dirent.name -
dirent.isDirectory() -
dirent.isFile() -
dirent.isSymbolicLink()
-
-
如果选项
.withFileTypes 是false 或缺失,函数返回文件名的字符串。
-
以下代码展示了
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
-
如果
.recursive 缺失或为false ,mkdirSync() 返回undefined ,并且如果:-
thePath 处已经存在一个目录(或文件)。 -
thePath 的父目录不存在。
-
-
如果
.recursive 是true :-
如果
thePath 处已经有一个目录,那没关系。 -
thePath 的祖先目录将根据需要创建。 -
mkdirSync() 返回第一个新创建目录的路径。
-
这是
assert.deepEqual( Array.from(traverseDirectory('.')), [ 'dir', ] ); fs.mkdirSync('dir/sub/subsub', {recursive: true}); assert.deepEqual( Array.from(traverseDirectory('.')), [ 'dir', 'dir/sub', 'dir/sub/subsub', ] );
函数
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}); } }
这里我们可以看到
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?创建临时目录
如果我们想要在操作系统特定的全局临时目录中创建临时目录,我们可以使用函数
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?复制文件或目录
-
.recursive (默认:false ):只有在此选项为true 时,目录(包括空目录)才会被复制。 -
.force (默认:true ):如果为true ,则覆盖现有文件。如果为false ,则保留现有文件。- 在后一种情况下,将
.errorOnExist 设置为true 会导致如果文件路径冲突则抛出错误。
- 在后一种情况下,将
-
.filter 是一个函数,让我们控制哪些文件被复制。 -
.preserveTimestamps (默认:false ):如果为true ,destPath 中的复制品将获得与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', ] );
函数
8.5.2 重命名或移动文件或目录
让我们使用这个函数来重命名一个目录:
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', ] );
函数
8.6 删除文件或目录
8.6.1 删除文件和任意目录(shell:rm ,rm -r )
-
.recursive (默认:false ):只有在此选项为true 时,才会删除目录(包括空目录)。 -
.force (默认:false ):如果为false ,则如果thePath 上没有文件或目录,将抛出异常。
让我们使用
assert.deepEqual( Array.from(traverseDirectory('.')), [ 'dir', 'dir/some-file.txt', ] ); fs.rmSync('dir/some-file.txt'); assert.deepEqual( Array.from(traverseDirectory('.')), [ 'dir', ] );
在这里,我们使用
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', ] );
函数
8.6.2 删除空目录(shell:rmdir )
以下代码显示了这个函数的工作原理:
assert.deepEqual( Array.from(traverseDirectory('.')), [ 'dir', 'dir/subdir', ] ); fs.rmdirSync('dir/subdir'); assert.deepEqual( Array.from(traverseDirectory('.')), [ 'dir', ] );
函数
8.6.3 清除目录
将其输出保存到目录
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:rm ,rm -r )”中有解释。
这是使用
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 将文件或目录移到垃圾箱
库
import trash from 'trash'; await trash(['*.png', '!rainbow.png']);
8.7 读取和更改文件系统条目
8.7.1 检查文件或目录是否存在
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 );
函数
8.7.2 检查文件的统计信息:它是一个目录吗?它是什么时候创建的?等等。
有趣的
-
.throwIfNoEntry (默认:true ):如果在path 上没有实体会发生什么?-
如果此选项为
true ,则会抛出异常。 -
如果为
false ,则返回undefined 。
-
-
.bigint (默认:false ):如果为true ,则此函数将使用 bigints 作为数值(例如时间戳,请参见下文)。
-
它是什么类型的文件系统条目?
-
stats.isFile() -
stats.isDirectory() -
stats.isSymbolicLink()
-
-
stats.size 是以字节为单位的大小 -
时间戳:
-
有三种时间戳:
-
stats.atime :上次访问时间 -
stats.mtime :上次修改时间 -
stats.birthtime :创建时间
-
-
这些时间戳中的每一个都可以用三种不同的单位指定,例如
atime :-
stats.atime :Date 的实例 -
stats.atimeMS :自 POSIX 纪元以来的毫秒数 -
stats.atimeNs :自 POSIX 纪元以来的纳秒数(需要选项.bigint )
-
-
在以下示例中,我们使用
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 );
函数
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?) 从path 到target 创建一个符号链接。 -
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”
-
“异步函数”
-
“异步迭代”
-
评论