跳至主要内容

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 键)。

即插即用数据文件包含项目中使用的包及其依赖项的集合。

__信息

任意字符串数组;仅用作标题字段,为 Yarn 用户提供一些上下文。
"此文件自动生成。请勿触碰,否则有",
"丢失修改内容的风险。",
],

dependencyTreeRoots

依赖项树根的包定位器列表。项目中通常每个工作区有一个条目(至少一个,因为顶级包本身就是一个工作区)。
name: "@app/monorepo",
reference: "workspace:.",
}, {
name: "@app/website",
引用: "workspace:website",
}],

ignorePatternData

可为 null 的正则表达式。如果设置,所有项目相对导入器路径都应与其匹配。如果匹配成功,则解析应遵循经典 Node.js 解析算法,而不是 Plug'n'Play 算法。请注意,与清单中的其他路径不同,与此正则表达式匹配的路径不会以 `./` 开头。
ignorePatternData: "^examples(/|$)",

enableTopLevelFallback

如果为 true,则当未在 `fallbackExclusionList` 中明确列出的导入器的依赖项解析失败时,运行时必须首先检查解析是否会对 `fallbackPool` 中的任何软件包成功;如果会,则透明地返回此解析。请注意,即使未在此处列出,顶级软件包的所有依赖项也隐式包含在后备池中。

fallbackPool

所有软件包都可以访问的定位符映射,无论它们是否在依赖项中列出它们。
"@app/monorepo",
"workspace:.",
]],

fallbackExclusionList

即使已启用,也绝不能使用后备逻辑的软件包映射。键是软件包标识符,值是引用集。将标识符与每个单独的引用相结合,可得到受影响的定位符集。
"@app/server",
["workspace:sources/server"],
]],

packageRegistryData

这是 PnP 数据文件的主要部分。此表包含所有包的列表,首先按包标识符进行键控,然后按包引用进行键控。一个条目在两个字段中都将具有 `null`,并表示绝对顶级包。
[null, [
[null, {

packageRegistryData.packageLocation

包在磁盘上的位置,相对于 Plug'n'Play 清单。此路径必须以 `./` 或 `../` 开头,并以尾随 `/` 结尾。

packageRegistryData.packageDependencies

包允许访问的依赖项集。每个条目都是一个元组,其中第一个键是包名称,值是包引用。请注意,此引用可能为 null!仅当缺少对等依赖项时才会发生这种情况。
["react", "npm:18.0.0"],
],

packageRegistryData.linkType

可以是 SOFT 或 HARD。硬包链接是最常见的,这意味着目标位置完全归包管理器所有。另一方面,软链接通常指向磁盘上任意用户定义的位置。
对于大多数实现者来说,链接类型不应该很重要 - 仅需要它,因为将 Plug'n'Play 树转换为 node_modules 树时涉及一些细微差别。
linkType: "SOFT" | "HARD",

packageRegistryData.discardFromLookup

如果为 true,此可选字段表示当 Plug'n'Play 运行时尝试找出包含给定路径的包时,不应考虑该包。例如,当我们使用 `link:` 协议时,我们使用它,因为它们通常指向包的子文件夹,而不是指向其他包。

packageRegistryData.packagePeers

对等依赖项的包列表。与 `linkType` 一样,此字段不会被 Plug'n'Play 运行时本身使用,而仅会被可能希望利用数据文件来创建 node_modules 文件夹的工具使用。
}],
]],
["react", [
["npm:18.0.0", {

packageRegistryData.packageLocation

包在磁盘上的位置,相对于 Plug'n'Play 清单。此路径必须以 `./` 或 `../` 开头,并以尾随 `/` 结尾。
packageLocation: "./.yarn/cache/react-npm-18.0.0-a0b1c2d3.zip",

packageRegistryData.packageDependencies

包允许访问的依赖项集。每个条目都是一个元组,其中第一个键是包名称,值是包引用。请注意,此引用可能为 null!仅当缺少对等依赖项时才会发生这种情况。
["react-dom", null],
],

packageRegistryData.linkType

可以是 SOFT 或 HARD。硬包链接是最常见的,这意味着目标位置完全归包管理器所有。另一方面,软链接通常指向磁盘上任意用户定义的位置。
对于大多数实现者来说,链接类型不应该很重要 - 仅需要它,因为将 Plug'n'Play 树转换为 node_modules 树时涉及一些细微差别。
linkType: "SOFT" | "HARD",

packageRegistryData.discardFromLookup

如果为 true,此可选字段表示当 Plug'n'Play 运行时尝试找出包含给定路径的包时,不应考虑该包。例如,当我们使用 `link:` 协议时,我们使用它,因为它们通常指向包的子文件夹,而不是指向其他包。

packageRegistryData.packagePeers

对等依赖项的包列表。与 `linkType` 一样,此字段不会被 Plug'n'Play 运行时本身使用,而仅会被可能希望利用数据文件来创建 node_modules 文件夹的工具使用。
"react-dom",
],
}],
]],
],

解析算法

信息

为简单起见,此算法并未提及所有允许将一个模块映射到另一个模块的 Node.js 特性,例如 importsexports 或其他供应商特定的特性。

NM_RESOLVE

NM_RESOLVE(specifier, parentURL)
  1. 此函数在 Node.js 文档 中指定

PNP_RESOLVE

