本文是 基于Vite+AntDesignVue打造业务组件库[4] 专栏第 2 篇文章【组件库技术选型和开发环境搭建】,为了让读者们沉浸式体验组件库开发,我将会手把手带着读者们搭建起一个组件库的 monorepo 开发环境,相关源码可在 vue-pro-components[5] 仓库中取得。
monorepo 这个词大家或多或少都听过,甚至已经在项目中应用过,问题来了,你能给 monorepo 下个定义吗?
别慌,我也不会,我们来看看维基百科给出的定义[6]。
In version control systems[7], a monorepo ("mono[8]" meaning 'single' and "repo" being short for '[repository](https://en.wikipedia.org/wiki/Repository_(version_control "repository") "Repository (version control)")') is a software development strategy where code for many projects is stored in the same repository.
可见,monorepo 的含义就是在一个单体仓库中管理多个项目,这种项目管理模式在一些大型项目中已经被广泛应用,比如 Vite[9], Vue[10], React[11], Angular[12], React Native[13], Jest[14], Pinia[15], Vue CLI[16], Element Plus[17], Modern.js[18], Next.js[19] 等。如果你打开这些项目仓库,你可以发现其中一个很明显的共性:它们都采用了packages
目录来管理子包,每个子包中都包含一个package.json
文件,也就是说子包也是一个独立的npm
包。
进一步研究这些仓库时,我们可以发现,这些项目在支撑起整个 monorepo 体系时采用的技术方案是不一样的。
有的项目简单采用了 yarn workspaces,有的则使用了 Lerna,也有的用了 pnpm,还有的用了 Changesets,再卷一点的已经用上了 Turborepo。
Changesets 和 Turborepo 不能定义为 monorepo 方案,而是 monorepo 体系中强有力的配套工具。
鉴于笔者还未全面使用过以上所有方案,对于这些方案中的优缺点,无法给出客观的评价,读者们可以自行去查阅更多资料。
这里简单给个参考意见,帮助不了解这块的读者先有个粗略的认识,如有错误,还请评论指出:
yarn 内置的 workspaces[20] 特性可以让子包之间的引用变得简单(其中也用到了 symbol link),在此基础上可以衍生出更多上层的能力,Lerna 就是在此基础上发展而来的工具。workspaces 支持了 monorepo 最基础的能力,但是仅靠它也显得有点单薄,因为它没有提供包的全生命周期管理能力。
Yarn workspaces aim to make working with monorepos[21] easy, solving one of the main use cases for
yarn link
in a more declarative way. In short, they allow multiple projects to live together in the same repository AND to cross-reference each other - any modification to one's source code being instantly applied to the others.
Lerna[22] 可以解决上面说的问题,它提供了包的全生命周期管理能力,包括但不限于 新建子包 / 删除子包 / 管理子包依赖 / 发包 等等,并且有相关的命令行支持,能较大程度上提升 monorepo 项目开发和维护效率。除此之外,Lerna 团队还竭力提升性能和开发体验,具体见 Why Lerna?[23]
pnpm[24] 从设计上就天然支持了 monorepo,同时还通过 严格的依赖结构 / symbol link / hard link 等能力解决了 幽灵依赖、依赖占用大量存储空间 等问题。pnpm 也可以搭配 Lerna 使用。
Changesets[25] 是 pnpm 推荐的一个致力于解决变更记录集、changelog、version 等问题的工具,据说比 Lerna Version 这块的处理更科学。它有一个生产和消费.changeset
的过程,用户在一些复杂版本控制场景中有一定的自主控制权,因为你可以对 changeset 等内容做一定调整,自由度更高。
Turbo,涡轮增压嘛,这就是要起飞的节奏,Turborepo 内部的核心代码是基于 Go 来实现的,这跟 esbuild 一样,直接是降维打击啊!
简单看了一些 Turborepo[26] 官网的文档,可以发现 Turborepo是专注于提升构建性能的工具和平台,它在 Pipeline 编排、Output Caching、Remote Caching、Output Replaying 等方面做了很多努力,同样的事情不做第二次,这与 Lerna 现在的管理团队 Nrwl 研发的构建平台 Nx[27] 的发力方向有点相似。
合理的 Pipeline 编排可以最大限度发挥 CPU 性能。缓存用好了真的是一把利刃,对于重复的工作,得到秒级甚至毫秒级的响应是真的香,这在 monorepo 项目中尤其重要,因为你不知道一个 monorepo 可能会演变成多大的工程!而且 Remote Caching 在 CI/CD 中也能发挥很大作用!
为什么这些明星项目都不约而同选择了 monorepo 呢?背后的原因可能有这些:
npm link
之类的方案开发体验太差。在组件库的技术选型这块没有太多可说的,基本上是围绕项目的需求和自身的能力展开,按照开发过程中的实际需求引入相关的技术方案。这中间会存在主观意愿,仅供参考!
说太多概念也不太容易消化,我们来实操一下。
首先我们需要新建并进入vue-pro-components
工程目录,接着通过npx lerna init
创建一个工程。
$ mkdir vue-pro-components && cd vue-pro-components
$ npx lerna init
lerna notice cli v4.0.0
lerna info Initializing Git repository
lerna info Creating package.json
lerna info Creating lerna.json
lerna info Creating packages directory
lerna success Initialized Lerna files
可以发现 Lerna 为我们生成了一个 monorepo 项目的基本骨架:
$ tree
.
|-- lerna.json
|-- package.json
`-- packages
粗略看一下,package.json
中的private
字段设置为了true
。
{
"name": "root",
"private": true,
"devDependencies": {
"lerna": "^4.0.0"
}
}
这代表什么意思呢?我们看看 npm 文档中关于 private[28] 的描述。
If you set
"private": true
in your package.json, then npm will refuse to publish it.
当private
设置为true
时,就代表你不需要在npm
公开发布这个包。看到这,有的读者可能会纳闷了,“这好像有点问题吧,组件库一般是要发布的呀!”
不用慌,由于我们采用的是 monorepo 架构,具体发布的组件库其实是packages
目录下的一个子包。而整个工程的主包则是用来组织起整个大框架,不发布到npm
也是可以理解的。
同时,这也符合 Yarn 1.X 版本的强制要求[29],如果需要用到workspaces
特性,必须声明private
为true
。虽然 Yarn Modern Version[30] 已经取消了这个限制,但是迟迟没有作为 Yarn 的默认安装版本,在升级迁移[31]这块还有不少阻力。
我们再观察一下lerna.json
这个文件,它通过packages
字段约定了子包都分布在哪些目录下,这里支持 glob pattern 匹配,也可以是一个 package 的 path。
{
"packages": [
"packages/*"
],
"version": "0.0.0"
}
对于version
字段,Lerna 提供了两种版本策略[32]供我们选择,我们应该怎么选择呢?这里先不展开说,免得大家产生太多疑问导致不必要的焦虑。
工程搭建完毕后,我们先与 remote 仓库(您需要保证远程仓库存在)关联一下,方便后续提交代码。如果您已经 fork 该仓库,请将仓库地址改成您自己的。
git remote add origin https://github.com/cumt-robin/vue-pro-components.git
有了上面的基本框架,我们可以着手新建一个组件库子包,这个包将存放组件相关源码,这里用到了lerna create
命令。我们可以根据交互提示填上一些必要的信息,先把子包建好,一些信息可以后续再更改,不必过于纠结。
$ lerna create vue-pro-components
// 在一步步提示下,先将一个npm子包搭建起来
package name: (vue-pro-components)
version: (0.0.0)
description: pro components based on vue3
keywords: components,vue3,vite,typescript,unplugin,on demand,pro
homepage:
license: (ISC) MIT
entry point: (lib/vue-pro-components.js) index.js
git repository:
注意,我这里用到的 package name 是 vue-pro-components,这个 name 也将作为我要发布到 npm 上的包名。
也不用担心这个包名 vue-pro-components 和整个工程的目录名相同,因为主工程是不会发布到 npm 的。
这里先不急着加具体内容,因为我们需要先把大的框架理清楚,继续往下看。
playground 翻译过来就是游乐场,这个子包可以作为我们调试组件表现的地方,这里直接选择用 Vite 初始化一个工程。
我们尝试一下不使用lerna create
命令新建 package。
cd packages && yarn create vite playground --template vue-ts
用 Vite 创建的这个 playground 包默认也是 private 的,playground 本来的作用就是调试或展示组件的基本效果,可以打包后作为一个 web 应用发布到公网,但是不需要发布到 npm,所以设置为 private 符合预期。
回到上文留下的疑问,两种版本策略,我们该怎么选?
1 . Fixed/Locked mode (default)[33]
Fixed mode 意味着version
字段对应着具体的版本号,比如0.1.0
。在这种模式下,各个子包的版本号相对集中,一般来说可以理解为同一个版本号(也有例外),Lerna 会在执行lerna version
命令时根据用户的选择自动更新version
字段,同时会修改发生过代码变更的子包的package.json
中的version
字段。
我们可以来试验一下,先把代码 commit & push 到远程,这里我用了一个新分支c1
。
// 回到根目录
git checkout -b c1
git add .
git commit -m 'chore: 先将代码提交到远程,方便后续测试lerna version'
git push --set-upstream origin c1
文档中提到,如果当前 major 版本号是 0,则认为所有变更都是破坏性的,这意味着修改任何一个包中的内容,lerna version
都会更新所有子包的版本号。我们来试试,修改其中一个子包vue-pro-components
的内容,在index.js
加了一行注释,然后 commit,接着使用lerna version
更新版本号。
$ git add .
$ git commit -m 'chore: 测试 major 版本号为 0 时修改一个包'
$ lerna version
lerna notice cli v5.3.0
lerna info current version 0.0.0
lerna info Assuming all packages changed
? Select a new version (currently 0.0.0) (Use arrow keys)
> Patch (0.0.1)
Minor (0.1.0)
Major (1.0.0)
Prepatch (0.0.1-alpha.0)
Preminor (0.1.0-alpha.0)
Premajor (1.0.0-alpha.0)
Custom Prerelease
Custom Version
当我们选择 Patch 更新后,可以看到,两个子包的版本号都变成了 0.0.1,并且lerna.json
中的version
也变成了0.0.1
。
Changes:
- playground: 0.0.0 => 0.0.1 (private)
- vue-pro-components: 0.0.0 => 0.0.1
我们再试试加一行注释,模拟把一个包的大版本号变成 1 的场景,可以看到两个包的版本号以及lerna.json
中的version
也变成了1.0.0
。
Changes:
- playground: 0.0.1 => 1.0.0 (private)
- vue-pro-components: 0.0.1 => 1.0.0
此时,我们再加一行注释,模拟引入一个 feature,再发起 minor 位的版本号变更,会发现仅有一个包的version
变成了1.1.0
,同时lerna.json
中的version
也变成1.1.0
,而另一个包的版本号没有变化,这看起来还比较合理,因为我们认为主版本号 1 以上的是相对稳定的版本,按需更新版本号是比较合理的。
Changes:
- vue-pro-components: 1.0.0 => 1.1.0
接着,我们把两个包都改一点内容再测试一次。
Changes:
- playground: 1.0.0 => 1.1.1 (private)
- vue-pro-components: 1.1.0 => 1.1.1
做了这些尝试我们可以发现,lerna version
做版本变更时,只会让我们选择一次版本,这一次选择将作用到多个包上。
假设某次更新版本时,我希望一个包是 minor 更新,另一个包是 patch 更新,该怎么办呢?我们继续往下看。
2 . Independent mode[34]
Independent Mode 就是采用独立的版本号控制,会在执行lerna version
命令时逐个询问各个 package 的新版本号,我们可以通过修改lerna.json
中的version
字段值为independent
打开这个模式。
当我只修改其中一个包,lerna version
会提示我选择一个版本号,这个版本号也将只作用到这个包上,其他的包不受影响。
$ lerna version
lerna notice cli v5.3.0
lerna info versioning independent
lerna info Looking for changed packages since v2.0.0
? Select a new version for vue-pro-components (currently 2.0.0) Patch (2.0.1)
Changes:
- vue-pro-components: 2.0.0 => 2.0.1
接着我们修改两个包的内容再测试一次,Lerna 会让我们单独为每个包选择新的版本号。
$ lerna version
lerna notice cli v5.3.0
lerna info versioning independent
lerna info Looking for changed packages since vue-pro-components@3.0.0
? Select a new version for playground (currently 2.0.0) Minor (2.1.0)
? Select a new version for vue-pro-components (currently 3.0.0) Minor (3.1.0)
Changes:
- playground: 2.0.0 => 2.1.0 (private)
- vue-pro-components: 3.0.0 => 3.1.0
也就是说,Independent Mode 下,版本号是各管各的,按需选择。
简单总结一下:在 Fixed Mode 下,lerna.json 中记录了各个包中最新的版本号。如果当前大版本号是 0,则修改任意一个包中的内容都会引起所有包的版本号更新;反之,仅更新变动的包的版本号。还有一个场景,就是继续选择大版本的更新,也会引起所有包的版本号更新。总的来说,Fixed Mode 下,版本号捆绑性还是很强的。而在 Independent Mode 下,各个包的版本号相对独立,需要开发者结合包的修改情况来手动选择各个包的版本号。
个人建议:如果你的整个 monorepo 项目中各个子包联系性非常紧密,目标是对外提供统一的服务,那么 Fixed Mode 是一个不错的选择,例如 Vue CLI,就是采用了 Fixed Mode。对用户来说,他享受的是整个 Vue CLI x.x.x 版本带来的能力,而不太关心 @vue/cli-ui
和 @vue/cli-service
现在是哪个版本。如果你的 monorepo 项目中各个子包联系性稍弱,对外提供多种能力(比如 Lint 配置、Utils 工具、通用 Hooks、UI 库等等),那选择 Independent Mode 则是一个不错的选择,这种做法常见于企业内部,通常 monorepo 是作为整合多种能力的一个重要工具,既能在各个子包之间实现一部分复用,又能单独对外提供输出能力。当然,任何事情都不是一成不变的,如果你对版本控制欲很强,也可以果断选择 Independent Mode。
我这里选择的是 Independent Mode。
对版本策略有个粗略的认识后,我们给子包之间建立一点联系,感受一下 monorepo 最大的魅力。
我们先在vue-pro-components
子包写一个简单的组件Icon
,无需真正实现图标组件,仅仅用来测试一下。
<template>
<i>{{ icon }}</i>
</template>
<script>
export default {
props: {
icon: {
type: String,
default: '默认图标'
}
}
}
</script>
一个粗略的目录结构大概是这样的:
由于 Vue3 组件需要用到框架依赖,我们需要在package.json
中声明一个peerDependencies
。
"peerDependencies": {
"vue": "^3.2.0"
}
然后在项目根目录的package.json
中加一个统一安装依赖的脚本。
"scripts": {
"bootstrap": "lerna bootstrap -- --hoist"
}
接着执行这个bootstrap
命令,
yarn bootstrap
我们可以发现,在根目录中出现了node_modules
目录,而在各个子包中没有出现node_modules
,这是--hoist
在起作用,将依赖提升到了根目录,可以节省一部分空间。
我们还注意到,vue-pro-components
和playground
两个包也出现在了node_modules
目录中,实际上它们是软链接,链接的源目录是packages
目录中对应的子包目录,这样的目录结构符合 Node 的模块加载策略,于是子包之间就可以像使用一个普通的 npm 包一样互相引用了。
软链接就是 symbolic link,类似于 Windows 系统中快捷方式的概念。但是在 Windows 系统中, workspace 的具体实现并不是快捷方式,而是采用了 junction。
lerna bootstrap
不仅为各个 package 安装了自身的依赖,还将各个 package 以 symlink 的方式安装到了node_modules
中,让其他 package 拥有了引用自己的能力。
接着我们试着在playground
子包中引用一下vue-pro-components
子包的组件 Icon。
1 . 首先需要将vue-pro-components
作为playground
子包的一个依赖。
lerna add vue-pro-components --scope=playground
2 . import 引入组件并使用。
// 1. script 中引入 Icon 组件
import { Icon } from "vue-pro-components"
// 2. template 中使用组件
<Icon icon="icon-up"></Icon>
<br>
<Icon icon="icon-down"></Icon>
3 . 预览效果。这需要把 playground 这个子包的开发环境跑起来,也就是要执行它的dev
脚本。为了方便起见,我们可以在项目根目录的package.json
中加一个playground:dev
脚本,这里用到lerna run
,它可以根据scope
选项执行某个子包的脚本。
"scripts": {
"bootstrap": "lerna bootstrap -- --hoist",
// 加入这条脚本
"playground:dev": "lerna run --scope playground dev"
}
这样,我们就可以直接在根目录直接跑 playground 的开发环境了。虽然 Icon 组件还没什么太多的内容,但是我们可以看到,playground 子包已经可以顺利引用 vue-pro-components 子包的组件了。
gif 太大,给个链接吧:https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f3f819d4665440b3a3640840ae66142c~tplv-k3u1fbpfcp-watermark.image?
整个组件库的工程配置一股脑说完,也是很难吸收的,我们先来点简单的,也是最重要的一步,把组件库先发布到 npm 上。
首先你需要有一个 npm 账户[35]。
有了账号后,可以来到你的项目工程目录下,通过终端登录 npm,可以输入npm adduser
或者npm login
进行登录。
npm adduser
如果登录失败,考虑你的 registry 是不是正确,如果用了国内的 npm 代理,建议登录时带上--registry=https://registry.npmjs.org/
参数。
登录成功后,就可以试着发布你的 npm 包了。npm publish
可以发布包,但是在 lerna 项目中,我们可以用 lerna publish
代替。
通常,我们会在项目中通过.npmrc
或者.yarnrc
配置一个国内的 registry 代理,加快安装依赖的速度。但是在发包的时候,我们还是要发布到 npm 官方的 registry 中,所以就需要给 lerna publish
配置一个 registry 参数,告诉 lerna publish 发布到哪个 registry 中。
我们修改一下lerna.json
:
同时,还有一个地方需要修改,那就是vue-pro-components
子包的package.json
,需要将其publishConfig.access
字段设置为"public"
。
从上图我们可以知道,如果一个包是 scoped package,也就是带命名空间的包,例如它的包名是@vue-pro-components/utils
,对于这样的包,如果不设置access
为"public"
,是不能公开发布和安装的。虽然我们发布的这个 vue-pro-components 不是 scoped package,但是为了养成一个好习惯,我们还是给它设置一下access
。
接着,我们在根目录package.json
中增加一个脚本,方便我们进行发布操作。
lerna publish from-package --yes
我们试着执行这个publish:package
脚本,如果能看到下面这样的信息,就表示发布成功了。
gif太大,给个链接:https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3adbb28a06f04565ab37b345471d56a3~tplv-k3u1fbpfcp-watermark.image?
截至到目前,我们只是在组件库开发环境搭建上做了一些粗略的尝试,对一些关键节点做了验证,整个项目还是处于一个非常简陋的状态,但是读者们也不必担心内容的丰富度,随着专栏后续内容的深入,一些工程化配置(包括 TypeScript)也会慢慢完善起来。如果您对我的专栏感兴趣,欢迎您订阅关注本专栏[36],接下来可以一同探讨和交流组件库开发过程中遇到的问题。
[1]基于Vite打造业务组件库(开篇介绍): https://juejin.cn/post/7146022961894391821
[2]实战案例:初探工程配置 & 图标组件热身: https://juejin.cn/post/7160549169566842893
[3]vue-pro-components c1 分支: https://github.com/cumt-robin/vue-pro-components/tree/c1
[4]基于Vite+AntDesignVue打造业务组件库: https://juejin.cn/column/7140103979697963045
[5]vue-pro-components: https://github.com/cumt-robin/vue-pro-components
[6]维基百科给出的定义: https://en.wikipedia.org/wiki/Monorepo
[7]Version control: https://en.wikipedia.org/wiki/Version_control
[8]wikt:mono-: https://en.wiktionary.org/wiki/mono-#English
[9]Vite: https://github.com/vitejs/vite
[10]Vue: https://github.com/vuejs/core
[11]React: https://github.com/facebook/react
[12]Angular: https://github.com/angular/angular
[13]React Native: https://github.com/facebook/react-native
[14]Jest: https://github.com/facebook/jest
[15]Pinia: https://github.com/vuejs/pinia
[16]Vue CLI: https://github.com/vuejs/vue-cli
[17]Element Plus: https://github.com/element-plus/element-plus
[18]Modern.js: https://github.com/modern-js-dev/modern.js
[19]Next.js: https://github.com/vercel/next.js
[20]workspaces: https://yarnpkg.com/features/workspaces
[21]monorepos: https://yarnpkg.com/advanced/lexicon#monorepository
[22]Lerna: https://lerna.js.org/
[23]Why Lerna?: https://lerna.js.org/docs/introduction#why-lerna
[24]pnpm: https://pnpm.io/
[25]Changesets: https://github.com/changesets/changesets
[26]Turborepo: https://turborepo.org/
[27]Nx: https://nx.dev/
[28]private: https://docs.npmjs.com/cli/v8/configuring-npm/package-json#private
[29]Yarn 1.X 版本的强制要求: https://classic.yarnpkg.com/en/docs/workspaces
[30]Yarn Modern Version: https://yarnpkg.com/features/workspaces
[31]升级迁移: https://yarnpkg.com/getting-started/migration
[32]两种版本策略: https://lerna.js.org/docs/features/version-and-publish#versioning-strategies
[33]Fixed/Locked mode (default): https://lerna.js.org/docs/features/version-and-publish#fixedlocked-mode-default
[34]Independent mode: https://lerna.js.org/docs/features/version-and-publish#independent-mode
[35]npm 账户: https://www.npmjs.com/signup
[36]订阅关注本专栏: https://juejin.cn/column/7140103979697963045
[37]laobaife: https://qncdn.wbjiang.cn/%E5%BE%AE%E4%BF%A1%E4%BA%8C%E7%BB%B4%E7%A0%81%E5%90%8D%E7%89%87.jpg
[38]vue-pro-components c1 分支: https://github.com/cumt-robin/vue-pro-components/tree/c1
[39]实战案例:初探工程配置 & 图标组件热身: https://juejin.cn/post/7160549169566842893
END
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8