天天看点

为什么使用pnpm可以光速建立好用的monorepo(比yarn/lerna效率高)

前言

首先要说的是,

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/**'
           
为什么使用pnpm可以光速建立好用的monorepo(比yarn/lerna效率高)

只需一步,即可免除上面

yarn + lerna + workspace

的所有烦恼,具体的 workspace 配置均默认好用,如需自定义可以查看官方配置文档:pnpm Workspace

二. 清理依赖

假如你都是 react 技术栈,每个项目都要安装 react 是不是太过于繁琐和占用时间、空间?

yarn + lerna

中的方案是配置自动抬升,这种方案会存在依赖滥用的问题,因为抬升到顶层后是没有任何限制的,一个依赖可能加载到任何存在于顶层

node_modules

存在的依赖,而不受他是否真正依赖了这个包,这个包的版本是多少的影响。但在 pnpm 中,无论是否被抬升,存在默认隔离的策略,这一切都是安全的。

所以建议去除每个子项目的共同依赖,比如

react

lodash

等,然后统一放入顶层的

package.json

内:

为什么使用pnpm可以光速建立好用的monorepo(比yarn/lerna效率高)

在顶层安装全局依赖的命令如下:

pnpm add -w lodash
	pnpm add -D -w typescript
           

默认情况下,为了节省时间和空间,pnpm 会默认不在子项目中软链接顶层安装的全局依赖,如需关闭这一行为(不推荐),可以在项目根目录的

.npmrc

配置:

三. 设定启动命令

假定我们有两个 react 子项目,分别为

@mono/app1

@mono/app2

(这里指的是所在子项目的

package.json

里的

name

名字):

为什么使用pnpm可以光速建立好用的monorepo(比yarn/lerna效率高)

那么在项目根目录

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 请三思而后行。