使用 Node.js 进行 Shell 脚本编程
您可以购买本书的离线版本(HTML、PDF、EPUB、MOBI),并支持免费在线版本。
(广告,请不要屏蔽。)

5 包:JavaScript 的软件分发单元



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

**所需知识:**我假设您大致熟悉 ECMAScript 模块的语法。如果您不熟悉,可以阅读“JavaScript for impatient programmers”中的“模块”一章

5.1 什么是包?

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

一个包可以_依赖_其他包(称为其_依赖项_),其中包含

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

包之间的一个常见区别是

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

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

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

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

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

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

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

5.2 包的文件系统布局

一旦包 my-package 完全安装,它几乎总是如下所示

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

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

一些包还具有与 package.json 并列的 package-lock.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"
}

这些属性的用途是什么?

其他有用的属性

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

5.2.2 package.json"dependencies" 属性

这就是 package.json 文件中依赖项的外观

"dependencies": {
  "minimatch": "^5.1.0",
  "mocha": "^10.0.0"
}

这些属性记录了包的名称及其版本的约束。

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

  1. _主版本_:当包以不兼容的方式更改时,此数字会更改。
  2. _次版本_:当以向后兼容的方式添加功能时,此数字会更改。
  3. _补丁版本_:当进行向后兼容的错误修复时,此数字会更改。

Node 的版本范围在semver 存储库中进行了说明。例子包括

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 注册表

开发依赖项(package.json 中的属性 devDependencies)仅在开发期间安装,而当我们从 npm 注册表安装包时不会安装。

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

5.3.1 从 git 安装包

要从 git 安装包 pkg,我们克隆其存储库并

cd pkg/
npm install

然后执行以下步骤

如果根包没有 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

此命令执行以下步骤

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

静态导入和动态导入都使用*模块说明符*来引用模块

模块说明符有三种

5.4.1 模块说明符中的文件名扩展名

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

5.5 Node.js 中的模块说明符

让我们看看模块说明符在 Node.js 中是如何工作的。

5.5.1 在 Node.js 中解析模块说明符

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

这就是算法

解析算法的结果必须指向一个文件。这解释了为什么绝对说明符和相对说明符始终具有文件名扩展名。裸说明符通常没有,因为它们是在包导出中查找的缩写。

模块文件通常具有以下文件名扩展名

如果 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" 指定,并支持两个重要功能

回想一下三种样式的裸说明符

包导出可以帮助我们处理所有三种样式

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/*"

这些不是文件系统 glob,而是如何将外部模块说明符映射到内部模块说明符的说明。

5.5.2.6 公开子树,同时隐藏其部分内容

使用以下技巧,我们公开了目录 my-package/dist/src/ 中的所有内容,但 my-package/dist/src/internal/ 除外

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

请注意,此技巧在导出*没有*文件名扩展名的子树时也有效。

5.5.2.7 条件包导出

我们还可以使导出有条件:然后,根据使用包的上下文,给定路径映射到不同的值。

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

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

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

**开发与生产。** 条件包导出的另一个用例是在“开发”和“生产”环境之间切换

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

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

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

包导入的用例是什么?

将包导入与打包器一起使用时要小心:此功能相对较新,您的打包器可能不支持它。

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 中查找的原因。