Workspace 那些破事 - 浅探 npm、yarn、pnpm、bun

🎼 前言

作为前端,你一定听说过 Workspace,一定也听说过 Monorepo。相关的工具链也层出不穷,你或许正在用着某些。

可是,这些工具对我们如何组织管理前端项目有哪些帮助,它们之间有哪些区别,我们应该怎样取舍呢?

本文将利用一个很小的项目,带你了解最流行的前端包管理工具 npm-workspacesyarn-workspacespnpm-workspaces 之间的差异。

虽然提到了最近很火的 bun,可是它很早就输了。

TL;DR

pnpm 管理 Workspace,可能是目前的最佳方案。

主要内容

适合读者

  • 前端开发
  • 主要是 package 开发者,需要 Monorepo 的

你将获得

  • Workspace 的基本概念
  • 支持 Workspace 的包管理器有:npm、yarn、pnpm、bun
  • 以上工具之间的重要功能比较结果
  • 决定最适合自己的工具

历史

日期 版本说明
2023/09/11 V1

🧨 什么是 Workspace

以下引用各官方的说明。

npm-workspaces: Workspaces is a generic term that refers to the set of features in the npm cli that provides support to managing multiple packages from your local file system from within a singular top-level, root package.
yarn-workspaces: Workspaces are the name of individual packages that are part of the same project and that Yarn will install and link together to simplify cross-references.
pnpm-workspaces: pnpm has built-in support for monorepositories (AKA multi-package repositories, multi-project repositories, or monolithic repositories). You can create a workspace to unite multiple projects inside a single repository.
bun-workspaces: Bun supports workspaces in package.json. Workspaces make it easy to develop complex software as a monorepo consisting of several independent packages.

总结来说就是,Workspace 是一套功能,用来辅助在一个项目(repo)下一起开发很多包的应用场景。

⚒️ 准备工具

本文将用到的工具:

| 包管理器 | 版本 | | --- | --- | --- | | Node.js | 20.5.1 | | npm | 10.1.0 | | yarn | 1.22.19 | | pnpm | 8.7.5 | | bun | 1.0.1 |

其中,本文涉及到的「包管理器」是 npmyarnpnpmbun 四个。

Node.js

官网下载安装包,或者(我的选择):

bash 复制代码
brew install node

提一嘴 corepack(我最后卸载了它)。

「包管理器的管理器」,目前还处于实验阶段,需要调用命令 corepack enable 以启用。

需要注意的是,brew install 的 node 是没有 corepack 的,需要单独安装:

bash 复制代码
brew install corepack

这样安装后的 corepack 即为启用状态:

如果你此时执行 corepack disable 会发现上图中的几个软链接被干掉了。

之所以提 corepack 是因为,yarn 的版本,只有它可以安装到最新的 3.x。然而💥,我试了无论 v1、v2 还是 v3,yarn 都没有支持 yarn workspaces foreach。而且 v3 在 install 后并不能如 1.x 那样初始化 Workspace,故此,本篇依然使用 yarn@1.x

npm

支持 Workspaces:v7.0.0,2020 年。

npm 跟随 Node.js 安装,但版本不是最新的,比如 node@20.6.1 捆绑了 npm@9.8.1,执行命令 npm i -g npm 安装最新的 10.1.0

我的原则是,如果是 npm 和 brew 都有,优先使用 npm i -g

yarn

支持 Workspace:v0.28.1,2016。

yarn 是第一个提供原生 Workspace 支持的包管理器。

Workspaces were initially popularized by projects like Lerna, but Yarn was the first package manager to provide native support for them - support which never stopped improving over years as we build more features around them.

bash 复制代码
npm i -g yarn
# 或
brew install yarn

pnpm

支持 Workspace:?可能一开始就支持的吧。

bash 复制代码
npm i -g pnpm
# 或
brew install pnpm

bun

支持 Workspace:?可能一开始就支持的吧。

