之前小组的monorepo
单仓一直采用的是Yarn Workspace
包管理策略,随着项目的扩大,逐渐出现了一些问题。为此在调研了社区的Pnpm Workspace
方案后,决定将小组单仓的包管理工具由Yarn
切换为Pnpm
。
起因
笔者所在开发小组采用采用的是monorepo
单仓,通过 link 仓库中的各个 package,达到跨项目复用的目的。其中一个项目A使用了 TDesign作为组件库,为了方便跨项目使用,TDesign
被安装到了 root workspace
下,对monorepo
内的所有项目都可用。
之后在一次普通的Code Review
中发现有同学使用了 dayjs 进行日期转换
这一点让我很困惑,因为之前项目里用的日期库一直都是 moment,也没有其他项目安装过dayjs
呀,那这里怎么就能使用了呢?
之后查找node_modules
,在root workspace
的node_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,其优势有:
- 速度更快:
yarn
缓存了每个下载过的包,所以再次使用时无需重复下载。 同时利用并行下载以最大化资源利用率,因此安装速度更快。 - 更安全:在执行代码之前,
yarn
会通过算法校验每个安装包的完整性。 - 更可靠:使用详细、简洁的锁文件格式和明确的安装算法,
yarn
能够保证在不同系统上无差异的工作(自动生成yarn.lock
)。
当然,随着npm的不断改进,其参考yarn
也引入了安装缓存、版本锁的概念,所以从技术架构上讲,npm
和yarn
已经没有多少差别。
跟npm3
一样,yarn
也采用平铺的方式组织node_modules
。
破圈
npm3
和 yarn
采用的都是平铺方式组织 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-design
、app-types
、app-utils
,其引用命令为:
bash
pnpm add app-design app-types app-utils --workspace
安装成功后在app-micro/main
的package.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~)
总结
npm | yarn
平铺node_modules的方式对幻影依赖
是无解的,pnpm
独特的node_modules组织方式能有效解决幻影依赖问题pnpm
通过软链复用相同版本的 package,避免重复打包(相同版本package只保留一个),解决 NPM doppelgnger(顺带解决磁盘占用)- 开启
pnpm workspace
能力从而可以使用 workspace: 协议实现 monorepo 内部项目的互相引用 - 使用
pnpm workspace
引用内部package需要修改构建器配置,让构建器能够将外部package纳入到构建范围里 - 尽管
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]
:软链安装内部的workspacepnpm store path
:查看本机npm包存储路径