pnpm 中的“p”代表“性能”——哇,它确实提供了性能!
我对使用 npm 感到非常沮丧。似乎越来越慢了。使用越来越多的代码仓库意味着进行更频繁的 npm 安装。我花了这么多时间坐着等待它完成并思考,一定有更好的方法!
然后,在同事的坚持下,我开始使用 pnpm 并且没有回去。对于大多数新的(甚至是一些旧的)项目,我已经用 pnpm 替换了 npm,我的工作生活也因此变得更好了。
虽然我开始使用 pnpm 是因为它著名的性能(我并没有失望),但我很快发现 pnpm 具有许多用于工作区的特殊功能,非常适合管理多包monorepo(甚至是多包元 repo)。
在这篇博文中,我们将通过以下部分探索如何使用 pnpm 来管理我们的全栈、多包 monorepo:
-
什么是全栈、多包单仓库?
-
创建一个基本的多包 monorepo
-
安装 Node.js 和 pnpm
-
创建根项目
-
创建嵌套子包
-
我们基本的monorepo的布局
-
-
在全栈 JavaScript monorepo 中共享代码
-
检查我们的项目结构
-
在 JavaScript 中的所有包上运行脚本
-
国旗有什么作用--stream?
-
在特定包上运行脚本
-
-
在全栈 TypeScript monorepo 中共享类型
-
在项目之间共享类型定义
-
在 TypeScript 中的所有包上运行脚本
-
-
pnpm 是如何工作的?
如果您只关心 pnpm 与 npm 的比较,请直接跳至第 5 节。
1. 什么是全栈、多包单仓库?
那么我们到底在说什么?让我分解一下。
它是一个repo,因为它是一个代码存储库。在这种情况下,我们谈论的是 Git 代码存储库,Git 是卓越的主流版本控制软件。
这是一个monorepo,因为我们将多个(子)项目打包到一个代码存储库中,通常是因为它们出于某种原因属于一起,并且我们同时处理它们。
超过 20 万开发人员使用 LogRocket 来创造更好的数字体验了解更多 →
除了 monorepo,它也可以是meta repo,一旦它变得太大和复杂,这对于你的 monorepo 来说是一个很好的下一步——或者,例如,你想将它拆分并为每个单独的 CI/CD 管道项目。
我们可以通过将每个子项目拆分到其自己的存储库中,从 monorepo 转到元存储库,然后使用元工具将它们重新组合在一起。元存储库具有单存储库的便利性,但允许我们为每个子项目拥有单独的代码存储库。
它是多包的,因为我们在 repo 中有一个或多个包。Node.js 包是一个项目,ackage.json其根目录中有 ap 元数据文件。通常,要在多个项目之间共享一个包,我们必须将其发布到npm,但如果该包只在少数项目之间共享,这将是多余的,尤其是对于专有或闭源项目。
它是全栈的,因为我们的 repo 包含一个全栈项目。monorepo 包含前端和后端组件、基于浏览器的 UI 和 REST API。我认为这是展示 pnpm 工作空间优势的最佳方式,因为我可以向您展示如何在前端和后端项目之间共享 monorepo 中的包。
下图显示了典型的全栈、多包 monorepo 的布局:
当然,pnpm 非常灵活,这些工作空间可以以多种不同的方式使用。
其他一些例子:
-
我现在将 pnpm 用于我公司的闭源微服务元存储库
-
我还使用它来管理我的开源Data-Forge Notebook项目,该项目有用于浏览器和 Electron 的项目,它们在它们之间共享包,所有这些都包含在一个 monorepo 中
2.创建一个基本的多包monorepo
这篇博文附带了工作代码,您可以在 GitHub 上亲自试用。您也可以在此处下载 zip 文件,或使用 Git 克隆代码存储库:
来自 LogRocket 的更多精彩文章:
-
不要错过来自 LogRocket 的精选时事通讯The Replay
-
使用 React 的 useEffect优化应用程序的性能
-
在多个 Node 版本之间切换
-
了解如何使用 AnimXYZ 为您的 React 应用程序制作动画
-
探索 Tauri,一个用于构建二进制文件的新框架
-
比较NestJS 与 Express.js
-
发现TypeScript 领域中使用的流行 ORM
git clone git@github.com :ashleydavis/pnpm-workspace-examples.git
现在,打开一个终端并导航到目录:
cd pnpm-工作区-示例
让我们从使用 pnpm 创建一个简单的多包 monorepo 开始,只是为了学习基础知识。
如果这看起来太简单,请直接跳到第 3 节,看看更真实的全栈 monorepo。
这是我们将创建的简单结构:
我们有一个带有根包和子包 A 和 B 的工作区。为了演示 monorepo 中的依赖关系:
-
根工作区依赖于包 A 和 B
-
包 B 依赖于包 A
让我们学习如何为我们的项目创建这个结构。
安装 Node.js 和 pnpm
要尝试其中的任何代码,您首先需要安装 Node.js。如果您还没有 Node.js,请按照他们网页上的说明进行操作。
在我们可以用 pnpm 做任何事情之前,我们还必须安装它:
npm 安装 -g pnpm
根据您的操作系统,还有许多其他方法可以安装 pnpm 。
创建根项目
现在让我们创建我们的根项目。我们将我们的项目称为basic,它与您可以在 GitHub 中找到的代码对齐。第一步是为项目创建一个目录:
mkdir基本
或在 Windows 上:
md基本
从现在开始我就用mkdir;如果您在 Windows 上,请记住md改用。
现在,切换到该目录并package.json为我们的根项目创建文件:
cd基本 pnpm 初始化
在许多情况下,pnpm 的使用与常规的旧 npm 一样。例如,我们将包添加到我们的项目中:
pnpm 安装 dayjs
请注意,这会生成一个pnpm-lock.yaml文件,而不是 npm 的 package-lock.json 文件。您需要将此生成的文件提交给版本控制。
然后我们可以在我们的代码中使用这些包:
常量 dayjs = 要求(“dayjs”); console.log(dayjs().format());
然后我们可以使用 Node.js 运行我们的代码:
节点索引.js
到目前为止,pnpm 与 npm 没有什么不同,除了(在这个小例子中你可能不会注意到)它比 npm 快得多。随着我们项目规模的增长和依赖项数量的增加,这一点将变得更加明显。
创建嵌套子包
pnpm 有一个“工作区”工具,我们可以使用它来在我们的 monorepo 中创建包之间的依赖关系。为了演示基本示例,我们将创建一个名为 A 的子包,并从根包创建对它的依赖项。
为了让 pnpm 知道它正在管理子包,我们在pnpm-workspace.yaml根项目中添加一个文件:
包: - “包/*”
这向 pnpm 表明该目录下的任何子目录packages都可以包含子包。
packages现在让我们为包 A创建目录和子目录:
光盘包 mkdir a 光盘
现在我们可以package.json为 package 创建文件A:
pnpm 初始化
我们将为带有导出函数的包 a 创建一个简单的代码文件,我们可以从根包中调用它:
函数 getMessage() { 返回“来自包 A 的你好”; } 模块.exports = { 获取消息, };
接下来,更新package.json根包以添加对包 A 的依赖项。将此行添加到您的package.json:
"a": "工作区:*",
更新后的package.json文件如下所示:
{ “名称”:“基本”, ... “依赖”:{ "a": "工作区:*", “dayjs”:“^1.11.2” } }
现在我们已经将根包链接到子包 A,我们可以在根包中使用包 A 中的代码:
常量 dayjs = 要求(“dayjs”); 常量 a = 要求(“一”); console.log(`今天的日期:${dayjs().format()}`); console.log(`来自包 a: ${a.getMessage()}`);
注意我们是如何引用包 A 的。如果没有工作空间,我们可能会使用这样的相对路径:
const a = require("./packages/a");
相反,我们通过名称引用它,就好像它是node_modules从Node 包存储库中安装的一样:
常量 a = 要求(“一”);
通常,要实现这一点,我们必须将我们的包发布到 Node 包存储库(公开或私下)。被迫发布一个包以便方便地重用它会导致一个痛苦的工作流程,特别是如果你只在一个单一的monorepo中重用包。在第 5 节中,我们将讨论使共享这些包而不发布它们成为可能的魔力。
再次在我们的终端中,我们导航回根项目的目录并调用pnpm install以将根包链接到子包:
光盘... pnpm 安装
现在我们可以运行我们的代码,看看效果:
节点索引.js
请注意如何从包 A 中检索消息并显示在输出中:
来自 A 包:来自 A 包的你好
这显示了根项目如何使用子包中的功能。
我们基本的monorepo的布局
我们可以像包A一样将包B添加到我们的monorepo中。您可以在示例代码的基本目录下看到最终结果。
此图显示了包含 A 和 B 包的基本项目的布局:
我们已经学会了如何创建一个基本的 pnpm 工作区!让我们继续研究更高级的全栈 monorepo。
3. 在全栈 JavaScript monorepo 中共享代码
将 monorepo 用于全栈项目可能非常有用,因为它允许我们将后端和前端组件的代码放在一个存储库中。这很方便,因为后端和前端通常会紧密耦合并且应该一起更改。使用 monorepo,我们可以对两者进行代码更改并提交到单个代码存储库,同时更新两个组件。推送我们的提交然后触发我们的持续交付管道,该管道同时将前端和后端部署到我们的生产环境。
使用 pnpm 工作区会有所帮助,因为我们可以创建可以在前端和后端之间共享的嵌套包。我们将在这里讨论的示例共享包是一个验证代码库,前端和后端都使用它来验证用户的输入。
您可以在此图中看到,前端和后端都是包本身,并且都依赖于验证包:
请自己尝试全栈 repo:
cd 全栈 pnpm 安装 pnpm 开始
您应该在待办事项列表中看到一些项目。尝试输入一些文本并单击添加待办事项以将项目添加到您的待办事项列表中。
看看如果您不输入文本并单击Add todo item会发生什么。尝试将空的待办事项添加到列表中会在浏览器中显示警报;前端的验证库阻止您添加无效的待办事项。
如果您愿意,您可以绕过前端并直接点击 REST API,使用后端中的 VS Code REST 客户端脚本添加无效的待办事项。然后,后端的验证库做同样的事情:它拒绝无效的待办事项。
检查我们的项目结构
在全栈项目中,该pnpm-workspace.yaml文件包括后端和前端项目作为子包:
包: - 后端 - 前端 - 包/*
在packages子目录下,您可以找到在前端和后端之间共享的验证包。例如,这里package.json from the backend显示了它对验证包的依赖:
{ “名称”:“后端”, ... “依赖”:{ “正文解析器”:“^1.20.0”, "cors": "^2.8.5", “快递”:“^4.18.1”, “验证”:“工作区:*” } }
前端使用验证包来验证新的待办事项是否有效,然后再将其发送到后端:
常量验证 = 要求(“验证”); // ... 异步函数 onAddNewTodoItem() { 常量 newTodoItem = { 文本:newTodoItemText }; 常量结果 = validation.validateTodo(newTodoItem); 如果(!result.valid){ alert(`验证失败:${result.message}`); 返回; } 等待 axios.post(`${BASE_URL}/todo`, { todoItem: newTodoItem }); setTodoList(todoList.concat([ newTodoItem ])); setNewTodoItemText(""); }
后端还使用了验证包。在前端和后端验证用户输入总是一个好主意,因为您永远不知道用户何时会绕过您的前端并直接访问您的 REST API。
如果你愿意,你可以自己试试。在fullstack/backend/test/backend.http下的示例代码存储库中查找VS Code REST 客户端脚本,该脚本允许您使用无效的待办事项触发 REST API。使用该脚本直接触发POST将项目添加到待办事项列表的 HTTP 路由。
您可以在后端代码中看到它如何使用验证包来拒绝无效的待办事项:
// ... app.post("/todo", (req, res) => { 常量 todoItem = req.body.todoItem; 常量结果 = validation.validateTodo(todoItem) 如果(!result.valid){ res.status(400).json(结果); 返回; } // // 待办事项有效,将其添加到待办事项列表中。 // todoList.push(todoItem); res.sendStatus(200); }); // ...
在 JavaScript 中的所有包上运行脚本
使 pnpm 对于管理多包 monorepo 如此有用的一件事是,您可以使用它在嵌套包中递归地运行脚本。
要了解这是如何设置的,请查看根工作区的 package.json 文件中的脚本部分:
{ “名称”:“全栈”, ... “脚本”:{ "开始": "pnpm --stream -r 开始", "start:dev": "pnpm --stream -r run start:dev", “干净”:“rm -rf .parcel-cache && pnpm -r 运行干净” }, ... }
让我们看一下脚本start:dev,它用于在开发模式下启动应用程序。这是来自的完整命令package.json:
pnpm --stream -r 运行开始:dev
该-r标志使 pnpmstart:dev在工作区中的所有包上运行脚本——好吧,至少所有有start:dev脚本的包!它不会在没有实现它的包上运行它,比如验证包,它不是一个可启动的包,所以它不需要那个脚本。
前端和后端包确实实现start:dev了,所以当你运行这个命令时,它们都会被启动。我们可以发出这个命令并同时启动我们的前端和后端!
国旗有什么作用--stream?
--stream启用流输出模式。这只会导致 pnpm 连续显示终端中每个包的完整和交错的脚本输出。这是可选的,但我认为这是同时轻松查看前端和后端的所有输出的最佳方式。
不过,我们不必运行完整的命令,因为这是start:dev在工作区的package.json. 所以,在我们工作区的根目录,我们可以简单地调用这个命令来在开发模式下启动我们的后端和前端:
pnpm 运行开始:开发
在特定包上运行脚本
有时能够在特定子包上运行一个脚本也很有用。您可以使用 pnpm 的--filter标志来执行此操作,它将脚本定位到请求的包。
例如,在根工作区中,我们可以start:dev只为前端调用,如下所示:
pnpm --filter 前端运行开始:dev
--filter我们可以使用该标志将任何脚本定位到任何子包。
4. 在全栈 TypeScript monorepo 中共享类型
在我们的 monorepo 中共享代码库的最佳示例之一是在 TypeScript 项目中共享类型。
全栈 TypeScript 示例项目与之前的全栈 JavaScript 项目具有相同的结构,我只是将它从 JavaScript 转换为 TypeScript。TypeScript 项目还在前端和后端项目之间共享一个验证库。
不过,不同之处在于 TypeScript 项目也有类型定义。特别是在这种情况下,我们正在使用接口,我们希望在我们的前端和后端之间共享这些类型。然后,我们可以确定它们都在同一页面上,关于它们之间传递的数据结构。
请亲自尝试全栈 TypeScript 项目:
光盘打字稿 pnpm 安装 pnpm 开始
现在,与全栈 JavaScript 示例一样,您应该会看到一个待办事项列表并能够向其中添加待办事项。
CamScanner Pro扫描全能王黄金版,手机秒变扫描仪,高效办公必备!
在项目之间共享类型定义
验证库的 TypeScript 版本包含定义前端和后端共享的通用数据结构的接口:
// // 表示待办事项列表中的一项。 // 导出接口 ITodoItem { // // 待办事项的文本。 // 文本:字符串; } // // 负载到 REST API HTTP POST /todo. // 导出接口 IAddTodoPayload { // // 要添加到列表中的待办事项。 // todoItem:ITodoItem; } // // 来自 REST API 的响应 GET /todos. // 导出接口 IGetTodosResponse { // // 检索到的待办事项列表。 // todoList: ITodoItem[]; } // ... 验证代码在这里 ...
这些类型在index.ts file验证库中定义,并在前端用于验证我们在编译时通过 HTTP 发送到后端的数据结构POST:
异步函数 onAddNewTodoItem() { const newTodoItem: ITodoItem = { text: newTodoItemText }; 常量结果 = validateTodo(newTodoItem); 如果(!result.valid){ alert(`验证失败:${result.message}`); 返回; } 等待 axios.post<IAddTodoPayload>( `${BASE_URL}/todo`, { 待办事项:新待办事项 } ); setTodoList(todoList.concat([ newTodoItem ])); setNewTodoItemText(""); }
这些类型还用于后端验证(同样,在编译时)我们通过 HTTP 从前端接收的数据的结构POST:
app.post("/todo", (req, res) => { const payload = req.body as IAddTodoPayload; 常量 todoItem = 有效载荷.todoItem; 常量结果 = validateTodo(todoItem) 如果(!result.valid){ res.status(400).json(结果); 返回; } // // 待办事项有效,将其添加到待办事项列表中。 // todoList.push(todoItem); res.sendStatus(200); });
我们现在对我们在前端和后端之间共享的数据结构进行了一些编译时验证。当然,这就是我们使用 TypeScript 的原因。编译时验证有助于防止编程错误——它为我们提供了一些自我保护。
但是,我们仍然需要运行时保护以防止我们的用户误用,无论是意外还是恶意,您可以看到validateTodo在之前的两个代码片段中仍然存在调用。
在 TypeScript 中的所有包上运行脚本
全栈 TypeScript 项目包含在所有子包上运行脚本的另一个很好的示例。
使用 TypeScript 时,我们经常需要构建我们的代码。这是在开发过程中发现错误的有用检查,也是将代码发布到生产环境的必要步骤。
在这个例子中,我们可以像这样在我们的 monorepo 中构建所有 TypeScript 项目(我们有三个独立的 TS 项目!):
pnpm 运行构建
如果您查看 TypeScript 项目的package.json文件,您会看到build脚本实现如下:
pnpm --stream -r 运行构建
这会build在每个嵌套的 TypeScript 项目上运行脚本,并为每个项目编译代码。
同样,该--stream标志从每个子脚本产生流式交错输出。我更喜欢默认选项,它分别显示每个脚本的输出,但有时它会折叠输出,这可能会导致我们错过重要信息。
另一个很好的例子是clean脚本:
pnpm 运行干净
没有什么比亲自尝试来建立理解更好的了。您应该尝试在您自己的全栈 TypeScript 项目副本中运行这些build和命令。clean
5. pnpm 是如何工作的?
在我们结束之前,这里简要总结一下 pnpm 与 npm 的工作方式。如果您正在寻找更完整的图片,请查看此帖子。
pnpm 比 npm 快得多。多快?显然,根据基准,它快了 3 倍。我不知道。对我来说,pnpm 感觉快了 10 倍。这就是它对我的影响。
pnpm 有一种非常有效的方法来存储下载的包。通常,npm 将为您在计算机上安装的每个项目提供单独的包副本。当您的许多项目将共享依赖项时,这会浪费大量磁盘空间。
pnpm 还有一种完全不同的存储方法。它将所有下载的包存储在您的主目录中的单个.pnpm-store子目录下。从那里,它将包符号链接到需要它们的项目中,从而在所有项目之间共享包。他们制作这项工作的方式看似简单,但它产生了巨大的不同。
正如我们所见,pnpm 对在工作空间中共享包和针对子包运行脚本的支持也很棒。npm 现在也提供工作空间,它们是可用的,但是使用 npm 对子包运行脚本似乎不像使用 pnpm 那样容易。如果我遗漏了什么,请告诉我!
pnpm 也支持在项目中共享包,同样使用符号链接。node_modules它为每个共享包创建符号链接。npm 做了类似的事情,所以它并不是那么令人兴奋,但我认为 pnpm 在这里获胜,因为它提供了更方便的方法来跨子包运行脚本。
结论
我一直在寻找成为更有效的开发人员的方法。采用支持我的工具并避免使用阻碍我的工具是成为快速开发人员的关键部分,也是我的新书《快速全栈开发》中的一个关键主题。这就是我选择 pnpm 而不是 npm 的原因——它快得多,对我的工作效率产生了重要影响。
感谢您的阅读,希望您在使用 pnpm 时玩得开心。在 Twitter 上关注我以获得更多这样的内容!
您是否正在添加新的 JS 库以提高性能或构建新功能?如果他们反其道而行之呢?
毫无疑问,前端变得越来越复杂。当您将新的 JavaScript 库和其他依赖项添加到您的应用程序时,您将需要更多的可见性以确保您的用户不会遇到未知问题。
LogRocket是一个前端应用程序监控解决方案,可让您重放 JavaScript 错误,就好像它们发生在您自己的浏览器中一样,因此您可以更有效地对错误做出反应。
无论框架如何, LogRocket 都可以完美地与任何应用程序配合使用,并且具有用于记录来自 Redux、Vuex 和 @ngrx/store 的附加上下文的插件。无需猜测问题发生的原因,您可以汇总并报告问题发生时应用程序所处的状态。LogRocket 还监控您的应用程序的性能,报告客户端 CPU 负载、客户端内存使用情况等指标。