这个工具太新了,以至于 Homebrew 上都还没有。

bash 复制代码
curl -fsSL https://bun.sh/install | bash

😈 准备 Demo Project

初始化 Project

bash 复制代码
md workspace-demo     # 新建目录
cd workspace-demo     # 进入目录
npm init -y           # 初始化 package.json 一切按默认
git init              # 初始化本地 git

加个 .gitignore,内容如下:

gitignore 复制代码
# common
.*
!.*ignore
!.*.yaml
!.*rc
!.*rc.*
!.husky
*.log*
.npmrc
*-lock.yaml
*-lock.json

# dev

**/node_modules

# generated

**/build/

根 package.json

对自动生成的 package.json 做一些调整:

  1. 去掉 main,因为根目录不会被发布成 package
  2. 增加 "private": true,确保根目录下执行 npm publish 报错
  3. 增加 workspaces 配置项
json 复制代码
{
  "name": "workspace-demo",
  "version": "1.0.0",
  "description": "",
  "private": true,
  "keywords": [],
  "author": "",
  "license": "ISC",
  "workspaces": [
    "packages-*/*"
  ],
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  }
}

packages-*/* 表示项目根目录下所有 packages-xx 下的一级目录都将属于 Workspace。

workspaces 还有对象格式:

json 复制代码
{
  ...,
  "workspaces": {
    "packages": [
      "packages-*/*"
    ]
  }
}

既然是对象,你肯定会问,除了 packages 之外,还有其他什么字段?

完整的 workspaces 类型描述如下:

ts 复制代码
{
  workspaces: string | {
    packages?: string[];
    nohoist?: string[];
  };
}

我没有找到官方的文档,仅在 Yarn 的一篇 blog nohoist in Workspaces 中有提及。

