前言
首先要说的是,
yarn + lerna
的组合已经是现在 monorepo 的通用方案,也是功能最多,最火的方案,使用这套方案绝对是正确的。
但是其上手存在一定的门槛,相比
pnpm
这种自带 workspace 的方案,在效率上不能匹敌。
问题
下面谈论几个为什么不使用
yarn + lerna
方案的理由。
认清 pnpm 的小而简
pnpm
作为一个新颖的依赖管理工具,无论是兼容性,还是功能丰富度,社区生态,都是非常弱小的,无论打出什么新牌,都很难与
yarn
的霸主地位抗衡,而他作为一个 “小” 的工具,也存在一些方便,效率高的点。
lerna 功能往往不是所需
像
lerna
这样一个 monorepo 管理工具,包括了很多的功能。
比如很多时候我们并不需要统一版本发布管理,像
vue
这样一个大型开源库,他需要所有子包都统一的版本发布管理,但是我们个人很多时候是不需要的,所以像
lerna version
这种命令其实是多余的,为何不舍弃复杂从简?
yarn + lerna 配置复杂
对于一个新入门 monorepo 的小白来说,光搞清单独使用
lerna
和
yarn + workspace + lerna
的区别,就足够吃一壶的了。
具体来说,首先你需要这么大的
lerna.json
:
// lerna.json
{
"packages": [
"packages/*"
],
"npmClient": "yarn",
"useWorkspaces": true,
"command": {
"bootstrap": {
"hoist": true
}
},
"version": "0.0.0"
}
像
yarn
这种已经标配的管理工具竟然让我手动去配置使用,为了效率还要手动打开依赖提升,不开启
workspace
就丧失了使用
yarn
的意义,不提其他自定义配置,光是这些必备的,一连串下来,真是好复杂的模板配置!
不光如此,还需要在
package.json
明确指明
workspace
位置:
// package.json 追加
{
"workspaces": [
"packages/*"
]
}
对于新手来说,这些配置需要接收的信息量实在太多,
yarn workspace
是什么?为什么要提升依赖?
packages/*
竟然要在两个地方写 😅 ,直接劝退了。
使用 pnpm 光速建立 monorepo
出于种种问题的驱使,我们意识到,需要一个简单、效率高的 monorepo 管理工具,不要吹什么 pnpm 安装依赖是软链接所以快,省空间,这都不是重点。
下面看 pnpm 如何光速建立 monorepo。
一. 建立 workspace
先在项目根目录建立一个 pnpm 的 workspcae 配置文件
pnpm-workspace.yaml
(官方说明),意味着这是一个 monorepo:
packages:
# all packages in subdirs of packages/ and components/
- 'packages/**'
# exclude packages that are inside test directories
- '!**/test/**'
只需一步,即可免除上面
yarn + lerna + workspace
的所有烦恼,具体的 workspace 配置均默认好用,如需自定义可以查看官方配置文档:pnpm Workspace
二. 清理依赖
假如你都是 react 技术栈,每个项目都要安装 react 是不是太过于繁琐和占用时间、空间?
在
yarn + lerna
中的方案是配置自动抬升,这种方案会存在依赖滥用的问题,因为抬升到顶层后是没有任何限制的,一个依赖可能加载到任何存在于顶层
node_modules
存在的依赖,而不受他是否真正依赖了这个包,这个包的版本是多少的影响。但在 pnpm 中,无论是否被抬升,存在默认隔离的策略,这一切都是安全的。
所以建议去除每个子项目的共同依赖,比如
react
,
lodash
等,然后统一放入顶层的
package.json
内:
在顶层安装全局依赖的命令如下:
pnpm add -w lodash
pnpm add -D -w typescript
默认情况下,为了节省时间和空间,pnpm 会默认不在子项目中软链接顶层安装的全局依赖,如需关闭这一行为(不推荐),可以在项目根目录的
.npmrc
配置:
三. 设定启动命令
假定我们有两个 react 子项目,分别为
@mono/app1
和
@mono/app2
(这里指的是所在子项目的
package.json
里的
name
名字):
那么在项目根目录
package.json
配置项目启动命令:
"scripts": {
"dev:app1": "pnpm start --filter \"@mono/app1\"",
"dev:app2": "pnpm start --filter \"@mono/app2\""
},
这里
--filter
参数即特定要作用到哪个子项目,详见:pnpm 过滤
到此为止,所有准备工作已经完成,可以开始愉快使用 monorepo !
愉快使用 monorepo
我们定一个小目标:
能在
app1
里使用
app2
的 typescript 代码,而且可以实时热更新,不需要改动一次
app2
的代码就要打包一次。
提供来源
在
app2
内我们建立一个需要 share 的组件
packages/app2/src/shared/index.tsx
:
import React, { FC } from 'react'
// 使用 ts 专有语法,来验证我们是否真正的转译了
export enum EConstans {
value = 'value'
}
export const Button: FC = () => {
return <div>app2 button: {EConstans.value}</div>
}
之后直接将
app2
的
package.json
入口
main
配置成该处:
// packages/app2/package.json 增加
{
"main": "./src/shared/index.tsx"
}
由此一来,其他子项目引用该包(
@mono/app2
)时就会直接从该
main
字段指明处引入代码。
引用代码
在
app1
的
package.json
依赖列表里指明引用了
workspace
的
app2
:
// packages/app1/package.json 增加
{
"dependencies": {
"@mono/app2": "workspace:*"
}
}
这是 pnpm 独有写法,为了指明这是工作区的依赖,防止自动去 npm 上寻找导致混乱,详见:pnpm Workspace protocol
之后在项目根目录运行命令重链一遍依赖:
pnpm i
之后便可以在
app1
内愉快使用
app2
的代码了:
// packages/app1/src/App.tsx
import React from 'react';
import { Button } from '@mono/app2'
function App() {
return (
<div>
<Button />
</div>
);
}
export default App;
配置跨项目编译
一个关键是,
app2
也使用了需要转义的代码(此处是 typescript ),而
babel-loader
默认的配置没有
include
他(只
include
了自己所在项目的
src
目录),所以导致此时虽然能引用到
app2
的代码,但由于是未转义的 ts 代码,并不能直接使用。
为了解决这个问题,我们需要修改该项目的构建配置,关于如何修改配置的手段有很多,并不在本文探讨范围内,这里只列举
craco
的使用例子:
// packages/app1/craco.config.js
const path = require('path')
module.exports = {
webpack: {
configure: (webpackConfig, { env, paths }) => {
webpackConfig.module.rules.forEach(rule => {
if (!rule.oneOf) {
return
}
// 在这个位置找 babel-loader
// 不要问我为什么知道 babel-loader 在这里面,可以自己找新项目 eject 或者 write 出来 json 文件看
rule.oneOf.forEach(ruleItem => {
const isBabelLoader = ruleItem.test?.source?.includes?.('ts')
if (isBabelLoader) {
ruleItem.include = [
...getAllWorkspaceDepPaths(),
ruleItem.include
]
}
})
})
return webpackConfig
},
},
}
/**
* 这个方法可以动态获取所有 package.json 里引用的其他子项目
*/
function getAllWorkspaceDepPaths() {
const SCOPE_PREFIX = '@mono'
const pkg = require('./package.json')
const depsObj = pkg.dependencies
if (!depsObj) {
return []
}
const depPaths = []
Object.entries(depsObj).forEach(([name, version]) => {
if (name.startsWith(SCOPE_PREFIX) && version.startsWith('workspace:')) {
depPaths.push(path.resolve(`../${name.slice(SCOPE_PREFIX.length + 1)}/src`))
}
})
return depPaths
}
如果你还看不懂做了什么,其实就是把
package.json
里引用的所有
@mono
开头的其他子项目的绝对路径找出来,然后放到了 babel-loader 的
include
里,这样 babel 就会去转义引用的其他子项目的 ts 代码了。
到此为止,我们的小目标顺利完成,此时在
app2
内改动代码也可以热更新反映到
app1
内(此时只启动
app1
)。
总结
关于 pnpm 的优点真的无需多吹,现代计算机的性能都十分过剩,做这些软链接也好,隔离也好,为了节省时间和空间,其实都是虚的。
但毋庸置疑的是 pnpm 天生的优势让他可以很简单的,又有效率的支持 workspace monorepo,对新手十分友好。特别是在多个项目之间共享代码时,无需单独抽离代码发包。
pnpm 不是银弹,任何新事物都必须多面的看待,特别是 pnpm 特立独行的 lock 文件,不具备对
yarn
的双向兼容,而
yarn
对
npm
有双向兼容,yarn 已经得到全世界的检验而 pnpm 没有,这在未来因某些需求和功能的需要,必须发生架构变动迁移时将造成不可逆转的技术债,使用 pnpm 请三思而后行。