PnP 规范
关于本文件
为了让第三方项目更容易实现互操作性,本文件描述了我们在 即插即用安装策略 下安装文件到磁盘时遵循的规范。它还意味着
- 我们对本文件的任何更改都将遵循 semver 规则
- 我们将尽力保持向后兼容性
- 新功能旨在优雅降级
高级理念
即插即用通过在内存中保留依赖项树中所有包的表来工作,这样我们就可以轻松回答两个不同的问题
- 给定一个路径,它属于哪个包?
- 给定一个包,它可以访问哪些依赖项?
因此,解析包导入成为交织这两个操作的问题
- 首先,找到请求解析的包
- 然后检索其依赖项,检查请求的包是否在其中
- 如果在,则检索依赖项信息,并返回其位置
然后可以设计额外的功能,但这些功能是可选的。例如,Yarn 利用它了解项目的信息,在无法解析依赖项时抛出语义错误:由于我们知道整个依赖项树的状态,因此我们也知道包可能缺失的原因。
基本概念
所有软件包都由定位符唯一引用。定位符是软件包标识符(如果相关,则包括其作用域)和软件包引用的组合,软件包引用可以看作用于区分同一软件包的不同实例(或版本)的唯一 ID。软件包引用应视为不透明值:从解析算法的角度来看,它们以 workspace:
、virtual:
、npm:
或任何其他协议开头并不重要。
可移植性
出于可移植性原因,清单中的所有路径
- 必须使用 Unix 路径格式(
/
作为分隔符)。 - 必须相对于清单文件夹(因此无论项目在磁盘上的位置如何,它们都是相同的)。
本规范中的所有算法都假定路径已根据这两条规则进行了规范化。
回退
为了提高与旧代码库的兼容性,Plug'n'Play 支持我们称之为“回退”的功能。当软件包向其依赖项(未在其依赖项中列出)发出解析请求时,会触发回退。在正常情况下,解析器会抛出异常,但当启用回退时,解析器应首先尝试在特殊软件包集的依赖项中查找依赖项软件包。如果找到,则会透明地返回。
从某种意义上说,回退可以看作是一种有限且更安全的提升形式。虽然提升允许通过多个级别的依赖项进行不受约束的访问,但回退需要明确定义回退软件包 - 通常是顶级软件包。
软件包位置
虽然 Plug'n'Play 规范本身不要求运行时在访问软件包文件时支持常规文件系统之外的任何内容,但生产者可能依赖于更复杂的数据存储机制。例如,Yarn 本身需要以下两个扩展,我们强烈建议支持
Zip 访问
名为 *.zip
的文件必须被视为文件夹,以便访问文件。例如,/foo/bar.zip/package.json
需要访问位于 /foo/bar.zip
zip 存档中的 package.json
文件。
如果编写 JS 工具,@yarnpkg/fslib
软件包可能会有所帮助,它提供了一个名为 ZipOpenFS
的支持 zip 的文件系统层。
虚拟文件夹
为了正确显示列出对等依赖项的包,Yarn 依赖于一个名为虚拟包的概念。它们最显著的特性是它们都有不同的路径(以便 Node.js 根据需要实例化它们多次),同时仍然由磁盘上的同一个具体文件夹烘焙。
这是通过为以下方案添加路径支持来完成的
/path/to/some/folder/__virtual__/<hash>/<n>/subpath/to/file.dat
当找到此模式时,必须删除__virtual__/<hash>/<n>
部分,忽略hash
,并将dirname
操作应用n
次于/path/to/some/folder
部分。一些示例
/path/to/some/folder/__virtual__/a0b1c2d3/0/subpath/to/file.dat
/path/to/some/folder/subpath/to/file.dat
/path/to/some/folder/__virtual__/e4f5a0b1/0/subpath/to/file.dat
/path/to/some/folder/subpath/to/file.dat (different hash, same result)
/path/to/some/folder/__virtual__/a0b1c2d3/1/subpath/to/file.dat
/path/to/some/subpath/to/file.dat
/path/to/some/folder/__virtual__/a0b1c2d3/3/subpath/to/file.dat
/path/subpath/to/file.dat
如果编写 JS 工具,@yarnpkg/fslib
包可能会有所帮助,它提供了一个名为VirtualFS
的虚拟感知文件系统层。
__virtual__
文件夹名称出现在 Yarn 3.0 中。早期版本使用$$virtual
,但我们发现此模式在将路径用作正则表达式或替换的软件中触发错误后对其进行了更改。例如,在String.prototype.replace
的第二个参数中找到的$$
会静默地变成$
。
清单参考
当pnpEnableInlining
明确设置为false
时,Yarn 将生成一个附加的.pnp.data.json
文件,其中包含以下字段。
此文档仅涵盖数据文件本身 - 您应该定义自己的内存中数据结构,在运行时使用清单中的信息填充这些结构。例如,Yarn 将 packageRegistryData
表转换为两个单独的内存表:一个将路径映射到包,另一个将包映射到路径。
您可能会注意到,各个地方使用元组数组来代替映射。这主要是为了使 ES6 映射更容易注入,但也为了有时拥有非字符串键(例如,在一种特定情况下,packageRegistryData
将具有 null
键)。
__信息
dependencyTreeRoots
ignorePatternData
enableTopLevelFallback
fallbackPool
fallbackExclusionList
packageRegistryData
packageRegistryData.packageLocation
packageRegistryData.packageDependencies
packageRegistryData.linkType
packageRegistryData.discardFromLookup
packageRegistryData.packagePeers
packageRegistryData.packageLocation
packageRegistryData.packageDependencies
packageRegistryData.linkType
packageRegistryData.discardFromLookup
packageRegistryData.packagePeers
解析算法
NM_RESOLVE
NM_RESOLVE(specifier, parentURL)
- 此函数在 Node.js 文档 中指定
PNP_RESOLVE
PNP_RESOLVE(specifier, parentURL)
-
令
resolved
为 undefined -
如果
specifier
是 Node.js 内置项,则- 将
resolved
设置为specifier
本身并返回它
- 将
-
否则,如果
specifier
是绝对路径或以“./”或“../”为前缀的路径,则- 将
resolved
设置为NM_RESOLVE
(specifier, parentURL)
并返回它
- 将
-
否则,
-
注意:
specifier
现在是一个裸标识符 -
令
unqualified
为RESOLVE_TO_UNQUALIFIED
(specifier, parentURL)
-
将
resolved
设置为NM_RESOLVE
(unqualified, parentURL)
-
RESOLVE_TO_UNQUALIFIED
RESOLVE_TO_UNQUALIFIED(specifier, parentURL)
-
令
resolved
为 undefined -
令
ident
和modulePath
为PARSE_BARE_IDENTIFIER
(specifier)
的结果 -
令
manifest
为FIND_PNP_MANIFEST
(parentURL)
-
如果
manifest
为 null,则- 将
resolved
设置为NM_RESOLVE
(specifier, parentURL)
并返回它
- 将
-
令
parentLocator
为FIND_LOCATOR
(manifest, parentURL)
-
如果
parentLocator
为 null,则- 将
resolved
设置为NM_RESOLVE
(specifier, parentURL)
并返回它
- 将
-
令
parentPkg
为GET_PACKAGE
(manifest, parentLocator)
-
令
referenceOrAlias
为parentPkg.packageDependencies
中由ident
引用的条目 -
如果
referenceOrAlias
为null或undefined,则-
如果
manifest.enableTopLevelFallback
为true,则-
如果
parentLocator
不在manifest.fallbackExclusionList
中,则-
令
fallback
为RESOLVE_VIA_FALLBACK
(manifest, ident)
-
如果
fallback
既不为null也不为undefined- 将
referenceOrAlias
设置为fallback
- 将
-
-
-
-
如果
referenceOrAlias
仍然为undefined,则- 抛出解析错误
-
如果
referenceOrAlias
仍然为null,则-
注意:这意味着
parentPkg
对ident
有一个未满足的对等依赖项 -
抛出解析错误
-
-
否则,如果
referenceOrAlias
是一个数组,则-
令
alias
为referenceOrAlias
-
令
dependencyPkg
为GET_PACKAGE
(manifest, alias)
-
返回
path.resolve(manifest.dirPath, dependencyPkg.packageLocation, modulePath)
-
-
否则,
-
令
reference
为referenceOrAlias
-
令
dependencyPkg
为GET_PACKAGE
(manifest, {ident, reference})
-
返回
path.resolve(manifest.dirPath, dependencyPkg.packageLocation, modulePath)
-
GET_PACKAGE
GET_PACKAGE(manifest, locator)
-
令
referenceMap
为parentPkg.packageRegistryData
中由locator.ident
引用的条目 -
令
pkg
为referenceMap
中由locator.reference
引用的条目 -
返回
pkg
- 注意:
pkg
此处不能为undefined;任何 Plug'n'Play 数据表中引用的所有包必须在packageRegistryData
中有一个对应的条目。
- 注意:
FIND_LOCATOR
FIND_LOCATOR(manifest, moduleUrl)
注意:此处描述的算法效率很低。在读取清单时,应确保准备更适合此任务的数据结构。
-
令
bestLength
为 0 -
令
bestLocator
为 null -
令
relativeUrl
为manifest
和moduleUrl
之间的相对路径- 注意:相对路径不得以
./
开头;如有必要,请对其进行修剪
- 注意:相对路径不得以
-
如果
relativeUrl
匹配manifest.ignorePatternData
,则- 返回 null
-
令
relativeUrlWithDot
为必要时以./
或../
为前缀的relativeUrl
-
对于
manifest.packageRegistryData
中的每个referenceMap
值-
对于
referenceMap
中的每个registryPkg
值-
如果
registryPkg.discardFromLookup
不为 true,则-
如果
registryPkg.packageLocation.length
大于bestLength
,则-
如果
relativeUrl
以registryPkg.packageLocation
开头,则-
将
bestLength
设置为registryPkg.packageLocation.length
-
将
bestLocator
设置为当前registryPkg
定位器
-
-
-
-
-
-
返回
bestLocator
RESOLVE_VIA_FALLBACK
RESOLVE_VIA_FALLBACK(manifest, ident)
-
令
topLevelPkg
为GET_PACKAGE
(manifest, {null, null})
-
令
referenceOrAlias
为ident
引用的topLevelPkg.packageDependencies
中的条目 -
如果定义了
referenceOrAlias
,则- 立即返回它
-
否则,
-
令
referenceOrAlias
为ident
引用的manifest.fallbackPool
中的条目 -
立即返回它,无论它是否已定义
-
FIND_PNP_MANIFEST
FIND_PNP_MANIFEST(url)
找到要用于解析的正确 PnP 清单并不总是容易的。有两个主要选项
-
假设有一个 PnP 清单覆盖整个项目。这是最常见的情况,即使引用第三方项目(例如通过
portal:
协议),它们的依赖项树也与主项目存储在同一清单中。为此,请在进程开始时调用
FIND_CLOSEST_PNP_MANIFEST
(require.main.filename)
一次,缓存其结果,并将其返回给FIND_PNP_MANIFEST
的每次调用(如果你在 Node.js 中运行,你甚至可以使用require.resolve('pnpapi')
,它将为你完成这项工作)。 -
尝试在多项目世界中操作。这很少需要。我们在 Node.js PnP 加载器中支持它,但仅是因为像
create-react-app
这样的“项目生成器”工具通过yarn create react-app
运行,并且需要两个不同的项目(生成器项目和
生成项目)在同一个 Node.js 进程中协作。支持此用例很困难,因为它需要一个簿记机制来跟踪用于访问模块的清单,尽可能地重复使用它们,并且仅在链断开时才寻找新的清单。
FIND_CLOSEST_PNP_MANIFEST
FIND_CLOSEST_PNP_MANIFEST(url)
-
让
manifest
为 null -
让
directoryPath
为url
的目录 -
让
pnpPath
为directoryPath
与/.pnp.cjs
连接 -
如果
pnpPath
存在于文件系统中,则-
让
pnpDataPath
为directoryPath
与/.pnp.data.json
连接 -
将
manifest
设置为JSON.parse(readFile(pnpDataPath))
-
将
manifest.dirPath
设置为directoryPath
-
返回
manifest
-
-
否则,如果
directoryPath
为/
,则- 返回 null
-
否则,
- 返回
FIND_PNP_MANIFEST
(directoryPath)
- 返回
PARSE_BARE_IDENTIFIER
PARSE_BARE_IDENTIFIER(specifier)
-
如果
specifier
以“@”开头,则-
如果
specifier
不包含“/”分隔符,则- 抛出一个错误
-
否则,
- 将
ident
设置为specifier
的子字符串,直到第二个“/”分隔符或字符串结尾,以先发生的为准
- 将
-
-
否则,
- 将
ident
设置为specifier
的子字符串,直到第一个“/”分隔符或字符串结尾,以先发生的为准
- 将
-
将
modulePath
设置为从ident.length
开始的specifier
的子字符串 -
返回
{ident, modulePath}