nohoist 举例:

  • **/react-native,不要去提升 react-native,不管它的位置在哪里
  • **/react-native/**,不要去提升 react-native 的任何依赖包

文件总览

为了调戏 Workspace 的工作机制,我们需要:

  1. 一个没有依赖的包 @workspace-demo/ts-config,它将被所有 TS 写的包依赖
  2. 两个具有相同依赖 react@18 的包 @workspace-demo/react-hook-is-unmounted@workspace-demo/react-hook-safe-state(用以查看 Workspace 对相同依赖的处理)
  3. @workspace-demo/react-hook-safe-state 会依赖 @workspace-demo/react-hook-is-unmounted 的构建产物
  4. @workspace-demo/react-hook-safe-state@workspace-demo/react-hook-is-unmounted 多一份 lodash-es 的依赖(用以查看 Workspace 对仅某一个包的依赖项的处理)

以往的经验告诉我,包会越来越多,推荐预先开辟几个 packages-xx,以二级目录的形式组织:

bash 复制代码
md packages-dev/ts-config                   # 无依赖,当前项目下的共享 tsconfig
md packages-hook/react-hook-is-unmounted    # 依赖 react
md packages-hook/react-hook-safe-state      # 依赖 react、lodash-es 和 packages-hook/react-hook-is-unmounted

没有任何多余文件的文件结构如下:

txt 复制代码
workspace-demo/
├─ packages-dev/
│  └─ ts-config/
│     ├─ index.json
│     └─ package.json
├─ packages-hook/
│  ├─ react-hook-is-unmounted/
│  │  ├─ src/
│  │  │  └─ index.ts
│  │  ├─ package.json
│  │  └─ tsconfig.json
│  └─ react-hook-safe-state/
│     ├─ src/
│     │  └─ index.ts
│     ├─ package.json
│     └─ tsconfig.json
├─ .gitignore
└─ package.json

所有的 tsconfig.json 的内容统一如下,后不再赘述:

json 复制代码
{
  "extends": "@workspace-demo/ts-config/index.json",
  "include": [
    "src"
  ]
}

根据 Workspace 的工作原理,对 @workspace-demo/ts-config/index.json 的引用将最终指向 packages-dev/ts-config/index.json

packages-dev/ts-config/

一个不需要构建的 package,将被用于所有其他 package 作为 tsconfig.json 的基础。

package.json

json 复制代码
{
  "name": "@workspace-demo/ts-config",
  "version": "1.0.0",
  "description": "A sharable tsconfig, extend it and put an `include` in your own tsconfig.json",
  "license": "MIT",
  "main": "index.json"
}

index.json

json 复制代码
{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "es2015",
      "es2017"
    ],
    "moduleResolution": "node",
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "strict": true,
    "allowJs": false,
    "allowSyntheticDefaultImports": true,
    "noUnusedParameters": true,
    "noUnusedLocals": true,
    "noUncheckedIndexedAccess": true,
    "declaration": true,
    "skipLibCheck": true,
    "sourceMap": true,
    "jsx": "react",
    "baseUrl": "./",
    "outDir": "lib"
  }
}

packages-hook/react-hook-is-unmounted/

一个简单的 useIsUnmounted hook。

package.json

json 复制代码
{
  "name": "@workspace-demo/react-hook-is-unmounted",
  "version": "1.0.0",
  "description": "useIsUnmounted",
  "license": "MIT",
  "sideEffects": false,
  "main": "build/cjs/index.js",
  "module": "build/esm/index.js",
  "types": "build/types/index.d.ts",
  "devDependencies": {
    "@workspace-demo/ts-config": "^1.0.0",
    "@types/react": "^18.2.21",
    "react": "^18.2.0",
    "typescript": "^5.2.2"
  },
  "peerDependencies": {
    "react": ">=16.8"
  },
  "scripts": {
    "clean": "rm -rf build",
    "build:esm": "tsc --module es6 --declaration false --rootDir src --outDir build/esm",
    "build:cjs": "tsc --module commonjs --declaration false --rootDir src --outDir build/cjs",
    "build:types": "tsc --rootDir src --outDir build/types --declaration --emitDeclarationOnly",
    "build": "npm run build:esm && npm run build:cjs && npm run build:types",
    "prepublishOnly": "npm run clean && npm run build"
  }
}

src/index.ts

ts 复制代码
import {
  useRef,
  useCallback,
  useEffect
} from 'react';

export default function useIsUnmounted(): () => boolean {
  const ref = useRef<boolean>(false);
  const isUnmounted = useCallback(() => ref.current, []);
  
  useEffect(() => {
    return () => {
      ref.current = true;
    };
  }, []);
  
  return isUnmounted;
}

packages-hook/react-hook-safe-state/

一个简单的 useSafeState,永不报错的 useState,依赖 useIsUnmounted

package.json

json 复制代码
{
  "name": "@workspace-demo/react-hook-safe-state",
  "version": "1.0.0",
  "description": "useSafeState",
  "license": "MIT",
  "sideEffects": false,
  "main": "build/cjs/index.js",
  "module": "build/esm/index.js",
  "types": "build/types/index.d.ts",
  "devDependencies": {
    "@workspace-demo/ts-config": "^1.0.0",
    "@types/react": "^18.2.21",
    "react": "^18.2.0",
    "typescript": "^5.2.2"
  },
  "peerDependencies": {
    "react": ">=16.8"
  },
  "dependencies": {
    "@workspace-demo/react-hook-is-unmounted": "^1.0.0",
    "lodash-es": "^4.17.21"
  },
  "scripts": {
    "clean": "rm -rf build",
    "build:esm": "tsc --module es6 --declaration false --rootDir src --outDir build/esm",
    "build:cjs": "tsc --module commonjs --declaration false --rootDir src --outDir build/cjs",
    "build:types": "tsc --rootDir src --outDir build/types --declaration --emitDeclarationOnly",
    "build": "npm run build:esm && npm run build:cjs && npm run build:types",
    "prepublishOnly": "npm run clean && npm run build"
  }
}

src/index.ts

未使用 lodash-es,仅仅是为了观察依赖安装后,是否会将单独依赖安装在本地(实验证明不会)。

ts 复制代码
import {
  Dispatch,
  SetStateAction,
  useCallback,
  useState
} from 'react';

import useIsUnmounted from '@workspace-demo/react-hook-is-unmounted';

export default function useSafeState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>] {
  const [state, setState] = useState<S>(initialState);
  const isUnmounted = useIsUnmounted();
  
  const setSafeState = useCallback((v: SetStateAction<S>): void => {
    if (!isUnmounted()) {
      setState(v);
    }
  }, [isUnmounted]);
  
  return [state, setSafeState];
}

收功,打 Tag

项目所有文件准备完毕,打个 Tag 存一下:

bash 复制代码
git add .
git commit -m 'chore: files ready'
git tag '1-files-ready'

🛵 调戏 Workspace 1:初始化、构建与发布

至此,一个「健康」的项目已经准备完毕,依赖版本都是目前最新的,且不存在同名依赖不同版本的问题。

由于尚未安装任何依赖,WebStorm 各种报错:

npm

初始化(npm install)

执行 npm i 即可完成所有依赖的安装:

安装后的效果:

  1. 依赖全部「提升」安装在根目录下 node_moduels
  2. 自己的所有 package 均被 npm-linknode_moduels 对应的目录下
  3. 有个 package-lock.json
  4. @workspace-demo/react-hook-is-unmounted 的引用依然报错(因为它尚未构建)
  5. @workspace-demo/ts-config 的引用已经正确(因为它不需要构建)

构建(prepublishOnly)

只有 @workspace-demo/react-hook-is-unmounted@workspace-demo/react-hook-safe-state 需要构建,在它们的 package.json 中我已经写好构建脚本:

json 复制代码
{
  "scripts": {
    "clean": "rm -rf build",
    "build:esm": "tsc --module es6 --declaration false --rootDir src --outDir build/esm",
    "build:cjs": "tsc --module commonjs --declaration false --rootDir src --outDir build/cjs",
    "build:types": "tsc --rootDir src --outDir build/types --declaration --emitDeclarationOnly",
    "build": "npm run build:esm && npm run build:cjs && npm run build:types",
    "prepublishOnly": "npm run clean && npm run build"
  }
}

我们可以手动 cd 到对应的目录,先构建 @workspace-demo/react-hook-is-unmounted,再构建 @workspace-demo/react-hook-safe-state。但这不是 Workspace 式的做法。

「更好」的办法方式利用 --workspaces 参数(注意有 s 是复数),在根目录下运行(--if-present 是为了防止在 ts-config 下报错):

bash 复制代码
npm run --workspaces --if-present prepublishOnly

@workspace-demo/react-hook-is-unmounted 为例,可以看到构建结果全部正确:

非字母序构建(prepublishOnly)

我们目前的项目结构简单,@workspace-demo/react-hook-safe-state 依赖了 @workspace-demo/react-hook-is-unmounted,而它们的目录字母序恰好是 react-hook-is-unmountedreact-hook-safe-state ,这样在构建 @workspace-demo/react-hook-safe-state 的时候,其所有的依赖均已准备好。

本着「求甚解」的精神,我们来测一下非字母序是否依然好使:

bash 复制代码
rm -rf **/build     # 删除之前的构建结果
mv packages-hook/react-hook-safe-state/ packages-hook/react-hook-a-safe-state/  # 改名
npm run --workspaces --if-present prepublishOnly
mv packages-hook/react-hook-a-safe-state/ packages-hook/react-hook-safe-state/  # 还原

