Yarn Workspace项目切换到Pnpm Workspace完整指南

之前小组的monorepo单仓一直采用的是Yarn Workspace包管理策略,随着项目的扩大,逐渐出现了一些问题。为此在调研了社区的Pnpm Workspace方案后,决定将小组单仓的包管理工具Yarn切换为Pnpm

起因

笔者所在开发小组采用采用的是monorepo单仓,通过 link 仓库中的各个 package,达到跨项目复用的目的。其中一个项目A使用了 TDesign作为组件库,为了方便跨项目使用,TDesign被安装到了 root workspace下,对monorepo内的所有项目都可用。

之后在一次普通的Code Review中发现有同学使用了 dayjs 进行日期转换

这一点让我很困惑,因为之前项目里用的日期库一直都是 moment,也没有其他项目安装过dayjs呀,那这里怎么就能使用了呢?

之后查找node_modules,在root workspacenode_modules下发现确实有dayjs,但是package.json却没有dayjs的依赖声明。

什么情况?这个依赖从哪冒出来的?

当项目中使用了一个没有在 package.json 文件中定义的包时,幻影依赖便出现了

上面代码里的dayjs就是幻影依赖的直观表现,其源自另一个库:TDesign

刨根问底

node_modules里的依赖树为什么会平铺?平铺可以带来什么样的好处?不平铺不行吗?

npm2

npm2 及之前的时代, node_modules 的结构是干净且可预测的,因为 node_modules 中的每个依赖项都有其自己的 node_modules 文件夹,其所有依赖项都在 package.json 中指定。

因为 npm 设计的初衷就是考虑到了包依赖的版本错综复杂的关系,同一个包因为被依赖的关系原因会出现多个版本,简单地填充结构保证了无论是安装还是删除都会有统一的行为和结构。

js 复制代码
node_modules
└─ foo
   ├─ index.js
   ├─ package.json
   └─ node_modules
      └─ bar
         ├─ index.js
         └─ package.json

这样做的缺陷随着项目依赖的增加而逐渐显现出来,太深的目录树结构会严重影响安装和查找效率,甚至在 Windows 系统下可能会因此文件路径过长超出系统路径限制的长度。另外,因此其嵌套的层次过深,在 Windows 系统下删 node_modules 目录都会经历漫长的等待。

npm3

针对 npm2 的问题,npm3 加了点算法,直白的解释就是:npm install 时会按照 package.json 里依赖的顺序依次解析,遇到新的包就把它放在第一级目录,后面如果遇到一级目录已经存在的包,会先判断版本,如果版本一样则忽略,否则会按照 npm2 的方式依次挂在依赖包目录下,于是就出现了我们熟悉的 node_modules 结构:

js 复制代码
node_modules
├─ foo
|  ├─ index.js
|  └─ package.json
└─ bar
   ├─ index.js
   └─ package.json

yarn

yarn 是meta(原Facebook)开源的一款包管理工具,其诞生之初就引起了极大的关注,它弥补npm的一些缺陷,相比npm,其优势有:

  1. 速度更快:yarn 缓存了每个下载过的包,所以再次使用时无需重复下载。 同时利用并行下载以最大化资源利用率,因此安装速度更快。
  2. 更安全:在执行代码之前,yarn 会通过算法校验每个安装包的完整性。
  3. 更可靠:使用详细、简洁的锁文件格式和明确的安装算法,yarn 能够保证在不同系统上无差异的工作(自动生成yarn.lock)。

当然,随着npm的不断改进,其参考yarn也引入了安装缓存、版本锁的概念,所以从技术架构上讲,npmyarn已经没有多少差别。

npm3一样,yarn也采用平铺的方式组织node_modules

破圈

npm3yarn 采用的都是平铺方式组织 node_modules,在幻影依赖问题上基本无解,那如何解决?

"供需相生,市场因需求而存在。"

