规则手册
编写可移植包非常重要,因为它确保无论用户使用哪种包管理器,都能获得最佳体验。
为帮助实现这一目标,本页面详细介绍了最新的一系列最佳实践,你应遵循这些最佳实践以使你的包在所有三个主要包管理器(Yarn、pnpm 和 npm)上无缝运行,如果你想了解更多信息,还提供了说明。
包应仅要求其依赖项中正式列出的内容
原因:否则,你的包将容易受到不可预测的提升,这将导致一些使用者遇到伪随机崩溃,具体取决于他们碰巧使用的其他包。
假设 Alice 使用 Babel。Babel 依赖于一个实用程序包,该实用程序包本身依赖于旧版本的 Lodash。由于实用程序包已经依赖于 Lodash,因此 Babel 维护者 Bob 决定在 Babel 本身中使用 Lodash 而没有正式声明它。
由于提升,Lodash 将被放在顶部,树形结构将变成如下所示
到目前为止,一切都很好:实用程序包仍然需要 Lodash,但我们不再需要在 Babel 中创建子目录。现在,想象一下 Alice 也将 Gatsby 添加到组合中,我们假装它也依赖于 Lodash,但这一次是现代版本;树形结构将如下所示
提升变得更加有趣 - 由于 Babel 没有正式声明依赖项,因此可能会发生两种不同的提升布局。第一个与我们之前拥有的布局几乎相同,唯一的例外是我们现在有两个 Lodash 副本,只有一个提升到停止位置,这样我们不会造成冲突
但第二个布局同样有可能!而这正是事情变得棘手的时候
首先,让我们检查此布局是否有效:Gatsby 仍然获得其 Lodash 4 依赖项,Babel 实用程序包仍然获得 Lodash 1,而 Babel 本身仍然获得实用程序包,就像以前一样。但其他一些东西发生了变化!Babel 将不再访问 Lodash 1!它将改为检索 Gatsby 提供的 Lodash 4 副本,这可能与 Babel 最初预期的不兼容。在最好的情况下,应用程序将崩溃,在最坏的情况下,它将静默传递并生成不正确的结果。
如果 Babel 相反将 Lodash 1 定义为其自己的依赖项,则包管理器将能够对该约束进行编码,并确保无论提升如何,都将满足该要求。
解决方案:在大多数情况下(当缺少的依赖项是实用程序包时),修复方法实际上只是将缺少的条目添加到 dependencies
字段 中。虽然通常足够,但有时会出现一些更复杂的情况
-
如果您的包是插件(例如
babel-plugin-transform-commonjs
),并且缺少的依赖项是核心(例如babel-core
),则您需要在peerDependencies
字段 中注册依赖项。 -
如果你的包自动加载插件(例如
eslint
),则对等依赖显然不是一个选择,因为你无法合理地列出所有插件。相反,你应该使用createRequire
函数(或其 polyfill)来加载插件,代表列出要加载的插件的配置文件 - 无论是 package.json 还是自定义文件,例如.eslintrc.js
文件。 -
如果你的包仅在用户控制的特定情况下需要依赖项(例如
mikro-orm
,它仅在使用者实际使用 SQLite3 数据库时才依赖于sqlite3
),请使用peerDependenciesMeta
字段 将对等依赖项声明为可选,并在未满足时消除任何警告。 -
如果你的包是实用程序的元包(例如 Next.js,它本身依赖于 Webpack,以便其使用者不必这样做),则情况有点复杂,你有两个不同的选择
-
首选方法是将依赖项(在 Next.js 的情况下为
webpack
)列为常规依赖项和对等依赖项。Yarn 将此模式解释为“具有默认值的对等依赖项”,这意味着如果需要,你的用户将能够拥有 Webpack 包的所有权,同时仍允许包管理器在提供的版本与你的包期望的版本不兼容时发出警告。 -
另一种方法是将依赖项重新导出为公共 API 的一部分。例如,Next 可以公开一个
next/webpack
文件,该文件仅包含module.exports = require('webpack')
,而使用者需要它而不是典型的webpack
模块。然而,这不是推荐的方法,因为它无法很好地与期望 Webpack 成为对等依赖项的插件配合使用(它们不知道需要使用此next/webpack
模块)。
-
模块不应硬编码 node_modules
路径来访问其他模块
原因:提升机制使得无法确保 node_modules
文件夹的布局始终相同。事实上,根据确切的安装策略,node_modules
文件夹甚至可能不存在。
解决方案:如果你需要通过 fs
API 访问依赖项中的一个文件(例如读取依赖项的 package.json
),只需使用 require.resolve
获取路径,而无需对依赖项位置进行假设。
const fs = require(`fs`);
const data = fs.readFileSync(require.resolve(`my-dep/package.json`));
如果你需要访问依赖项的依赖项(我们不建议这样做,但在某些极端情况下可能会发生),请不要对 node_modules
路径进行硬编码,而是使用 createRequire
函数。
const {createRequire} = require(`module`);
const firstDepReq = createRequire(require.resolve(`my-dep/package.json`));
const secondDep = firstDepReq(`transitive-dep`);
请注意,虽然 createRequire
是 Node 12+,但有一个名为 create-require
的垫片。
用户脚本不应对 node_modules/.bin
文件夹进行硬编码
原因:.bin
文件夹是一个实现细节,可能根本不存在,具体取决于安装策略。
解决方案:如果你正在编写 脚本,你可以直接引用二进制文件的名称!因此,不要使用 node_modules/.bin/jest -w
,而应只写 jest -w
,它将正常工作。如果由于某种原因 jest
不可用,请检查当前包是否正确 将其定义为依赖项。
有时你可能会发现自己有更复杂的需求,例如如果你希望使用特定的 Node 标志生成脚本。根据上下文,我们建议通过 NODE_OPTIONS
环境变量 传递选项,而不是通过 CLI,但如果这不是一种选择,你可以使用 yarn bin name
获取指定的二进制路径。
yarn node --inspect $(yarn bin jest)
请注意,在这种特殊情况下,yarn run
也支持 --inspect
标志,因此你可以直接编写
已发布的包应避免在脚本中使用 npm run
原因:这是一个棘手的问题... 基本上,归结为:包管理器不可互换。在一个由另一个包管理器安装的项目上使用一个包管理器是制造麻烦的秘诀,因为它们遵循不同的配置设置和规则。例如,Yarn 提供了一个钩子系统,允许其用户跟踪执行哪些脚本以及它们花费了多少时间。因为 npm run
不知道如何调用这些钩子,所以它们会被忽略,从而给你的消费者带来令人沮丧的体验。
解决方案:虽然不是最美观的选择,但目前最便携的选择是简单地用 npm run name
(或 yarn run name
)替换你的 postinstall 脚本,并由以下内容派生
$npm_execpath run <name>
$npm_execpath
环境变量将根据你的消费者将使用的包管理器替换为正确的二进制文件。Yarn 还支持只调用 run <name>
而无需提及包管理器,但到目前为止,没有其他包管理器这样做。
包绝不应该在 postinstall 之外写入它们自己的文件夹
原因:根据安装策略,包可能会保存在拒绝写入访问的只读数据存储中。当使用“系统全局”存储时尤其如此,其中修改一个包的源代码会冒着破坏同一台计算机上依赖它的所有项目的风险。
解决方案:只需写入另一个目录,而不是你自己的包。任何东西都可以,但一种非常常见的习惯用法是使用 node_modules/.cache
文件夹来存储缓存数据 - 例如,Babel、Webpack 等就是这样做的。
如果你绝对需要写入你的包的源文件夹(但实际上,我们以前从未遇到过这种情况),你仍然可以选择使用 preferUnplugged
指示 Yarn 禁用你包上的优化,并将其存储在其自己的项目本地副本中,在那里你可以随意修改它。
包应该使用 prepack
脚本在发布之前生成 dist 文件
原因:最初的 npm 支持多种不同的脚本。事实上,太多以至于很难知道在什么情况下应该使用哪个脚本。特别是,prepack
、prepare
、prepublish
和 prepublish-only
脚本之间非常细微的差异导致许多人在错误的情况下使用了错误的脚本。出于这个原因,Yarn 2 弃用了大多数脚本,并将它们合并到一组受限的可移植脚本中。
解决方案:如果您希望在发布包之前生成 dist 工件,请始终使用 prepack
脚本。它将在调用 yarn pack
(它本身在调用 yarn npm publish
之前调用)之前调用,当将您的 git 存储库克隆为 git 依赖项时,以及任何时候您将运行 yarn prepack
时。至于 prepublish
,切勿在有副作用的情况下使用它 - 它唯一的用途是在发布步骤之前运行测试。