可惜,构建失败:

可见 npm-workspaces 并没有对依赖树做排序。要在构建顺序上得到解决,需要引入 monorepo 管理工具,这个不在本篇讨论范围内。

发布(publish)

workspace-demo 项目下的包都是 @workspace-demo/xx,我没有建此 org(有也不是我的),因此是无法正式发布的,所以调戏 publish 的时候需要带上 --dry-run 参数。

bash 复制代码
npm --workspaces publish --dry-run

汇总

将之前用到的命令写到根 package.json 下,增加相关 clean 方法:

json 复制代码
{
  ...,
  "scripts": {
    "clean:build": "rm -rf packages-*/**/build",
    "clean:node_modules": "rm -rf node_modules packages-*/**/node_modules",
    "clean": "npm run clean:build && npm run clean:node_modules",
    "boot": "npm i && rm package-lock.json",
    "boot:packages": "npm run --workspaces --if-present prepublishOnly",
    "pub": "npm --workspaces publish --dry-run"
  }
}

说明:

  1. clean 若只写 **/build 无法删除在 packages-xx/name 下对应的目录
  2. boot 安装成功后删除了 lock 文件(无法通过参数,可以用本地的 .npmrc 或全局禁用)

收功:验证 → 打 Tag

bash 复制代码
npm run clean                       # 1. 还原
npm run boot                        # 2. 初始化 workspace
npm run boot:packages               # 3. 对 workspace 下的 package 进行构建
npm run pub                         # 4. 实验 publish
npm run clean                       # 5. 还原
git commit -am 'chore: npm workspaces'    # 6. 提交
git tag 2-npm-workspaces            # 7. 打 Tag