恶龙已经出现,谁会成为那位屠龙者呢? ------ pnpm 横空出世!
平铺的结构不是 node_modules 的唯一实现方式 ------ Zoltan Kochan,pnpm作者专门用一篇文章回答了这个问题,如何在不平铺的方式下实现依赖包的管理并保证安装效率。

简单来讲就是在由 pnpm 创建的 node_modules 文件夹中,所有 package 都与自身的依赖项分组在一起(隔离),但是依赖层级却不会过深,因为pnpm使用软链接到外面真正的地址,而不是在当前层级下创建新的文件夹,(实现package逻辑上的隔离,物理上的引用)。

js 复制代码
-> - a symlink in Macos | Linux (or junction on Windows)

node_modules
├─ foo -> .registry.npmjs.org/foo/1.0.0/node_modules/foo
└─ .registry.npmjs.org
   ├─ foo/1.0.0/node_modules
   |  ├─ bar -> ../../bar/2.0.0/node_modules/bar
   |  └─ foo
   |     ├─ index.js
   |     └─ package.json
   └─ bar/2.0.0/node_modules
      └─ bar
         ├─ index.js
         └─ package.json

切换到Pnpm workspace实践

安装并配置Pnpm workspace

  • 首先在本地安装pnpm,这里用的是npm全局安装方式,其他安装方式可参见官方指引
bash 复制代码
npm install -g pnpm
  • 配置pnpm-workspace.yaml 在monorepo根目录下增加pnpm-workspace.yaml,添加配置:
js 复制代码
packages:
  - "app-micro/main"
  - "app-micro/exercise"
  - "app-micro/rest"
  - "app-micro/sleep"
  - "app-design"
  - "app-types"
  - "app-utils"
  • 执行pnpm i,安装依赖:

可以发现安装的依赖后面出现了软链的标记。

使用软链安装monorepo内的package

首先本文项目的目录组织结构为:

css 复制代码
├─ app-micro
|  ├─ main
|  ├─ exercise
|  ├─ rest
|  └─ sleep
├─ app-design
├─ app-utils
└─ app-types

在上面的配置中,整个monorepo里拥有7个workspace(也就是7个独立的项目)。这些项目之间可以内部软链的方式互相引用。比如,在app-micro/main中引用了app-designapp-typesapp-utils,其引用命令为:

bash 复制代码
pnpm add app-design app-types app-utils --workspace

安装成功后在app-micro/mainpackage.json中会出现下面的配置:

上述配置表明该依赖来自workspace内部,安装时优先从workspace内部寻找包,而不是从npm注册表中寻找包。看下app-micro/main下node_modules的结构:

修改webpack配置(vite可忽略)

在pnpm视角下虽然引用了其他项目,但对于构建器webpack,这些依赖是不存在的。需要指引webpack将引用的workspace纳入到构建路径中。这里需要修改两个配置文件:

  • config/paths.js: 增加外部依赖的引用路径
js 复制代码
module.exports = {
  dotenv: resolveApp('.env'),
  appPath: resolveApp('.'),
  appBuild: resolveApp(buildPath),
  appPublic: resolveApp('public'),
  appHtml: resolveApp('public/index.html'),
  appIndexJs: resolveModule(resolveApp, 'src/index'),
  appPackageJson: resolveApp('package.json'),
  appSrc: resolveApp('src'),
  appTsConfig: resolveApp('tsconfig.json'),
  proxySetup: resolveApp('src/setupProxy.js'),
  appNodeModules: resolveApp('node_modules'),
  appWebpackCache: resolveApp('node_modules/.cache'),
  appTsBuildInfoFile: resolveApp('node_modules/.cache/tsconfig.tsbuildinfo'),
  // 路径根据项目位置来动态确定,本文描述项目的目录结构可见章节开头
  appUtils: resolveApp('../../app-utils'),
  appTypes: resolveApp('../../app-types'),
  appDesign: resolveApp('../../app-design'),
  publicUrlOrPath,
};
  • config/webpack.config.js中增加构建路径