PNP_RESOLVE(specifier, parentURL)
  1. resolvedundefined

  2. 如果 specifier 是 Node.js 内置项,则

    1. resolved 设置为 specifier 本身并返回它
  3. 否则,如果specifier是绝对路径或以“./”或“../”为前缀的路径,则

    1. resolved设置为NM_RESOLVE(specifier, parentURL)并返回它
  4. 否则,

    1. 注意:specifier现在是一个裸标识符

    2. unqualifiedRESOLVE_TO_UNQUALIFIED(specifier, parentURL)

    3. resolved设置为NM_RESOLVE(unqualified, parentURL)

RESOLVE_TO_UNQUALIFIED

RESOLVE_TO_UNQUALIFIED(specifier, parentURL)
  1. resolvedundefined

  2. identmodulePathPARSE_BARE_IDENTIFIER(specifier)的结果

  3. manifestFIND_PNP_MANIFEST(parentURL)

  4. 如果manifest为 null,则

    1. resolved设置为NM_RESOLVE(specifier, parentURL)并返回它
  5. parentLocatorFIND_LOCATOR(manifest, parentURL)

  6. 如果parentLocator为 null,则

    1. resolved设置为NM_RESOLVE(specifier, parentURL)并返回它
  7. parentPkgGET_PACKAGE(manifest, parentLocator)

  8. referenceOrAliasparentPkg.packageDependencies中由ident引用的条目

  9. 如果referenceOrAliasnullundefined,则

    1. 如果manifest.enableTopLevelFallbacktrue,则

      1. 如果parentLocator不在manifest.fallbackExclusionList中,则

        1. fallbackRESOLVE_VIA_FALLBACK(manifest, ident)

        2. 如果fallback既不为null也不为undefined

          1. referenceOrAlias设置为fallback
  10. 如果referenceOrAlias仍然为undefined,则

    1. 抛出解析错误
  11. 如果referenceOrAlias仍然为null,则

    1. 注意:这意味着parentPkgident有一个未满足的对等依赖项

    2. 抛出解析错误

  12. 否则,如果referenceOrAlias是一个数组,则

    1. aliasreferenceOrAlias

    2. dependencyPkgGET_PACKAGE(manifest, alias)

    3. 返回path.resolve(manifest.dirPath, dependencyPkg.packageLocation, modulePath)

  13. 否则,

    1. referencereferenceOrAlias

    2. dependencyPkgGET_PACKAGE(manifest, {ident, reference})

    3. 返回path.resolve(manifest.dirPath, dependencyPkg.packageLocation, modulePath)

GET_PACKAGE

GET_PACKAGE(manifest, locator)
  1. referenceMapparentPkg.packageRegistryData中由locator.ident引用的条目

  2. pkgreferenceMap中由locator.reference引用的条目

  3. 返回pkg

    1. 注意:pkg此处不能为undefined;任何 Plug'n'Play 数据表中引用的所有包必须packageRegistryData中有一个对应的条目。

FIND_LOCATOR

FIND_LOCATOR(manifest, moduleUrl)

注意:此处描述的算法效率很低。在读取清单时,应确保准备更适合此任务的数据结构。

  1. bestLength0

  2. bestLocatornull

  3. relativeUrlmanifestmoduleUrl 之间的相对路径

    1. 注意:相对路径不得以 ./ 开头;如有必要,请对其进行修剪
  4. 如果 relativeUrl 匹配 manifest.ignorePatternData,则

    1. 返回 null
  5. relativeUrlWithDot 为必要时以 ./../ 为前缀的 relativeUrl

  6. 对于 manifest.packageRegistryData 中的每个 referenceMap

    1. 对于 referenceMap 中的每个 registryPkg

      1. 如果 registryPkg.discardFromLookup 不为 true,则

        1. 如果 registryPkg.packageLocation.length 大于 bestLength,则

          1. 如果 relativeUrlregistryPkg.packageLocation 开头,则

            1. bestLength 设置为 registryPkg.packageLocation.length

            2. bestLocator 设置为当前 registryPkg 定位器

  7. 返回 bestLocator

RESOLVE_VIA_FALLBACK

RESOLVE_VIA_FALLBACK(manifest, ident)
  1. topLevelPkgGET_PACKAGE(manifest, {null, null})

  2. referenceOrAliasident 引用的 topLevelPkg.packageDependencies 中的条目

  3. 如果定义了 referenceOrAlias,则

    1. 立即返回它
  4. 否则,

    1. referenceOrAliasident 引用的 manifest.fallbackPool 中的条目

    2. 立即返回它,无论它是否已定义

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)
  1. manifestnull

  2. directoryPathurl 的目录

  3. pnpPathdirectoryPath/.pnp.cjs 连接

  4. 如果 pnpPath 存在于文件系统中,则

    1. pnpDataPathdirectoryPath/.pnp.data.json 连接

    2. manifest 设置为 JSON.parse(readFile(pnpDataPath))

    3. manifest.dirPath 设置为 directoryPath

    4. 返回 manifest

  5. 否则,如果 directoryPath/,则

    1. 返回 null
  6. 否则,

    1. 返回 FIND_PNP_MANIFEST(directoryPath)

PARSE_BARE_IDENTIFIER

PARSE_BARE_IDENTIFIER(specifier)
  1. 如果 specifier 以“@”开头,则

    1. 如果 specifier 不包含“/”分隔符,则

      1. 抛出一个错误
    2. 否则,

      1. ident 设置为 specifier 的子字符串,直到第二个“/”分隔符或字符串结尾,以先发生的为准
  2. 否则,

    1. ident 设置为 specifier 的子字符串,直到第一个“/”分隔符或字符串结尾,以先发生的为准
  3. modulePath 设置为从 ident.length 开始的 specifier 的子字符串

  4. 返回 {ident, modulePath}