yarn

初始化(yarn install)

执行 yarn install(注意没有短命令别名):

注意到有两个「warning」,这个问题好解决,只需要在根 package.json 增加 devDependencies 即可(但我们先不这么做):

json 复制代码
{
  ...,
  "devDependencies": {
    "react": "^18.2.0"
  }
}

再来看安装的内容和 npm install 的区别:

  • 相同点
    1. 在根 node_modules 下安装了所有依赖
    2. 在根 node_modules 下 link 了项目下的包
  • 不同点
    1. .yarn-integrity 对应 npm 的 .package-lock.json
    2. 在 package 下也会有 node_modules ,但只有 .bin 目录,且里边是软链接
    3. yarn.lock 对应 npm 的 package-lock.json

构建(prepublishOnly)

执行 yarn workspaces run prepublishOnly,会报错:

不幸的是,yarn 并没有 --if-present 或类似的参数,唯一的办法就是给 ts-config/package.json 来上这么一段:

json 复制代码
{
  ...,
  "scripts": {
    "prepublishOnly": "echo '`yarn workspaces run` has no --if-present arg'"
  }
}

再构建:

非字母序构建

bash 复制代码
rm -rf **/build     # 删除之前的构建结果
mv packages-hook/react-hook-safe-state/ packages-hook/react-hook-a-safe-state/  # 改名
yarn workspaces run prepublishOnly
mv packages-hook/react-hook-a-safe-state/ packages-hook/react-hook-safe-state/  # 还原

npm run --workspaces 一样,yarn workspace run 也只是按照字母序执行:

发布

没找到方法...

汇总

将之前用到的命令写到根 package.json 下:

json 复制代码
{
  ...,
  "scripts": {
    ...
    "boot-yarn": "yarn install --no-lockfile",
    "boot-yarn:packages": "yarn workspaces run prepublishOnly",
    "pub-yarn": "echo 'no f* way with yarn'"
  }
}

说明:

  1. yarn install 加了 --no-lockfile
  2. boot-yarn:packages 要求每个包都必须有 prepublishOnly

这样,对新 clone 的项目,就可以两步初始化完毕:

bash 复制代码
yarn boot-yarn            # 初始化 workspace
yarn boot-yarn:packages   # 对 workspace 下的 package 进行构建

收功:验证 → 打 Tag

记得还原 ts-config/package.json