js 复制代码
oneOf: [{
              test: /\.(js|mjs|jsx|ts|tsx)$/,
              include: [paths.appSrc, paths.appUtils, paths.appTypes, paths.appDesign],
              loader: require.resolve('babel-loader'),
              options: {
                customize: require.resolve('babel-preset-react-app/webpack-overrides'),
                presets: [
                  [
                    require.resolve('babel-preset-react-app'),
                    {
                      runtime: hasJsxRuntime ? 'automatic' : 'classic',
                    },
                  ],
                ],

                plugins: [isEnvDevelopment && shouldUseReactRefresh && require.resolve('react-refresh/babel')].filter(
                  Boolean,
                ),
                // This is a feature of `babel-loader` for webpack (not Babel itself).
                // It enables caching results in ./node_modules/.cache/babel-loader/
                // directory for faster rebuilds.
                cacheDirectory: true,
                // See #6846 for context on why cacheCompression is disabled
                cacheCompression: false,
                compact: isEnvProduction,
              },
            },
            ...
            ]

修改之后项目应该就能正常跑起来了。如果运行过程中提示包缺失,可以查看一下是否引用了幻影依赖,如果有则需要在安装一下该依赖(最好是没有,因为有的话说明幻影依赖已经上到生产环境了,这可是个大隐患,hhh~

总结

  1. npm | yarn平铺node_modules的方式对幻影依赖是无解的,pnpm独特的node_modules组织方式能有效解决幻影依赖问题
  2. pnpm通过软链复用相同版本的 package,避免重复打包(相同版本package只保留一个),解决 NPM doppelgnger(顺带解决磁盘占用)
  3. 开启pnpm workspace 能力从而可以使用 workspace: 协议实现 monorepo 内部项目的互相引用
  4. 使用pnpm workspace引用内部package需要修改构建器配置,让构建器能够将外部package纳入到构建范围里
  5. 尽管pnpm的链接策略遵循了当下 NodeJS 版本解析标准,但是很多老包并没有,这可能存在一些兼容性问题,对于历史项目建议保持原状

附录

pnpm 常用命令一览:

  • pnpm i | pnpm install:初始化项目依赖
  • pnpm add [package]:安装指定包到并保存到 dependencies 配置下
  • pnpm add [package] --dev:安装指定依赖并保存在 devDependencies 配置下
  • pnpm add -g [package]:全局安装依赖
  • pnpm remove [package]:卸载依赖
  • pnpm remove -g [package]:卸载全局依赖
  • pnpm add [package] [-w | --workspace-root]:安装依赖到根workspace下
  • pnpm add [package] [--workspace]:软链安装内部的workspace
  • pnpm store path:查看本机npm包存储路径

参考

相关推荐
理想不理想v1 分钟前
vue经典前端面试题
前端·javascript·vue.js
不收藏找不到我2 分钟前
浏览器交互事件汇总
前端·交互
YBN娜16 分钟前
Vue实现登录功能
前端·javascript·vue.js
阳光开朗大男孩 = ̄ω ̄=16 分钟前
CSS——选择器、PxCook软件、盒子模型
前端·javascript·css
阿伟*rui19 分钟前
认识微服务,微服务的拆分,服务治理(nacos注册中心,远程调用)
微服务·架构·firefox
minDuck21 分钟前
ruoyi-vue集成tianai-captcha验证码
java·前端·vue.js
小政爱学习!41 分钟前
封装axios、环境变量、api解耦、解决跨域、全局组件注入
开发语言·前端·javascript
魏大帅。1 小时前
Axios 的 responseType 属性详解及 Blob 与 ArrayBuffer 解析
前端·javascript·ajax
ZHOU西口1 小时前
微服务实战系列之玩转Docker(十八)
分布式·docker·云原生·架构·数据安全·etcd·rbac
花花鱼1 小时前
vue3 基于element-plus进行的一个可拖动改变导航与内容区域大小的简单方法
前端·javascript·elementui