package.json
package.json
的 "dependencies"
属性package.json
的 "bin"
属性package.json
的 "license"
属性node:
协议导入本章解释了什么是 npm 包,以及它们如何与 ESM 模块交互。
**所需知识:**我假设您大致熟悉 ECMAScript 模块的语法。如果您不熟悉,可以阅读“JavaScript for impatient programmers”中的“模块”一章。
在 JavaScript 生态系统中,_包_是一种组织软件项目的方式:它是一个具有标准化布局的目录。一个包可以包含各种文件,例如
一个包可以_依赖_其他包(称为其_依赖项_),其中包含
包的依赖项安装在该包中(我们很快就会看到)。
包之间的一个常见区别是
下一小节解释了如何发布包。
发布包的主要方式是将其上传到包注册表——一个在线软件存储库。事实上的标准是_npm 注册表_,但这并不是唯一的选择。例如,公司可以托管自己的内部注册表。
_包管理器_是一个命令行工具,它从注册表(或其他来源)下载包并将其安装在本地或全局。如果包包含 bin 脚本,它也会在本地或全局提供这些脚本。
最流行的包管理器叫做_npm_,它与 Node.js 捆绑在一起。它的名字最初代表“Node Package Manager”。后来,当 npm 和 npm 注册表不仅用于 Node.js 包时,定义改为“npm 不是包管理器”(来源)。
还有其他流行的包管理器,如 yarn 和 pnpm。所有这些包管理器默认都使用 npm 注册表。
npm 注册表中的每个包都有一个名称。名称有两种
_全局名称_在整个注册表中是唯一的。以下是两个例子
minimatch mocha
_作用域名称_由两部分组成:作用域和名称。作用域是全局唯一的,名称在每个作用域内是唯一的。以下是两个例子
@babel/core
@rauschma/iterable
作用域以 @
符号开头,并用斜杠与名称分隔。
一旦包 my-package
完全安装,它几乎总是如下所示
my-package/
package.json
node_modules/
[More files]
这些文件系统条目的用途是什么?
package.json
是每个包都必须具有的文件node_modules/
是一个目录,包的依赖项安装到该目录中。每个依赖项也有一个 node_modules
文件夹,其中包含其依赖项,等等。结果是一个依赖项树。一些包还具有与 package.json
并列的 package-lock.json
文件:它记录了已安装依赖项的确切版本,如果我们通过 npm 添加更多依赖项,它会保持最新状态。
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
用于版本管理,并遵循语义化版本控制,其中包含三个点分隔的数字公共包的其他属性是可选的
description
、keywords
、author
是可选的,可以更容易地找到包。license
说明了如何使用此包。如果包以任何方式公开,则提供此值是有意义的。“选择一个开源许可证”可以帮助做出此选择。main
是具有库代码的包的属性。它指定了“是”包的模块(本章稍后解释)。
scripts
是用于设置_包脚本_的属性——开发时 Shell 命令的缩写。这些可以通过 npm run
执行。例如,脚本 test
可以通过 npm run test
执行。有关此主题的更多信息,请参阅§15 “通过 npm 包脚本运行跨平台任务”。
其他有用的属性
dependencies
列出了包的依赖项。其格式将在稍后解释。
devDependencies
是仅在开发期间需要的依赖项。
以下设置意味着所有扩展名为 .js
的文件都将被解释为 ECMAScript 模块。除非我们正在处理遗留代码,否则添加它是有意义的
"type": "module"
bin
列出了_bin 脚本_,即 npm 作为 Shell 脚本安装的包中的 Node.js 模块。其格式将在稍后解释。
license
指定包的许可证。其格式将在稍后解释。
通常,属性 name
和 version
是必需的,如果缺少它们,npm 会警告我们。但是,我们可以通过以下设置更改它
"private": true
这可以防止包意外发布,并允许我们省略名称和版本。
**有关 package.json
的更多信息,**请参阅npm 文档。
package.json
的 "dependencies"
属性这就是 package.json
文件中依赖项的外观
"dependencies": {
"minimatch": "^5.1.0",
"mocha": "^10.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
(如前例所示)是_插入符号范围_,意味着安装的版本可以是 version
或更高版本,但不得引入重大更改。也就是说,主版本必须相同
"pkg8": "^4.17.21",
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-script
和 another-script
在命令行中可用。
如果我们在本地安装包,我们可以在包脚本中或通过npx
命令使用这两个命令。
字符串也可以作为 "bin"
的值
{
"name": "my-package",
"bin": "./src/main.mjs"
}
这是以下内容的缩写
{
"name": "my-package",
"bin": {
"my-package": "./src/main.mjs"
}
}
package.json
的 "license"
属性属性 "license"
的值始终是带有 SPDX 许可证 ID 的字符串。例如,以下值拒绝其他人以任何条款使用包(如果包未发布,这很有用)
"license": "UNLICENSED"
SPDX 网站列出了所有可用的许可证 ID。如果您发现难以选择,网站“选择一个开源许可证”可以提供帮助——例如,如果您“希望它简单且宽松”,则建议如下
MIT 许可证简短扼要。它允许人们对您的项目做几乎任何他们想做的事情,例如制作和分发闭源版本。
Babel、.NET 和 Rails 使用 MIT 许可证。
您可以像这样使用该许可证
"license": "MIT"
npm 注册表中的包通常以两种不同的方式打包
无论哪种方式,包在打包时都没有包含其依赖项——我们必须先安装依赖项才能使用它。
如果包存储在 git 存储库中
package-lock.json
的原因。如果一个包被发布到 npm 注册表
package-lock.json
从未上传到 npm 注册表的原因。开发依赖项(package.json
中的属性 devDependencies
)仅在开发期间安装,而当我们从 npm 注册表安装包时不会安装。
请注意,在开发过程中,git 存储库中未发布的包的处理方式与已发布的包类似。
要从 git 安装包 pkg
,我们克隆其存储库并
cd pkg/
npm install
然后执行以下步骤
node_modules
并安装依赖项。安装依赖项还意味着下载该依赖项并安装其依赖项(等等)。package.json
配置哪些步骤。如果根包没有 package-lock.json
文件,则会在安装过程中创建该文件(如前所述,依赖项没有此文件)。
在依赖项树中,同一个依赖项可能存在多次,可能在不同的版本中。有一些方法可以最大程度地减少重复,但这超出了本章的范围。
这是一种(稍微粗略的)修复依赖项树中问题的方法
cd pkg/
rm -rf node_modules/
rm package-lock.json
npm install
请注意,这可能会导致安装不同的、更新的包。我们可以通过不删除 package-lock.json
来避免这种情况。
有许多用于设置新包的工具和技术。这是一种简单的方法
mkdir my-package
cd my-package/
npm init --yes
之后,目录如下所示
my-package/
package.json
此 package.json
具有我们已经看到的入门内容。
目前,my-package
没有任何依赖项。假设我们要使用库 lodash-es
。这是我们如何将其安装到我们的包中
npm install lodash-es
此命令执行以下步骤
该包被下载到 my-package/node_modules/lodash-es
中。
它的依赖项也被安装。然后是其依赖项的依赖项。等等。
package.json
中添加了一个新属性
"dependencies": {
"lodash-es": "^4.17.21"
}
package-lock.json
使用安装的确切版本进行更新。
其他 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);
; })
静态导入和动态导入都使用*模块说明符*来引用模块
from
之后的字符串。模块说明符有三种
*绝对说明符*是完整的 URL——例如
'https://www.unpkg.com/browse/[email protected]/browser.mjs'
'file:///opt/nodejs/config.mjs'
绝对说明符主要用于访问直接托管在 Web 上的库。
*相对说明符*是相对 URL(以 '/'
、'./'
或 '../'
开头)——例如
'./sibling-module.js'
'../module-in-parent-dir.mjs'
'../../dir/other-module.js'
每个模块都有一个 URL,其协议取决于其位置(file:
、https:
等)。如果它使用相对说明符,JavaScript 会通过针对模块的 URL 解析该说明符将其转换为完整的 URL。
相对说明符主要用于访问同一代码库中的其他模块。
*裸说明符*是不以斜杠或点开头的路径(没有协议和域)。它们以包的名称开头。这些名称后面可以选择跟*子路径*
'some-package'
'some-package/sync'
'some-package/util/files/path-tools.js'
裸说明符也可以引用具有作用域名称的包
'@some-scope/scoped-name'
'@some-scope/scoped-name/async'
'@some-scope/scoped-name/dir/some-module.mjs'
每个裸说明符都引用包内的确切一个模块;如果它没有子路径,则它引用其包的指定“主”模块。裸说明符从不直接使用,而是始终被*解析*——转换为绝对说明符。解析的工作方式取决于平台。我们很快就会了解更多信息。
.js
或 .mjs
。样式 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'
样式 3 裸说明符的注意事项:如何解释文件名扩展名取决于依赖项,并且可能与导入包不同。例如,导入包可能对 ESM 模块使用 .mjs
,对 CommonJS 模块使用 .js
,而依赖项导出的 ESM 模块可能具有文件名扩展名为 .js
的裸路径。
让我们看看模块说明符在 Node.js 中是如何工作的。
Node.js 解析算法 的工作原理如下
这就是算法
如果说明符是绝对的,则解析已经完成。三种协议最常见
file:
用于本地文件https:
用于远程文件node:
用于内置模块(稍后讨论)如果说明符是相对的,则针对导入模块的 URL 解析它。
如果说明符是裸的
如果它以 '#'
开头,则通过在*包导入*(稍后解释)中查找并解析结果来解析它。
否则,它是一个具有以下格式之一的裸说明符(子路径是可选的)
«package»/sub/path
@«scope»/«scoped-package»/sub/path
解析算法遍历当前目录及其祖先,直到找到一个目录 node_modules
,该目录具有与裸说明符开头匹配的子目录,即
node_modules/«package»/
node_modules/@«scope»/«scoped-package»/
该目录是包的目录。默认情况下,包 ID 之后的(可能为空的)子路径被解释为相对于包目录。默认值可以通过*包导出*覆盖,这将在接下来解释。
解析算法的结果必须指向一个文件。这解释了为什么绝对说明符和相对说明符始终具有文件名扩展名。裸说明符通常没有,因为它们是在包导出中查找的缩写。
模块文件通常具有以下文件名扩展名
.mjs
,则它始终是 ES 模块。.js
,并且最近的 package.json
具有以下条目,则它是 ES 模块"type": "module"
如果 Node.js 执行通过 stdin、--eval
或 --print
提供的代码,我们使用 以下命令行选项,以便将其解释为 ES 模块
--input-type=module
在本小节中,我们将使用具有以下文件布局的包
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'
一旦该属性存在,则只能使用其中列出的说明符。其他所有内容都对外隐藏。
回想一下三种样式的裸说明符
包导出可以帮助我们处理所有三种样式
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
package.json
:
{
"exports": {
"./util/errors": "./dist/src/util/errors.js"
}
}
我们将说明符子路径 'util/errors'
映射到一个模块文件。这将启用以下导入
import {UserError} from 'my-lib/util/errors';
上一小节解释了如何为无扩展名子路径创建单个映射。还有一种方法可以通过单个条目创建多个此类映射
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"
这些是如何将子路径映射到实际路径的更多说明,而不是匹配文件路径片段的通配符。
package.json
:
{
"exports": {
"./util/errors.js": "./dist/src/util/errors.js"
}
}
我们将说明符子路径 'util/errors.js'
映射到一个模块文件。这将启用以下导入
import {UserError} from 'my-lib/util/errors.js';
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,而是如何将外部模块说明符映射到内部模块说明符的说明。
使用以下技巧,我们公开了目录 my-package/dist/src/
中的所有内容,但 my-package/dist/src/internal/
除外
"exports": {
"./*": "./dist/src/*",
"./internal/*": null
}
请注意,此技巧在导出*没有*文件名扩展名的子树时也有效。
我们还可以使导出有条件:然后,根据使用包的上下文,给定路径映射到不同的值。
**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
包导入 允许包为其自身内部使用的模块说明符定义缩写(其中包导出为其他包定义缩写)。这是一个例子
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
。
(只有包导入可以引用外部包,包导出不能这样做。)
包导入的用例是什么?
将包导入与打包器一起使用时要小心:此功能相对较新,您的打包器可能不支持它。
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
中查找的原因。