bash 复制代码
yarn clean                            # 1. 还原
yarn boot-yarn                        # 2. 初始化 workspace
yarn boot-yarn:packages               # 3. 对 workspace 下的 package 进行构建
yarn pub-yarn                         # 4. 仅输出一段话
yarn clean                            # 5. 还原,并且还原掉 ts-config/package.json
git commit -am 'chore: yarn workspaces'    # 6. 提交
git tag 3-yarn-workspaces             # 7. 打 Tag

pnpm

初始化(yarn install)

此时执行 pnpm ipnpm install),结果什么事情都不做:

pnpm 并不鸟 package.json 里的 workspaces 配置:

A workspace must have a pnpm-workspace.yaml file in its root. A workspace also may have an .npmrc in its root.

建一个 pnpm-workspace.yaml,内容:

yaml 复制代码
packages:
  - 'packages-*/*'

再次执行 pnpm i

注意,它说的我们有 4 projects,多了一个,这是因为:

The root package is always included, even when custom location wildcards are used.

安装后依赖效果:

  1. 所有 package 依赖真正安装的位置在 node_modules/.pnpm 下,并且命名+版本平铺化
  2. .modules.yaml
  3. 每个 package 有自己的 node_modules ,看上去跟在 package 下单独执行 npm i 差不多
  4. package _module_modules/.bin 下的命令被 pnpm 重写过,都是真文件,不像之前两种方式的软连接形式
  5. pnpm-lock.yaml

构建(prepublishOnly)

执行 pnpm -r run prepublishOnly(显示效果比前两个好太多):

注意此时我已经把 ts-config 下的 prepublishOnly 杀掉了,没有报错,也不需要参数 --if-present(它有)。

非字母序构建

bash 复制代码
rm -rf **/build     # 删除之前的构建结果
mv packages-hook/react-hook-safe-state/ packages-hook/react-hook-a-safe-state/  # 改名
pnpm -r run prepublishOnly
mv packages-hook/react-hook-a-safe-state/ packages-hook/react-hook-safe-state/  # 还原

按照依赖顺序,而不是字母序,构建成功!🎉

发布

bash 复制代码
pnpm -r run publish --dry-run

报错:

提交代码后再执行:

汇总

将之前用到的命令写到根 package.json 下:

json 复制代码
{
  ...,
  "scripts": {
    ...
    "boot-pnpm": "pnpm i --no-lockfile",
    "boot-pnpm:packages": "pnpm -r run prepublishOnly",
    "pub-pnpm": "pnpm -r run publish --dry-run"
  }
}

说明:

  1. pnpm i 加了 --no-lockfile

收功:验证 → 打 Tag

记得还原 ts-config/package.json

bash 复制代码
pnpm clean                            # 1. 还原
pnpm boot-pnpm                        # 2. 初始化 workspace
pnpm boot-pnpm:packages               # 3. 对 workspace 下的 package 进行构建
git commit -am 'chore: pnpm workspaces'    # 4. 提交,否则 publish 报错
pnpm pub-pnpm                         # 5. publish dry-run
pnpm clean                            # 6. 还原
git tag 4-pnpm-workspaces            # 7. 打 Tag

bun

运行 bun install 报错了:

必须把根 package.jsonworkspaces 改了:

json 复制代码
{
  ...,
  "workspaces": [
    "packages-dev/*",
    "packages-hook/*"
  ]
}

我给提了一个 Issue,然后发现他们文档里边已经有说明了(光这一点,我觉得可以放弃 bun)。

Glob support --- Bun supports simple <directory>/* globs in "workspaces". Full glob syntax (e.g. ** and ?) is not yet supported. - bun-workspaces

效果和 npm 的方式比较像:

💥 没有找到对应的办法,直接彻底先放弃 bun 吧。

🚜 调戏 Workspaces 2:某三方依赖有多个不同版本的情况

本次调戏,只看安装后的效果,不进行构建。

准备

这次我们新建一个 package:md packages-rc/rc-button,文件组织:

txt 复制代码
rc-button/
├─ src/
│ └─ index.tsx
├─ package.json
└─ tsconfig.json

package.json

这里的 react 版本特意写 17,而不是之前的 18。这样,我们有两个 package 是 react@18,和一个 package 的 react@17。

json 复制代码
{
  "name": "@workspace-demo/rc-button",
  "version": "1.0.0",
  "description": "Button",
  "license": "MIT",
  "sideEffects": false,
  "main": "build/cjs/index.js",
  "module": "build/esm/index.js",
  "types": "build/types/index.d.ts",
  "devDependencies": {
    "@workspace-demo/ts-config": "^1.0.0",
    "@types/react": "^17.0.65",
    "react": "^17.2.21",
    "typescript": "^5.2.2"
  },
  "peerDependencies": {
    "react": ">=16.8"
  },
  "dependencies": {
    "@workspace-demo/react-hook-safe-state": "^1.0.0"
  },
  "scripts": {
    "clean": "rm -rf build",
    "build:esm": "tsc --module es6 --declaration false --rootDir src --outDir build/esm",
    "build:cjs": "tsc --module commonjs --declaration false --rootDir src --outDir build/cjs",
    "build:types": "tsc --rootDir src --outDir build/types --declaration --emitDeclarationOnly",
    "build": "npm run build:esm && npm run build:cjs && npm run build:types",
    "prepublishOnly": "npm run clean && npm run build"
  }
}

src/index.tsx

tsx 复制代码
import React, {
  ButtonHTMLAttributes,
  ReactElement,
  MouseEvent,
  useCallback
} from 'react';

import useSafeState from '@workspace-demo/react-hook-safe-state';

interface IButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
  label: string | ReactElement;
}

export default function Button({
  label,
  onMouseEnter,
  onMouseLeave,
  ...props
}: IButtonProps): ReactElement {
  const [safeStateHovered, setSafeStateHovered] = useSafeState<boolean>(false);
  
  const handleMouseEnter = useCallback((e: MouseEvent<HTMLButtonElement>) => {
    setSafeStateHovered(true);
    onMouseEnter?.(e);
  }, [setSafeStateHovered]);
  const handleMouseLeave = useCallback((e: MouseEvent<HTMLButtonElement>) => {
    setSafeStateHovered(false);
    onMouseLeave?.(e);
  }, [setSafeStateHovered]);
  
  return <button {...{
    ...props,
    'data-hovered': safeStateHovered ? 1 : 0,
    onMouseEnter: handleMouseEnter,
    onMouseLeave: handleMouseLeave
  }}>{label}</button>
}
bash 复制代码
git add .
git commit -m 'feat: add rc-button with different react version'
git tag 5-diff-react-version

npm

bash 复制代码
npm run boot

可以看到,「额外」的版本被安装在了它当前的包下:

npm 多版本的主次策略是什么呢?一试便知:

bash 复制代码
npm run clean
mv packages-rc packages-0rc
npm run boot

结论

  1. 以先安装的版本为主版本
  2. 次版本是实体安装到对应目录下的,也就是说,会有 n-1 的冗余

还原

bash 复制代码
mv packages-0rc packages-rc
npm run clean

yarn

bash 复制代码
yarn boot-yarn
bash 复制代码
yarn clean
mv packages-rc packages-0rc
yarn boot

虽然 rc-button 下的 react@17 是先安装的,但 react@18 还是坐了根目录下的主位。不过,到底是因为版本大坐的主位,还是个数多坐的主位呢?我把 packages-0rcpackages-hook 下相关版本调换了一下,发现是「人多力量大」。

结论

  1. 以个数多的版本作为主版本
  2. 次版本也是实体安装,也会有 n-1 的冗余,但由于多者为主,相对 npm 的方式稍稍合理一些

还原

bash 复制代码
mv packages-0rc packages-rc
yarn clean

pnpm

我们已经知道 Workspace 的每个 package 都有自己完整的 node_modules ,只是里边的包都是根 node_modules/.pnpm 下对应的软链接。由于 .pnpm 下的依赖项会根据版本平铺,因此可以想到 pnpm 下的效果,也就没有必要改名测试了。

结论

  1. 没有主副版本
  2. 没有实体冗余

确实应了它宣称的 Saving disk space

还原

bash 复制代码
pnpm clean

🎰 大总结

bun 在最初的配置项就被刷下来了,不做比较。

能力 npm yarn pnpm
支持 Workspace 7.0.0 2020 0.28.1 2016
配置 package.json#workspaces package.json#workspaces pnpm-workspace.yaml
install npm i yarn install pnpm i
install 无 lock 💥 无参数,只能 rm --no-lockfile --no-lockfile
run 命令 npm run --workspaces <script> yarn workspaces run <script> pnpm -r run
run 可判空 --if-present 💥 无法判空 🎊 天然判空
run 顺序,假设 A 依赖 B 💥 字母序 A → B 💥 字母序 A → B 🎊 按照依赖顺序 B → A
publish npm --workspaces publish --dry-run 💥 不行 pnpm -r publish --dry-run
自定义 script 可省 run 执行 💥 否 🎊 是 🎊 是
多版本处理 💥 以安装先后,先安装的为主版本,其余为独立冗余副版本 💥 以引用多的为主版本,其余为独立冗余副版本 🎊 都是软链接,每个版本都只有一份实体

加一些别的方面的比较:

能力 npm yarn pnpm
支持 workspaces.nohoist 配置 🎊 都是软链接,无所谓
向某个 package 单独安装依赖 npm -w <workspace> npm i <dep> 这里 <workspace> 既可以是包名,也可以是包目录 yarn workspace <workspace> add <dep> 这里 <workspace> 只能是包名 💥 未找到方法
exec 运行系统命令 npm --workspaces exec <command> 💥 不行 pnpm -r exec <command>

上述两个表格,已经可以很明显看出 pnpm 的优势。

最后,如果你的项目是这样一个架构:

txt 复制代码
your-project/
├─ packages-data/
├─ packages-rc/
├─ ...
├─ src/
└─ package.json

其中 packages- 是用来发包的,最外层 src 是某个前端项目的 assets,整个项目会被推到云端进行构建。

那么用 pnpm 无疑比 npm 更合适,因为云端一般都会用 npm。不配 npm workspace 的情况下,npm i 得到的依赖永远是 src 下真正需要的,而不是可能在 packages- 下的 link 文件。

最后的最后,Workspace 对本地项目初始化、包的初始化有很好的帮助,但对于多包的发布,尤其是当需要联动修改版本号的时候,可能就力不从心了。

这个时候,就需要有 monorepo 管理工具出场了,这个不在本文讨论范围内,不过还是需要顺带一提:

  • Lerna 我一直在用的,最熟
  • Bit pnpm 推荐的,看起来非常不错的样子

更多的工具,可以看 Monorepo Tools

🙋 FAQ

还没有。

📌️ Links

相关推荐
Tiffany_Ho17 分钟前
【TypeScript】知识点梳理(三)
前端·typescript
安冬的码畜日常1 小时前
【D3.js in Action 3 精译_029】3.5 给 D3 条形图加注图表标签(上)
开发语言·前端·javascript·信息可视化·数据可视化·d3.js
小白学习日记2 小时前
【复习】HTML常用标签<table>
前端·html
丁总学Java3 小时前
微信小程序-npm支持-如何使用npm包
前端·微信小程序·npm·node.js
yanlele3 小时前
前瞻 - 盘点 ES2025 已经定稿的语法规范
前端·javascript·代码规范
懒羊羊大王呀3 小时前
CSS——属性值计算
前端·css
DOKE3 小时前
VSCode终端:提升命令行使用体验
前端
xgq3 小时前
使用File System Access API 直接读写本地文件
前端·javascript·面试
用户3157476081353 小时前
前端之路-了解原型和原型链
前端